Skip to main content

lodestone_types/
relationship.rs

1//! Mapping-owned relationship records and their canonical source projection.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{CollectionIndexRef, DocumentId, IndexMode, IndexName, IndexRecordModel};
9
10pub const RELATIONSHIP_RECORD_MODEL_FORMAT_VERSION: u16 = 1;
11pub const RELATIONSHIP_RECORD_FORMAT_VERSION: u16 = 1;
12pub const RELATIONSHIP_SOURCE_FORMAT_VERSION: u16 = 1;
13pub const MAX_RELATIONSHIP_RECORD_BYTES: usize = 1_048_576;
14pub const MAX_RELATIONSHIP_ID_BYTES: usize = 512;
15pub const MAX_RELATIONSHIP_LABEL_BYTES: usize = 256;
16pub const MAX_RELATIONSHIP_ENTITY_ID_BYTES: usize = 1_024;
17pub const MAX_RELATIONSHIP_PROVENANCE: usize = 32;
18pub const MAX_RELATIONSHIP_EVIDENCE: usize = 32;
19
20#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
21#[serde(deny_unknown_fields)]
22pub struct EntityRefV1 {
23    pub entity_kind: String,
24    pub entity_id: String,
25}
26
27#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
28#[serde(deny_unknown_fields)]
29pub struct RelationshipEvidenceRefV1 {
30    pub index: CollectionIndexRef,
31    pub index_name: IndexName,
32    pub record_id: DocumentId,
33}
34
35#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum RelationshipDirectionalityV1 {
38    Directed,
39    Undirected,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct RelationshipRecordV1 {
45    pub format_version: u16,
46    pub relationship_id: String,
47    pub source: EntityRefV1,
48    pub target: EntityRefV1,
49    pub relationship_type: String,
50    pub directionality: RelationshipDirectionalityV1,
51    pub valid_from_unix_nanos: Option<u64>,
52    pub valid_through_unix_nanos: Option<u64>,
53    pub provenance: Vec<String>,
54    pub evidence: Vec<RelationshipEvidenceRefV1>,
55    pub assertion_class: String,
56}
57
58impl RelationshipRecordV1 {
59    pub fn validate(
60        &self,
61        model: &IndexRecordModel,
62        containing_collection: crate::CollectionId,
63    ) -> Result<(), RelationshipRecordError> {
64        if self.format_version != RELATIONSHIP_RECORD_FORMAT_VERSION {
65            return Err(RelationshipRecordError::UnsupportedRecordVersion(self.format_version));
66        }
67        if !matches!(model, IndexRecordModel::RelationshipV1 { .. }) {
68            return Err(RelationshipRecordError::RecordModelMismatch);
69        }
70        validate_identifier(&self.relationship_id, MAX_RELATIONSHIP_ID_BYTES)?;
71        validate_entity(&self.source)?;
72        validate_entity(&self.target)?;
73        validate_label(&self.relationship_type)?;
74        validate_label(&self.assertion_class)?;
75        if self
76            .valid_from_unix_nanos
77            .zip(self.valid_through_unix_nanos)
78            .is_some_and(|(start, end)| start >= end)
79        {
80            return Err(RelationshipRecordError::InvalidValidityInterval);
81        }
82        validate_sorted_unique_labels(&self.provenance, MAX_RELATIONSHIP_PROVENANCE)?;
83        if self.evidence.len() > MAX_RELATIONSHIP_EVIDENCE || self.evidence.windows(2).any(|pair| pair[0] >= pair[1]) {
84            return Err(RelationshipRecordError::InvalidEvidenceReferences);
85        }
86        if self
87            .evidence
88            .iter()
89            .any(|reference| reference.index.collection_id() != containing_collection)
90        {
91            return Err(RelationshipRecordError::CrossCollectionEvidence);
92        }
93        let encoded = serde_json::to_vec(self).map_err(|error| RelationshipRecordError::Codec(error.to_string()))?;
94        if encoded.len() > MAX_RELATIONSHIP_RECORD_BYTES {
95            return Err(RelationshipRecordError::RecordBudgetExceeded(encoded.len()));
96        }
97        Ok(())
98    }
99
100    pub fn routing_identity_v1(
101        &self,
102        model: &IndexRecordModel,
103        containing_collection: crate::CollectionId,
104    ) -> Result<[u8; 32], RelationshipRecordError> {
105        self.validate(model, containing_collection)?;
106        let mut hasher = blake3::Hasher::new();
107        hasher.update(b"lodestone/relationship-routing/v1\0");
108        hasher.update(&(self.relationship_id.len() as u64).to_be_bytes());
109        hasher.update(self.relationship_id.as_bytes());
110        Ok(*hasher.finalize().as_bytes())
111    }
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(deny_unknown_fields)]
116pub struct CanonicalRelationshipSourceV1 {
117    pub format_version: u16,
118    pub relationship: RelationshipRecordV1,
119}
120
121pub fn canonical_relationship_source_v1(
122    relationship: RelationshipRecordV1,
123    model: &IndexRecordModel,
124    containing_collection: crate::CollectionId,
125) -> Result<(Vec<u8>, [u8; 32]), RelationshipRecordError> {
126    let routing = relationship.routing_identity_v1(model, containing_collection)?;
127    let source = CanonicalRelationshipSourceV1 {
128        format_version: RELATIONSHIP_SOURCE_FORMAT_VERSION,
129        relationship,
130    };
131    let encoded = serde_json::to_vec(&source).map_err(|error| RelationshipRecordError::Codec(error.to_string()))?;
132    if encoded.len() > MAX_RELATIONSHIP_RECORD_BYTES {
133        return Err(RelationshipRecordError::RecordBudgetExceeded(encoded.len()));
134    }
135    Ok((encoded, routing))
136}
137
138pub fn decode_canonical_relationship_source_v1(
139    bytes: &[u8],
140    model: &IndexRecordModel,
141    containing_collection: crate::CollectionId,
142) -> Result<(CanonicalRelationshipSourceV1, [u8; 32]), RelationshipRecordError> {
143    if bytes.len() > MAX_RELATIONSHIP_RECORD_BYTES {
144        return Err(RelationshipRecordError::RecordBudgetExceeded(bytes.len()));
145    }
146    let source: CanonicalRelationshipSourceV1 =
147        serde_json::from_slice(bytes).map_err(|error| RelationshipRecordError::Codec(error.to_string()))?;
148    if source.format_version != RELATIONSHIP_SOURCE_FORMAT_VERSION {
149        return Err(RelationshipRecordError::UnsupportedSourceVersion(source.format_version));
150    }
151    let routing = source.relationship.routing_identity_v1(model, containing_collection)?;
152    let canonical = serde_json::to_vec(&source).map_err(|error| RelationshipRecordError::Codec(error.to_string()))?;
153    if canonical != bytes {
154        return Err(RelationshipRecordError::NonCanonicalSource);
155    }
156    Ok((source, routing))
157}
158
159pub fn validate_relationship_record_model_v1(
160    format_version: u16,
161    mode: IndexMode,
162) -> Result<(), crate::MetricRecordError> {
163    if format_version != RELATIONSHIP_RECORD_MODEL_FORMAT_VERSION {
164        return Err(crate::MetricRecordError::UnsupportedRecordModelVersion(format_version));
165    }
166    if !matches!(mode, IndexMode::Mutable | IndexMode::AppendLog) {
167        return Err(crate::MetricRecordError::RelationshipModelRequiresMutableOrAppendLog);
168    }
169    Ok(())
170}
171
172fn validate_entity(entity: &EntityRefV1) -> Result<(), RelationshipRecordError> {
173    validate_label(&entity.entity_kind)?;
174    validate_identifier(&entity.entity_id, MAX_RELATIONSHIP_ENTITY_ID_BYTES)
175}
176
177fn validate_label(value: &str) -> Result<(), RelationshipRecordError> {
178    validate_identifier(value, MAX_RELATIONSHIP_LABEL_BYTES)
179}
180
181fn validate_identifier(value: &str, maximum: usize) -> Result<(), RelationshipRecordError> {
182    if value.is_empty() || value.len() > maximum || value.bytes().any(|byte| byte == 0 || byte <= 0x1f || byte == 0x7f)
183    {
184        return Err(RelationshipRecordError::InvalidIdentifier);
185    }
186    Ok(())
187}
188
189fn validate_sorted_unique_labels(values: &[String], maximum: usize) -> Result<(), RelationshipRecordError> {
190    if values.len() > maximum || values.windows(2).any(|pair| pair[0] >= pair[1]) {
191        return Err(RelationshipRecordError::InvalidProvenance);
192    }
193    for value in values {
194        validate_label(value)?;
195    }
196    Ok(())
197}
198
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub enum RelationshipRecordError {
201    UnsupportedRecordVersion(u16),
202    UnsupportedSourceVersion(u16),
203    RecordModelMismatch,
204    InvalidIdentifier,
205    InvalidValidityInterval,
206    InvalidProvenance,
207    InvalidEvidenceReferences,
208    CrossCollectionEvidence,
209    RecordBudgetExceeded(usize),
210    NonCanonicalSource,
211    Codec(String),
212}
213
214impl Display for RelationshipRecordError {
215    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
216        write!(formatter, "relationship record: {self:?}")
217    }
218}
219
220impl Error for RelationshipRecordError {}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::{CollectionId, IndexId};
226
227    fn model() -> IndexRecordModel {
228        IndexRecordModel::RelationshipV1 {
229            format_version: RELATIONSHIP_RECORD_MODEL_FORMAT_VERSION,
230        }
231    }
232
233    fn record(collection: CollectionId) -> Result<RelationshipRecordV1, Box<dyn Error>> {
234        Ok(RelationshipRecordV1 {
235            format_version: RELATIONSHIP_RECORD_FORMAT_VERSION,
236            relationship_id: "edge-1".to_owned(),
237            source: EntityRefV1 {
238                entity_kind: "service".to_owned(),
239                entity_id: "api".to_owned(),
240            },
241            target: EntityRefV1 {
242                entity_kind: "service".to_owned(),
243                entity_id: "db".to_owned(),
244            },
245            relationship_type: "calls".to_owned(),
246            directionality: RelationshipDirectionalityV1::Directed,
247            valid_from_unix_nanos: Some(10),
248            valid_through_unix_nanos: Some(20),
249            provenance: vec!["runtime".to_owned()],
250            evidence: vec![RelationshipEvidenceRefV1 {
251                index: CollectionIndexRef::new(collection, IndexId::new(7)?),
252                index_name: IndexName::new("evidence")?,
253                record_id: DocumentId::new("span-1")?,
254            }],
255            assertion_class: "observed".to_owned(),
256        })
257    }
258
259    #[test]
260    fn canonical_source_round_trips_and_routes_by_logical_relationship() -> Result<(), Box<dyn Error>> {
261        let collection = CollectionId::new(3)?;
262        let (encoded, routing) = canonical_relationship_source_v1(record(collection)?, &model(), collection)?;
263        let (decoded, decoded_routing) = decode_canonical_relationship_source_v1(&encoded, &model(), collection)?;
264        assert_eq!(decoded.relationship.relationship_id, "edge-1");
265        assert_eq!(routing, decoded_routing);
266        Ok(())
267    }
268
269    #[test]
270    fn invalid_interval_and_cross_collection_evidence_refuse() -> Result<(), Box<dyn Error>> {
271        let collection = CollectionId::new(3)?;
272        let mut invalid = record(collection)?;
273        invalid.valid_through_unix_nanos = Some(10);
274        assert_eq!(
275            invalid.validate(&model(), collection),
276            Err(RelationshipRecordError::InvalidValidityInterval)
277        );
278        let mut cross_collection = record(collection)?;
279        cross_collection.evidence[0].index = CollectionIndexRef::new(CollectionId::new(4)?, IndexId::new(7)?);
280        assert_eq!(
281            cross_collection.validate(&model(), collection),
282            Err(RelationshipRecordError::CrossCollectionEvidence)
283        );
284        Ok(())
285    }
286}