Skip to main content

relay_knowledge/domain/core/
ontology.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3
4use super::{DomainError, SourceScope};
5
6/// Typed ontology node identity. Untyped legacy graph entities keep their label-derived ids.
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum OntologyEntityKind {
10    #[default]
11    Untyped,
12    BusinessDomain,
13    BusinessTerm,
14}
15
16impl OntologyEntityKind {
17    /// Stable storage and wire representation.
18    pub const fn as_str(self) -> &'static str {
19        match self {
20            Self::Untyped => "untyped",
21            Self::BusinessDomain => "business_domain",
22            Self::BusinessTerm => "business_term",
23        }
24    }
25}
26
27/// Immutable ontology identity independent of a display label.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct OntologyIdentity {
30    pub source_scope: SourceScope,
31    pub domain_id: String,
32    pub entity_id: String,
33    pub entity_kind: OntologyEntityKind,
34}
35
36impl OntologyIdentity {
37    /// Validates the scoped identity used to create stable typed entity ids.
38    pub fn new(
39        source_scope: SourceScope,
40        domain_id: impl Into<String>,
41        entity_id: impl Into<String>,
42        entity_kind: OntologyEntityKind,
43    ) -> Result<Self, DomainError> {
44        let domain_id = validate_identity_text("domain_id", domain_id.into())?;
45        let entity_id = validate_identity_text("entity_id", entity_id.into())?;
46        if entity_kind == OntologyEntityKind::Untyped {
47            return Err(DomainError::invalid(
48                "entity_kind",
49                "scoped ontology identities must be typed",
50            ));
51        }
52        Ok(Self {
53            source_scope,
54            domain_id,
55            entity_id,
56            entity_kind,
57        })
58    }
59
60    /// Returns a deterministic id that does not depend on the display name.
61    pub fn stable_entity_id(&self) -> String {
62        let mut digest = Sha256::new();
63        for part in [
64            self.source_scope.as_str(),
65            self.domain_id.as_str(),
66            self.entity_id.as_str(),
67            self.entity_kind.as_str(),
68        ] {
69            digest.update((part.len() as u64).to_be_bytes());
70            digest.update(part.as_bytes());
71        }
72        format!("ontology:{:x}", digest.finalize())
73    }
74}
75
76fn validate_identity_text(field: &'static str, value: String) -> Result<String, DomainError> {
77    let value = value.trim();
78    if value.is_empty() {
79        return Err(DomainError::invalid(field, "must not be empty"));
80    }
81    if value.len() > 128 {
82        return Err(DomainError::invalid(field, "must be 128 bytes or less"));
83    }
84    if value.contains('\0') {
85        return Err(DomainError::invalid(field, "must not contain NUL bytes"));
86    }
87    Ok(value.to_owned())
88}
89
90#[cfg(test)]
91#[path = "ontology_tests.rs"]
92mod tests;