1use 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}