Audit findings
Non-normative. This is the register of known gaps between what
spec/ requires, what the documentation claims, and what the code
does. Every finding carries evidence a reader can check.
A finding stays here until it is fixed or the specification is amended to match
reality. Deleting one because it is inconvenient, or because the text that
stated it was rewritten, is the failure mode this file exists to prevent
(C0.9).
Audit date: 2026-07-31; remediation pass: 2026-08-01. Scope: the whole crate at first release
(0.1.0). Method: every requirement in spec/*.md read against the code that
implements it and the test that exercises it; the specification sources
re-fetched from specifications.openehr.org and openEHR/specifications-TERM;
cargo clippy --all-targets and cargo test run clean.
41 findings, 41 in the table below: 6 High, 25 Medium, 10 Low. 35 fixed or
classified, 6 open. These counts are checked against the table by CI
(claims / the audit summary counts itself correctly) — if this paragraph
and the table disagree, the table is correct (W0.3: never claim more than is
verified), and the check should have failed. Every one of the 6 open findings
is open by a stated reason rather than by omission — A-40 is the newest and
the largest, an entire specification section in force with no code behind it —
and the rest: A-02, A-08 and
A-19 are declared departures the crate does not intend to close; A-05, A-10,
A-30 are recorded limitations or residuals with the reasoning
for leaving them written beside them — A-38 is a defect in serde_json
that this repository could not repair — a conclusion that turned out to be
wrong, and is the reason that entry is worth reading; A-27 was closed by making the
decision it recorded as unmade. A-09
(no property-based testing) is closed: tests/properties.rs covers the laws, and
openehr-fuzz now drives five targets over the parsers — ISO 8601, the
identifier grammars, AQL, paths, and canonical-JSON deserialization — run in CI
on every push rather than merely committed. The canonical_json target is
seeded with a real composition and reaches roughly 4,800 covered edges, against
650 for iso8601; without the seed it would have exercised the JSON lexer and
nothing else.
Deliberately not treated as findings: deep nesting on deserialization, which
S1.15 states as a documented limitation rather than a defect, and for which
serde_json's own recursion limit bounds the input. A-10 was opened by the work that closed
A-06, which is the usual pattern: closing a finding is what surfaces the next
one.
A-13, A-14, and A-15 were all found in a single afternoon by running the
generated DDL against PostgreSQL 18 and MySQL 8.4 — the two engines that could
be provisioned locally. Both crates passed every golden DDL test while emitting
a script the engine rejected. That is the measured cost of the distance between
Dialect and Schema in openehr-store/spec/conformance.md: two of two crates
tested were wrong, and the tests that existed could not have said so, because a
golden test compares an emitter against its author's belief rather than against
an engine.
Four of the seven were closed by reading a primary source — one per
Reference Model package — and all four times the source contradicted what had
been implemented from the rendered specification pages. The rendered pages
omit every class-definition table: the sources include:: them from a UML
export generated at build time, and the model itself is a binary .mdzip. So
anything written from the prose alone is a guess.
Across the four: three rules backwards, sixteen invariants missing, five
reported under names openEHR does not use, two undeclared narrowings, and one
place where openEHR contradicts itself (V8.7a). Every one of the four also
caught at least one of this crate's own test fixtures, which had been wrong
since they were written.
That is the standing lesson of this register: for an invariant, go to the published PDF and the amendment record. Never the rendered page.
A-01 was the one worth chasing. Two of the three rules it flagged were
genuinely wrong, both rejecting conformant data, and reading the primary source
found four further invariants missing entirely — including
Normal_range_and_status_consistency, which is the one that lets a clinician
see a normal flag beside an abnormal result. None of the nine is a false claim
in the documentation, which is the class this register most exists to catch.
Severity#
| Meaning | |
|---|---|
| High | Makes a false claim about clinical software, or defeats a control. |
| Medium | A real gap with a bounded blast radius, or one that will grow. |
| Low | Cosmetic, or already stated where a reader will meet it. |
Summary#
| Id | Severity | Finding | Status |
|---|---|---|---|
| A-01 | Medium | Three quantity rules came from secondary sources; two were wrong | fixed |
| A-02 | Low | ISO 8601 basic format refused | open, by decision |
| A-03 | Medium | Reading a composition cost more stack than a test thread has | fixed, residual documented |
| A-04 | Low | Reference ranges were not navigable by path | fixed |
| A-05 | Low | AQL re-rendering differs cosmetically from its input | open |
| A-06 | Medium | 54 requirements implemented with no test | fixed |
| A-07 | Low | COMPOSITION persistent/context invariant not implemented |
fixed — the uncertainty was a misreading |
| A-08 | Low | The property and extract_* terminology groups are not carried |
open, by decision |
| A-09 | Low | No property-based or fuzz testing; mutation verification is not systematic | fixed — property tests added (A-17); openehr-fuzz drives five targets over the parsers, run in CI |
| A-10 | Low | X11.24 fail-closed has no provokable error path |
open |
| A-18 | Medium | ORIGINAL_VERSION cannot carry a signature; openEHR puts it on VERSION |
fixed — field, builder, accessor, round-trip test |
| A-19 | Medium | COMPOSITION.Territory_valid and Language_valid are neither enforced nor declared |
classified — declared as S1.18, and in-crate enforcement is the wrong answer rather than missing work: ISO 3166-1 and ISO 639-1 change, and a table compiled into a library rejects conformant data from the day one does. What was genuinely open — whether a caller can do the check S1.18 tells them to — is now pinned by a test |
| A-20 | Medium | L10.4 requires openEHR's own invariant names; citations diverged and nothing checked |
fixed — 15 renamed; the 13 crate additions declared under L10.9; both checked every build |
| A-21 | Medium | EHR.Ehr_status_valid and Ehr_access_valid unenforced; the shared fixture violated both |
fixed — Ehr::new checks, fixture corrected, round-trip assertion strengthened |
| A-22 | Medium | DV_MULTIMEDIA: Integrity_check_validity reported for the wrong rule; three checkable invariants unenforced despite the crate shipping their code sets |
fixed — four checks added, the addition renamed and declared |
| A-23 | High | A VERSION's invariants were checked by OriginalVersion::new and by nothing else — deserialization bypassed them and no Validate impl existed, so the path an HTTP service takes was unchecked |
fixed — Validate for Version, the store validates the envelope, and Preceding_version_uid_validity enforced for the first time |
| A-24 | Medium | The 75 unnamed RM invariants were undifferentiated, so a real gap was indistinguishable from a class deliberately not modelled | classified — 29 out of scope, 17 vacuous, 25 unenforced, 1 enforced-but-misnamed; the build now fails on an unclassified one. Unenforced now 21: the four EHR reference rules and the interval rename are fixed; two sub-findings open |
| A-25 | High | The invariant-coverage count matched invariant names without their class, matched names in comments, and saw only two of the ways a rule is reported | fixed — matches the cited (class, name) pair through a real scanner; 83 named became 69, and 24 invariants nobody had examined were revealed |
| A-26 | Low | The conformance matrix boasted mechanical completeness — "291 ids, 291 covered, none missing" — and six requirements added afterwards had no row | fixed — 297 of 297, and CI re-derives the count on every push |
| A-27 | Medium | AQL could not express a negative numeric literal — WHERE o/value/magnitude > -2.5 was refused at the lexer, and Parser::integer's v >= 0 guard was therefore unreachable |
fixed — the sign is resolved by the parser at operand position, never by the number scanner, so an archetype id cannot be affected (Q12.9b, Q12.9d) |
| A-28 | High | The query surface — aql.rs and path.rs — had 115 surviving mutants of 435, the largest untested area in the crate; fifty of them were navigation-table arms whose loss turns a resolvable path into an empty result |
fixed for the navigation table and the AQL parser; the count is in the A-09 table |
| A-29 | Medium | The four temporal data types carry DV_ORDERED attributes and implement DvOrdered, but path.rs reached them on five classes only — a normal range on a DV_DATE was unreachable by path, against Q12.7a |
fixed — nine classes; found by a test written to kill a mutant |
| A-30 | Low | AQL has no node-id predicate shorthand: c[at0001] is refused, not read as archetype_node_id = 'at0001' |
open, pinned by a test |
| A-31 | Medium | The invariant scanner paired any uppercase literal with a following identifier-shaped one, so eleven pairs that were never a citation — ROLE._type, EHR_STATUS._type, ELEMENT.archetype_node_id and eight more — stood in the committed divergence register |
fixed — the two must be one call's arguments; 74 named is unchanged, so no real citation was lost |
| A-32 | Medium | Eq on the ISO 8601 types was lexical while PartialOrd compared instants, so 11:00:00Z and 12:00:00+01:00 ordered Equal and were not == — contrary to the standard library's requirement that the two agree |
fixed — PartialOrd/Ord removed from Date, Time, DateTime, Duration; semantic ordering is now the plain method semantic_cmp, so no trait contract applies |
| A-33 | Medium | The Gregorian leap rule was implemented twice — base::iso8601 and rm::data_structures — byte-identical but for the fallback arm, and the second copy had never been run by any test |
fixed — one implementation, pub(crate); the interval-event arithmetic that used it is now tested against hand-computed dates |
| A-34 | Medium | DV_ENCAPSULATED's charset and language were preserved across a round trip and unreadable — EncapsulatedAttrs is exported but no type returned one, so a caller holding a DV_MULTIMEDIA or DV_PARSABLE could not ask what it declared |
fixed — an encapsulated() accessor on both; found because the two accessors had no reachable caller to test |
| A-36 | Medium | DV_URI and DV_EHR_URI enforced their invariants in the constructor only. A DV_URI deserialized from {"value":"nocolon"} panicked in scheme(), whose rustdoc said "# Panics — Never"; a DV_EHR_URI deserialized from {"value":"https://example.org/x"} reported scheme https, which is what the type exists to make impossible. Validate for DataValue reached both through a _ => {} arm, and LINK.target was validated nowhere |
fixed — scheme()/rest() are total (D3.30a), validation checks both types and every LOCATABLE's links, and openehr-fuzz has a uri target that reproduces the panic in seconds |
| A-37 | High | The aql fuzz target had been failing in CI since 2026-08-04 and nobody had triaged it. Two defects: the lexer copied a string literal one UTF-8 byte at a time, so 'Müller' lexed to 'Müller' and a WHERE against it matched nobody; and the FROM renderer omitted the parentheses its own grammar needs, so Or(Contains(a,b), c) rendered as text that re-parsed to Contains(a, Or(b,c)) — a query over different records |
fixed — slices not bytes, escaped rendering, precedence-correct parentheses (Q12.15, Q12.15a, Q12.15b) |
| A-38 | Medium | serde_json's float parser was not the inverse of its own serializer, so a DV_QUANTITY magnitude drifted across repeated canonical round trips. Filed as open and upstream (serde-rs/json#1336); it was neither |
fixed — serde_json's float_roundtrip feature already existed and this repository had not enabled it (spec/serde-json-float-roundtrip-arbitrary-precision/ SJ1) |
| A-39 | Medium | Two matches in DataValue whose arms could be deleted in silence — semantic_cmp (6 of 9) and is_strictly_comparable_to (all of them, plus the whole function replaceable with false) — and trim_float, whose guarded branch produced the same string as its else for every finite f64. Found by the retrospective mutation pass W-18 required, not by anything failing |
fixed — one table-driven test per arm with a row-count assertion, and the dead branch deleted rather than tested |
| A-35 | Medium | Ten types — every DV_ORDERED descendant and DataValue — implemented PartialOrd while deriving PartialEq over all their fields, so a != b while a <= b and a >= b were both true. Recorded as the lexical-vs-semantic shape of A-32 and scoped to five types; the mechanism is OrderedAttrs, which every DV_ORDERED carries, and it reached five more |
fixed — no DV_ORDERED implements PartialOrd; comparison is DvOrdered::semantic_cmp, and INTERVAL<T> is bounded on a new SemanticOrd (D3.18b, D3.18c) |
| A-11 | Medium | The Common Information Model was implemented from prose | fixed |
| A-12 | Medium | The Data Structures model was implemented from prose | fixed |
| A-13 | Medium | One IF NOT EXISTS flag covered two statements MySQL treats differently |
fixed, verified on MySQL 8.4 |
| A-14 | Medium | SQL Server and Oracle documented an idempotence guard that was never emitted | fixed, not verified on either engine |
| A-15 | High | Append-only was enforced in the schema on two engines of five | fixed, verified on PostgreSQL 18 and MySQL 8.4 |
| A-16 | High | Time/DateTime panicked on a multi-byte character in the offset |
fixed, regression pinned |
| A-17 | Medium | The first property tests passed vacuously | fixed, mutation-verified |
| A-40 | Medium | The Archetype Model is specified and mostly not implemented: §15 and S1.21 are in force, 18 of 32 requirements with no code |
open — object model, in-memory-archetype validation, and repository resolution of a filled slot built 2026-08-26/30; no parser, flattening, or template expansion |
| A-41 | Low | The conformance matrix's own totals went stale a second time — 291 claimed, 300 in one sentence, 311 in the rows | fixed — re-derived mechanically to 344 on 2026-08-26 |
A-01 — Three quantity rules came from secondary sources#
Severity: Medium. Status: fixed — two of the three were wrong.
Requirements: D3.20, D3.20a, D3.21, D3.21a, D3.22, D3.23a,
D3.24a, D3.24b.
What was found. The rendered RM 1.1.0 Data Types page omits its
class-definition tables — the specification sources include:: them from a UML
export generated at build time and absent from the repository, and the .mdzip
model is binary. Three rules had therefore been written from the class
descriptions and from implementation practice. The invariants were eventually
read from the Release 1.0.2 publication (Rev 2.1.1, 20 Nov 2008,
§6.2.1–6.2.12), and the amendment record checked for every change since (see
§3, Where the quantity invariants come from).
Two of the three were wrong, both in the direction that rejects conformant data:
| Rule | Was | openEHR says |
|---|---|---|
DV_QUANTITY.precision |
>= 0 |
Precision_valid: precision >= -1 — -1 is the stated "unlimited decimal places" |
DV_PROPORTION.precision |
an integral kind forbids a non-zero precision | Precision_validity: precision = 0 implies is_integral — the other direction entirely |
magnitude_status |
{=, <, >, <=, >=, ~} |
the same six, confirmed verbatim |
Reading the tables also surfaced four rules that were simply missing:
DV_AMOUNT.Accuracy_is_percent_validity— an accuracy of0must not be flagged as a percentage;0means 100% accurate and "0%" reads as the opposite.DV_AMOUNT.unknown_accuracy_value—-1.0records "accuracy not measured". Nothing in the crate honoured it, so-1would have been read as an error of minus one.DV_ORDERED.Normal_status_validity— the abnormal flag must come from the openEHR code set. A renderer prints it verbatim beside a result.DV_ORDERED.Normal_range_and_status_consistency—Nif and only if the value is inside its normal range. This is the one that matters clinically: it is exactly the case where a result arrives from one system and its flag from another, and a clinician sees a normal flag beside a potassium of 9.9.
Fix. All seven rules are now implemented and each has a test that names the
failure it guards. DvQuantity::UNLIMITED_PRECISION and
DvQuantity::UNKNOWN_ACCURACY name the two sentinels rather than leaving them
as bare numbers, and the two DV_ORDERED invariants are checked by
[crate::validation] because the second is a relationship between attributes
rather than a property of one.
Residual. One inference is not read from a source: 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 record describes it as DV_ORDINAL
with a Real value, so the inference is a short one — but it is an inference,
and it is recorded rather than assumed.
What this finding is really evidence of. The two wrong rules were both
plausible, both written by someone reading the prose carefully, and neither
would have failed a test written by the same person from the same reading. The
only thing that found them was going back to a primary source. That is the
argument for C0.3.
A-02 — ISO 8601 basic format is refused#
Severity: Low. Requirement: D3.13a, which records the decision.
20240517 and T091500 are valid ISO 8601 and are refused. They do not appear
in openEHR canonical JSON, and accepting four bare digits would make 2024
ambiguous between a year and a basic-format fragment.
Consequence: an instance converted from a system that emits basic format fails to parse, with a clear error, at the boundary.
To close, if it should be closed: accept basic format only where the length is unambiguous, and never for a bare four-digit string. Recorded here rather than left for a reader to discover from a rejection.
A-03 — Reading a composition cost more stack than a test thread has#
Severity: Medium. Status: fixed; residual documented.
Requirements: J9.15, S1.15.
What was found. Deserializing even a minimal COMPOSITION overflowed the
2 MiB stack Rust gives a spawned thread, in a debug build. cargo test runs
every test on a spawned thread, so this was not an edge case: a caller's own
test suite would have aborted with a stack overflow the first time it read a
composition. It surfaced when the README's own fragments were made into tests
(tests/readme.rs) — the first document small enough that nobody would have
suspected it.
Cause. #[serde(flatten)] and internally tagged enums both make serde
generate a visit_map holding an Option<T> local per field, and flattening
composes them: an ENTRY carries three flattened attribute groups, each
embedding types that reach DATA_VALUE — a 22-variant enum. In an unoptimized
build the locals are not coalesced, so the frames multiply down the tree.
Fix. The heavy optional fields on the hot path were boxed:
LocatableAttrs::archetype_details and feeder_audit, Element::value,
EVENT::state, HISTORY::summary, COMPOSITION::context,
EHR_STATUS::other_details, EVENT_CONTEXT::other_context,
ENTRY::provider, CARE_ENTRY::protocol, and OBSERVATION::state. Each trades
an allocation on a field that is absent from most nodes for a stack cost paid on
every node of every document.
Measured, 2026-07-31 on rustc 1.96.1, debug profile, by binary-searching an
explicit stack_size per type with each probe in its own #[inline(never)]
function — the last detail matters, because a debug frame is sized for the union
of all branches and an earlier measurement that dispatched inside one function
reported a uniform 1 MiB for everything, including
serde_json::from_str::<u32>:
| Type | Before | After |
|---|---|---|
LocatableAttrs |
128 KiB | 24 KiB |
ELEMENT |
192 KiB | 48 KiB |
ITEM_STRUCTURE |
384 KiB | 96 KiB |
ENTRY (EVALUATION) |
1024 KiB | 192 KiB |
COMPOSITION (minimal) |
2048 KiB | 256 KiB |
the ~10 KB fixture in tests/canonical_json.rs |
> 2048 KiB | 768 KiB |
tests/canonical_json.rs::reading_a_composition_stays_within_a_small_stack
guards the result at a 1 MiB ceiling, so a regression fails there rather than in
a user's CI.
What remains open. The cost is still a function of document depth and build
profile, and it is still unbounded (S1.15): a caller reading untrusted
documents must bound depth at the edge, because unbounded nesting is a
denial-of-service vector in every recursive-descent reader. Two earlier changes
made for the same reason are also still in place and were not sufficient on
their own: two intermediate serde_json::Value round trips were removed from
the Text and PARTY_PROXY readers, and ContentItem's tagged-plus-untagged
encoding was replaced with one flat six-way dispatch.
A-04 — Reference ranges were not navigable by path#
Severity: Low. Status: fixed. Requirements: Q12.7a, Q12.7b.
normal_range, other_reference_ranges, and normal_status were modelled,
serialized, and round-tripped, and the path navigator would not walk into them:
a path into an interval bound needs lower/upper steps that were not defined.
AQL addressing a reference range therefore parsed and would not have resolved.
Why it mattered more than it looked. "Results outside their own normal
range" is a population query clinicians actually ask, and it is precisely the
one that needs …/value/normal_range/upper/magnitude.
Fix. Node gained Interval, ReferenceRange, and PlainText variants;
the three DV_ORDERED attributes are resolved once for all five ordered
classes rather than per class — five chances to omit one, taken away.
A-05 — AQL re-rendering differs cosmetically from its input#
Severity: Low. Requirement: Q12.15 is satisfied; this is about the
text, not the meaning.
AqlQuery's Display writes [ehr_id/value = $ehrUid] where the input had
[ehr_id/value=$ehrUid], and parenthesises boolean groups it did not have to.
The rendered text re-parses to an equal query, which is what Q12.15 requires,
so this is a normalisation and not a defect — recorded because a caller
round-tripping query text for storage will see it and should not have to guess
whether it matters.
Evidence: run cargo run --example 03_paths_and_aql and compare the
normalised: line with QUERY.
A-06 — 54 requirements were implemented with no test#
Severity: Medium. Status: fixed. Requirement: T13.1.
The matrix marked 54 of 269 requirements ?: the code appeared to implement
them and nothing would have failed if the implementation were removed. Most of
them were rejections — a constructor refusing something openEHR forbids —
which is the worst kind of untested code, because the failure mode is silent.
The check stops working, nothing errors, and invalid clinical data is accepted
and stored.
Fix. tests/invariants.rs adds 29 tests covering 41 of them, each asserting
both directions — the invalid case refused and the valid one not — because a
constructor that refuses everything passes the first half. The largest is
every_validation_check_fires_on_a_document_that_breaks_it, which drives twelve
L10.6 checks from JSON documents rather than from constructed values, since
validation exists precisely for data that never met a constructor (L10.1a).
A further 13 were reclassified type rather than tested: requirements the
compiler enforces, where a runtime test could not fail. Marking them type
rather than • keeps the verified count meaning what it says.
Mutation verification. Five checks were disabled one at a time and the tests
watched to fail: DV_QUANTITY.Units_valid, DV_PROPORTION.Valid_denominator,
DV_PROPORTION.Precision_validity, the skip_serializing_if that keeps null
out of the JSON, and the document-order walk in validation. All five were
detected — but two initially reported "survived" and had never applied,
because the substitution silently failed to match. A mutation that does not
mutate is a false negative, and the technique needs the mutation itself
verified. That is now noted in T13.2.
What remains. Three requirements are still ?, and the reasons differ: a
constant-time comparison nobody has timed (X11.12), an error path that cannot
be provoked (X11.24, now A-10), and the fact that mutation verification is
five checks rather than a policy (T13.2, part of A-09).
A-07 — COMPOSITION persistent/context invariant was not implemented#
Severity: Low. Status: fixed — the uncertainty was mine, not the
specification's. Requirements: E6.6a, E6.6b, E6.3a, E6.7a,
E6.12a.
What the finding claimed. That RM 1.0.2 and 1.1.0 disagreed about whether a
persistent composition may carry an EVENT_CONTEXT, so neither rule could be
enforced.
What was actually true. They do not disagree. Reading the 1.0.2 class table
(EHR Information Model, Rev 5.1.1, §5.4.1) gives the formal invariant —
Is_persistent_validity: is_persistent implies context = Void — and the prose
two sections earlier says the same thing in words: "Persistent Compositions do
not have an Event context." The EHR amendment record shows nothing after 5.1.1
touching it. The 1.1.0 narrative sentence I had read as a relaxation
("optional for persistent composition updates") is about when an event
context is used at all, not about persistent compositions specifically. The
misreading was mine.
What reading the tables also found. Four more invariants missing, none of them related to the original question:
| Invariant | Was |
|---|---|
COMPOSITION.Is_archetype_root |
not enforced |
EHR_STATUS.Is_archetype_root |
not enforced |
EVENT_CONTEXT.location_valid |
an empty location was accepted |
COMPOSITION.Language_valid, Territory_valid |
not enforced, and not declared |
The first two are now checked by validation; the third is refused by the
constructor and reported by validation; the fourth is a declared
non-enforcement (E6.7a) — the code sets are external and out of scope
(S1.10), which is a legitimate position but only once it is written down
(C0.12).
Evidence the checks work. Adding Is_archetype_root immediately failed five
of this crate's own fixtures, in validation, redact, guarantees,
canonical_json, and the crate-level doctest — every one of them a
COMPOSITION built without archetype_details. They were wrong and had been
wrong since they were written.
A-08 — The property and extract_* terminology groups are not carried#
Severity: Low.
src/terminology.rs carries sixteen of the twenty groups in
openehr_terminology.xml. Absent are property (about seventy physical
property codes, used by archetypes rather than by the Reference Model) and the
three extract_* groups, which belong to the EHR Extract model that is out of
scope (S1.6).
Consequence: a caller validating DV_QUANTITY against the openEHR property
vocabulary has to supply it. Nothing in the Reference Model refers to these
codes, so nothing in this crate reports them as unknown.
To close, if wanted: transcribe the property group. The extract_* groups
should stay out while S1.6 stands — shipping their codes would imply support
for a model this crate cannot build.
A-09 — No property-based or fuzz testing#
Severity: Low.
Every test is example-based. The parsers most likely to reward fuzzing are the ISO 8601 reader, the path parser, and the AQL lexer — all three take attacker-influenced text and all three are hand-written.
What exists instead: the round-trip property is asserted over one broad
fixture (T13.4), and each parser has an explicit rejection test enumerating
near-misses.
To close: add proptest round-trip properties for the ISO 8601 types and
cargo-fuzz targets for the three parsers. Neither is in the dependency tree
today, and adding either is a supply-chain decision rather than a code change.
Amended 2026-08-03 — mutation testing, measured. T13.2 has said since it
was written that mutation verification is "not systematic": four checks in
tests/invariants.rs and one in validation had been mutated by hand, and the
rest of the suite had not. This session added roughly fifteen more by hand, one
per change, which is evidence and still not systematic.
cargo-mutants was run over security/audit_chain.rs — the module carrying the
tamper-evidence chain, and the one where a weak test is worth the most.
| Run | Missed | Caught | Unviable |
|---|---|---|---|
| Before | 40 | 27 | 13 |
| After | 1 | 66 | 13 |
What it found, which reading had not. The largest cluster of survivors was
arithmetic inside the hex codecs — every *, /, % and + in
hex_bytes/hex_vec could be changed without a test noticing, because
nothing in the crate had ever put a Chain through serde. A digest or a tag
is what a chain is; a codec that drops a nibble drops the evidence. Four tests
closed it: a keyed chain through JSON, malformed hex refused, key ids selecting
keys, and a digest displaying as hex.
What it found about coverage itself. Chain::from_stored and
Chain::resume_from both survived being replaced with Default::default(),
because their only callers are in openehr-store and cargo mutants runs the
tests of the crate it mutates. A cross-crate caller is not coverage of this
crate — the openehr-sqlite tamper tests exercise both paths and could not have
caught a regression here.
The one survivor. Debug for Mac replaced with Ok(()) prints nothing,
which is safer than what it does now, and pinning exact Debug output would
freeze formatting for no benefit. Recorded rather than chased.
The blind spot, applied. The cross-crate caveat above was then used as a
lead rather than a footnote, on the most safety-critical code written this
session: openehr_store::integrity, the detector M3.16d requires. Its tests
all live in openehr-sqlite/tests/tamper.rs.
| Run | Missed | Caught |
|---|---|---|
| Before | 15 of 15 viable | 0 |
| After | 0 | 15 |
Nothing in openehr-store caught anything. is_breach could return true for
every verdict, is_intact either constant, the content-digest comparison could
be inverted, and every match arm could be deleted, with that crate's suite
green. The engine tests would have caught each — in another crate's job, after
this one reported success.
A conformance suite shared by engines is the right home for engine behaviour. This file is pure logic and needs no engine, and it now has seven unit tests beside it.
It also found dead code. verify_versions began with an early return for an
empty slice, which made the ChainStatus::Empty arm below unreachable —
deleting that arm changed nothing, which is how the mutant survived. Removed:
an empty slice hashes nothing, builds an empty chain, and verify reports
Empty. One path instead of two saying the same thing.
Four modules, and the pattern holds.
| Module | Missed before | After | What the survivors were |
|---|---|---|---|
openehr/security/audit_chain.rs |
40 of 67 | 1 | nothing had put a Chain through serde |
openehr-store/integrity.rs |
15 of 15 | 0 | every test was in openehr-sqlite |
openehr-store/record.rs |
4 of 6 | 0 | M3.34 and half of D-07 asserted on one side only |
openehr-store/dialect.rs |
25 of 27 | 5 | the shared generator is only run by the six engine crates |
openehr-loco/auth.rs |
0 of 6 | 0 | already covered; the module was written test-first with mutation checks |
openehr-loco/controllers/ |
6 of 36 | 0 | an endpoint with no test at all, and the status mapping |
openehr/validation.rs |
25 of 115 | 9 | three whole visit bodies removable, two of them added the day before |
openehr-loco/access.rs |
1 of 4 | 0 | a public accessor with no caller |
openehr-sqlite/store.rs |
9 of 28 | 1 | create_contribution could write nothing |
openehr/security/redact.rs |
8 of 33 | 0 | two of three redaction rule kinds had no test |
openehr/security/access.rs |
6 of 20 | 1 | the EHR_ACCESS accessors |
openehr/security/canonical.rs |
1 of 13 | 0 | nothing canonicalised an array |
openehr/aql.rs + openehr/path.rs |
115 of 435 | 4, all equivalent | the query surface: fifty navigation-table arms, the whole path parser |
openehr/base/iso8601.rs + object_id.rs |
95 of 510 | 2 | days_from_civil, DURATION ordering, the offset parser, the identifier grammars |
openehr/rm/common.rs + data_types/quantity.rs |
72 of 386 | 1, equivalent | the change-control envelope's accessors, and the clinical markers |
openehr/rm/ehr.rs + rm/data_structures.rs |
59 of 313 | 0 | EHR_STATUS's two flags, and a duplicated calendar (A-33) |
openehr/rm/data_types/{text,encapsulated,basic}.rs + base/interval.rs |
53 of 228 | 3, all equivalent | EncapsulatedAttrs was unreachable (A-34); Interval::contains's strict comparison |
openehr/rm/demographic.rs + terminology.rs + base/{object_ref,uid}.rs |
49 of 148 | 0 | ObjectRef::is_local, Role::was_held_on — closes openehr's measurable surface |
five engine crates: src/lib.rs (postgresql, mysql, mariadb, mssql, oracle) |
20 of 59 | 0 | Dialect::name and append_only_sql were unchecked in all five |
openehr-sqlite/dialect.rs |
2 of 10 | 0 | Dialect::name, same as the five above |
openehr-loco/app.rs + tasks.rs + views.rs |
4 of 25 | 1, structural | App::before_run never installed anything under test — see below |
record.rs is the one worth reading twice. A test called
the_attributes_that_used_to_be_dropped_are_persisted existed, named for
D-07, and asserted two of the four attributes that finding restored — the two
that do not go through encode_if_any. Replacing that function with Ok(None)
dropped the other two and the test stayed green: the defect D-07 is about,
reachable again with one edit, under a test named after it.
And party_name could return Some("xyzzy") for every party without failing
anything, while M3.34 — an anonymous committer stored as NULL, so that a
privacy decision does not become a data-quality problem someone later cleans
up — was marked • in the conformance matrix.
openehr-loco is the counter-example that makes the pattern legible. auth.rs
missed nothing on the first run — it is the one module written test-first,
with a mutation check per guarantee. The controllers missed six, and the
sharpest was contribution::routes returning Default::default(): an endpoint
added earlier this session, wired into the router, documented in the README, and
never called by a test. Every other test passed with it removed.
The other two were the hex helper rendering every chain digest as an empty
string — a response claiming a digest of "", which a reader compares against a
witness and finds equal — and three arms of status_for, where a duplicate
commit would answer 500 instead of 409 and tell a caller to retry when it
should re-read.
validation.rs is where this session's invariant work lives, so it is the run
that matters most, and it found three Validate impls whose entire body
could be replaced with () while openehr stayed green.
Version<T>—A-23's fix. Its tests are inopenehr-locoandopenehr-sqlite, so the remedy for a High finding was removable without this crate noticing.EhrAccessandParty— added the day before, with no test at all. Not cross-crate coverage; none. Reading the diff had not shown it, including by the person who wrote it.
It also found that Section, ContentItem and Event could each stop
descending: every test put its entry directly in content and its element
directly in a tree, so the two nesting paths a real composition uses were never
walked. And check_ordered ran only for DV_QUANTITY — a DV_COUNT with a
normal status outside openEHR's code set is a result a renderer shows verbatim
beside a number, and nothing checked it.
Nine survive. Four are the Range_is_simple variant arms, which need a
reference range whose endpoint is itself an ordered value carrying reference
ranges; the rest are boolean operators and one boundary. Recorded rather than
chased.
The security modules produced two findings worth naming, and one methodological correction.
Redaction had one rule kind tested of three. Every test used
RedactionRule::node_id, so the arms matching by name and by archetype
root could each be inverted with the suite green. Redaction is the
PHI-withholding mechanism (X11.24, X11.25); two thirds of its vocabulary
unexercised is not a coverage statistic but a rule nobody has watched work.
Nothing canonicalised an array. The separator's i > 0 could become
i < 0 — emitting no commas, producing [1 2 3] — and no test noticed. A
COMPOSITION is arrays most of the way down, and every digest in the system is
taken over these bytes (db:M3.16), so a broken separator breaks the chain and
the checkpoint together.
The correction. A survivor in matches_element looked like a live
PHI-disclosure bug, so it was reproduced by hand — and the hand-applied mutation
did fail the suite, which suggested cargo mutants was reporting falsely.
It was not. The mutation was on line 262, which is the Name arm; the
hand-edit had changed the NodeId arm on the line above. The tool was right and
the reproduction was wrong. Checking that before writing it up cost ten minutes
and would have cost a false finding in this register.
Redaction also turned out to depend on shape rather than _type: this crate
does not tag a bare ELEMENT, measured rather than assumed, so is_element's
structural fallback is the path every ITEM_SINGLE takes and was untested.
The query surface was the largest untested area in the crate — 115
survivors of 435, more than every other module measured so far put together
(A-28). Three things came out of closing it, and only the first is a test.
Fifty were arms of the navigation table. Node::children answers an
attribute a class does not have with an empty vector, deliberately, so that a
wrong attribute is NoMatch rather than an error. The consequence is that
losing an arm is silent: the path stops resolving, an AQL query returns no
rows, and an empty result set reads as "there is no such record". Two
table-driven tests now state every navigable attribute of every data value and
every structural node, which is also the first place a reader can see what the
path language actually reaches.
Writing those fixtures found a defect the mutant only pointed at.
ordered_attrs_of listed five classes. The four temporal types implement
DvOrdered and carry OrderedAttrs like any other, so a normal range on a
DV_DATE was unreachable by path although the model held it — against
Q12.7a, whose stated purpose is the query "results outside their own normal
range" (A-29). The mutation report said one arm was untested; the fixture is
what read the list.
Two survivors were proofs rather than gaps. Parser::integer's v >= 0
could become true because it is unreachable: a numeric token starts only at an
ASCII digit and - is not in the symbol table, so AQL here cannot express a
negative literal at all (A-27, declared as Q12.9b). And no bracketed
predicate may be a bare node id (A-30, Q12.9c) — which is what makes those
cases evidence, because widening either && in Parser::predicate accepts them
as archetype ids, and archetype_ids() is what an authorisation check reads
before a query runs (Q12.13).
Four survivors remain, and each was checked rather than left. All four are equivalent mutants — no test could distinguish them — and the reason is recorded here because an unexplained survivor and an impossible one look identical in a report:
| Survivor | Why no test can see it |
|---|---|
aql.rs:958 v >= 0 → true |
unreachable: the lexer never emits a negative integer (A-27) |
path.rs:166 || → && |
both "" and "/" reach the same empty-segment result by the ordinary path; the early return is a shortcut, not a decision |
path.rs:195 += → -= |
i is the index of [, and an empty attribute name is already refused, so open >= 1 and the character before [ is part of an attribute name — never a quote. The scan arrives at open + 1 with the same state |
path.rs:195 += → *= |
i * 1 == i: the scan starts at [ instead of past it, and [ is not a quote either |
Each was confirmed by applying it and running a probe over the parser, not by reading. That is the same standard the rest of this register holds, and the one time it was skipped a survivor was nearly reported as a live PHI-disclosure bug (below).
The parsers of untrusted input came next, and the shape of what was missing repeated: the tests exercised each function on inputs where most of its arithmetic cancels.
days_from_civil had every operation free — the era division, the y - 399
correction for negative years, the day-of-year term. It is reachable only
through diff_seconds, and every existing test differenced two dates in the
same era on the same side of the epoch, which is precisely where those terms
cancel. This is the conversion behind the derived UTC column that db:P6.14
requires time-ranged queries to use, so a wrong day is a query that returns the
wrong encounters and says nothing. The replacement table's values come from
Python's datetime; a table generated by running the function under test would
confirm only that it still does what it did.
The same for Duration::approx_seconds, where a flipped sign made P2W
shorter than P1W, and for Time::millis_local, whose fraction padding meant
.5 could be read as smaller than .499.
And for the offset parser, which is where A-16 lived: nineteen mutants, the
sign among them. A flipped sign on -05:00 is a ten-hour error in a stored
clinical timestamp.
Five of the nine survived the first round of tests too, and the reason is the
one worth remembering: days_from_civil is private and its only caller
differences two of its results. A difference cancels every constant, so
+ day - 1 could become + day + 1 and - 719_468 could become + 719_468
— shifting every date by the same amount — and no comparison could tell. The
fix was to call the function directly from the module's own test, which is
possible and was simply not done. Testing a function only through the caller
that cancels half of it is a coverage measurement that flatters itself.
The same round found that no test reached a negative y: 0001-01-01
gives y = 0, which is still the non-negative branch, so the y - 399
correction that keeps the era division truncating the right way was never
exercised. datetime cannot represent year 0; those rows come from their
year-400 counterparts less one 400-year cycle of 146,097 days, which is exact
by construction and was checked against a representable pair.
And in Time::millis_local, comparing 09:00:00 with 09:01:00 cannot tell
m * 60_000 from m + 60_000 — addition is monotonic too, so the ordering is
unchanged. Only a pair that crosses a component boundary, like 00:02:00
against 00:00:59, tests the scale of a term rather than its direction.
A third round found the DURATION ordering untested altogether. Seven
mutants lived in partial_cmp, including the guard that refuses to order
P1M against P30D. That refusal is the point of the impl — a month is 28 to
31 days, so there is no order without a calendar anchor — and answering Equal
because the approximations agree would sort a medication interval into the
wrong place. The sign in approx_seconds was free too, which makes -P1D and
P1D the same length; openEHR permits negative durations (SPECRM-96).
Two survivors were refused for the wrong reason rather than not refused. A
non-numeric VERSION_TREE_ID component is caught again by parse, and a bare
0 again by the zero check, so both guards could be inverted and the value
stayed rejected. Refusal alone could not tell them apart; only the reason
could, and the reason is what a caller is shown. Pinning it also surfaced that
0 reports trunk_version is 0 rather than the branch constructor's message —
correct, and previously unstated.
Two survivors remain, and both were checked.
split_offset's digits.len() == 2 can be true with no observable
difference: the hh.len() != 2 check below it rejects everything the widened
arm would admit. Confirmed by applying the mutation and probing eight offset
spellings, not by reading — every result was identical. The other is a serde
Visitor::expecting, which only shapes the text of a deserialization error;
pinning that text would freeze a message for no benefit, the same judgement
already recorded for Debug for Mac.
Writing those tests found A-32, which was not a coverage gap at all: Eq
on these types is derived and lexical while PartialOrd normalised to UTC, so
11:00:00Z and 12:00:00+01:00 ordered Equal and were not ==. That
contradicted the standard library's requirement that the two agree. First
recorded as D3.18a and left declared rather than fixed, on the reasoning that
both halves were load-bearing — the text is the stored value (db:M3.28),
.5 and .50 must round-trip, and Hash must agree with Eq — and a caller
who sorted and then deduped a collection of times got both spellings of one
instant.
Later fixed properly, once asked to be: PartialOrd/Ord do not have to
exist for a type at all, and removing them from Date, Time, DateTime and
Duration costs nothing that mattered — nobody needs < on a bare ISO 8601
value to compile, only the comparison itself to be available. Semantic
ordering is now the inherent method semantic_cmp, which returns the same
Option<Ordering> as before under a name that does not claim to be Ord. The
RM-level wrapper types (DvDate, DvTime, DvDateTime, DvDuration) keep
their own PartialOrd impls unchanged — they now delegate to semantic_cmp
internally rather than to the removed trait method — so Interval<DvDate>,
ReferenceRange, and everything built on DvOrdered kept compiling and kept
behaving identically. Those wrapper types have the same lexical-Eq-versus-
semantic-order shape one layer up and were not touched; that is a sibling gap,
not this one, and is left for whoever next has reason to look at it.
The Reference Model classes failed a third way: nothing read them back.
Almost every survivor in rm/common.rs was an accessor returning a
constant. The constructors here are thorough — Basic_validity,
Data_valid, System_id_valid, Versions_valid are all enforced on the way
in — but a getter could answer None, "" or "xyzzy" on the way out and the
suite stayed green.
That matters more in this module than it would elsewhere, because these are the
fields the store projects into columns and the audit chain is taken over. A
lying accessor produces a record that reads back wrong while its digest still
verifies, because the digest is computed from the stored bytes and not from
what an accessor says about them (db:M3.16d).
The ones worth naming:
| Accessor | A constant answer means |
|---|---|
AuditDetails::is_deletion |
every version looks logically deleted |
OriginalVersion::data |
the same, from the other side: absent data is how a deletion is recorded |
Version::is_deleted |
as above, at the envelope |
VersionedObject::has_version_at_time |
a record existed before it did — the query db:P6.11 requires |
PartyIdentified::identifiers, external_ref |
a party that satisfies Basic_validity reads as anonymous |
Contribution::versions |
a change set that changed nothing, contradicting Versions_valid |
quantity.rs was worse in kind if not in number, because its survivors
carry clinical meaning rather than structure:
MagnitudeStatus— the</>/~marker.as_strcould return one wrong constant for all six variants and threeparsearms could be deleted. Confusing<with>inverts what a result means while leaving the number correct: a below-detection-limit reading becomes a measured one.ReferenceRange::contains— a constanttruereports every result as within its normal range. This is the machineryA-01already found rules missing from; the membership test underneath was equally unwatched.DvScale::is_strictly_comparable_to—D3.16. Answeringtruelets a pain-scale 2 order against a sedation-scale 2 as though they measured the same thing.accuracy_is_percent— a constant makes±5read as±5%; on a magnitude of 200 those differ by an order of magnitude.ProportionKind::from_i32— openEHR encodes the kind as a bare integer, so a deleted arm silently reinterprets1/2between a half, fifty percent, and one-in-two.
One survivor remains and it is the mutant writing itself: PartySelf::anonymous
replaced by Default::default() is the same code, because that is the whole
body of the function. The last real one was subtler — the tests read
Version::attestations through the enum but never OriginalVersion::attestations
on the concrete type, which is a different accessor that could still answer
nothing. Testing the wrapper is not testing what it wraps.
Note the denominator: 149 of 386 mutants here were unviable, meaning they did not compile. That is generic and macro-generated code, and it makes the usable sample 237 rather than 386 — worth knowing before reading 72 as a rate.
Three of the tests written for this were wrong before the code was: a version
with no data must be in the DELETED lifecycle state, links and
feeder_audit are defaults on the Locatable trait rather than on
LocatableAttrs, and FEEDER_AUDIT.original_content must be a
DV_ENCAPSULATED. All three are invariants the crate enforces correctly and the
test author did not know.
rm/ehr.rs and rm/data_structures.rs repeated the accessor pattern, and
added two findings of their own.
EhrStatus::is_queryable could answer true for every record. That is not a
descriptive field: is_queryable = false means the record must not appear in
population queries, because a patient or organisation excluded it. An accessor
that always says true discloses a record that opted out, and nothing
downstream can detect it. is_modifiable was equally free in both directions —
a constant true admits writes to a closed record, a constant false refuses
every write. All four combinations are now asserted, so neither flag can be a
constant nor be answering the other's field.
The Gregorian leap rule existed twice — base::iso8601 and
rm::data_structures, byte-identical but for the fallback arm, and the second
copy had never been run by any test. That is W-01 one level down: a calendar
rule fixed in one of two copies is a rule that disagrees with itself. Recorded
as A-33 and consolidated to one pub(crate) implementation rather than
tested twice. The arithmetic that copy existed for —
IntervalEvent::interval_start_time, which computes when a measurement window
opened — had all fifteen of its mutants surviving, and is now tested against
hand-computed dates including 1900-03-01 (the century exception) and
2000-03-01.
Three more worth naming. Item::type_name could return one wrong constant for
both variants, and it is what goes into _type in canonical JSON — so a
CLUSTER would deserialize as an ELEMENT, under a digest that still
verifies. ItemList::named_item's == could be !=, returning the first
element that is not the one asked for; the method matches on runtime name
rather than node id precisely because a list built from a repeating archetype
node shares one node id across every item, so the test gives three elements the
same at0001. And History::is_period_consistent must distinguish None from
Some(false): a series declared periodic whose samples are off the period
will be resampled or graphed wrongly by anything that trusts the declaration.
A structural note. Several optional attributes have no builder at all —
IntervalEvent::state, Folder::details, CareEntryAttrs::guideline_id,
FeederAuditDetails::version_id. The crate can read records it cannot
construct. That is legitimate for round-tripping, but it means those paths are
reachable only through deserialization, and a test that only builds objects
will never touch them. Each is now covered through JSON.
Two of the survivors were gaps in the first round of tests, not in the code.
The seconds term of subtract_seconds survived because every event time in the
new table ended :00 — added to zero, + and - are the same. And
Folder::details survived because only its absent case was asserted. Asserting
None is half a test.
The last four openehr modules turned up one accessor that did not exist at
all. lib:A-34: DV_ENCAPSULATED's charset and language were preserved
across a round trip and unreadable. EncapsulatedAttrs is exported and both
its accessors exist, but neither DvMultimedia nor DvParsable returned one —
a caller holding either type had no way to ask what character set or language
it declared. This was not found by reading; it was found by trying to write a
test and discovering there was nowhere to call it from. Fixed with an
encapsulated() accessor on both types.
Interval::contains's strict-exclusive-bound comparison (value < hi) had
survived becoming value > hi, because the only existing test checked the
value equal to the excluded bound — which both comparisons reject identically.
A value strictly inside the range is what tells them apart, and this is the
membership test ReferenceRange::contains delegates to (found closing
rm/common.rs two rounds earlier), so a flipped comparison here silently
inverts which results read as abnormal.
The rest repeated the now-familiar pattern: DvIdentifier's id_type,
issuer, assigner (the fields that route a national identifier to the right
authority's namespace), TermMapping's three predicates (is_broader,
is_equivalent, is_narrower — what an ICD-10 crosswalk claims about a
mapped SNOMED CT code), and several Display impls that could print nothing.
Three survivors remain in base64::{encode,decode}, where | could become
^: both operators agree because the implementation always assembles bits
into disjoint, non-overlapping ranges, which is what the algorithm is.
The last four openehr modules closed out the crate's measurable surface,
and repeated the accessor pattern once more. terminology.rs — the code-set
lookups everything else depends on — came through with zero survivors,
consistent with being exercised by every other module's tests.
The two worth naming: ObjectRef::is_local could be a constant or have its
comparison inverted. This is the flag an access-control decision reads first —
a reference into this system's own identifier space is one the system can
resolve and enforce policy on; a foreign one is not. And Role::was_held_on
could answer a constant — the predicate behind "was this person the on-call
registrar at the time?", where a wrong answer is a wrongly attributed
signature. It joins Capability::was_valid_on, already tested, as the same
question asked of a role rather than a credential.
Also closed: Party::type_name — one wrong constant for any of the five
variants would deserialize a PERSON as an ORGANISATION under a digest that
still verifies — and Uuid's Hash, which could be replaced with a no-op.
Hash has to agree with the type's hand-written case-insensitive PartialEq,
or a HashSet keyed on an OBJECT_ID silently gains a duplicate entry per
case spelling of what is really one identity; the test proves the point with
an actual HashSet.
Two of the survivors from the first pass were gaps in the tests, not the
code, and both repeat lessons from earlier in this register. The first
Uuid::hash test used a HashSet and could not distinguish a real hash from a
no-op: HashSet correctness only requires equal keys to hash equal, so a
constant hash is pathological but not wrong, and lookup still works via Eq.
Fixed by hashing through DefaultHasher directly and comparing the digests.
And four demographic.rs accessors — Capability::time_validity,
PartyRelationship::details, PartyAttrs::details, Role::time_validity —
had only their absent case asserted, the same "asserting None is half a
test" mistake recorded during the rm/ehr.rs round.
Mutation testing then moved outside openehr for the first time, into
openehr-store and the five schema-level engine crates. openehr-store's
schema.rs, store.rs and error.rs turned out to have almost no mutable
surface: schema.rs is the shared table layout declared as const data plus
six tests asserting invariants over it, store.rs is a bare trait with no
method bodies, and error.rs is thiserror-derived enums. conformance.rs —
the actual logic, the suite every engine runs — was deliberately not measured
from this crate, because nothing in openehr-store's own test target calls
it; it is a library function the engine crates consume as a dev-dependency,
and mutating it here would report "untested" for code that openehr-sqlite's
own run already exercises. Recorded rather than silently skipped.
Every one of the five schema-level dialects — postgresql, mysql,
mariadb, mssql, oracle — had the same two blind spots, and finding them
identical five times is itself the finding: Dialect::name() is used only
inside conformance::check_dialect's panic messages, never compared against
anything, so it could return "" in every dialect and nothing would notice.
And append_only_sql — the SQL enforcing V8.10, the rule the whole
change-control model rests on — is asserted structurally by the existing
golden tests/ddl.rs (does the DDL contain this table, this index, this
quoting) but never checked for containing an actual refusal. A generator that
emitted an empty trigger body would have passed every existing test in all
five crates.
Fixing it also exposed why the five differ: PostgreSQL and Oracle emit one
trigger covering both operations; MySQL and MariaDB emit two, because
SIGNAL cannot name more than one triggering event; SQL Server uses INSTEAD OF rather than AFTER, so the refusal happens before anything is written.
Oracle's terminator() — the SQL*Plus block marker /, not ; — was
equally unchecked and is now pinned alongside it.
openehr-mariadb's tests/ddl.rs is more thorough than its four siblings',
because it is the crate W-01 was found in — the copy-of-MySQL defect — and
was hardened afterward. The other four never received the same treatment even
though the same risk applies to each of them individually (an Oracle crate
that silently started emitting MySQL types has no test here that would catch
it structurally beyond the type-spelling assertions already present). Adding
the same name/append_only_sql test to all five is a step toward that parity,
not the whole of it.
openehr-sqlite/dialect.rs had the identical name() gap the five
schema-only crates did, confirming the pattern is about the trait method's one
call site, not about any one crate's test discipline.
openehr-loco's App::before_run never actually ran in a test.
before_run is the fail-closed startup path the module's own doc comment
singles out — a service that started without a working verifier would serve an
entire EHR to anyone who asked, with a green health check and no symptom
(db:PR12.16) — and tests/http.rs builds its router against a hand-populated
AppContext, bypassing before_run (and the whole Hooks trait) entirely. A
version that did nothing at all would have failed no test in this crate.
Testing it directly needs three environment variables (PasetoVerifier:: from_env, AccessLog::from_env, OPENEHR_SQLITE_PATH), and setting a
process environment variable has required unsafe since Rust's 2024 edition —
which this crate forbids outright (unsafe_code = "forbid"), with no local
override, by design. So the fix was a split, not a test-only workaround:
before_run now does three from_env() calls and one ?-propagated error
each, then hands the results to a new private install, which does the part
with a consequence — putting the verifier, the access log, and the store into
ctx.shared_store. install takes its three arguments directly and is fully
tested.
One survivor remains, and it is before_run's own body, not install's:
the whole function could still be replaced with Ok(()), skipping all three
from_env() calls and the call to install. Closing it would mean either the
forbidden env-var mutation, or spawning the compiled binary as a long-running
server subprocess and managing its lifecycle from the test — disproportionate
for three lines whose only content is ?-propagating three independently-
tested constructors into a function that is itself tested. Recorded rather
than forced.
store.rs produced the one that would have mattered most in production:
create_contribution could return Ok(()) without inserting anything and
the entire suite passed, because nothing reads a contribution back and no later
operation depends on the row. A change set that silently vanished takes the
attribution of every version in it — db:PR12.10 keeps a contribution's audit
distinct from its versions' precisely so one act can be traced across several
changes. It also found the untested half of db:O10.16: an empty database
predating the version table is treated as fresh, and only the populated half was
covered, so the comparison could have been >= and a fresh install refused.
access.rs produced the opposite kind of result — one survivor, and the right
answer was to delete rather than test. AccessLog::path was a public
accessor with no caller, carrying a doc comment describing a use that did not
exist. A log's location is the deployment's to know.
Two runs needed --in-place: openehr-sqlite dev-depends on its five sibling
engine crates so one test can compare all six dialects (W-01), and
cargo mutants copies a crate to a temporary directory where those relative
paths do not resolve. Worth knowing before concluding a crate cannot be
measured.
dialect.rs stops at 5. The remainder are branch conditions needing a third and
fourth test dialect for idempotence modes the schema does not currently use, and
the two written cover what it does.
Residual. Four modules of many, and not in CI: 80 mutants take two minutes
for one file, and a whole crate would be hours. T13.2 stays ? — what
changed is that "not systematic" is a measurement with numbers rather than an
impression, the method is written down, and its sharpest use has been aiming it
at code whose tests live in another crate.
A-11 — the Common Information Model was implemented from prose#
Severity: Medium. Status: fixed. Requirements: M5.13a, M5.18a,
V8.7a, V8.7b, V8.17a, L10.5a.
Opened and closed on 2026-08-01 by applying the lesson of A-01 and A-07 to the one large package that had not been checked against a primary source. Reading the Common Information Model (Rev 2.1.1, 20 Dec 2008) found five gaps and one genuine contradiction in openEHR itself.
The contradiction. REVISION_HISTORY's class table says three things about
the order of items:
| Where | Says |
|---|---|
| class Purpose | most-recent-first |
items Meaning |
most-recent-last |
most_recent_version postcondition |
items.**last**.version_id.value |
This crate had implemented most-recent-first, from the Purpose line and from the rendered narrative. Two of the three sources say last, and one of those two is executable, so the crate now follows the postcondition. A caller rendering an audit trail from the other sentence gets it backwards — and gets it backwards silently, because both orders look plausible.
The four missing checks, all of the same shape: an attribute openEHR binds
to a terminology group when it happens to be coded —
PARTY_RELATED.relationship (Relationship_valid),
PARTICIPATION.function and .mode (Function_valid, Mode_valid), and
ATTESTATION.reason (Reason_valid). None was checked. The first matters most:
relationship is the attribute that says whom an entry is about, so an
unrecognised code means a finding may be attributed to the wrong person.
The check is deliberately conditional. Applying it to a code from another terminology would reject a SNOMED-coded participation function, which is the commonest real case — so it fires only for openEHR's own terminology, and a test asserts both halves.
One misattribution. An empty LOCATABLE.name was reported as
LOCATABLE.Name_valid; openEHR's Name_valid is only name /= Void, and an
empty name breaks DV_TEXT.Valid_value — reported as Value_valid until
A-20 corrected the crate to openEHR's own spelling. The wrong invariant name
sends a reader to the wrong class definition, which is what L10.4 exists to
prevent — so L10.5a now requires the attribution to be right.
A-12 — the Data Structures model was implemented from prose#
Severity: Medium. Status: fixed. Requirements: R4.12a–R4.12c,
R4.15a, and invariant-name corrections across R4.3, R4.8, R4.11,
R4.12, R4.15.
The last of the four Reference Model packages to be checked against a primary source (Data Structures Information Model, Rev 1.7.1, 5 Nov 2008). Four for four: the source again contradicted what had been implemented.
A missing invariant with teeth. HISTORY.period_consistency —
is_periodic implies events.for_all (e | e.offset.to_seconds.mod(period.to_seconds) = 0)
— was not implemented at all. A history that declares a period its samples do
not follow is not periodic, and software that resamples or graphs it on the
strength of that declaration draws the wrong picture with nothing in the data
looking wrong.
Implementing it required EVENT.offset (time.diff(parent.origin)), which the
crate also did not have, which in turn required exact date-time differencing —
now DateTime::diff_seconds, using Hinnant's civil-days algorithm and
inheriting the partial semantics of D3.14: an offset that is not established
yields not answerable, never a verdict.
Evidence it works. The check immediately failed this crate's own kitchen-sink
fixture, which declared period = PT8H over events fifteen minutes apart. The
fixture had been wrong since it was written.
Five invariants were reported under names openEHR does not use —
Items_valid for CLUSTER.Items_non_empty, Value_null_flavour_valid for
ELEMENT.Null_flavour_indicated, and three more. L10.4 requires openEHR's own
invariant names precisely so a reader can find the rule in the class definition;
a name the specification does not contain fails that.
One undeclared narrowing. INTERVAL_EVENT.width >= 0 is this crate's rule,
not openEHR's. Now declared (R4.15a) with what it buys and what it costs.
A-10 — X11.24 fail-closed has no provokable error path#
Severity: Low. Requirement: X11.24.
Redaction returns Result and yields nothing on error, so a caller cannot
forward the unredacted original by mistake. There is no test, because there is
no way to make it fail: every Composition this crate can construct
serializes, and the only error variant is a round-trip failure.
This is recorded rather than papered over with a test that constructs an
impossible value through unsafe means. The requirement is satisfied by
construction — the function has no partial-output path — and "satisfied by
construction with no test" is ?, not • (C0.8).
Amended 2026-08-02 — the premise is now checked, and the reason it holds was
wrong. This finding said every constructible Composition serializes. True,
and not for the reason implied. Measured:
serde_json::to_string(&f64::NAN) = Ok("null")
serde_json::to_value(f64::NAN) = Ok(Null)
security::to_canonical_string(&f64::NAN) = Ok("null")
serde_json does not refuse a non-finite float. It writes null. So a
NaN magnitude reaching serialization would not fail the redactor — it would
silently become an absent value, in the canonical form the content digest of
db:M3.16 is taken over. Serialization is not a barrier; it is a place where
the value disappears quietly.
What actually holds the line is the constructors. Every f64 entry point in the
crate — DV_QUANTITY.magnitude, DV_SCALE.value, DV_AMOUNT.accuracy, and
both parts of DV_PROPORTION — refuses NaN and both infinities, and
guarantees::no_document_this_crate_can_build_carries_a_non_finite_float
asserts all five against all three values, plus the null behaviour that makes
them load-bearing.
That changes what this finding is about. It is not "a Result nobody can
provoke"; it is "five constructors standing between a document and silent data
loss, with nothing downstream to catch a miss". The test fails and names the
constructor if one is ever relaxed.
To close: unchanged. X11.24 stays ? — the redactor's error path is still
unprovokable, and making the redactor generic purely to inject a failing type
would be a test of the test harness. What has changed is that the premise is
no longer taken on trust.
A-13 — one flag covered two statements that differ per engine#
Severity: Medium. Requirement: C0.8. Status: fixed.
Dialect::supports_if_not_exists() was a single boolean governing both
CREATE TABLE and CREATE INDEX. MySQL accepts CREATE TABLE IF NOT EXISTS
and rejects CREATE INDEX IF NOT EXISTS, so openehr-mysql emitted a
script that created all five tables and then failed at the first index with
ERROR 1064. An operator running install() would be left with a schema that
had every table, no index, and no error until the first slow query.
Every golden DDL test passed throughout. They asserted the emitter's output against an expectation written by the same author, from the same wrong belief.
Fixed by replacing the flag with Idempotence per object kind
(IfNotExists / Guard / Inline). MySQL declares Inline and now carries
its indexes inside CREATE TABLE, where they inherit the table's own
idempotence. Verified against MySQL 8.4: three consecutive runs, all clean, all
seven indexes present.
Found by executing the DDL against the engine it names, which is exactly the step the Dialect level says it does not perform.
A-14 — a guard that was documented but never emitted#
Severity: Medium. Requirement: C0.8. Status: fixed.
The Dialect trait's own documentation read: "Oracle and SQL Server do not
[support IF NOT EXISTS], and both need a guard around the statement instead."
No guard existed. Both dialects emitted bare CREATE TABLE and CREATE INDEX,
so re-running install() on either engine fails outright.
This is the repository's signature failure mode — prose describing a mechanism that was never built — and it was reintroduced here in the one file that warns about it.
Fixed by giving Dialect::guard a real contract: SQL Server wraps in
IF NOT EXISTS (SELECT 1 FROM sys.objects …) EXEC(…), Oracle in a PL/SQL block
that swallows ORA-00955 and re-raises every other SQLCODE.
conformance::check_dialect now fails any dialect that declares Guard and
inherits the no-op default, so the gap cannot silently reopen.
Not verified against either engine. SQL Server 2022 segfaults under qemu on arm64 and the Oracle images require registry authentication. The fix is reasoned and unit-tested, not observed. Both crates therefore stay at Dialect.
A-15 — append-only was enforced on two engines of five#
Severity: High. Requirement: V8.10, X11.9. Status: fixed.
Dialect::append_only_sql returns empty by default, and openehr-mysql,
openehr-mssql, and openehr-oracle all inherited that default. Only
PostgreSQL and SQLite refused mutation in the schema.
The severity is not the missing SQL; it is that the guarantee was described in the shared documentation as a property of the design. The method's own doc comment says an engine that can enforce it in the schema should, "because a guarantee enforced only in application code is a guarantee that ends the first time somebody opens a SQL console" — and all three silently could and did not. For a clinical record, an append-only claim that holds on 40% of the supported engines is worse than no claim, because it is relied upon.
Fixed on all three: MySQL via SIGNAL SQLSTATE '45000' (with
DROP TRIGGER IF EXISTS first, because MySQL 8 has neither
CREATE TRIGGER IF NOT EXISTS nor CREATE OR REPLACE TRIGGER — found when
run 2 failed with ERROR 1359), SQL Server via an INSTEAD OF trigger that
throws, Oracle via raise_application_error.
dialects.rs now fails any dialect whose append-only tables lack enforcement
for both UPDATE and DELETE, so a sixth engine cannot be added without it.
Observed refusing both operations, with a row present, on PostgreSQL 18 and MySQL 8.4. Reasoned only on SQL Server and Oracle.
A-16 — a parser panicked on one multi-byte character#
Severity: High. Requirement: X11.7, T13.3. Status: fixed.
split_offset in base/iso8601.rs split a suspected UTC offset with
None if digits.len() == 4 => digits.split_at(2),
len() counts bytes. A single four-byte character satisfies len() == 4,
and split_at(2) then lands inside it and panics instead of returning None.
Time::from_str("0-\u{10348}") panicked. So did any date-time whose offset
position held such a character.
The severity is the reachability, not the subtlety: parsers exist to consume text from outside, and this crate's own module header says so. A service accepting an openEHR document could be stopped by one character in a field it was about to reject anyway. It is a denial of service reachable before authentication in any deployment that parses before it authorizes.
Fixed by rejecting non-ASCII in the offset before any split, which is the actual domain constraint — an offset is digits and an optional colon — and so removes the class rather than that one index.
Found by the first run of the new parsers_never_panic property. Every
example-based test passed, and had passed since the parser was written: nobody
writes 0-𐍈 as an example. Pinned additionally as
a16_multibyte_offset_returns_err_and_does_not_panic, because the property
only finds it while its generator still emits multi-byte characters, and a
later edit could narrow that generator without anyone noticing what was lost.
A-17 — the first property tests passed without testing anything#
Severity: Medium. Requirement: T13.2. Status: fixed.
The partial-order laws were written over a generator drawing years uniformly
from 0–9999. Every interesting comparison in a partial order happens between
values agreeing on a prefix — same year, different precision — and two
independent draws share a year about once in ten thousand. At proptest's
default 256 cases the None branch was reached essentially never.
All four laws passed. They would have passed against almost any implementation.
Caught by mutating Date::partial_cmp to compare on the left operand's
precision, which makes the order non-antisymmetric: every law still passed.
Narrowing the generator to four values per component makes prefix collisions
the common case, and the same mutation now fails antisymmetry.
Mutation also showed the four laws were jointly satisfiable by a total order
— reflexivity, antisymmetry, and transitivity say nothing about when a
comparison must be undecidable, and returning Some unconditionally passed all
of them. That is the entire purpose of the type. Two laws were added:
incomparability of a value with its own refinement, and its complement, that
differing known components stay decidable so None cannot come to mean merely
"different precision".
The general lesson, recorded because it recurs here: a passing test is
evidence only after it has been shown capable of failing. This is the third
time in this work that a check reported success while proving nothing — the
others were a mutation that silently failed to apply (T13.2) and an
append-only trigger tested against zero rows (openehr-store/spec/conformance.md).
The three share one shape: the subject of the test was absent, and absence
reads as success.
A-23 — a version's invariants were checked in one place, and not the one that matters#
Severity: High. Requirement: V8.1, L10.9, J9.9. Status: fixed.
Found while classifying the 75 invariants
assets/invariant-coverage.md reports as
unnamed — a list whose own header says distinguishing out-of-scope from vacuous
from unenforced "needs a human".
Found. OriginalVersion::new checks Lifecycle_state_valid and
Data_valid. The type derives Deserialize, which writes the fields straight
in, and no Validate implementation existed for a version at all — validation.rs
did not mention OriginalVersion once. The store validated
version.data(), the composition inside the envelope, and never the envelope.
So a version arriving as JSON was checked by nothing. Measured, not inferred: a
document naming lifecycle state 9999 and carrying no data at all deserialized
to Ok, and openehr-loco's POST accepted exactly that shape.
A third invariant was enforced nowhere.
VERSION.Preceding_version_uid_validity — uid.version_tree_id.is_first xor preceding_version_uid /= Void — was in neither the constructor nor the store.
The store refuses a rootless successor only by comparing against the container's
head, so committing version 2 into an empty container succeeded and produced
a history whose first entry says it is not the first.
Two things make it credible that this went unnoticed:
- The conformance suite tested the rootless successor with a head present. One case, and the guard was only as wide as it.
- A unit test in
common.rsbuilt version 2 with no predecessor while testing something else entirely. The impossible version was easy enough to construct that a test did it by accident, and enforcing the invariant is what surfaced it.
Fixed. Three parts, because the gap had three:
OriginalVersion::newenforcesPreceding_version_uid_validity.impl Validate for Version<T>coversLifecycle_state_valid,Data_valid, andPreceding_version_uid_validity, and descends into the data. This is the half that covers deserialized input.commit_compositionvalidates the version, not the composition inside it.
Deserialization stays lenient rather than being made to refuse, because J9.9
says so and the reason holds: a document that cannot be read cannot be inspected,
repaired, or reported on. What was missing was any way to ask whether it was
valid, and now there is one.
Attestations_valid and Other_input_version_uids_valid are named in the new
impl and deliberately not checked: both are X /= Void implies not X.is_empty,
and a Vec has no way to be present and empty in the openEHR sense. Named so a
reader finds the reason rather than concluding they were missed.
Residual. None. The other 74 were classified by A-24, which this finding
prompted — and that work found A-25 in turn.
A-24 — seventy-five invariants nobody had looked at#
Severity: Medium. Requirement: L10.4, L10.9, W0.4. Status:
classified; three sub-findings open.
Found. assets/invariant-coverage.md reported 75 of RM 1.1.0's 155
invariants as "not named in the crate's source", and said in its own header that
telling out-of-scope from vacuous from unenforced "needs a human, and this file
does not attempt it".
That sentence was true for as long as nobody did it. While it stood, a genuine
gap and a class this crate deliberately does not model looked identical, which
is W0.4 exactly: a gap not written down reads as a pass. A-23 was found in
the first hour of doing the work.
Classified. Every one is now dispositioned in openehr-assets, and the
build fails if an invariant is named nowhere and dispositioned nowhere — or if a
disposition outlives the invariant it explains.
| Count | |
|---|---|
| Out of scope | 29 |
| Cannot fail in Rust | 17 |
| Enforced under another name | 1 |
| Not enforced | 25 |
"Cannot fail in Rust" is the largest honest answer and the least obvious one.
Most are X /= Void implies not X.is_empty, and a Vec has no way to be
present and empty — openEHR's absent case is the empty collection. The rest
are predicates derived from the field they constrain: is_null() returns
value.is_none(), is_archetype_root() returns archetype_details.is_some(),
is_merged() returns !other_input_version_uids.is_empty(). Each is true by
construction, and each would have read as a missing check forever.
Three sub-findings, open:
Fixed.DV_INTERVAL.Limits_consistentis enforced under the wrong name.Interval::newrefusedlower > upperand reportedINTERVALwith prose.L10.4requires openEHR's own name so a reader can find the rule in the class definition — the same defectA-20fixed fifteen times, surviving because the grep that found those looks for names that are used and this one used none.TERM_MAPPING.Purpose_validis unenforced although the crate ships the code set.term_mapping_purpose::GROUPexists and is registered; nothing checks a mapping's purpose against it. Same shape asA-22, which found threeDV_MULTIMEDIAinvariants unenforced "despite the crate shipping their code sets".Resolved 2026-08-02, and the suspicion was wrong. This said confirming it needed the BMM's attribute lists read rather than inferred from, and that the register does not guess (VERSION.owner_idis not modelled at all.W0.3). The BMM was then read.VERSIONdeclares three properties —contribution,signature,commit_audit— andowner_idis not among them. It is a function, whose documentation says: "Copy of the owningVERSIONED_OBJECT._uid_value; extracted from the local_uid_property's_object_id_." SoOwner_id_validconstrains a derived value against the thing it is derived from, and cannot fail. The crate is right not to store it.Reading the BMM for that one question answered six more.
is_simple,purpose,typeonPARTY,ADDRESS,CONTACT,PARTY_IDENTITYandPARTY_RELATIONSHIPare all derived functions —purposeandtypeare documented as "taken from the value of the inheritednameattribute" — soIs_simple_validity,Purpose_valid,Type_validandType_validityare definitional too. Unenforced fell from 25 to 18.The distinction the register had been missing is now vendored as
assets/rm-1.1.0-attributes.json, so the next classification does not have to re-derive it: an invariant constraining a property is a rule, and one constraining a function is usually a definition.
Also recorded rather than fixed: the four PARTY/PARTY_RELATIONSHIP graph
invariants need a demographic repository — an object store that can be asked
for the reverse of a relationship. The crate models demographics as values with
no back-references. That is a legitimate exclusion and it is not declared
anywhere, which C0.16 calls a defect in its own right. It needs a numbered
requirement beside S1.4 and S1.6.
Fixed alongside the classification. The four EHR reference-collection
rules — Compositions_valid, Contributions_valid, Folders_valid,
Directory_valid — now have an impl Validate for Ehr, and the store validates
an EHR before it writes one.
That fix is A-23 in a second class, and worth stating as such. A-21 made
Ehr::new check Ehr_status_valid and Ehr_access_valid. The four collections
are filled by infallible with_* builders, which no constructor can see,
and Deserialize is derived — so an EHR read from JSON reached none of the six
checks. Both of A-21's rules are therefore repeated in the Validate impl
rather than assumed, and a test deserializes an EHR whose status and access
references are both typed "EHR" and asserts both violations come back.
Every one of these is an OBJECT_REF. Rust cannot tell a reference to a
composition from one to a contribution, so the type name is the only thing that
can — which is why the rules exist and why nothing else would have caught a
compositions list naming a CONTRIBUTION.
Since. ENTRY.Is_archetype_root and ENTRY.Subject_validity are now
enforced, and VERSIONED_OBJECT.Latest_version_valid joined its two siblings as
definitional — latest_version() returns versions.last().
Enforcing Is_archetype_root found seven fixtures that violated it,
including the README's own example of "a composition another openEHR
implementation wrote". Every one carried the archetype id as its
archetype_node_id and no archetype_details, which LOCATABLE.Archetyped_valid
makes the same statement as not being an archetype root. That is A-21's shape
for the third time: the fixtures were built from what an entry looks like rather
than from what the model requires, and nothing compared the two.
Subject_validity is the more interesting of the pair, because openEHR gets it
free and this crate does not. The BMM documents subject_is_self as "True if
this Entry is about the subject of the EHR, in which case the subject attribute
is of type PARTY_SELF" — an implication that holds by construction there.
Here PartyProxy::is_subject also answers true for a PARTY_RELATED whose
relationship is self, so an entry could claim to be about the patient while
naming a related party, and the two readings of "who is this about" diverged in
silence.
Since, again. REFERENCE_RANGE.Range_is_simple and Is_archetype_root on
EHR_ACCESS and PARTY are enforced, and the two EVENT timing rules turned
out to be definitional — the BMM makes offset and interval_start_time
derived functions, and this crate stores neither.
Ten unenforced invariants remain, and none of them is merely undone. Nine
need external code sets the crate deliberately does not carry — ISO 639, ISO
3166, IANA character sets and media types, the A-19 decision. The tenth,
EHR_ACCESS.Scheme_valid, is a declared departure: openEHR derives scheme
from the concrete settings and requires it non-empty, so an EHR_ACCESS must
always carry a policy. EhrAccess::new deliberately records none, because "no
access policy has been set" and "the policy is deny-all" are different facts and
collapsing them would invent one.
That is the end of the classification A-24 began. Every one of RM 1.1.0's 155
invariants is now either cited by the crate, definitional, out of scope,
enforced under another name, or unenforced for a stated reason — and the
build fails if a new one appears with no answer.
Residual. None. Both departures this finding recorded are now declared:
S1.19 excludes the demographic repository the four PARTY graph invariants
constrain, and S1.20 declares the EHR_ACCESS.Scheme_valid departure. L10.11
adds the register L10.9 had only in the other direction — every openEHR
invariant the crate does not enforce, with its reason — and openehr-assets
fails the build when that register and the generated report disagree, in either
direction.
A-25 — the measurement was wrong, and wrong in the flattering direction#
Severity: High. Requirement: L10.4, W0.3, W0.4. Status: fixed.
Found by A-24's own staleness guard. After enforcing TERM_MAPPING.Purpose_valid
the build refused to proceed, naming ten dispositions it said were now
unnecessary — among them CONTACT.Purpose_valid and PARTY_IDENTITY.Purpose_valid,
which nothing had been done to.
Found. invariant_coverage decided an invariant was named by asking whether
the crate's source contained the string. Two things follow, and both inflate:
- The class was ignored. openEHR reuses 15 invariant names —
Language_validbelongs to seven classes,Value_validto six,Is_archetype_rootto five,Purpose_validto four. Naming one marked all of them. - Comments counted. A doc comment saying an invariant is not checked, and
why, made it count as named.
A-24's own careful notes — "Attestations_validandOther_input_version_uids_validare named here and deliberately not checked" — did exactly that.
Both are false passes, in a file written to remove that kind of ambiguity.
Fixed. An invariant counts as named when the source cites the
(class, name) pair, using the same parser that already reads
ParseError::invariant(...) and ctx.violation(...) for the L10.4 divergence
check. Comments cannot satisfy it; a rule belonging to another class cannot
satisfy it.
| Before | After | |
|---|---|---|
| Named | 83 | 69 |
| Not named | 72 | 86 |
Twenty-four invariants nobody had examined were revealed, and are now dispositioned: 6 enforced under another name, 5 that cannot fail, 4 out of scope, and 7 genuinely unenforced.
Two of them I called unenforced, and they were not. ATTESTATION.Reason_valid
and PARTICIPATION.Function_valid are checked by check_optional_group, which
takes the class and invariant as literals and reports through them. The parser
matched two call forms — ParseError::invariant( and .violation( — and a
rule enforced by a helper was invisible to both. I wrote "genuinely unenforced,
with their groups already shipping" into this finding before reading the code
that enforces them, which is W0.3 in the register that exists to enforce it.
An enumerated list of call forms is a guard only as wide as its list, and that shape had now bitten twice in one finding. The parser matches the pair — any two adjacent string literals shaped like a class and an invariant — so a helper added tomorrow is covered without anyone remembering to add it.
Making that change needed a second correction. Comments must not count, and the
first attempt skipped them by searching for // — which truncated "https://…"
mid-literal and put every quote after it out of phase, silently unnaming forty
rules that were fine. Replaced with a scanner that tracks whether it is inside a
string, a line comment, or a block comment.
It also caught my own code. The first impl Validate for Ehr raised its six
violations through a closure taking the invariant name as a variable. The
citation parser reads literals, so all four new EHR rules still counted as
unnamed. Rewritten with the class and invariant spelled out at each call site —
which is what makes them findable by a human grepping too, and is now noted in
the code so the next person does not tidy it back.
The honest reading. This did not make the crate worse; it made the report true. Sixteen invariants that had been counted as covered never were, and the number had been quoted in a generated file that says at the top it is evidence.
A-26 — a total that was derived once#
Severity: Low. Requirement: C0.7, C0.20. Status: fixed.
Found by checking db:D-09's defect against the other tree.
Found. The matrix said, of itself:
Counted mechanically from the tables below, with every requirement id in
spec/*.mdchecked to appear exactly once — 291 ids, 291 covered, none missing. A hand-written total in a file like this is a number nobody rechecks; this one was derived from the rows.
It was derived from the rows, once. Six requirements added afterwards had no row
at all: S1.18, S1.19, S1.20, L10.9, L10.10, L10.11 — three of them
added the same day, the other three earlier in the same sequence of work. The
sentence warning against a number nobody rechecks was itself the number nobody
rechecked.
Milder than D-09: nothing contradicted anything, and the covered figure was
right. What was wrong was the denominator, and the claim of completeness that
rested on it.
Fixed. The six have rows, the header records the version it was actually
assessed against, and CI re-derives the count on every push — expanding the
ranges in the Id column, comparing against the requirements the specification
defines, and failing on a requirement with no row, a row for a requirement that
does not exist, or an id covered twice.
A near miss worth recording. The first measurement reported nine missing,
including R4.12a–R4.12c. Those are covered, by a row reading
R4.12a–R4.12c; the range expander dropped letter suffixes and collapsed it to
R4.12. Three of nine reported gaps were defects in the instrument, which is
A-25 in miniature and the second time in two days that a measurement of this
specification has been wrong before the specification was.
A-36 — a URI checked at one gate, and a panic behind the other#
Found 2026-08-20, by writing a fuzz target for a type that had none and running it. Two defects, one cause.
The panic. DvUri::scheme() read:
self.value.split_once(':').map(|(s, _)| s).expect("constructor guarantees a scheme")
with rustdoc saying # Panics — Never: the constructor guarantees a colon is present. The constructor does. Deserialize is derived, writes value
straight in, and calls no constructor — which is L10.1a, stated in this
crate's own specification and in CLAUDE.md, and the sentence in the rustdoc
was written as though only one construction path existed.
let u: DvUri = serde_json::from_str(r#"{"value":"nocolon"}"#).unwrap(); // Ok
u.scheme(); // panicked at src/rm/data_types/uri.rs:86
rest() was the same function with the other half of the tuple.
The silent half, which is worse. DvEhrUri is #[serde(transparent)] over
DvUri. Its whole reason for existing is that LINK.target is typed
DV_EHR_URI so that a link cannot point out of the record without saying so
(D3.31, M5.9) — and the type's own doctest asserts that
"https://example.org/x".parse::<DvEhrUri>() is an error. It is. The JSON path
is not:
let u: DvEhrUri = serde_json::from_str(r#"{"value":"https://example.org/x"}"#).unwrap();
assert_eq!(u.scheme(), "https"); // passed
No panic, no error, no violation — a link out of the record, in a record, with the type system satisfied.
Why nothing reported it. impl Validate for DataValue ends in _ => {}.
Uri and EhrUri fell into it. This is the hazard CLAUDE.md records about
Node::children — a path that resolves to nothing is not an error — appearing
in the validation table instead of the navigation one: an absent arm is
indistinguishable from a value with nothing to check. And LINK.target was
reached by no validation at all, on any class.
The invariant scanner did not catch it either, and could not have: it asks
whether an invariant is named somewhere in the crate.
DV_URI.Value_valid was named — as a disposition, Renamed, reading "the
URI parser refuses invalid text and reports itself". True of the parser. The
disposition described gate one and was read as covering both.
Consequence. openehr-loco deserializes documents from HTTP. A composition
carrying one malformed link target was a panic in a request handler, and one
carrying a well-formed https:// target was accepted and stored. Neither
required anything unusual — a hand-written JSON document reaches both.
Fixed, in four parts:
scheme()andrest()are total: no colon means an empty scheme, which compares unequal toehrand to every other real scheme, so a caller that dispatches on it fails closed.D3.30anow requires this of any accessor on a type whoseDeserializeis derived.check_uriandcheck_ehr_uriinvalidation.rs, reached from theDataValuearms that used to fall through.check_urire-runsDvUri::newrather than restating its rules, so the two gates cannot drift (W0.1). Emptiness reports openEHR's ownDV_URI.Value_valid; the scheme and character rules reportUri_well_formed, registered as a crate addition underL10.9because openEHR'sValue_validis onlynot value.is_empty.- Links are validated on
LOCATABLE, so every node of every structure is covered by one call, at path/links[N]/target. - The
DV_URI.Value_validdisposition is removed.openehr-assetsrefused the build until it was — "dispositions for invariants the crate now names" — which isA-24working as designed.
Reproduced before and after. Four tests in tests/guarantees.rs, each
naming its failure mode, plus the uri fuzz target, which finds the panic from
an empty corpus.
What this says about the class. Every finding here is some version of "a claim was written once and never re-checked". This one is narrower and worth naming separately: a mitigation was recorded against the gate it worked at, and read as covering the gate it did not. The disposition was not wrong. It was scoped, and nothing carried the scope.
A-35 — ten types whose equality and order contradicted each other#
Opened while fixing A-32, as a note that the base-layer defect had the
same shape one level up, in DvDate, DvTime, DvDateTime, DvDuration, and
DataValue. Left open because closing it looked like it meant rippling a trait
removal through Interval<T>'s bound.
Closed 2026-08-21, and the survey that closed it found the record was wrong about both the scale and the cause.
The cause is not lexical form. That is where it was first seen. Every
DV_ORDERED descendant carries OrderedAttrs — normal range, normal status,
other reference ranges — and every one derives PartialEq over all its fields
while comparing only its magnitude. Run against each type in turn:
| These two values | == |
partial_cmp |
|---|---|---|
DV_DATE_TIME 11:00:00Z and 12:00:00+01:00 |
false | Some(Equal) |
DV_TIME 11:00:00Z and 12:00:00+01:00 |
false | Some(Equal) |
DV_DURATION PT60M and PT1H |
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) |
DATA_VALUE wrapping any of the above |
false | Some(Equal) |
Five types were named in the finding. Ten had it — the four temporal wrappers,
DV_QUANTITY, DV_COUNT, DV_ORDINAL, DV_SCALE, DV_PROPORTION, and
DATA_VALUE — and the three that the original note did not reach have nothing
to do with ISO 8601.
What was actually wrong. Rust requires a == b if and only if
partial_cmp(a, b) is Some(Equal). Every row above ships a != b together
with a <= b and a >= b. Inside this crate that is invisible, because every
comparison here goes through the ordering consistently — which is exactly why it
survived two audits. It surfaces in a caller: binary_search can return a hit
that is not == to the needle, dedup_by leaves adjacent "equal" elements,
sort_by and max_by are underdetermined.
Neither trait could move, which is why the fix is to drop one:
- Making
==semantic would makeDvCount::new(5).with_normal_range(r)equal toDvCount::new(5), and would let a canonicaliser that rewrote1.10as1.1pass its own round-trip test. That isdb:D-08reintroduced, in the crate rather than in a database. - Making
partial_cmpreturnNonewhere the values are ordered-equal but not==would satisfy the contract and break reference ranges: an interval of[11:00Z, 13:00Z]would stop containing12:00+01:00. A wrong clinical answer in exchange for a satisfied trait.
Fixed as D3.18a was, one level up:
- No
DV_ORDEREDimplementsPartialOrd, and neither doesDataValue. Comparison isDvOrdered::semantic_cmp, a required method so a newDV_ORDEREDcannot forget it, andDataValue::semantic_cmpfor the enum. INTERVAL<T>is bounded onSemanticOrdrather thanPartialOrd(D3.18c), with explicit impls and deliberately no blanket one — a blanketimpl<T: PartialOrd> SemanticOrd for Tcollides under coherence, and the explicit list is what stops a type with this defect reachingINTERVAL<T>again without anyone deciding it should.Interval::containsis rewritten againstsemantic_cmp. The operators it used read "not comparable" as "not greater, therefore below", which is a wrong answer rather than a missing one.
No behaviour changed. The comparison logic is the same logic; what changed
is which trait it is reachable through. Verified by the suite, by the doctests,
and by the four downstream crates, which needed no edits at all — the whole
blast radius was inside openehr, and every affected call site was a compile
error rather than a silent change. That is the argument for the trait removal
over the alternatives: the compiler enumerated the work.
Breaking for callers, and cheaply: a < b becomes
a.semantic_cmp(&b) == Some(Ordering::Less), a.partial_cmp(&b) becomes
a.semantic_cmp(&b), and DvOrdered has to be in scope. Recorded in
CHANGELOG.md; the next release is not a patch.
Pinned by guarantees::equality_and_order_disagree_by_design_and_neither_is_partial_ord,
which asserts both halves for each shape — that the values are !=, and that
they order Equal — so restoring PartialOrd fails the suite rather than
quietly reintroducing the contradiction. And by
guarantees::a_reference_range_is_unmoved_by_how_an_instant_is_spelled, because
the rewrite of contains is the part that could have changed an answer.
A-37 — a red fuzz job nobody read, and two ways an AQL query changed meaning#
Found 2026-08-21, not by fuzzing but by looking at the last CI run on
main. It was a failure, from 2026-08-04, seventeen days old:
thread '<unnamed>' panicked at fuzz_targets/aql.rs:33:9:
assertion `left == right` failed: AQL normalisation is not idempotent
The finding before the findings. CLAUDE.md said "CI is green".
openehr-fuzz/README.md said "No crashes. All seven targets run in CI on every
push." Both were written when they were true and neither was re-checked, and a
red job on the default branch is about as visible as a signal gets. W0.3 is
usually about a claim nobody could check; this one anybody could, in one
command, and for seventeen days nobody did.
Two independent defects were behind it.
The lexer widened UTF-8 bytes into characters#
value.push(bytes[i] as char); // one byte -> one char
Scanning the input as bytes is correct — the only bytes the lexer examines are ASCII delimiters, and an ASCII byte never occurs inside a multi-byte UTF-8 sequence. Copying by byte is not. Every non-ASCII character in a string literal came out as Latin-1 mojibake:
| Written | Lexed as |
|---|---|
'Müller' |
'Müller' |
'日本語' |
'日本語' |
WHERE c/name/value = 'Müller' therefore parsed, checked clean, and asked
about a string nobody is named. There was no error to see: the query was valid,
it was simply about something else. This is the same shape as db:D-08, where
MySQL rewrote a magnitude of 1.10 as 1.1 — a silent transformation of
clinical data by a layer that was only supposed to carry it.
Fixed by appending slices of the input, tracking the start of each run between escapes.
The renderer omitted the parentheses its own grammar needs#
FROM puts CONTAINS, AND and OR at one precedence level, and CONTAINS
takes the whole remainder as its right operand — containment calls itself
there. The renderer wrote {left} CONTAINS {right} with no parentheses:
| Tree | Rendered | Re-parsed as |
|---|---|---|
Or(Contains(a, b), c) |
(a CONTAINS b OR c) |
Contains(a, Or(b, c)) |
The caller asked for either (a containing b) or c. What came back was a containing either b or c. Both are valid AQL, both select records, and they select different records.
Q12.15 said a rendered query must re-parse to an "equivalent" query, and by
that word the renderer passed: the output parses. The word was doing no work.
It now says equal — the same tree — and the test compares trees rather than
strings.
Fixed by parenthesising any operand that is not a bare class, which leaves the
ordinary EHR e CONTAINS COMPOSITION c unchanged.
A third, found while fixing the second#
Literal::Display wrote '{v}' with no escaping, so a string containing a
quote rendered as 'it's'. Rendering now escapes ' and \ — and only
those, because the lexer's rule is "a backslash introduces the next character
literally" and not a C-style table, so a rendered \n would mean the letter
n.
Verified. The aql target, which reproduced all of this from an empty
corpus in under two minutes, now runs seven minutes clean. Pinned by
guarantees::an_aql_string_literal_is_not_mangled_by_the_lexer and
guarantees::aql_rendering_round_trips_through_the_parser, the second of which
asserts tree equality and not only text equality — the assertion Q12.15's
original wording could not make.
What this says about the process. Every finding in this register was found by running something. This one was found by reading the result of something that had already run — which had been sitting in the open, red, on the default branch. The fuzz target did its job on 2026-08-04. The gap was between the job failing and anyone looking, and no amount of additional checking closes that one.
A-38 — serde_json reads back a number it did not write#
Found 2026-08-21 by the data_value fuzz target, on its first CI run,
which is the outcome that justifies writing a fuzz target at all.
assertion `left == right` failed: canonical JSON is not a fixed point
left: …,"value":1.5777777777770001}
right: …,"value":1.577777777777}
It is not this crate's arithmetic. Reduced:
| Operation | Result |
|---|---|
core::str::parse::<f64>("1.5777777777770001") |
0x3ff9_3e93_e93e_863b |
serde_json::from_str::<f64>("1.5777777777770001") |
0x3ff9_3e93_e93e_863a |
One ULP apart. serde_json 1.0.151 serialises …863b as
1.5777777777770001 — correctly, since core reads that string back as
…863b — and then its own parser reads it as …863a. The serializer and the
parser disagree.
The consequence is drift, not a single lost bit. The first version of this
finding said the value "converges on the second application and stays there".
That was written from one example and was wrong; the fuzz target disproved it
within two minutes of the claim being made. Iterating on a DV_QUANTITY
magnitude:
4.4444444444444444e-7 → 4.4444444444444454e-7 → 4.444444444444446e-7 → stable
Three applications there. No bound is established — it settles in the cases observed, and nothing here proves it must. Each serialise-and-reparse cycle can move a magnitude, and a record that is read, amended in one field, and re-committed passes every other field through such a cycle.
What this costs, exactly. Measured rather than reasoned about:
| Question | Answer |
|---|---|
| Are the stored bytes stable? | Yes. 1.5777777777770001, written once. |
| Is the digest over them stable? | Yes. |
Can verify_versions raise a false tamper alarm? |
No. |
| Is the value read back equal to the value written? | No. One ULP low. |
| Do re-canonicalised bytes match the stored bytes? | No. |
The first three are "no" because of a decision already made.
db:M3.43 requires canonical JSON to be stored in a byte-preserving type, and
openehr_store::integrity::hashed_bytes hashes row.data_json as bytes
rather than re-deriving them from the parsed value. Had the integrity check
re-canonicalised — the obvious implementation — a clinical record carrying a
high-precision number would report ContentAltered with nobody having altered
it.
That requirement was written for db:D-08, where MySQL rewrote a magnitude of
1.10 as 1.1. It turns out to defend against the JSON library too. A rule
that pays twice for reasons its author did not know about is the argument for
writing rules about properties rather than about the specific thing that went
wrong.
Reported upstream 2026-08-21 as serde-rs/json#1336, "Bug? float parser is not the inverse of its own serializer." Open, unlabelled, no maintainer response yet.
Still open here, because a report is not a fix. Nothing in this repository
can make serde_json's parser agree with its serializer, and this finding stays
open until either the upstream issue is resolved and the dependency moves, or
this repository takes option 2 below. Three responses exist and none is free:
- Leave it, with the containment above and this finding. The residual is that a caller who reads a value back and compares it to what they wrote finds them unequal in the last bit. This is the current position.
serde_json'sarbitrary_precision, which keeps a number as its original text and would preserve the input digits exactly. It changes the type of every numeric Reference-Model field fromf64to something text-backed — a large API change with its own arithmetic questions.Report it upstream— done, see above. Worth doing regardless of 1 or 2, and it is the only one of the three that helps anybody outside this repository.
What to watch for. If #1336 is fixed, the pinning test below starts failing
— by design, because it asserts the drift is present. That failure is the
signal to bump serde_json, delete the test, and close this finding. Do not
"fix" the test by relaxing it.
Choosing between them is a design decision, not a repair, so it is written down
rather than made silently (W0.19).
The fuzz targets assert only that canonical form re-parses (W0.31: a
target must not report a documented limitation as a finding). Convergence was
tried first and is not assertable — see above; the attempt is why the drift is
described accurately here instead of by extrapolation from one example.
The behaviour is pinned instead by
guarantees::canonical_json_drifts_on_a_high_precision_float, which fails when
the drift stops — an upstream fix or a move to arbitrary_precision — so
whoever closes this finding is told what it was. That test also asserts the
containment rather than restating it: a digest over the stored bytes is stable
because it is taken over bytes.
A-27 — a sign the lexer could not have added#
Closed 2026-08-21. Opened as a limitation with the decision explicitly
unmade: WHERE o/value/magnitude > -2.5 is ordinary clinical AQL — a base
excess, a temperature difference, a scale scored below zero — and this parser
refused it outright.
Why it stayed open. - is also the character that separates the parts of an
archetype id. Adding a sign to the number scanner means deciding what
openEHR-EHR-COMPOSITION.encounter.v1 is when it follows an operator, and the
finding said so rather than guessing. CLAUDE.md carried the warning in as many
words: do not "fix" it by adding a sign to the number scanner.
The decision. The sign is resolved by the parser, at a position where an
operand is expected — never by the lexer's number scanner. The lexer gains -
as an ordinary symbol and nothing else changes there.
The ambiguity then cannot arise, and not by care: an archetype id begins with a
letter, so it is scanned as a word, and the word scanner already absorbs
its own hyphens. It never reaches the symbol branch. A - stands alone only
where no word claimed it, and the parser looks at that position and asks a
single question — is a number next?
| Input | Result |
|---|---|
WHERE o/value/magnitude > -2.5 |
a comparison against −2.5 |
WHERE c/v MATCHES {-1, 0, 1} |
a three-element set; MATCHES parses operands like everything else |
COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1] |
unchanged, and asserted to be |
WHERE c/v > -openEHR-EHR-… |
error: "expected a number after -" |
A dead guard, made honest. Parser::integer's v >= 0 was unreachable —
Token::Integer starts at a digit and never carries a sign — and mutation
testing could have replaced it with true unnoticed. It is gone. In its place
LIMIT/OFFSET refuse a sign deliberately (Q12.9d), because what used to
be refused incidentally as unexpected character stopped being refused that way
the moment a sign became lexically well formed. The message now names the
reason: LIMIT and OFFSET are counts and must not be negative. A LIMIT that
clamped -5 to 0 would return an empty result set that looks like an answer
(db:P6.15).
And it uncovered an older one. The fuzz target, run against the widened
grammar, found SELECT -0.0 rendering as -0 and reparsing as Integer(0).
The sign was not the defect: format!("{v}") writes 0 for 0.0, and this
lexer reads digits with no . as an integer, so Number(0.0) had always
round-tripped to Integer(0) — a literal changing type, which Q12.15's tree
equality forbids. It was invisible because the text matched; -0.0 is the
first value where it does not. Fixed by Q12.9e: a real renders with a decimal
point.
Two defects, one of them years older than the other, and the second only reachable because the first was fixed. That is the ordinary shape of this work and is worth noting against the instinct to treat a new failure after a change as evidence the change was wrong.
Verified. aql::a_sign_is_a_number_where_a_value_belongs_and_nowhere_else
drives all four rows above; the aql fuzz target ran clean over the widened
grammar. The test that pinned the limitation was rewritten rather than
deleted, and its doc comment says what it used to assert — a pinned limitation
and a pinned capability are the same test with the sign flipped, and the history
is the part worth keeping.
A-39 — two silent matches and a branch that never ran#
Found 2026-08-21 by the retrospective mutation pass that W-18 left as a
residual — nine commits had reached main without the mutants job ever
running on them. Nothing had failed. The code was correct; the tests were not
checking it.
Deleting a match arm is silent, again#
CLAUDE.md carries this warning about path.rs:
A path that resolves to nothing is not an error. … The consequence is that deleting a match arm from the navigation table is silent … Fifty such arms had no test (
A-28).
The same shape, in a different file, unnoticed. DataValue::semantic_cmp is a
match over nine same-class pairs ending in _ => None, and six of the nine
arms could be deleted with the whole suite still green: Ordinal, Scale,
Proportion, Date, Time, Duration. DataValue::is_strictly_comparable_to
is the same match again and was worse — every arm deletable, and the entire
function replaceable with false.
The wrong answer is not an error, which is why it is quiet. A deleted arm
falls to _ => None — not comparable — which is a correct answer for a
quantity against a count and a wrong one for two dates. And it does not stop
there:
DvOrdered::is_abnormal asks normal_range.contains(&DataValue::…), and
Interval::contains reads "not comparable" as "not inside" — deliberately, so
that an undecidable comparison never admits a value. So a DV_DATE outside
its recorded normal range would report as not abnormal, and that method's
own documentation says what happens next: "a dashboard that renders the first
as the second is reassuring for the wrong reason."
Fixed by one table-driven test with a row per arm, asserting Less,
Greater and Equal in both directions, plus is_strictly_comparable_to for
the same rows, plus the cross-class None that makes the _ arm's own job
visible. It asserts rows.len() == 9, so adding a variant to either match
without adding a row fails — the same instruction CLAUDE.md gives for
path.rs.
A branch that could not change its own output#
trim_float read:
if v.fract() == 0.0 && v.abs() < 1e15 { format!("{v:.0}") } else { format!("{v}") }
Every mutation of that comparison survived — < replaced by ==, by >, by
<=. That reads as an untested bound. It is not: the two branches produce the
same string for every finite f64, measured across 3,952 values spanning
1e-20 to f64::MAX in both signs, with zero differences.
Both halves were solving a problem Display had already solved. {:.0} existed
to drop the trailing .0 — Display writes 184, not 184.0. And
v.abs() < 1e15 existed to stop {:.0} printing a large float's exact binary
value: 9876543209999998976 where Display writes 9876543210000000000 —
sixteen digits of noise implying precision the value does not have. The guard
protected a branch that was never needed.
A test could not have fixed this, and that is the point worth keeping. Two
branches with identical output are indistinguishable by any assertion on the
output, so the surviving mutants were equivalent mutants over dead code
rather than a coverage gap. The remedy was to delete the branch and pin the
property instead: a whole number renders without .0, a huge one gains no
false digits. That test stays, because the property is not this crate's to
guarantee — it rests on Display for f64 choosing the shortest round-tripping
form, and if that ever changed a chart would start reading 184.0 mm[Hg].
What this says about the method#
Every finding in this register was found by running something. This one was
found by running something at the code rather than at the behaviour — the
suite passed, the fuzz targets passed, CI was green, and three of these
functions were still not doing anything a test would notice. Mutation testing is
the only check here that asks what would break if this were wrong, and it was
switched off for the branch these changes landed on (W-18).
A-19 — a departure whose advice nobody had checked was followable#
Classified 2026-08-22. Opened because COMPOSITION.Territory_valid and
Language_valid were neither enforced nor declared; the declaration landed as
S1.18 and the row has read "enforcement open" ever since, which implied
work that S1.18 argues against.
The register and the specification disagreed, quietly. S1.18 does not say
enforcement is pending. It says implementing it in this crate would be wrong:
ISO 3166-1 and ISO 639-1 are closed, small, and mutable, a table compiled into
a library is wrong from the day a country changes, and validating against a
stale copy rejects conformant data — which D3.5's own reasoning calls the
worse failure. That is a decision, and A-02 and A-08 are already carried as
"open, by decision" for departures of exactly this kind.
What was actually open is the sentence the departure ends on:
A deployment that needs the check should do it where the tables can be updated.
That is only true while a caller can reach every code the crate declines to
check, and reachability is not free. A-34 is the finding where
DV_ENCAPSULATED's charset and language round-tripped perfectly and could
not be read at all — EncapsulatedAttrs was exported and no type returned one.
The accessors that make S1.18's advice followable were added by that
finding, two departures later, by accident of looking at something else.
Checked, and it holds. All nine codes behind the ten unenforced invariants are reachable through the public API:
| Code | Reached by |
|---|---|
COMPOSITION.language, .territory |
direct accessors |
ENTRY.language, .encoding |
Entry::entry_attrs() — one indirection, the A-34 shape |
DV_TEXT.language, .encoding |
optional accessors; absent is a fact and is asserted too |
DV_ENCAPSULATED.charset, .language |
encapsulated(), added by A-34 |
DV_MULTIMEDIA.media_type |
direct accessor |
guarantees::a_caller_can_read_every_code_the_crate_declines_to_check pins it.
The failure mode it guards is specific: an accessor with no caller can be
deleted or narrowed with every test still green, and at that moment the
departure becomes silently worse than declared — the crate does not do the
check and the caller can no longer do it either.
The encapsulated codes are reached by deserializing, not by building, because
EncapsulatedAttrs has no builder. That is the right path to test anyway: a
code the crate will not check is a code that came from outside it.
A-38 — an upstream defect that was a missing feature#
Closed 2026-08-22, by being handed a specification naming a serde_json
feature I had not looked for.
The finding was accurate about the behaviour and wrong about everything else.
serde_json did parse 1.5777777777770001 one ULP below core::str::parse,
its parser was not the inverse of its serializer, and a magnitude did drift
across repeated canonical round trips. What was wrong was the conclusion:
Open, and upstream. Nothing in this repository can make
serde_json's parser agree with its serializer.
serde_json has a float_roundtrip feature that makes exactly that true,
and this repository had not enabled it. The fix is one word in thirteen
manifests. With it the two agree bitwise and canonical form is a fixed point
from the first application:
before 4.4444444444444444e-7 → …4454e-7 → …446e-7 → stable
after 4.4444444444444444e-7 → stable
Three responses were written down and the real one was not among them. The
entry listed: leave it contained, adopt arbitrary_precision, report upstream.
All three took as given that the crate could not fix it. That premise was never
checked — it came from reducing the defect to a minimal case, confirming it,
and stopping. Nobody read the feature list of the dependency the finding was
about.
The upstream report stands and is not wasted: the default parser really is not the inverse of the default serializer, which is worth someone's attention whatever this repository does. But it was filed as the remedy rather than as a courtesy, and it delayed the fix by a day.
arbitrary_precision is separately refused, with evidence, as SJ2. It is
incompatible with this crate's #[serde(tag)] and #[serde(flatten)] layout —
four round-trip tests fail with invalid type: map, expected f64 — and its
benefit reaches only serde_json::Number, where the Reference Model stores
magnitudes as f64 fields.
What the pinning test did. canonical_json_drifts_on_a_high_precision_float
asserted the drift was present and failed the moment it stopped, with the
message "If serde-rs/json#1336 was fixed, that is the good outcome: bump
serde_json, delete this test, and close A-38." It fired on the first run
after the feature was enabled and told the next reader what to do. It is kept,
inverted: the property now rests on a cargo feature staying enabled, which is
one careless edit from silently reverting to drift nothing else would notice.
The two fuzz targets are restored to asserting canonical form is a fixed point, which was weakened to "must re-parse" while this was open.
Closed findings#
A-01 and A-03 are fixed and kept above with their evidence, because the
evidence is the reason each fix is trusted and because each leaves a residual
that is still live — an inference about DV_SCALE, and unbounded recursion
depth. A finding is not deleted when it is fixed; it is marked.
A-40 — an entire section in force, and nothing behind it#
Severity: Medium. Status: open — object model built 2026-08-26; validation against an in-memory archetype, and repository resolution of a filled slot, built 2026-08-30; no parser, flattening, or template expansion.
What happened. S1.4 — the crate MUST NOT implement the Archetype Model —
was withdrawn on 2026-08-26 and replaced by S1.21 and
§15: AOM2 as types, ADL 2 parsing, ADL 1.4 ingestion,
specialisation and flattening, template expansion, operational templates,
validation against an operational template, and a repository abstraction for
retrieval. Thirty-one requirements in §15 plus S1.21, all in force from the
day they were written.
The gap, as it stands. K15.1–K15.4 are implemented and tested:
openehr::am is the AOM2 object model — ARCHETYPE, the constraint tree,
multiplicities, and archetype terminology — with construction-time checking of
the AOM2 validity conditions decidable from one artefact (VARDT, VATDF,
VACDF, VATCD, VOKU), a lossless JSON round trip, and an Unsupported
primitive-constraint variant so an unmodellable constraint is carried rather
than dropped. K15.18–K15.23 are implemented and tested since 2026-08-30:
openehr::am::validate walks a Reference Model instance against an
Archetype's definition — existence, cardinality, occurrences, RM class and
node identity, and primitive value constraints — reports a construct it cannot
check (a slot filler, an unmodelled primitive kind, a C_STRING pattern) as
unchecked rather than passing it, and keeps the verdict a distinct type from
[crate::validation]'s, per K15.19. K15.24–K15.27 are implemented and
tested since 2026-08-30 as well: openehr::am::repository defines the
retrieval abstraction (openehr itself performs no I/O, K15.25), and
validate_with_repository resolves a C_ARCHETYPE_ROOT filler through it —
verifying the repository answered the identifier asked for, requiring the
caller to opt in before validating against a result with no established
provenance (K15.26), and never treating a retrieval failure as a pass
(K15.27).
Eighteen requirements have no code. No ADL 2 parser (K15.5–K15.7), no
ADL 1.4 ingestion (K15.8–K15.10), no flattening (K15.11–K15.13), and no
template expansion or operational template (K15.14–K15.17). For a caller,
the practically important sentence is now narrower still: this crate can tell
you whether a COMPOSITION conforms to an archetype it already holds in
memory or can retrieve through a repository it is given, and still cannot tell
you whether it conforms to the published archetype named on the instance,
because nothing here reads ADL or merges a specialisation's inherited
constraints in first. A bare ARCHETYPE_SLOT stays unchecked regardless of a
repository: which archetype fills it lives on the instance's own
ARCHETYPED.archetype_id, which crate::path::Node does not expose — a
residual named in its own right below, not folded into this count because it
is a gap in crate::path, not in §15.
Why this is a finding rather than a plan. C0.9 — a gap that is not written
down reads as a pass. A specification section with no code is the most flattering
possible document about a crate, and this repository has already been caught
believing its own documentation twice (W-09, A-26). The register is where
the distance between the specification and the code is kept visible, and 32
requirements was the largest such distance this crate has carried.
Evidence a reader can check. grep -rln "K15\." openehr/src returns the
am module and lib.rs, and nothing else — no parser module, no flattening, no
retrieval. Twenty-two §15 rows in
conformance-matrix.md read spec, a status that did
not exist before this reversal and means exactly this; fourteen read • and
name the tests that earn them.
What holds in the meantime. K15.30: every entry point that would implement
an unbuilt part of §15 refuses explicitly, and no documentation may state or
imply that this crate validates against a published archetype — only against
one already held in memory, and openehr::am::validate's own module
documentation says so before it says anything else. openehr::am's own module
header carries the table of what is and is not built, and validation's header
keeps the sentence that passing Reference-Model validation does not mean an
instance conforms to its archetype. K15.31: a partial implementation is not
described as more archetype support than it is — validation without a parser or
flattening is validation of what is handed to it, and must be called that.
L10.2, amended, keeps the sentence that a passing composition may still
violate its archetype in place until the matrix says every requirement in §15 is
satisfied.
Residual, and it is real. The scope reversal leaves citations elsewhere in
the repository that point at a withdrawn requirement while stating a reason that
is no longer the reason: openehr-store/src/store.rs,
openehr-store/src/schema.rs, openehr-fuzz/README.md, and the engine crates'
"not implemented anywhere (lib:S1.4)" rows. Every one of them remains
factually correct about the code — nothing implements archetypes — and every
one now cites a withdrawn decision instead of an unbuilt requirement. They are
not rewritten here on purpose: W0.6 says a citation's whole value is that it
does not change, and a mass rewrite during a specification change is how a
citation stops meaning anything. They are re-pointed when the code they describe
changes, and this paragraph is the record that they are known.
Residual, added 2026-08-30. validate_with_repository resolves a
C_ARCHETYPE_ROOT, but a bare ARCHETYPE_SLOT stays unchecked with or
without a repository, and this is a gap in a different module than the one
this finding is about: which archetype fills a slot is recorded on the
instance's ARCHETYPED.archetype_id, and crate::path::Node — built for AQL
and path resolution, before archetype support existed — exposes only
archetype_node_id, the short code, never that attribute. Closing it means
adding a variant or a method to crate::path::Node, which is path.rs's
surface, not am's; openehr::am::validate's own module documentation states
the gap rather than working around it with something that looks like a
resolution and is not one.
A-41 — the matrix's totals went stale, again#
Severity: Low. Status: fixed — re-derived mechanically to 344 on 2026-08-26.
What happened. conformance-matrix.md stated three
different totals at once: a sentence claiming 300 ids, 300 covered, a totals
table summing to 291, and rows covering 311. All three were written to be
derived from the tables, and none was re-derived after the requirements that
followed them.
Why the existing guard did not catch it. The claims job re-derives
coverage — every requirement has exactly one row — which is what A-26
asked for, and it passed the whole time. Coverage and tally are different
claims, and only one of them was mechanised. That is A-26 one level down: the
boast moved from "every id has a row" to "here is how many of each", and the new
boast had no check.
Fix. Both numbers re-derived by expanding every Id cell and counting
statuses: 344 requirements, 344 covered, and a per-status tally that now includes
the spec and withdrawn statuses this reversal introduced.
Residual. The tally is still hand-transcribed into the file. The honest statement is in the matrix itself — it names the method, so the next reader can re-run it — and mechanising the tally the way coverage is mechanised is the better fix, not yet done.