openehr-rm 0.0.2

openEHR RM 1.2.0 Reference Model, generated from BMM
Documentation
//! Hand-written RM spec functions of `VERSION`.
//!
//! The surface is `canonical_form()` over an already-serialised Version value,
//! plus the subtype-dispatching `uid()` / `owner_id()` / `is_branch()` on the
//! closed `VERSION` subtype set.
//!
//! Spec authority:
//! - RM common §"Digital Signature"
//!   (`docs/specs/openehr/RM/docs/common/master06-change_control_package.adoc`):
//!   "a Version object (an `ORIGINAL_VERSION` or `IMPORTED_VERSION`) is
//!   serialised into canonical form which is then hashed to produce a digest …
//!   note that the signature attribute will be Void at this point". For an
//!   `IMPORTED_VERSION` "all attributes of the object are serialised".
//! - `VERSION.canonical_form()`
//!   (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.common.version.adoc`):
//!   "A canonical serial form of this Version, created by serialising all
//!   attributes except signature, suitable for generating reliable hashes and
//!   signatures."
//!
//! NOTE: the spec leaves the exact serialization `[.tbd]` ("not yet defined by
//! openEHR; ODIN might be preferred"). We serialise the Version to canonical
//! openEHR JSON (the native ITS-JSON codec's encoding — `_type`-tagged,
//! nulls/empties omitted) with the top-level `signature` attribute removed, then
//! canonicalise per RFC 8785 (JSON Canonicalization Scheme, `serde_jcs`) so the
//! signed bytes are deterministic (key ordering, number formatting, string
//! escaping pinned by the RFC). This is the single source of the signed bytes
//! for both signing and verification. That choice is OUR OWN extension — no
//! released openEHR text licenses it, which is exactly what the `[.tbd]` says;
//! register entry AMB-188 carries the adjudication, including why RFC 8785 over
//! the served JSON is preferred to the ODIN the spec speculates about.
//!
//! The signed input is always assembled as a `serde_json::Value` (the shape the
//! versioning service and the wire boundary already hold), so this module works
//! purely on a `Value` — the canonical-JSON serialization of a *typed* Version
//! is a wire-boundary concern that lives with the codec in `openehr-its`, not
//! here.

#![expect(
    clippy::disallowed_types,
    reason = "the wire-boundary validation reads the canonical JSON node before the typed decode \
              (#1694 boundary class)"
)]

use serde_json::Value;

use crate::common::change_control::version::Version;
use openehr_base::prelude::{HierObjectId, ObjectVersionId};

impl<T> Version<T> {
    /// `VERSION.uid`: the version's own three-part identifier.
    ///
    /// `VERSION` declares this abstract; each subtype effects it — an
    /// `ORIGINAL_VERSION` stores it, and an `IMPORTED_VERSION` derives it from
    /// the version it wraps (`Post: Result = item.uid`, RM
    /// `UML/classes/org.openehr.rm.common.imported_version.adoc` §Functions).
    /// This is that dispatch.
    #[must_use]
    pub fn uid(&self) -> &ObjectVersionId {
        match self {
            Version::OriginalVersion(original) => &original.uid,
            Version::ImportedVersion(imported) => imported.uid(),
        }
    }

    /// `VERSION.owner_id`: "Copy of the owning `VERSIONED_OBJECT._uid_` value;
    /// extracted from the local `_uid_` property's `_object_id_`"
    /// (`Post: Result.value.is_equal (uid.object_id.value)`; the same equality
    /// is the class's `Owner_id_valid` invariant).
    ///
    /// The extraction is the BASE accessor `OBJECT_VERSION_ID.object_id()`, so
    /// there is one lexical decoder for the three-part form rather than a
    /// second one here.
    #[must_use]
    pub fn owner_id(&self) -> HierObjectId {
        HierObjectId::from(self.uid().object_id())
    }

    /// `VERSION.is_branch`: "True if this Version represents a branch. Derived
    /// from `_uid_` attribute" — delegated to
    /// `OBJECT_VERSION_ID.is_branch()`, which is itself
    /// `version_tree_id.is_branch` (BASE
    /// `UML/classes/org.openehr.base.base_types.version_tree_id.adoc`
    /// §Functions: "has `_branch_number()_` and `_branch_version()_` parts").
    #[must_use]
    pub fn is_branch(&self) -> bool {
        self.uid().is_branch()
    }
}

