openehr 0.3.0

openEHR Reference Model types, validation, paths, AQL parsing, and change-control security primitives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
# Audit findings

**Non-normative.** This is the register of known gaps between what
[`spec/`](index.md) 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.

**35 findings, 35 in the table below: 5 High, 21 Medium, 9 Low. 27 fixed or
classified, 8 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 8 open findings
is open by a stated reason rather than by omission: **A-02** and **A-08** are
declared departures the crate does not intend to close; **A-05**, **A-10**,
**A-30** and **A-35** are recorded limitations or residuals with the reasoning
for leaving them written beside them; **A-19** and **A-27** are declared
departures (`S1.18`, `Q12.9b`) whose *enforcement* remains open work. **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 | **declared** as `S1.18`; enforcement open |
| 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 cannot express a **negative numeric literal**`WHERE o/value/magnitude > -2.5` is refused at the lexer, and `Parser::integer`'s `v >= 0` guard is therefore unreachable | **declared** as `Q12.9b`; the parser gap is open |
| 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-35 | Medium | `DvDate`, `DvTime`, `DvDateTime`, `DvDuration` and `DataValue` have the same lexical-`Eq`-versus-semantic-`PartialOrd` shape `A-32` fixed at the base `iso8601` layer — found while fixing `A-32` and left alone because closing it would mean either the same trait-removal treatment rippling through `Interval<T>`'s `T: PartialOrd` bound everywhere it is instantiated (`Interval<DvDate>`, `Interval<DataValue>`, `Interval<i32>`, …), or a narrower fix scoped to just these types | open |
| 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-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 of `0` must not be
  flagged as a percentage; `0` means 100% accurate and "0%" reads as the
  opposite.
- `DV_AMOUNT.unknown_accuracy_value``-1.0` records "accuracy not measured".
  Nothing in the crate honoured it, so `-1` would 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``N` if 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 in `openehr-loco` and
  `openehr-sqlite`, so the remedy for a High finding was removable without this
  crate noticing.
- `EhrAccess` and `Party` — 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 `dedup`ed 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_str` could return one
  wrong constant for all six variants and three `parse` arms 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 constant `true` reports every result as within
  its normal range. This is the machinery `A-01` already found rules missing
  from; the membership test underneath was equally unwatched.
- `DvScale::is_strictly_comparable_to``D3.16`. Answering `true` lets a
  pain-scale 2 order against a sedation-scale 2 as though they measured the
  same thing.
- `accuracy_is_percent` — a constant makes `±5` read 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 reinterprets `1/2` between 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

```rust
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`](../../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.rs` built 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:

1. `OriginalVersion::new` enforces `Preceding_version_uid_validity`.
2. `impl Validate for Version<T>` covers `Lifecycle_state_valid`, `Data_valid`,
   and `Preceding_version_uid_validity`, and descends into the data. This is the
   half that covers deserialized input.
3. `commit_composition` validates 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:**

- ~~**`DV_INTERVAL.Limits_consistent` is enforced under the wrong name.**~~
  **Fixed.** `Interval::new` refused `lower > upper` and reported `INTERVAL`
  with prose. `L10.4` requires openEHR's own name so a reader can find the rule
  in the class definition — the same defect `A-20` fixed fifteen times,
  surviving because the grep that found those looks for names that *are* used
  and this one used none.

- **`TERM_MAPPING.Purpose_valid` is unenforced although the crate ships the code
  set.** `term_mapping_purpose::GROUP` exists and is registered; nothing checks
  a mapping's purpose against it. Same shape as `A-22`, which found three
  `DV_MULTIMEDIA` invariants unenforced "despite the crate shipping their code
  sets".

- ~~**`VERSION.owner_id` is not modelled at all.**~~ **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
  (`W0.3`). The BMM was then read.

  `VERSION` declares three **properties**`contribution`, `signature`,
  `commit_audit` — and `owner_id` is not among them. It is a **function**, whose
  documentation says: *"Copy of the owning `VERSIONED_OBJECT._uid_` value;
  extracted from the local `_uid_` property's `_object_id_`."* So
  `Owner_id_valid` constrains 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`, `type` on `PARTY`, `ADDRESS`, `CONTACT`, `PARTY_IDENTITY` and
  `PARTY_RELATIONSHIP` are all derived functions — `purpose` and `type` are
  documented as *"taken from the value of the inherited `name` attribute"*  so `Is_simple_validity`, `Purpose_valid`, `Type_valid` and `Type_validity`
  are 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:

1. **The class was ignored.** openEHR reuses 15 invariant names —
   `Language_valid` belongs to seven classes, `Value_valid` to six,
   `Is_archetype_root` to five, `Purpose_valid` to four. Naming one marked all
   of them.
2. **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_valid`
   and `Other_input_version_uids_valid` are 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 *un*naming 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/*.md` checked 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.

## 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.