1use std::collections::HashSet;
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{FixedSizeBinaryBuilder, StringArray, TimestampMicrosecondArray, UInt32Array};
7use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
8use arrow::record_batch::RecordBatch;
9use graphforge_core::canonical::{CANONICAL_CONTRACT_VERSION, CanonicalDomain, fingerprint};
10use uuid::Uuid;
11
12use crate::{
13 KNOWLEDGE_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
14 check_limit, fixed_column, invalid, require_schema, require_uuid, require_v7, required_i64,
15 required_text, required_u32, string_column, timestamp_column, u32_column, uuid_at, uuid_field,
16};
17
18pub const RETENTION_DEPENDENCY_CONTRACT_VERSION: u32 = 1;
20pub const RETENTION_REQUIRED_KIND_REGISTRY_VERSION: u32 = 1;
22pub const RETENTION_DEPENDENCY_CLASS_REGISTRY_VERSION: u32 = 1;
24
25pub static RETENTION_DEPENDENCY_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
27 Arc::new(Schema::new(vec![
28 uuid_field("dependency_uuid", false),
29 uuid_field("scope_uuid", false),
30 uuid_field("required_uuid", false),
31 Field::new("required_kind", DataType::Utf8, false),
32 Field::new("dependency_class", DataType::Utf8, false),
33 uuid_field("provenance_uuid", false),
34 Field::new(
35 "recorded_at",
36 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
37 false,
38 ),
39 Field::new("contract_version", DataType::UInt32, false),
40 ]))
41});
42
43static RETENTION_DEPENDENCY_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
44 fingerprint(
45 CanonicalDomain::Schema,
46 CANONICAL_CONTRACT_VERSION,
47 b"retention_dependency/1|dependency_uuid:fixed[16]:required|scope_uuid:fixed[16]:required|required_uuid:fixed[16]:required|required_kind:utf8:required|dependency_class:utf8:required|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
48 )
49 .expect("registered retention dependency schema is within canonical bounds")
50});
51
52#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum RetentionRequiredKind {
56 Artifact,
58 EvidenceLink,
60 Source,
62 Assertion,
64}
65
66impl RetentionRequiredKind {
67 #[must_use]
69 pub const fn as_str(self) -> &'static str {
70 match self {
71 Self::Artifact => "artifact",
72 Self::EvidenceLink => "evidence_link",
73 Self::Source => "source",
74 Self::Assertion => "assertion",
75 }
76 }
77
78 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
79 match value {
80 "artifact" => Ok(Self::Artifact),
81 "evidence_link" => Ok(Self::EvidenceLink),
82 "source" => Ok(Self::Source),
83 "assertion" => Ok(Self::Assertion),
84 _ => Err(invalid("retention_required_kind", "unknown closed value")),
85 }
86 }
87}
88
89#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum RetentionDependencyClass {
93 RequiredLocalBytes,
95 OutsideReference,
97 ComparisonBaseline,
99}
100
101impl RetentionDependencyClass {
102 #[must_use]
104 pub const fn as_str(self) -> &'static str {
105 match self {
106 Self::RequiredLocalBytes => "required_local_bytes",
107 Self::OutsideReference => "outside_reference",
108 Self::ComparisonBaseline => "comparison_baseline",
109 }
110 }
111
112 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
113 match value {
114 "required_local_bytes" => Ok(Self::RequiredLocalBytes),
115 "outside_reference" => Ok(Self::OutsideReference),
116 "comparison_baseline" => Ok(Self::ComparisonBaseline),
117 _ => Err(invalid("dependency_class", "unknown closed value")),
118 }
119 }
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct RetentionDependency {
125 pub dependency_uuid: Uuid,
127 pub scope_uuid: Uuid,
129 pub required_uuid: Uuid,
131 pub required_kind: RetentionRequiredKind,
133 pub dependency_class: RetentionDependencyClass,
135 pub provenance_uuid: Uuid,
137 pub recorded_at_micros: i64,
139 pub contract_version: u32,
141}
142
143impl RetentionDependency {
144 pub fn new(
146 dependency_uuid: Uuid,
147 scope_uuid: Uuid,
148 required_uuid: Uuid,
149 required_kind: RetentionRequiredKind,
150 dependency_class: RetentionDependencyClass,
151 provenance_uuid: Uuid,
152 recorded_at_micros: i64,
153 ) -> Result<Self, KnowledgeError> {
154 let row = Self {
155 dependency_uuid,
156 scope_uuid,
157 required_uuid,
158 required_kind,
159 dependency_class,
160 provenance_uuid,
161 recorded_at_micros,
162 contract_version: RETENTION_DEPENDENCY_CONTRACT_VERSION,
163 };
164 validate_dependency(&row)?;
165 Ok(row)
166 }
167}
168
169#[derive(Clone, Debug, Default, Eq, PartialEq)]
171pub struct RetentionDependencyLedger {
172 pub dependencies: Vec<RetentionDependency>,
174}
175
176impl RetentionDependencyLedger {
177 pub fn new(mut dependencies: Vec<RetentionDependency>) -> Result<Self, KnowledgeError> {
179 dependencies.sort_by_key(|row| (row.recorded_at_micros, row.dependency_uuid));
180 validate_dependencies(&dependencies)?;
181 Ok(Self { dependencies })
182 }
183
184 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
186 let mut merged = self.dependencies.clone();
187 for row in &staged.dependencies {
188 if let Some(existing) = merged
189 .iter()
190 .find(|candidate| candidate.dependency_uuid == row.dependency_uuid)
191 {
192 if existing != row {
193 return Err(KnowledgeError::Conflict("dependency_uuid"));
194 }
195 continue;
196 }
197 merged.push(row.clone());
198 }
199 Self::new(merged)
200 }
201
202 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
204 dependency_batch(&self.dependencies)
205 }
206
207 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
209 let mut dependencies = Vec::new();
210 for batch in batches {
211 require_schema(
212 batch,
213 &RETENTION_DEPENDENCY_SCHEMA,
214 "retention_dependencies.schema",
215 )?;
216 let ids = fixed_column(batch, "dependency_uuid")?;
217 let scopes = fixed_column(batch, "scope_uuid")?;
218 let required = fixed_column(batch, "required_uuid")?;
219 let kinds = string_column(batch, "required_kind")?;
220 let classes = string_column(batch, "dependency_class")?;
221 let provenance = fixed_column(batch, "provenance_uuid")?;
222 let recorded = timestamp_column(batch, "recorded_at")?;
223 let versions = u32_column(batch, "contract_version")?;
224 for row in 0..batch.num_rows() {
225 dependencies.push(RetentionDependency {
226 dependency_uuid: uuid_at(ids, row, "dependency_uuid")?,
227 scope_uuid: uuid_at(scopes, row, "scope_uuid")?,
228 required_uuid: uuid_at(required, row, "required_uuid")?,
229 required_kind: RetentionRequiredKind::parse(required_text(
230 kinds,
231 row,
232 "required_kind",
233 )?)?,
234 dependency_class: RetentionDependencyClass::parse(required_text(
235 classes,
236 row,
237 "dependency_class",
238 )?)?,
239 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
240 recorded_at_micros: required_i64(recorded, row, "recorded_at")?,
241 contract_version: required_u32(versions, row, "contract_version")?,
242 });
243 }
244 }
245 Self::new(dependencies)
246 }
247}
248
249pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
250 SchemaRegistryEntry {
251 capability_id: "knowledge",
252 capability_version: KNOWLEDGE_CAPABILITY_VERSION,
253 record_family: "retention_dependencies",
254 record_version: RETENTION_DEPENDENCY_CONTRACT_VERSION,
255 schema: Arc::clone(&RETENTION_DEPENDENCY_SCHEMA),
256 schema_fingerprint: *RETENTION_DEPENDENCY_SCHEMA_FINGERPRINT,
257 enum_registry_versions: &[
258 ("required_kind", RETENTION_REQUIRED_KIND_REGISTRY_VERSION),
259 (
260 "dependency_class",
261 RETENTION_DEPENDENCY_CLASS_REGISTRY_VERSION,
262 ),
263 ],
264 sort_key: &["recorded_at", "dependency_uuid"],
265 diff_identity_fields: &["dependency_uuid"],
266 diff_record_uuid_field: Some("dependency_uuid"),
267 fingerprint_domain: CanonicalDomain::RetentionDependency,
268 owner: "graphforge-knowledge",
269 implementation_issue: 1349,
270 max_rows: MAX_KNOWLEDGE_ROWS,
271 }
272}
273
274fn validate_dependency(row: &RetentionDependency) -> Result<(), KnowledgeError> {
275 if row.contract_version != RETENTION_DEPENDENCY_CONTRACT_VERSION {
276 return Err(invalid(
277 "dependency.contract_version",
278 "unsupported version",
279 ));
280 }
281 require_v7(row.dependency_uuid, "dependency_uuid")?;
282 require_uuid(row.scope_uuid, "scope_uuid")?;
283 require_uuid(row.required_uuid, "required_uuid")?;
284 require_uuid(row.provenance_uuid, "provenance_uuid")?;
285 Ok(())
286}
287
288fn validate_dependencies(dependencies: &[RetentionDependency]) -> Result<(), KnowledgeError> {
289 check_limit("retention_dependencies", dependencies.len())?;
290 let mut ids = HashSet::with_capacity(dependencies.len());
291 for row in dependencies {
292 validate_dependency(row)?;
293 if !ids.insert(row.dependency_uuid) {
294 return Err(KnowledgeError::Duplicate("dependency_uuid"));
295 }
296 }
297 Ok(())
298}
299
300fn dependency_batch(rows: &[RetentionDependency]) -> Result<RecordBatch, KnowledgeError> {
301 let mut ids = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
302 let mut scopes = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
303 let mut required = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
304 let mut provenance = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
305 for row in rows {
306 ids.append_value(row.dependency_uuid.as_bytes())?;
307 scopes.append_value(row.scope_uuid.as_bytes())?;
308 required.append_value(row.required_uuid.as_bytes())?;
309 provenance.append_value(row.provenance_uuid.as_bytes())?;
310 }
311 RecordBatch::try_new(
312 Arc::clone(&RETENTION_DEPENDENCY_SCHEMA),
313 vec![
314 Arc::new(ids.finish()),
315 Arc::new(scopes.finish()),
316 Arc::new(required.finish()),
317 Arc::new(StringArray::from_iter_values(
318 rows.iter().map(|row| row.required_kind.as_str()),
319 )),
320 Arc::new(StringArray::from_iter_values(
321 rows.iter().map(|row| row.dependency_class.as_str()),
322 )),
323 Arc::new(provenance.finish()),
324 Arc::new(
325 TimestampMicrosecondArray::from_iter_values(
326 rows.iter().map(|row| row.recorded_at_micros),
327 )
328 .with_timezone("UTC"),
329 ),
330 Arc::new(UInt32Array::from_iter_values(
331 rows.iter().map(|row| row.contract_version),
332 )),
333 ],
334 )
335 .map_err(Into::into)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::tests::uuid7;
342
343 #[test]
344 fn retention_dependency_round_trip() {
345 let local = RetentionDependency::new(
346 uuid7(1),
347 uuid7(2),
348 uuid7(3),
349 RetentionRequiredKind::Artifact,
350 RetentionDependencyClass::RequiredLocalBytes,
351 uuid7(4),
352 100,
353 )
354 .unwrap();
355 let outside = RetentionDependency::new(
356 uuid7(5),
357 uuid7(2),
358 uuid7(6),
359 RetentionRequiredKind::Artifact,
360 RetentionDependencyClass::OutsideReference,
361 uuid7(7),
362 200,
363 )
364 .unwrap();
365 let ledger = RetentionDependencyLedger::new(vec![local, outside]).unwrap();
366 let reopened = RetentionDependencyLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
367 assert_eq!(reopened, ledger);
368 }
369
370 #[test]
371 fn retention_registries_round_trip_and_reject_unknown_tokens() {
372 for kind in [
373 RetentionRequiredKind::Artifact,
374 RetentionRequiredKind::EvidenceLink,
375 RetentionRequiredKind::Source,
376 RetentionRequiredKind::Assertion,
377 ] {
378 assert_eq!(RetentionRequiredKind::parse(kind.as_str()).unwrap(), kind);
379 }
380 for class in [
381 RetentionDependencyClass::RequiredLocalBytes,
382 RetentionDependencyClass::OutsideReference,
383 RetentionDependencyClass::ComparisonBaseline,
384 ] {
385 assert_eq!(
386 RetentionDependencyClass::parse(class.as_str()).unwrap(),
387 class
388 );
389 }
390 assert!(RetentionRequiredKind::parse("node").is_err());
391 assert!(RetentionDependencyClass::parse("genealogy").is_err());
392 }
393}