use crate::error::{ValidationReport, Violation};
use crate::rm::common::{Attestation, Locatable as _, Participation, PartyProxy};
use crate::rm::data_structures::{Cluster, Element, Event, History, Item, ItemStructure};
use crate::rm::data_types::{DataValue, DvCodedText, DvOrdered, DvQuantity, OrderedAttrs, Text};
use crate::rm::ehr::{Composition, ContentItem, EhrStatus, Entry, EventContext, Section};
use crate::terminology;
#[derive(Debug, Default)]
pub struct Context {
path: Vec<String>,
report: ValidationReport,
}
impl Context {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn violation(
&mut self,
class: &'static str,
invariant: &'static str,
detail: &'static str,
) {
self.report.push(Violation {
path: self.path.join(""),
class,
invariant,
detail,
});
}
fn nested<F: FnOnce(&mut Self)>(&mut self, segment: String, f: F) {
self.path.push(segment);
f(self);
self.path.pop();
}
#[must_use]
pub fn finish(self) -> ValidationReport {
self.report
}
}
pub trait Validate {
fn visit(&self, ctx: &mut Context);
fn validate(&self) -> ValidationReport {
let mut ctx = Context::new();
self.visit(&mut ctx);
ctx.finish()
}
fn validate_ok(&self) -> Result<(), ValidationReport> {
self.validate().into_result()
}
}
fn check_locatable<L: crate::rm::common::Locatable>(node: &L, ctx: &mut Context) {
if node.archetype_node_id().is_empty() {
ctx.violation(
"LOCATABLE",
"Archetype_node_id_valid",
"archetype_node_id is empty, so the node cannot be reached by any path",
);
}
check_text(node.name().as_text(), ctx);
if node.name().value().is_empty() {
ctx.nested("/name".to_owned(), |c| {
c.violation("DV_TEXT", "Valid_value", "value is empty");
});
}
if let Some(details) = node.archetype_details() {
let entity = details.archetype_id().rm_entity();
if entity != node.rm_type_name() {
ctx.violation(
"ARCHETYPED",
"Archetype_id_rm_entity_matches",
"archetype id constrains a different RM class than the node it annotates",
);
}
if details.rm_version().is_empty() {
ctx.violation("ARCHETYPED", "Rm_version_valid", "rm_version is empty");
}
}
}
fn check_text(text: &crate::rm::data_types::DvText, ctx: &mut Context) {
for mapping in text.mappings() {
if let Some(purpose) = mapping.purpose()
&& !crate::terminology::term_mapping_purpose::GROUP
.contains(purpose.defining_code().code_string())
{
ctx.violation(
"TERM_MAPPING",
"Purpose_valid",
"a term mapping's purpose is not from the openEHR term_mapping_purpose group",
);
}
}
}
fn check_coded_text(coded: &DvCodedText, ctx: &mut Context) {
check_text(coded.as_text(), ctx);
if coded.check_openehr_rubric() == Some(false) {
ctx.violation(
"DV_CODED_TEXT",
"Value_is_rubric",
"value does not match the openEHR rubric for defining_code",
);
}
}
fn check_ordered(attrs: &OrderedAttrs, is_abnormal: Option<bool>, ctx: &mut Context) {
for range in attrs.other_reference_ranges() {
let simple = |bound: Option<&crate::rm::data_types::DataValue>| {
use crate::rm::data_types::DataValue;
let attrs = match bound {
Some(DataValue::Quantity(v)) => Some(v.ordered_attrs()),
Some(DataValue::Count(v)) => Some(v.ordered_attrs()),
Some(DataValue::Proportion(v)) => Some(v.ordered_attrs()),
Some(DataValue::Ordinal(v)) => Some(v.ordered_attrs()),
Some(DataValue::Scale(v)) => Some(v.ordered_attrs()),
_ => None,
};
attrs.is_none_or(|o| {
o.normal_range().is_none() && o.other_reference_ranges().is_empty()
})
};
if !simple(range.range().lower()) || !simple(range.range().upper()) {
ctx.violation(
"REFERENCE_RANGE",
"Range_is_simple",
"a reference range's endpoint carries reference ranges of its own",
);
}
}
let Some(status) = attrs.normal_status() else {
return;
};
if !crate::terminology::normal_status::GROUP.contains(status.code_string()) {
ctx.violation(
"DV_ORDERED",
"Normal_status_validity",
"normal_status is not from the openEHR normal-statuses code set",
);
}
if let Some(abnormal) = is_abnormal {
let says_normal = status.code_string() == crate::terminology::normal_status::NORMAL;
if says_normal == abnormal {
ctx.violation(
"DV_ORDERED",
"Normal_range_and_status_consistency",
"normal_status and the value's position in its normal_range disagree",
);
}
}
}
fn check_optional_group(
text: &Text,
group: crate::terminology::Group,
class: &'static str,
invariant: &'static str,
ctx: &mut Context,
) {
let Text::Coded(coded) = text else {
return;
};
check_coded_text(coded, ctx);
if coded.defining_code().is_openehr() && !group.contains(coded.defining_code().code_string()) {
ctx.violation(
class,
invariant,
"coded value is not from its openEHR group",
);
}
}
fn check_participation(participation: &Participation, ctx: &mut Context) {
check_optional_group(
participation.function(),
crate::terminology::participation_function::GROUP,
"PARTICIPATION",
"Function_valid",
ctx,
);
if let Some(mode) = participation.mode() {
check_coded_text(mode, ctx);
if mode.defining_code().is_openehr()
&& !crate::terminology::participation_mode::GROUP
.contains(mode.defining_code().code_string())
{
ctx.violation(
"PARTICIPATION",
"Mode_valid",
"mode is not from the openEHR participation-mode group",
);
}
}
check_party(
participation.performer(),
"PARTICIPATION",
"Performer_valid",
ctx,
);
}
pub fn check_attestation(attestation: &Attestation, ctx: &mut Context) {
check_optional_group(
attestation.reason(),
crate::terminology::attestation_reason::GROUP,
"ATTESTATION",
"Reason_valid",
ctx,
);
check_party(
attestation.audit().committer(),
"AUDIT_DETAILS",
"Committer_valid",
ctx,
);
}
impl Validate for DataValue {
fn visit(&self, ctx: &mut Context) {
match self {
Self::CodedText(t) => check_coded_text(t, ctx),
Self::Ordinal(o) => {
check_coded_text(o.symbol(), ctx);
check_ordered(o.ordered_attrs(), o.is_abnormal(), ctx);
}
Self::Scale(s) => {
check_coded_text(s.symbol(), ctx);
check_ordered(s.ordered_attrs(), s.is_abnormal(), ctx);
}
Self::Count(c) => check_ordered(c.ordered_attrs(), c.is_abnormal(), ctx),
Self::Quantity(q) => {
if !q.magnitude().is_finite() {
ctx.violation(
"DV_QUANTITY",
"Magnitude_finite",
"magnitude is not a finite number",
);
}
if q.units().is_empty() {
ctx.violation("DV_QUANTITY", "Units_valid", "units is empty");
}
if q.precision()
.is_some_and(|p| p < DvQuantity::UNLIMITED_PRECISION)
{
ctx.violation(
"DV_QUANTITY",
"Precision_valid",
"precision is below -1, the unlimited-precision sentinel",
);
}
check_ordered(q.ordered_attrs(), q.is_abnormal(), ctx);
}
Self::Proportion(p) => {
if p.denominator() == 0.0 {
ctx.violation("DV_PROPORTION", "Valid_denominator", "denominator is zero");
}
if p.kind().requires_integral() && !p.is_integral() {
ctx.violation(
"DV_PROPORTION",
"Fraction_validity",
"a fractional kind has a non-integral numerator or denominator",
);
}
if p.precision() == Some(0) && !p.is_integral() {
ctx.violation(
"DV_PROPORTION",
"Precision_validity",
"precision is 0 but a part has a fractional component",
);
}
check_ordered(p.ordered_attrs(), p.is_abnormal(), ctx);
}
Self::Multimedia(m) => check_multimedia(m, ctx),
Self::Text(t) if t.value().is_empty() => {
ctx.violation("DV_TEXT", "Valid_value", "value is empty");
}
_ => {}
}
}
}
impl Validate for Element {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
match (self.value().is_some(), self.null_flavour().is_some()) {
(true, true) => ctx.violation(
"ELEMENT",
"Inv_null_flavour_indicated",
"an element has both a value and a null_flavour",
),
(false, false) => ctx.violation(
"ELEMENT",
"Inv_null_flavour_indicated",
"an element has neither a value nor a null_flavour",
),
_ => {}
}
if self.null_reason().is_some() && self.value().is_some() {
ctx.violation(
"ELEMENT",
"Inv_null_reason_valid",
"a reason for absence is recorded on an element that has a value",
);
}
if let Some(flavour) = self.null_flavour() {
if !flavour.defining_code().is_openehr()
|| !crate::terminology::null_flavour::GROUP
.contains(flavour.defining_code().code_string())
{
ctx.violation(
"ELEMENT",
"Inv_null_flavour_valid",
"null_flavour is not one of the four openEHR null flavours",
);
}
check_coded_text(flavour, ctx);
}
if let Some(value) = self.value() {
ctx.nested("/value".to_owned(), |c| value.visit(c));
}
}
}
impl Validate for Cluster {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if self.items().is_empty() {
ctx.violation("CLUSTER", "Items_non_empty", "a cluster has no items");
}
for (i, item) in self.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| item.visit(c));
}
}
}
impl Validate for Item {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Cluster(v) => v.visit(ctx),
Self::Element(v) => v.visit(ctx),
}
}
}
impl Validate for ItemStructure {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Single(s) => {
check_locatable(s, ctx);
ctx.nested("/item".to_owned(), |c| s.item().visit(c));
}
Self::List(s) => {
check_locatable(s, ctx);
for (i, e) in s.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| e.visit(c));
}
}
Self::Table(s) => {
check_locatable(s, ctx);
if !s.is_regular() {
ctx.violation(
"ITEM_TABLE",
"Rows_regular",
"rows do not all have the same number of columns",
);
}
for (i, r) in s.rows().iter().enumerate() {
if r.items()
.iter()
.any(|item| !matches!(item, crate::rm::data_structures::Item::Element(_)))
{
ctx.nested(format!("/rows[{i}]"), |c| {
c.violation(
"ITEM_TABLE",
"Valid_structure",
"a row holds something other than ELEMENTs",
);
});
}
ctx.nested(format!("/rows[{i}]"), |c| r.visit(c));
}
}
Self::Tree(s) => {
check_locatable(s, ctx);
for (i, item) in s.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| item.visit(c));
}
}
}
}
}
impl Validate for Event {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Point(e) => {
check_locatable(e, ctx);
ctx.nested("/data".to_owned(), |c| e.data().visit(c));
if let Some(state) = e.state() {
ctx.nested("/state".to_owned(), |c| state.visit(c));
}
}
Self::Interval(e) => {
check_locatable(e, ctx);
if e.width().value().is_negative() {
ctx.violation(
"INTERVAL_EVENT",
"Width_non_negative",
"width is negative, so the interval ends before it starts",
);
}
check_coded_text(e.math_function(), ctx);
ctx.nested("/data".to_owned(), |c| e.data().visit(c));
if let Some(state) = e.state() {
ctx.nested("/state".to_owned(), |c| state.visit(c));
}
}
}
}
}
impl Validate for History {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if self.events().is_empty() && self.summary().is_none() {
ctx.violation(
"HISTORY",
"Events_valid",
"a history has neither events nor a summary",
);
}
if self
.period()
.is_some_and(|p| p.value().is_zero() || p.value().is_negative())
{
ctx.violation(
"HISTORY",
"Periodic_validity",
"a periodic history declares a zero or negative period",
);
}
if self.is_period_consistent() == Some(false) {
ctx.violation(
"HISTORY",
"Period_consistency",
"a periodic history has an event whose offset is not a multiple of the period",
);
}
for (i, event) in self.events().iter().enumerate() {
if matches!(
event.time().partial_cmp(self.origin()),
Some(core::cmp::Ordering::Less)
) {
ctx.nested(format!("/events[{i}]"), |c| {
c.violation(
"EVENT",
"Time_after_origin",
"event time is before the history origin",
);
});
}
ctx.nested(format!("/events[{i}]"), |c| event.visit(c));
}
if let Some(summary) = self.summary() {
ctx.nested("/summary".to_owned(), |c| summary.visit(c));
}
}
}
impl Validate for EventContext {
fn visit(&self, ctx: &mut Context) {
check_coded_text(self.setting(), ctx);
if !self.setting().defining_code().is_openehr()
|| !crate::terminology::setting::GROUP
.contains(self.setting().defining_code().code_string())
{
ctx.violation(
"EVENT_CONTEXT",
"Setting_valid",
"setting is not from the openEHR setting group",
);
}
if let Some(end) = self.end_time()
&& matches!(
end.partial_cmp(self.start_time()),
Some(core::cmp::Ordering::Less)
)
{
ctx.violation(
"EVENT_CONTEXT",
"End_time_valid",
"end_time is before start_time",
);
}
if self.location().is_some_and(str::is_empty) {
ctx.violation(
"EVENT_CONTEXT",
"location_valid",
"location is present and empty",
);
}
for (i, participation) in self.participations().iter().enumerate() {
ctx.nested(format!("/participations[{i}]"), |c| {
check_participation(participation, c);
});
}
if let Some(other) = self.other_context() {
ctx.nested("/other_context".to_owned(), |c| other.visit(c));
}
}
}
impl Validate for Entry {
fn visit(&self, ctx: &mut Context) {
check_locatable_entry(self, ctx);
check_party(self.subject(), "ENTRY", "Subject_valid", ctx);
if self.subject().is_subject() && self.subject().type_name() != "PARTY_SELF" {
ctx.violation(
"ENTRY",
"Subject_validity",
"the subject is about the record subject but is not a PARTY_SELF",
);
}
if let Some(provider) = self.entry_attrs().provider() {
check_party(provider, "ENTRY", "Provider_valid", ctx);
}
for (i, participation) in self.entry_attrs().other_participations().iter().enumerate() {
ctx.nested(format!("/other_participations[{i}]"), |c| {
check_participation(participation, c);
});
}
match self {
Self::Observation(o) => {
ctx.nested("/data".to_owned(), |c| o.data().visit(c));
if let Some(state) = o.state() {
ctx.nested("/state".to_owned(), |c| state.visit(c));
}
if let Some(protocol) = o.care_entry().protocol() {
ctx.nested("/protocol".to_owned(), |c| protocol.visit(c));
}
}
Self::Evaluation(e) => ctx.nested("/data".to_owned(), |c| e.data().visit(c)),
Self::AdminEntry(a) => ctx.nested("/data".to_owned(), |c| a.data().visit(c)),
Self::Instruction(i) => {
if i.activities().is_empty() {
ctx.violation(
"INSTRUCTION",
"Activities_valid",
"an instruction has no activities",
);
}
if i.narrative().value().is_empty() {
ctx.violation(
"INSTRUCTION",
"Narrative_valid",
"an instruction has an empty narrative",
);
}
for (n, activity) in i.activities().iter().enumerate() {
ctx.nested(format!("/activities[{n}]/description"), |c| {
activity.description().visit(c);
});
}
}
Self::Action(a) => {
check_coded_text(a.ism_transition().current_state(), ctx);
if !crate::terminology::instruction_state::GROUP.contains(
a.ism_transition()
.current_state()
.defining_code()
.code_string(),
) {
ctx.violation(
"ISM_TRANSITION",
"Current_state_valid",
"current_state is not an Instruction State Machine state",
);
}
ctx.nested("/description".to_owned(), |c| a.description().visit(c));
}
}
}
}
fn check_locatable_entry(entry: &Entry, ctx: &mut Context) {
let root = |e: &dyn Fn() -> bool, ctx: &mut Context| {
if !e() {
ctx.violation(
"ENTRY",
"Is_archetype_root",
"an entry has no archetype_details, so it is not an archetype root",
);
}
};
match entry {
Entry::Observation(e) => {
check_locatable(e, ctx);
root(&|| e.is_archetype_root(), ctx);
}
Entry::Evaluation(e) => {
check_locatable(e, ctx);
root(&|| e.is_archetype_root(), ctx);
}
Entry::Instruction(e) => {
check_locatable(e, ctx);
root(&|| e.is_archetype_root(), ctx);
}
Entry::Action(e) => {
check_locatable(e, ctx);
root(&|| e.is_archetype_root(), ctx);
}
Entry::AdminEntry(e) => {
check_locatable(e, ctx);
root(&|| e.is_archetype_root(), ctx);
}
}
}
fn check_party(
party: &PartyProxy,
class: &'static str,
invariant: &'static str,
ctx: &mut Context,
) {
let identified = match party {
PartyProxy::SelfParty(_) => return,
PartyProxy::Identified(p) => p,
PartyProxy::Related(p) => {
check_coded_text(p.relationship(), ctx);
let code = p.relationship().defining_code();
if code.is_openehr()
&& !crate::terminology::subject_relationship::GROUP.contains(code.code_string())
{
ctx.violation(
"PARTY_RELATED",
"Relationship_valid",
"relationship is not from the openEHR subject-relationship group",
);
}
p.as_identified()
}
};
if identified.name().is_none()
&& identified.identifiers().is_empty()
&& identified.external_ref().is_none()
{
ctx.violation(
class,
invariant,
"an identified party has no name, no identifiers, and no external reference",
);
}
}
impl Validate for Section {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
for (i, item) in self.items().iter().enumerate() {
ctx.nested(format!("/items[{i}]"), |c| item.visit(c));
}
}
}
impl Validate for ContentItem {
fn visit(&self, ctx: &mut Context) {
match self {
Self::Section(s) => s.visit(ctx),
Self::Entry(e) => e.visit(ctx),
}
}
}
impl Validate for EhrStatus {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if !self.is_archetype_root() {
ctx.violation(
"EHR_STATUS",
"Is_archetype_root",
"an EHR_STATUS has no archetype_details, so it is not an archetype root",
);
}
if let Some(other) = self.other_details() {
ctx.nested("/other_details".to_owned(), |c| other.visit(c));
}
}
}
impl Validate for Composition {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
check_coded_text(self.category(), ctx);
if !crate::terminology::composition_category::GROUP.contains(self.category_code()) {
ctx.violation(
"COMPOSITION",
"Category_validity",
"category is not from the openEHR composition_category group",
);
}
check_party(self.composer(), "COMPOSITION", "Composer_valid", ctx);
if !self.is_archetype_root() {
ctx.violation(
"COMPOSITION",
"Is_archetype_root",
"a composition has no archetype_details, so it is not an archetype root",
);
}
if self.is_persistent() && self.context().is_some() {
ctx.violation(
"COMPOSITION",
"Is_persistent_validity",
"a persistent composition carries an event context",
);
}
if let Some(context) = self.context() {
ctx.nested("/context".to_owned(), |c| context.visit(c));
}
for (i, item) in self.content().iter().enumerate() {
ctx.nested(format!("/content[{i}]"), |c| item.visit(c));
}
}
}
fn check_multimedia(m: &crate::rm::data_types::DvMultimedia, ctx: &mut Context) {
if !m.has_content() {
ctx.violation(
"DV_MULTIMEDIA",
"Not_empty",
"neither inline data nor a uri is present",
);
}
if m.verify_integrity() == crate::rm::data_types::IntegrityCheck::Failed {
ctx.violation(
"DV_MULTIMEDIA",
"Integrity_check_matches",
"the recorded digest does not match the inline data",
);
}
if m.integrity_check().is_some() && m.integrity_check_algorithm().is_none() {
ctx.violation(
"DV_MULTIMEDIA",
"Integrity_check_validity",
"an integrity check is present with no algorithm naming how it was made",
);
}
if let Some(algorithm) = m.integrity_check_algorithm()
&& terminology::integrity_check_algorithm::GROUP
.rubric(algorithm.code_string())
.is_none()
{
ctx.violation(
"DV_MULTIMEDIA",
"Integrity_check_algorithm_validity",
"the integrity check algorithm is not one openEHR names",
);
}
if let Some(algorithm) = m.compression_algorithm()
&& terminology::compression_algorithm::GROUP
.rubric(algorithm.code_string())
.is_none()
{
ctx.violation(
"DV_MULTIMEDIA",
"Compression_algorithm_validity",
"the compression algorithm is not one openEHR names",
);
}
if m.size().is_some_and(|s| s < 0) {
ctx.violation("DV_MULTIMEDIA", "Size_valid", "size is negative");
}
}
impl<T: Validate> Validate for crate::rm::common::Version<T> {
fn visit(&self, ctx: &mut Context) {
if !crate::terminology::version_lifecycle_state::GROUP.contains(self.lifecycle_state_code())
{
ctx.violation(
"ORIGINAL_VERSION",
"Lifecycle_state_valid",
"lifecycle_state is not from the openEHR version_lifecycle_state group",
);
}
if self.data().is_none()
&& self.lifecycle_state_code() != crate::terminology::version_lifecycle_state::DELETED
{
ctx.violation(
"ORIGINAL_VERSION",
"Data_valid",
"a version with no data must carry the deleted lifecycle state",
);
}
if self.uid().version_tree_id().is_first() == self.preceding_version_uid().is_some() {
ctx.violation(
"VERSION",
"Preceding_version_uid_validity",
"the first version must have no preceding_version_uid and every \
later version must have one",
);
}
if let Some(data) = self.data() {
ctx.nested("/data".to_owned(), |c| data.visit(c));
}
}
}
impl Validate for crate::rm::ehr::Ehr {
fn visit(&self, ctx: &mut Context) {
if self.ehr_status().type_name() != "VERSIONED_EHR_STATUS" {
ctx.violation(
"EHR",
"Ehr_status_valid",
"ehr_status does not reference a VERSIONED_EHR_STATUS",
);
}
if self.ehr_access().type_name() != "VERSIONED_EHR_ACCESS" {
ctx.violation(
"EHR",
"Ehr_access_valid",
"ehr_access does not reference a VERSIONED_EHR_ACCESS",
);
}
if self
.compositions()
.iter()
.any(|r| r.type_name() != "VERSIONED_COMPOSITION")
{
ctx.violation(
"EHR",
"Compositions_valid",
"a composition reference does not name a VERSIONED_COMPOSITION",
);
}
if self
.contributions()
.iter()
.any(|r| r.type_name() != "CONTRIBUTION")
{
ctx.violation(
"EHR",
"Contributions_valid",
"a contribution reference does not name a CONTRIBUTION",
);
}
if self
.folders()
.iter()
.any(|r| r.type_name() != "VERSIONED_FOLDER")
{
ctx.violation(
"EHR",
"Folders_valid",
"a folder reference does not name a VERSIONED_FOLDER",
);
}
if self
.directory()
.is_some_and(|d| d.type_name() != "VERSIONED_FOLDER")
{
ctx.violation(
"EHR",
"Directory_valid",
"directory does not reference a VERSIONED_FOLDER",
);
}
}
}
impl<T: Validate> Validate for crate::rm::common::VersionedObject<T> {
fn visit(&self, ctx: &mut Context) {
if self.uid().extension().is_some() {
ctx.violation(
"VERSIONED_OBJECT",
"Uid_validity",
"the container's uid carries an extension, so it names something \
narrower than the object its versions belong to",
);
}
for (i, version) in self.all_versions().iter().enumerate() {
ctx.nested(format!("/versions[{i}]"), |c| version.visit(c));
}
}
}
impl Validate for crate::security::access::EhrAccess {
fn visit(&self, ctx: &mut Context) {
check_locatable(self, ctx);
if !self.is_archetype_root() {
ctx.violation(
"EHR_ACCESS",
"Is_archetype_root",
"an EHR_ACCESS has no archetype_details, so it is not an archetype root",
);
}
}
}
impl Validate for crate::rm::demographic::Party {
fn visit(&self, ctx: &mut Context) {
use crate::rm::demographic::Party;
let check = |rooted: bool, ctx: &mut Context| {
if !rooted {
ctx.violation(
"PARTY",
"Is_archetype_root",
"a party has no archetype_details, so it is not an archetype root",
);
}
};
match self {
Party::Person(p) => {
check_locatable(p, ctx);
check(p.is_archetype_root(), ctx);
}
Party::Organisation(p) => {
check_locatable(p, ctx);
check(p.is_archetype_root(), ctx);
}
Party::Group(p) => {
check_locatable(p, ctx);
check(p.is_archetype_root(), ctx);
}
Party::Agent(p) => {
check_locatable(p, ctx);
check(p.is_archetype_root(), ctx);
}
Party::Role(p) => {
check_locatable(p, ctx);
check(p.is_archetype_root(), ctx);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Interval;
use crate::rm::common::{Archetyped, LocatableAttrs, PartyIdentified};
use crate::rm::data_structures::{ItemSingle, PointEvent};
use crate::rm::data_types::{CodePhrase, DvCount, DvDateTime, DvQuantity, DvText};
use crate::rm::ehr::{EntryAttrs, Evaluation, Observation};
use crate::terminology;
fn attrs(name: &str, node: &str) -> LocatableAttrs {
LocatableAttrs::named(name, node).unwrap()
}
fn structure() -> ItemStructure {
ItemSingle::new(
attrs("d", "at0001"),
Element::new(attrs("v", "at0002"), DataValue::Count(DvCount::new(1))),
)
.into()
}
fn entry_attrs() -> EntryAttrs {
EntryAttrs::about_subject(
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("IANA_character-sets", "UTF-8").unwrap(),
)
}
fn composition() -> Composition {
Composition::new(
attrs("Encounter", "openEHR-EHR-COMPOSITION.encounter.v1").with_archetype_details(
Archetyped::new("openEHR-EHR-COMPOSITION.encounter.v1", "1.1.0").unwrap(),
),
terminology::composition_category::EVENT,
PartyIdentified::named("Dr A Nurse").unwrap().into(),
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("ISO_3166-1", "GB").unwrap(),
)
.unwrap()
}
#[test]
fn a_well_formed_composition_has_no_violations() {
let c = composition().with_content(
Evaluation::new(
attrs("Problem", "openEHR-EHR-EVALUATION.problem.v1").with_archetype_details(
Archetyped::new("openEHR-EHR-EVALUATION.problem.v1", "1.1.0").unwrap(),
),
entry_attrs(),
structure(),
)
.into(),
);
let report = c.validate();
assert!(report.is_empty(), "{report}");
}
#[test]
fn deserialization_bypasses_constructors_and_validation_catches_it() {
let json = r#"{
"name": {"value": "Systolic"},
"archetype_node_id": "at0004",
"value": {"_type": "DV_COUNT", "magnitude": 1},
"null_flavour": {"value": "unknown", "defining_code":
{"terminology_id": {"value": "openehr"}, "code_string": "253"}}
}"#;
let element: Element = serde_json::from_str(json).unwrap();
let report = element.validate();
assert_eq!(report.len(), 1);
assert_eq!(
report.violations()[0].invariant,
"Inv_null_flavour_indicated"
);
}
#[test]
fn an_element_with_nothing_at_all_is_also_a_violation() {
let json = r#"{"name": {"value": "Systolic"}, "archetype_node_id": "at0004"}"#;
let element: Element = serde_json::from_str(json).unwrap();
assert_eq!(element.validate().len(), 1);
}
#[test]
fn an_archetype_id_on_the_wrong_class_is_reported() {
let wrong = Evaluation::new(
attrs("Problem", "openEHR-EHR-OBSERVATION.blood_pressure.v2").with_archetype_details(
Archetyped::new("openEHR-EHR-OBSERVATION.blood_pressure.v2", "1.1.0").unwrap(),
),
entry_attrs(),
structure(),
);
let report = Entry::from(wrong).validate();
assert_eq!(report.len(), 1);
assert_eq!(
report.violations()[0].invariant,
"Archetype_id_rm_entity_matches"
);
}
#[test]
fn a_coded_text_that_contradicts_its_own_code_is_reported() {
let json = r#"{
"name": {"value": "Category"},
"archetype_node_id": "at0001",
"value": {"_type": "DV_CODED_TEXT", "value": "deletion",
"defining_code": {"terminology_id": {"value": "openehr"}, "code_string": "249"}}
}"#;
let element: Element = serde_json::from_str(json).unwrap();
let report = element.validate();
assert_eq!(report.len(), 1);
assert_eq!(report.violations()[0].invariant, "Value_is_rubric");
assert_eq!(report.violations()[0].path, "/value");
}
#[test]
fn violation_paths_locate_the_node_and_never_the_value() {
let marker = "ZZ-DISTINCTIVE-9999";
let bad_element: Element = serde_json::from_str(&format!(
r#"{{"name": {{"value": "{marker}"}}, "archetype_node_id": "at0004"}}"#
))
.unwrap();
let structure: ItemStructure = ItemSingle::new(attrs("d", "at0001"), bad_element).into();
let report = structure.validate();
assert_eq!(report.violations()[0].path, "/item");
assert!(!report.to_string().contains(marker), "{report}");
}
#[test]
fn an_event_before_its_history_origin_is_reported() {
let event = PointEvent::new(
attrs("early", "at0006"),
DvDateTime::new("2026-07-31T08:00:00Z").unwrap(),
structure(),
);
let history = History::new(
attrs("Event Series", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").unwrap(),
vec![event.into()],
None,
)
.unwrap();
let observation = Observation::new(
attrs("Obs", "openEHR-EHR-OBSERVATION.blood_pressure.v2").with_archetype_details(
Archetyped::new("openEHR-EHR-OBSERVATION.blood_pressure.v2", "1.1.0").unwrap(),
),
entry_attrs(),
history,
);
let report = Entry::from(observation).validate();
assert_eq!(report.len(), 1);
assert_eq!(report.violations()[0].invariant, "Time_after_origin");
assert_eq!(report.violations()[0].path, "/data/events[0]");
}
#[test]
fn every_violation_is_reported_not_just_the_first() {
let json = r#"{
"name": {"value": ""},
"archetype_node_id": "",
"value": {"_type": "DV_QUANTITY", "magnitude": 1.0, "units": "", "precision": -3}
}"#;
let element: Element = serde_json::from_str(json).unwrap();
let report = element.validate();
assert_eq!(report.len(), 4, "{report}");
}
#[test]
fn a_normal_flag_beside_an_abnormal_number_is_reported() {
let range = Interval::closed(
DataValue::Quantity(DvQuantity::new(3.5, "mmol/l").unwrap()),
DataValue::Quantity(DvQuantity::new(5.5, "mmol/l").unwrap()),
)
.unwrap();
let lying = DvQuantity::new(9.9, "mmol/l")
.unwrap()
.with_normal_range(range.clone())
.with_normal_status(CodePhrase::openehr(terminology::normal_status::NORMAL).unwrap());
let report = Element::new(attrs("K", "at0004"), DataValue::Quantity(lying)).validate();
assert_eq!(report.len(), 1, "{report}");
assert_eq!(
report.violations()[0].invariant,
"Normal_range_and_status_consistency"
);
let truthful = DvQuantity::new(9.9, "mmol/l")
.unwrap()
.with_normal_range(range.clone())
.with_normal_status(
CodePhrase::openehr(terminology::normal_status::VERY_HIGH).unwrap(),
);
assert!(
Element::new(attrs("K", "at0004"), DataValue::Quantity(truthful))
.validate()
.is_empty()
);
let in_range = DvQuantity::new(4.2, "mmol/l")
.unwrap()
.with_normal_range(range)
.with_normal_status(CodePhrase::openehr(terminology::normal_status::NORMAL).unwrap());
assert!(
Element::new(attrs("K", "at0004"), DataValue::Quantity(in_range))
.validate()
.is_empty()
);
}
#[test]
fn a_normal_status_outside_the_code_set_is_reported() {
let odd = DvQuantity::new(4.2, "mmol/l")
.unwrap()
.with_normal_status(CodePhrase::openehr("VERY-HIGH-INDEED").unwrap());
let report = Element::new(attrs("K", "at0004"), DataValue::Quantity(odd)).validate();
assert_eq!(report.len(), 1, "{report}");
assert_eq!(report.violations()[0].invariant, "Normal_status_validity");
}
#[test]
fn the_unlimited_precision_sentinel_validates() {
let unlimited = DvQuantity::new(1.5, "mg")
.unwrap()
.with_precision(-1)
.unwrap();
assert!(
Element::new(attrs("q", "at0004"), DataValue::Quantity(unlimited))
.validate()
.is_empty()
);
}
#[test]
fn a_valid_quantity_with_a_normal_range_still_validates() {
let q = DvQuantity::new(4.2, "mmol/l").unwrap();
let element = Element::new(attrs("Glucose", "at0004"), DataValue::Quantity(q));
assert!(element.validate().is_empty());
let _ = DvText::new("unused");
}
#[test]
fn an_ehr_reference_pointing_at_the_wrong_kind_of_thing_is_a_violation() {
use crate::base::{HierObjectId, ObjectId, ObjectRef};
use crate::rm::data_types::DvDateTime;
use crate::rm::ehr::Ehr;
let uid = HierObjectId::from_uid_str("87284370-2D4B-4E3D-A3F3-F303D2F4F34B").unwrap();
let reference =
|ty: &str| ObjectRef::new("local", ty, ObjectId::HierObjectId(uid.clone())).unwrap();
let ehr = || {
Ehr::new(
HierObjectId::from_uid_str("11111111-2222-3333-4444-555555555555").unwrap(),
uid.clone(),
reference("VERSIONED_EHR_STATUS"),
reference("VERSIONED_EHR_ACCESS"),
DvDateTime::new("2026-08-01T09:00:00Z").unwrap(),
)
.unwrap()
};
assert!(ehr().validate().is_empty());
let cases = [
(
ehr().with_composition(reference("CONTRIBUTION")),
"Compositions_valid",
),
(
ehr().with_contribution(reference("VERSIONED_COMPOSITION")),
"Contributions_valid",
),
(
ehr()
.with_folders(vec![reference("VERSIONED_COMPOSITION")])
.unwrap(),
"Folders_valid",
),
(
ehr()
.with_folders(vec![reference("VERSIONED_COMPOSITION")])
.unwrap(),
"Directory_valid",
),
];
for (subject, invariant) in cases {
let report = subject.validate();
assert!(
report.violations().iter().any(|v| v.invariant == invariant),
"{invariant} not reported: {report:?}"
);
}
}
#[test]
fn an_ehr_that_never_went_through_the_constructor_is_still_checked() {
use crate::rm::ehr::Ehr;
let json = r#"{
"system_id": {"_type":"HIER_OBJECT_ID","value":"11111111-2222-3333-4444-555555555555"},
"ehr_id": {"_type":"HIER_OBJECT_ID","value":"87284370-2D4B-4E3D-A3F3-F303D2F4F34B"},
"ehr_status": {"_type":"OBJECT_REF","namespace":"local","type":"EHR",
"id":{"_type":"HIER_OBJECT_ID","value":"87284370-2D4B-4E3D-A3F3-F303D2F4F34B"}},
"ehr_access": {"_type":"OBJECT_REF","namespace":"local","type":"EHR",
"id":{"_type":"HIER_OBJECT_ID","value":"87284370-2D4B-4E3D-A3F3-F303D2F4F34B"}},
"time_created": {"_type":"DV_DATE_TIME","value":"2026-08-01T09:00:00Z"}
}"#;
let ehr: Ehr = serde_json::from_str(json).expect("deserialization is lenient (J9.9)");
let report = ehr.validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Ehr_status_valid"),
"{report:?}"
);
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Ehr_access_valid"),
"{report:?}"
);
}
#[test]
fn an_interval_reports_openehrs_own_name_for_its_bounds_rule() {
use crate::base::Interval;
let err = Interval::closed(10i64, 1i64).unwrap_err().to_string();
assert!(err.contains("DV_INTERVAL"), "{err}");
assert!(err.contains("Limits_consistent"), "{err}");
}
#[test]
fn a_term_mapping_purpose_outside_openehrs_group_is_a_violation() {
use crate::rm::common::LocatableAttrs;
use crate::rm::data_structures::Element;
use crate::rm::data_types::{
CodePhrase, DataValue, DvCodedText, DvText, MappingMatch, TermMapping,
};
let mapped = |purpose_code: &str| {
let mapping = TermMapping::new(
CodePhrase::new("SNOMED-CT", "73211009").unwrap(),
MappingMatch::Equivalent,
)
.with_purpose(
DvCodedText::new("purpose", CodePhrase::new("openehr", purpose_code).unwrap())
.unwrap(),
);
let name = DvText::new("Diabetes").unwrap().with_mapping(mapping);
Element::new(
LocatableAttrs::new(name.into(), "at0004").unwrap(),
DataValue::Text(DvText::new("present").unwrap()),
)
};
let ok = mapped("669");
assert!(ok.validate().is_empty(), "{:?}", ok.validate());
let bad = mapped("9999");
let report = bad.validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Purpose_valid"),
"{report:?}"
);
}
#[test]
fn a_versioned_object_uid_may_not_carry_an_extension() {
use crate::base::{HierObjectId, ObjectId, ObjectRef};
use crate::rm::common::VersionedObject;
use crate::rm::data_types::DvDateTime;
let owner = ObjectRef::new(
"local",
"EHR",
ObjectId::HierObjectId(
HierObjectId::from_uid_str("87284370-2D4B-4E3D-A3F3-F303D2F4F34B").unwrap(),
),
)
.unwrap();
let at = DvDateTime::new("2026-08-01T09:00:00Z").unwrap();
let plain: VersionedObject<crate::rm::ehr::Composition> = VersionedObject::new(
HierObjectId::from_uid_str("87284370-2D4B-4E3D-A3F3-F303D2F4F34B").unwrap(),
owner.clone(),
at.clone(),
);
assert!(plain.validate().is_empty());
let extended: VersionedObject<crate::rm::ehr::Composition> = VersionedObject::new(
"87284370-2D4B-4E3D-A3F3-F303D2F4F34B::narrower"
.parse()
.unwrap(),
owner,
at,
);
let report = extended.validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Uid_validity"),
"{report:?}"
);
}
#[test]
fn an_entry_that_is_not_an_archetype_root_is_reported() {
let entry = Entry::from(Evaluation::new(
attrs("Problem", "at0000"),
entry_attrs(),
structure(),
));
let report = entry.validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Is_archetype_root"),
"{report:?}"
);
}
#[test]
fn an_entry_about_the_record_subject_must_name_a_party_self() {
use crate::rm::common::{PartyRelated, PartySelf};
let rooted = || {
attrs("Problem", "openEHR-EHR-EVALUATION.problem.v1").with_archetype_details(
Archetyped::new("openEHR-EHR-EVALUATION.problem.v1", "1.1.0").unwrap(),
)
};
let about = |subject: crate::rm::common::PartyProxy| {
Entry::from(Evaluation::new(
rooted(),
crate::rm::ehr::EntryAttrs::about(
CodePhrase::new("ISO_639-1", "en").unwrap(),
CodePhrase::new("IANA_character-sets", "UTF-8").unwrap(),
subject,
),
structure(),
))
};
assert!(
!about(PartySelf::anonymous().into())
.validate()
.violations()
.iter()
.any(|v| v.invariant == "Subject_validity")
);
let related = PartyRelated::new(
PartyIdentified::named("The patient").unwrap(),
crate::terminology::subject_relationship::SELF,
)
.unwrap();
let report = about(related.into()).validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Subject_validity"),
"{report:?}"
);
}
#[test]
fn a_reference_range_endpoint_may_not_carry_reference_ranges_of_its_own() {
use crate::base::Interval;
use crate::rm::data_types::{DataValue, DvQuantity, ReferenceRange};
let plain = |v: f64| DvQuantity::new(v, "mmol/l").unwrap();
let nested = plain(4.0).with_other_reference_range(ReferenceRange::new(
DvText::new("inner").unwrap(),
Interval::closed(
DataValue::Quantity(plain(1.0)),
DataValue::Quantity(plain(2.0)),
)
.unwrap(),
));
let subject = plain(5.0).with_other_reference_range(ReferenceRange::new(
DvText::new("normal").unwrap(),
Interval::closed(
DataValue::Quantity(nested),
DataValue::Quantity(plain(6.0)),
)
.unwrap(),
));
let report = DataValue::Quantity(subject).validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Range_is_simple"),
"{report:?}"
);
let ok = plain(5.0).with_other_reference_range(ReferenceRange::new(
DvText::new("normal").unwrap(),
Interval::closed(
DataValue::Quantity(plain(4.0)),
DataValue::Quantity(plain(6.0)),
)
.unwrap(),
));
assert!(
!DataValue::Quantity(ok)
.validate()
.violations()
.iter()
.any(|v| v.invariant == "Range_is_simple")
);
}
#[test]
fn a_version_envelope_is_checked_on_data_that_arrived_as_json() {
let base = |lifecycle: &str, extra: &str| {
format!(
r#"{{
"_type": "ORIGINAL_VERSION",
"uid": {{"_type":"OBJECT_VERSION_ID","value":"87284370-2D4B-4E3D-A3F3-F303D2F4F34B::s::1"}},
"lifecycle_state": {{"_type":"DV_CODED_TEXT","value":"x",
"defining_code":{{"_type":"CODE_PHRASE","terminology_id":{{"value":"openehr"}},"code_string":"{lifecycle}"}}}},
"commit_audit": {{"_type":"AUDIT_DETAILS","system_id":"s",
"time_committed":{{"_type":"DV_DATE_TIME","value":"2026-08-01T09:00:00Z"}},
"change_type":{{"_type":"DV_CODED_TEXT","value":"creation",
"defining_code":{{"_type":"CODE_PHRASE","terminology_id":{{"value":"openehr"}},"code_string":"249"}}}},
"committer":{{"_type":"PARTY_IDENTIFIED","name":"N"}}}},
"contribution": {{"_type":"OBJECT_REF","namespace":"local","type":"EHR",
"id":{{"_type":"HIER_OBJECT_ID","value":"87284370-2D4B-4E3D-A3F3-F303D2F4F34B"}}}}
{extra}
}}"#
)
};
let parse = |json: &str| {
serde_json::from_str::<crate::rm::common::Version<crate::rm::ehr::Composition>>(json)
.expect("deserialization is lenient by design (J9.9)")
};
let reports = |json: &str| parse(json).validate();
assert!(
reports(&base("9999", ""))
.violations()
.iter()
.any(|v| v.invariant == "Lifecycle_state_valid")
);
assert!(
reports(&base("532", ""))
.violations()
.iter()
.any(|v| v.invariant == "Data_valid")
);
let with_predecessor = base(
"532",
r#", "preceding_version_uid": {"_type":"OBJECT_VERSION_ID","value":"87284370-2D4B-4E3D-A3F3-F303D2F4F34B::s::9"}"#,
);
assert!(
reports(&with_predecessor)
.violations()
.iter()
.any(|v| v.invariant == "Preceding_version_uid_validity")
);
assert!(parse(&base("9999", "")).validate_ok().is_err());
let deleted = parse(&base("523", ""));
assert!(
!deleted
.validate()
.violations()
.iter()
.any(|v| v.invariant == "Data_valid"),
"a deleted version may carry no data"
);
}
#[test]
fn an_ehr_access_and_a_party_must_be_archetype_roots() {
use crate::rm::demographic::{Party, PartyAttrs, PartyIdentity, Person};
use crate::security::access::EhrAccess;
let rooted = |archetype: &str| {
attrs("subject", archetype).with_archetype_details(
Archetyped::new(archetype, "1.1.0").expect("literal"),
)
};
let bare = EhrAccess::new(attrs("access", "at0000"));
assert!(
bare.validate()
.violations()
.iter()
.any(|v| v.invariant == "Is_archetype_root" && v.class == "EHR_ACCESS")
);
assert!(
EhrAccess::new(rooted("openEHR-EHR-EHR_ACCESS.generic.v1"))
.validate()
.is_empty()
);
let identity = PartyIdentity::new(
attrs("legal name", "at0001"),
crate::rm::data_structures::ItemStructure::Single(
crate::rm::data_structures::ItemSingle::new(
attrs("name", "at0002"),
Element::new(
attrs("full name", "at0003"),
DataValue::Text(DvText::new("A Patient").expect("literal")),
),
),
),
);
let person = |locatable: crate::rm::common::LocatableAttrs| {
let with_uid = locatable.with_uid(crate::base::UidBasedId::HierObjectId(
crate::base::HierObjectId::from_uid_str("6BA7B810-9DAD-11D1-80B4-00C04FD430C8")
.expect("literal"),
));
Party::Person(Person::new(
PartyAttrs::new(with_uid, vec![identity.clone()]).expect("literal"),
))
};
assert!(
person(attrs("patient", "at0000"))
.validate()
.violations()
.iter()
.any(|v| v.invariant == "Is_archetype_root" && v.class == "PARTY")
);
assert!(
person(rooted("openEHR-DEMOGRAPHIC-PERSON.person.v1"))
.validate()
.is_empty()
);
}
#[test]
fn a_violation_nested_in_a_section_or_an_event_is_still_reported() {
use crate::rm::data_structures::{History, PointEvent};
use crate::rm::ehr::{ContentItem, Evaluation, Observation, Section};
let irregular = || -> crate::rm::data_structures::ItemStructure {
let cell = |node: &str| {
Element::new(attrs("cell", node), DataValue::Count(DvCount::new(1))).into()
};
crate::rm::data_structures::ItemTable::new(
attrs("table", "at0009"),
vec![
Cluster::new(attrs("row 1", "at0010"), vec![cell("at0011")]).expect("literal"),
Cluster::new(
attrs("row 2", "at0012"),
vec![cell("at0013"), cell("at0014")],
)
.expect("literal"),
],
)
.into()
};
let entry_root = |archetype: &str| {
attrs("nested", archetype)
.with_archetype_details(Archetyped::new(archetype, "1.1.0").expect("literal"))
};
let sectioned = composition().with_content(
Section::new(
attrs("Findings", "at0002"),
vec![ContentItem::Entry(
Evaluation::new(
entry_root("openEHR-EHR-EVALUATION.problem.v1"),
entry_attrs(),
irregular(),
)
.into(),
)],
)
.into(),
);
let report = sectioned.validate();
assert!(
report
.violations()
.iter()
.any(|v| v.path.contains("/items")),
"nothing inside the section was walked: {report}"
);
let observed = Observation::new(
entry_root("openEHR-EHR-OBSERVATION.blood_pressure.v2"),
entry_attrs(),
History::new(
attrs("Event Series", "at0001"),
DvDateTime::new("2026-07-31T09:00:00Z").expect("literal"),
vec![
PointEvent::new(
attrs("any event", "at0006"),
DvDateTime::new("2026-07-31T09:15:00Z").expect("literal"),
irregular(),
)
.into(),
],
None,
)
.expect("literal"),
);
let report = Entry::from(observed).validate();
assert!(
report
.violations()
.iter()
.any(|v| v.path.contains("/events")),
"nothing inside the event was walked: {report}"
);
}
#[test]
fn a_bad_normal_status_is_reported_for_every_ordered_type() {
use crate::rm::data_types::{DvOrdinal, DvProportion, DvScale, ProportionKind};
let bogus = || CodePhrase::new("openehr", "ZZ").expect("literal");
let symbol = || {
DvCodedText::new("symbol", CodePhrase::new("local", "at0001").expect("literal"))
.expect("literal")
};
let values = [
DataValue::Count(DvCount::new(1).with_normal_status(bogus())),
DataValue::Proportion(
DvProportion::new(1.0, 2.0, ProportionKind::Ratio)
.expect("literal")
.with_normal_status(bogus()),
),
DataValue::Ordinal(DvOrdinal::new(1, symbol()).with_normal_status(bogus())),
DataValue::Scale(
DvScale::new(1.0, symbol())
.expect("literal")
.with_normal_status(bogus()),
),
DataValue::Quantity(
DvQuantity::new(1.0, "mm[Hg]")
.expect("literal")
.with_normal_status(bogus()),
),
];
for value in values {
let kind = value.type_name();
let report = value.validate();
assert!(
report
.violations()
.iter()
.any(|v| v.invariant == "Normal_status_validity"),
"{kind} did not have its normal_status checked: {report}"
);
}
}
}