/// Failure to produce a Version canonical form.
#[derive(Debug, thiserror::Error)]
pub enum CanonicalFormError {
    /// RFC 8785 (JCS) canonicalisation failed.
    #[error("RFC 8785 (JCS) canonicalisation: {0}")]
    Canonicalize(#[source] serde_json::Error),
}

/// Produces the spec `canonical_form` of a serialised Version JSON value.
///
/// The top-level `signature` attribute is dropped (Void during serialisation
/// per RM common §"Digital Signature") and the RFC 8785 (JCS) canonical string
/// is emitted.
///
/// The application service assembles the Version as a `serde_json::Value` before
/// persistence and calls this, so signing and verification share one source of
/// the signed bytes.
///
/// Spec authority: RM common §"Digital Signature"
/// (`docs/specs/openehr/RM/docs/common/master06-change_control_package.adoc`) and
/// `VERSION.canonical_form()`
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.common.version.adoc`).
///
/// # Errors
/// Returns [`CanonicalFormError::Canonicalize`] if RFC 8785 canonicalisation
/// of the value fails.
pub fn canonical_form_of_json(value: &Value) -> Result<String, CanonicalFormError> {
    let mut value = value.clone();
    if let Value::Object(map) = &mut value {
        map.remove("signature");
    }
    serde_jcs::to_string(&value).map_err(CanonicalFormError::Canonicalize)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::common::change_control::imported_version::ImportedVersion;
    use crate::common::change_control::original_version::OriginalVersion;
    use crate::common::generic::audit_details::{AuditDetails, AuditDetailsData};
    use crate::common::generic::party_proxy::PartyProxy;
    use crate::common::generic::party_self::PartySelf;
    use crate::data_types::quantity::date_time::dv_date_time::DvDateTime;
    use crate::data_types::text::code_phrase::CodePhrase;
    use crate::data_types::text::dv_coded_text::DvCodedText;
    use openehr_base::prelude::{ObjectId, ObjectRef, ObjectRefData, TerminologyId};

    /// A representative `ORIGINAL_VERSION` JSON whose `data` is a real corpus
    /// COMPOSITION (`openehr-its` vendored `minimal_persistent.json`), with fixed
    /// version metadata so the canonical form is deterministic.
    const COMPOSITION: &str = include_str!(
        "../../../../openehr-its/tests/vendor/openehr_sdk/composition/canonical_json/minimal_persistent.json"
    );

    /// Build the `ORIGINAL_VERSION` canonical-JSON `Value` around the corpus
    /// composition, with the given `signature` value (so tests can compare None
    /// vs Some). The signed input is assembled as a `Value` — the shape the
    /// versioning service holds — and canonicalised via [`canonical_form_of_json`].
    fn original_version(signature: Option<&str>) -> Value {
        let data: Value = serde_json::from_str(COMPOSITION).unwrap();
        let mut ov = serde_json::json!({
            "_type": "ORIGINAL_VERSION",
            "contribution": {
                "_type": "OBJECT_REF",
                "namespace": "local",
                "type": "CONTRIBUTION",
                "id": {
                    "_type": "HIER_OBJECT_ID",
                    "value": "0198f4a5-9df1-7d1e-8b6f-2b8c00000abc"
                }
            },
            "commit_audit": {
                "_type": "AUDIT_DETAILS",
                "system_id": "ferroehr.local",
                "time_committed": {
                    "_type": "DV_DATE_TIME",
                    "value": "2026-07-07T10:11:12.5Z"
                },
                "change_type": {
                    "_type": "DV_CODED_TEXT",
                    "value": "creation",
                    "defining_code": {
                        "_type": "CODE_PHRASE",
                        "terminology_id": { "_type": "TERMINOLOGY_ID", "value": "openehr" },
                        "code_string": "249"
                    }
                },
                "committer": { "_type": "PARTY_IDENTIFIED", "name": "FerroEHR" }
            },
            "uid": {
                "_type": "OBJECT_VERSION_ID",
                "value": "0198f4a5-9df1-7d1e-8b6f-2b8c00000001::ferroehr.local::1"
            },
            "lifecycle_state": {
                "_type": "DV_CODED_TEXT",
                "value": "complete",
                "defining_code": {
                    "_type": "CODE_PHRASE",
                    "terminology_id": { "_type": "TERMINOLOGY_ID", "value": "openehr" },
                    "code_string": "532"
                }
            },
            "data": data
        });
        if let Some(sig) = signature {
            ov.as_object_mut()
                .unwrap()
                .insert("signature".to_owned(), Value::String(sig.to_owned()));
        }
        ov
    }

    #[test]
    fn canonical_form_is_byte_stable_across_repeated_calls() {
        let ov = original_version(None);
        let a = canonical_form_of_json(&ov).unwrap();
        let b = canonical_form_of_json(&ov).unwrap();
        assert_eq!(a, b, "canonical_form must be deterministic");
    }

    #[test]
    fn canonical_form_independent_of_signature_value() {
        // RM common §"Digital Signature": the signature attribute is Void during
        // serialisation — so its stored value must not affect the canonical form.
        let none = canonical_form_of_json(&original_version(None)).unwrap();
        let some = canonical_form_of_json(&original_version(Some("sha256:tampered"))).unwrap();
        let other = canonical_form_of_json(&original_version(Some(
            "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----",
        )))
        .unwrap();
        assert_eq!(none, some);
        assert_eq!(none, other);
    }

    #[test]
    fn canonical_form_golden() {
        // Golden vector: pins the RFC 8785 canonical form of the corpus
        // ORIGINAL_VERSION so any accidental change to the algorithm is caught.
        let canonical = canonical_form_of_json(&original_version(None)).unwrap();
        insta::assert_snapshot!("original_version_canonical_form", canonical);
    }

    /// A plain `ORIGINAL_VERSION` whose `uid` is `value`, to exercise the
    /// identity-derived functions of `VERSION`.
    fn typed_original(value: &str) -> OriginalVersion<String> {
        OriginalVersion {
            contribution: ObjectRef::ObjectRef(ObjectRefData {
                namespace: "local".to_owned(),
                r#type: "CONTRIBUTION".to_owned(),
                id: ObjectId::HierObjectId(
                    HierObjectId::new("11111111-1111-4111-8111-111111111111".to_owned())
                        .expect("a well-formed identifier"),
                ),
            }),
            signature: None,
            commit_audit: AuditDetails::AuditDetails(AuditDetailsData {
                system_id: "ferroehr.local".to_owned(),
                time_committed: DvDateTime {
                    normal_status: None,
                    normal_range: None,
                    other_reference_ranges: openehr_base::containers::present_nonempty(Vec::new()),
                    magnitude_status: None,
                    accuracy: None,
                    value: "2026-07-07T10:11:12Z".to_owned(),
                },
                change_type: coded("249", "creation"),
                description: None,
                committer: PartyProxy::PartySelf(PartySelf { external_ref: None }),
            }),
            uid: ObjectVersionId::new(value.to_owned()).expect("a well-formed identifier"),
            preceding_version_uid: None,
            other_input_version_uids: openehr_base::containers::present_nonempty(Vec::new()),
            lifecycle_state: coded("532", "complete"),
            attestations: openehr_base::containers::present_nonempty(Vec::new()),
            data: Some("content".to_owned()),
        }
    }

    /// That same `ORIGINAL_VERSION` in the `VERSION` slot.
    fn typed_version(value: &str) -> Version<String> {
        Version::OriginalVersion(typed_original(value))
    }

    fn coded(code: &str, rubric: &str) -> DvCodedText {
        DvCodedText {
            value: rubric.to_owned(),
            hyperlink: None,
            formatting: None,
            mappings: openehr_base::containers::present_nonempty(Vec::new()),
            language: None,
            encoding: None,
            defining_code: CodePhrase {
                terminology_id: TerminologyId {
                    value: "openehr".to_owned(),
                },
                code_string: code.to_owned(),
                preferred_term: None,
            },
        }
    }

    /// `Post: Result.value.is_equal (uid.object_id.value)` — the same equality
    /// the `Owner_id_valid` invariant states.
    #[test]
    fn owner_id_is_the_uids_object_id() {
        let version = typed_version("8849182c-82ad-4088-a07f-48ead4180515::ferroehr.local::2.1.1");
        assert_eq!(
            version.owner_id().value(),
            "8849182c-82ad-4088-a07f-48ead4180515"
        );
        assert_eq!(
            version.owner_id(),
            HierObjectId::from(version.uid().object_id()),
            "Owner_id_valid"
        );
    }

    /// "True if this Version represents a branch. Derived from `uid` attribute"
    /// — a bare trunk version id is not a branch, a three-part
    /// `VERSION_TREE_ID` is.
    #[test]
    fn is_branch_follows_the_version_tree_id() {
        assert!(
            !typed_version("8849182c-82ad-4088-a07f-48ead4180515::ferroehr.local::2").is_branch()
        );
        assert!(
            typed_version("8849182c-82ad-4088-a07f-48ead4180515::ferroehr.local::2.1.1")
                .is_branch()
        );
    }

    /// The `IMPORTED_VERSION` arm of the same dispatch: the wrapper reports the
    /// WRAPPED original's identity, so both derived functions read the foreign
    /// version id (`Post: Result = item.uid`).
    #[test]
    fn an_imported_version_derives_its_identity_from_the_wrapped_original() {
        let item = typed_original("8849182c-82ad-4088-a07f-48ead4180515::remote.example::3.2.1");
        let wrapper = Version::ImportedVersion(ImportedVersion {
            contribution: ObjectRef::ObjectRef(ObjectRefData {
                namespace: "local".to_owned(),
                r#type: "CONTRIBUTION".to_owned(),
                id: ObjectId::HierObjectId(
                    HierObjectId::new("22222222-2222-4222-8222-222222222222".to_owned())
                        .expect("a well-formed identifier"),
                ),
            }),
            signature: Some("local-wrapper-signature".to_owned()),
            commit_audit: AuditDetails::AuditDetails(AuditDetailsData {
                system_id: "local.example".to_owned(),
                time_committed: DvDateTime {
                    normal_status: None,
                    normal_range: None,
                    other_reference_ranges: openehr_base::containers::present_nonempty(Vec::new()),
                    magnitude_status: None,
                    accuracy: None,
                    value: "2026-07-09T08:00:00Z".to_owned(),
                },
                change_type: coded("249", "creation"),
                description: None,
                committer: PartyProxy::PartySelf(PartySelf { external_ref: None }),
            }),
            item,
        });
        assert_eq!(
            wrapper.uid().value(),
            "8849182c-82ad-4088-a07f-48ead4180515::remote.example::3.2.1"
        );
        assert_eq!(
            wrapper.owner_id().value(),
            "8849182c-82ad-4088-a07f-48ead4180515"
        );
        assert!(wrapper.is_branch());
    }
}