Skip to main content

graphforge_knowledge/
supersession.rs

1//! Append-only M21 assertion supersession relations.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{
7    Array, FixedSizeBinaryArray, FixedSizeBinaryBuilder, TimestampMicrosecondArray,
8    TimestampMicrosecondBuilder, UInt32Array, UInt32Builder,
9};
10use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
11use arrow::record_batch::RecordBatch;
12use graphforge_core::canonical::{
13    CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
14};
15use uuid::{Uuid, Version};
16
17use crate::{
18    EPISTEMIC_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
19};
20
21/// Assertion-supersession record contract.
22pub const ASSERTION_SUPERSESSION_CONTRACT_VERSION: u32 = 1;
23/// Branch-preserving, non-selecting supersession policy.
24pub const ASSERTION_SUPERSESSION_POLICY_VERSION: u32 = 1;
25
26/// Authoritative `knowledge/assertion_supersessions.parquet` schema.
27pub static ASSERTION_SUPERSESSION_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
28    Arc::new(Schema::new(vec![
29        uuid_field("supersession_uuid"),
30        uuid_field("prior_assertion_uuid"),
31        uuid_field("replacement_assertion_uuid"),
32        uuid_field("status_event_uuid"),
33        uuid_field("reasoning_uuid"),
34        uuid_field("provenance_uuid"),
35        Field::new(
36            "recorded_at",
37            DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
38            false,
39        ),
40        Field::new("contract_version", DataType::UInt32, false),
41    ]))
42});
43
44static ASSERTION_SUPERSESSION_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
45    fingerprint(
46        CanonicalDomain::AssertionSupersession,
47        CANONICAL_CONTRACT_VERSION,
48        b"assertion_supersession/1|supersession_uuid:fixed[16]:required|prior_assertion_uuid:fixed[16]:required|replacement_assertion_uuid:fixed[16]:required|status_event_uuid:fixed[16]:required|reasoning_uuid:fixed[16]:required|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
49    )
50    .expect("registered assertion-supersession schema is within canonical bounds")
51});
52
53/// One immutable assertion-supersession relation.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct AssertionSupersession {
56    /// Caller-supplied UUIDv7 identity/idempotency key.
57    pub supersession_uuid: Uuid,
58    /// Existing assertion that becomes explicitly superseded.
59    pub prior_assertion_uuid: Uuid,
60    /// Existing replacement assertion.
61    pub replacement_assertion_uuid: Uuid,
62    /// Exact paired `superseded` status-event UUID.
63    pub status_event_uuid: Uuid,
64    /// Existing reasoning record attached to the prior assertion.
65    pub reasoning_uuid: Uuid,
66    /// Producing provenance event.
67    pub provenance_uuid: Uuid,
68    /// Transaction time shared with the paired status event.
69    pub recorded_at_micros: i64,
70    /// Frozen record contract.
71    pub contract_version: u32,
72}
73
74impl AssertionSupersession {
75    /// Validate and construct one immutable relation.
76    pub fn new(
77        supersession_uuid: Uuid,
78        prior_assertion_uuid: Uuid,
79        replacement_assertion_uuid: Uuid,
80        status_event_uuid: Uuid,
81        reasoning_uuid: Uuid,
82        provenance_uuid: Uuid,
83        recorded_at_micros: i64,
84    ) -> Result<Self, KnowledgeError> {
85        for (value, field) in [
86            (supersession_uuid, "supersession_uuid"),
87            (prior_assertion_uuid, "prior_assertion_uuid"),
88            (replacement_assertion_uuid, "replacement_assertion_uuid"),
89            (status_event_uuid, "status_event_uuid"),
90            (reasoning_uuid, "reasoning_uuid"),
91        ] {
92            require_v7(value, field)?;
93        }
94        require_uuid(provenance_uuid, "provenance_uuid")?;
95        if prior_assertion_uuid == replacement_assertion_uuid {
96            return Err(invalid(
97                "replacement_assertion_uuid",
98                "must differ from prior_assertion_uuid",
99            ));
100        }
101        Ok(Self {
102            supersession_uuid,
103            prior_assertion_uuid,
104            replacement_assertion_uuid,
105            status_event_uuid,
106            reasoning_uuid,
107            provenance_uuid,
108            recorded_at_micros,
109            contract_version: ASSERTION_SUPERSESSION_CONTRACT_VERSION,
110        })
111    }
112}
113
114/// Validated branch-preserving supersession participant.
115#[derive(Clone, Debug, Default, Eq, PartialEq)]
116pub struct AssertionSupersessionLedger {
117    /// Relations ordered by `(recorded_at, supersession_uuid)`.
118    relations: Vec<AssertionSupersession>,
119}
120
121impl AssertionSupersessionLedger {
122    /// Return validated relations in canonical order.
123    #[must_use]
124    pub fn relations(&self) -> &[AssertionSupersession] {
125        &self.relations
126    }
127
128    /// Validate, sort, and construct one complete participant.
129    pub fn new(mut relations: Vec<AssertionSupersession>) -> Result<Self, KnowledgeError> {
130        if relations.len() > MAX_KNOWLEDGE_ROWS {
131            return Err(KnowledgeError::Limit {
132                participant: "assertion_supersessions",
133                observed: relations.len(),
134                limit: MAX_KNOWLEDGE_ROWS,
135            });
136        }
137        let mut ids = HashSet::with_capacity(relations.len());
138        let mut status_ids = HashSet::with_capacity(relations.len());
139        for relation in &relations {
140            validate_relation(relation)?;
141            if !ids.insert(relation.supersession_uuid) {
142                return Err(KnowledgeError::Duplicate("supersession_uuid"));
143            }
144            if !status_ids.insert(relation.status_event_uuid) {
145                return Err(KnowledgeError::Duplicate("status_event_uuid"));
146            }
147        }
148        validate_acyclic(&relations)?;
149        relations.sort_by_key(|row| (row.recorded_at_micros, row.supersession_uuid));
150        Ok(Self { relations })
151    }
152
153    /// Merge append-only relations with exact replay semantics.
154    pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
155        let mut relations = self.relations.clone();
156        let mut by_id = relations
157            .iter()
158            .cloned()
159            .map(|row| (row.supersession_uuid, row))
160            .collect::<HashMap<_, _>>();
161        for relation in &staged.relations {
162            if let Some(existing) = by_id.get(&relation.supersession_uuid) {
163                if existing != relation {
164                    return Err(KnowledgeError::Conflict("supersession_uuid"));
165                }
166            } else {
167                relations.push(relation.clone());
168                by_id.insert(relation.supersession_uuid, relation.clone());
169            }
170        }
171        Self::new(relations)
172    }
173
174    /// Canonical fingerprint over one exact immutable relation.
175    pub fn relation_fingerprint(
176        &self,
177        supersession_uuid: Uuid,
178    ) -> Result<[u8; 32], KnowledgeError> {
179        let row = self
180            .relations
181            .iter()
182            .find(|row| row.supersession_uuid == supersession_uuid)
183            .ok_or(KnowledgeError::Dangling("supersession_uuid"))?;
184        let mut writer = CanonicalWriter::new();
185        for value in [
186            row.supersession_uuid,
187            row.prior_assertion_uuid,
188            row.replacement_assertion_uuid,
189            row.status_event_uuid,
190            row.reasoning_uuid,
191            row.provenance_uuid,
192        ] {
193            writer.raw(value.as_bytes())?;
194        }
195        writer.i64(row.recorded_at_micros)?;
196        writer.u32(row.contract_version)?;
197        fingerprint(
198            CanonicalDomain::AssertionSupersession,
199            CANONICAL_CONTRACT_VERSION,
200            &writer.finish(),
201        )
202        .map_err(Into::into)
203    }
204
205    /// Build the authoritative Arrow batch.
206    pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
207        let len = self.relations.len();
208        let mut supersessions = FixedSizeBinaryBuilder::with_capacity(len, 16);
209        let mut priors = FixedSizeBinaryBuilder::with_capacity(len, 16);
210        let mut replacements = FixedSizeBinaryBuilder::with_capacity(len, 16);
211        let mut statuses = FixedSizeBinaryBuilder::with_capacity(len, 16);
212        let mut reasoning = FixedSizeBinaryBuilder::with_capacity(len, 16);
213        let mut provenance = FixedSizeBinaryBuilder::with_capacity(len, 16);
214        let mut times = TimestampMicrosecondBuilder::with_capacity(len).with_timezone("UTC");
215        let mut versions = UInt32Builder::with_capacity(len);
216        for row in &self.relations {
217            for (builder, value, field) in [
218                (
219                    &mut supersessions,
220                    row.supersession_uuid,
221                    "supersession_uuid",
222                ),
223                (
224                    &mut priors,
225                    row.prior_assertion_uuid,
226                    "prior_assertion_uuid",
227                ),
228                (
229                    &mut replacements,
230                    row.replacement_assertion_uuid,
231                    "replacement_assertion_uuid",
232                ),
233                (&mut statuses, row.status_event_uuid, "status_event_uuid"),
234                (&mut reasoning, row.reasoning_uuid, "reasoning_uuid"),
235                (&mut provenance, row.provenance_uuid, "provenance_uuid"),
236            ] {
237                builder
238                    .append_value(value.as_bytes())
239                    .map_err(|_| invalid(field, "invalid UUID width"))?;
240            }
241            times.append_value(row.recorded_at_micros);
242            versions.append_value(row.contract_version);
243        }
244        RecordBatch::try_new(
245            Arc::clone(&ASSERTION_SUPERSESSION_SCHEMA),
246            vec![
247                Arc::new(supersessions.finish()),
248                Arc::new(priors.finish()),
249                Arc::new(replacements.finish()),
250                Arc::new(statuses.finish()),
251                Arc::new(reasoning.finish()),
252                Arc::new(provenance.finish()),
253                Arc::new(times.finish()),
254                Arc::new(versions.finish()),
255            ],
256        )
257        .map_err(|_| invalid("assertion_supersession", "Arrow batch construction failed"))
258    }
259
260    /// Decode and validate exact Arrow batches.
261    pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
262        let mut relations = Vec::new();
263        for batch in batches {
264            if batch.schema().as_ref() != ASSERTION_SUPERSESSION_SCHEMA.as_ref() {
265                return Err(invalid("assertion_supersession.schema", "schema mismatch"));
266            }
267            let supersessions = fixed(batch, "supersession_uuid")?;
268            let priors = fixed(batch, "prior_assertion_uuid")?;
269            let replacements = fixed(batch, "replacement_assertion_uuid")?;
270            let statuses = fixed(batch, "status_event_uuid")?;
271            let reasoning = fixed(batch, "reasoning_uuid")?;
272            let provenance = fixed(batch, "provenance_uuid")?;
273            let times = timestamp(batch, "recorded_at")?;
274            let versions = uint32(batch, "contract_version")?;
275            for row in 0..batch.num_rows() {
276                relations.push(AssertionSupersession {
277                    supersession_uuid: uuid_at(supersessions, row, "supersession_uuid")?,
278                    prior_assertion_uuid: uuid_at(priors, row, "prior_assertion_uuid")?,
279                    replacement_assertion_uuid: uuid_at(
280                        replacements,
281                        row,
282                        "replacement_assertion_uuid",
283                    )?,
284                    status_event_uuid: uuid_at(statuses, row, "status_event_uuid")?,
285                    reasoning_uuid: uuid_at(reasoning, row, "reasoning_uuid")?,
286                    provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
287                    recorded_at_micros: times.value(row),
288                    contract_version: versions.value(row),
289                });
290            }
291        }
292        Self::new(relations)
293    }
294}
295
296pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
297    SchemaRegistryEntry {
298        capability_id: "epistemic",
299        capability_version: EPISTEMIC_CAPABILITY_VERSION,
300        record_family: "assertion_supersessions",
301        record_version: ASSERTION_SUPERSESSION_CONTRACT_VERSION,
302        schema: Arc::clone(&ASSERTION_SUPERSESSION_SCHEMA),
303        schema_fingerprint: *ASSERTION_SUPERSESSION_SCHEMA_FINGERPRINT,
304        enum_registry_versions: &[(
305            "assertion_supersession_policy",
306            ASSERTION_SUPERSESSION_POLICY_VERSION,
307        )],
308        sort_key: &["recorded_at", "supersession_uuid"],
309        diff_identity_fields: &["supersession_uuid"],
310        diff_record_uuid_field: Some("supersession_uuid"),
311        fingerprint_domain: CanonicalDomain::AssertionSupersession,
312        owner: "graphforge-knowledge",
313        implementation_issue: 778,
314        max_rows: MAX_KNOWLEDGE_ROWS,
315    }
316}
317
318fn validate_acyclic(relations: &[AssertionSupersession]) -> Result<(), KnowledgeError> {
319    let mut adjacency = HashMap::<Uuid, Vec<Uuid>>::new();
320    for row in relations {
321        adjacency
322            .entry(row.prior_assertion_uuid)
323            .or_default()
324            .push(row.replacement_assertion_uuid);
325    }
326    let mut visiting = HashSet::new();
327    let mut acyclic = HashSet::new();
328    for node in adjacency.keys().copied() {
329        if !visit(node, &adjacency, &mut visiting, &mut acyclic) {
330            return Err(invalid("assertion_supersession", "cycle detected"));
331        }
332    }
333    Ok(())
334}
335
336fn visit(
337    node: Uuid,
338    adjacency: &HashMap<Uuid, Vec<Uuid>>,
339    visiting: &mut HashSet<Uuid>,
340    acyclic: &mut HashSet<Uuid>,
341) -> bool {
342    if acyclic.contains(&node) {
343        return true;
344    }
345    if !visiting.insert(node) {
346        return false;
347    }
348    if adjacency.get(&node).is_some_and(|next| {
349        next.iter()
350            .any(|child| !visit(*child, adjacency, visiting, acyclic))
351    }) {
352        return false;
353    }
354    visiting.remove(&node);
355    acyclic.insert(node);
356    true
357}
358
359fn validate_relation(row: &AssertionSupersession) -> Result<(), KnowledgeError> {
360    if row.contract_version != ASSERTION_SUPERSESSION_CONTRACT_VERSION {
361        return Err(invalid(
362            "assertion_supersession.contract_version",
363            "unsupported version",
364        ));
365    }
366    AssertionSupersession::new(
367        row.supersession_uuid,
368        row.prior_assertion_uuid,
369        row.replacement_assertion_uuid,
370        row.status_event_uuid,
371        row.reasoning_uuid,
372        row.provenance_uuid,
373        row.recorded_at_micros,
374    )
375    .map(|_| ())
376}
377
378fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
379    if value.get_version() != Some(Version::SortRand) {
380        return Err(invalid(field, "must be UUIDv7"));
381    }
382    require_uuid(value, field)
383}
384
385fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
386    if value.is_nil() {
387        return Err(invalid(field, "must not be nil"));
388    }
389    Ok(())
390}
391
392const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
393    KnowledgeError::Invalid { field, message }
394}
395
396fn uuid_field(name: &str) -> Field {
397    Field::new(name, DataType::FixedSizeBinary(16), false)
398}
399
400fn fixed<'a>(
401    batch: &'a RecordBatch,
402    name: &'static str,
403) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
404    batch
405        .column_by_name(name)
406        .and_then(|value| value.as_any().downcast_ref())
407        .ok_or_else(|| invalid(name, "column type mismatch"))
408}
409
410fn timestamp<'a>(
411    batch: &'a RecordBatch,
412    name: &'static str,
413) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
414    batch
415        .column_by_name(name)
416        .and_then(|value| value.as_any().downcast_ref())
417        .ok_or_else(|| invalid(name, "column type mismatch"))
418}
419
420fn uint32<'a>(
421    batch: &'a RecordBatch,
422    name: &'static str,
423) -> Result<&'a UInt32Array, KnowledgeError> {
424    batch
425        .column_by_name(name)
426        .and_then(|value| value.as_any().downcast_ref())
427        .ok_or_else(|| invalid(name, "column type mismatch"))
428}
429
430fn uuid_at(
431    values: &FixedSizeBinaryArray,
432    row: usize,
433    field: &'static str,
434) -> Result<Uuid, KnowledgeError> {
435    Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn uuid7(seed: u8) -> Uuid {
443        let mut bytes = [seed; 16];
444        bytes[6] = (bytes[6] & 0x0f) | 0x70;
445        bytes[8] = (bytes[8] & 0x3f) | 0x80;
446        Uuid::from_bytes(bytes)
447    }
448
449    fn relation(id: u8, prior: u8, replacement: u8, time: i64) -> AssertionSupersession {
450        AssertionSupersession::new(
451            uuid7(id),
452            uuid7(prior),
453            uuid7(replacement),
454            uuid7(id.wrapping_add(40)),
455            uuid7(id.wrapping_add(80)),
456            uuid7(id.wrapping_add(120)),
457            time,
458        )
459        .unwrap()
460    }
461
462    #[test]
463    fn chains_branches_order_round_trip_and_fingerprint_are_stable() {
464        let ledger = AssertionSupersessionLedger::new(vec![
465            relation(3, 10, 12, 2),
466            relation(2, 10, 11, 1),
467            relation(4, 11, 13, 2),
468        ])
469        .unwrap();
470        assert_eq!(ledger.relations()[0].supersession_uuid, uuid7(2));
471        let decoded =
472            AssertionSupersessionLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
473        assert_eq!(decoded, ledger);
474        assert_eq!(
475            decoded.relation_fingerprint(uuid7(3)).unwrap(),
476            ledger.relation_fingerprint(uuid7(3)).unwrap()
477        );
478    }
479
480    #[test]
481    fn self_links_cycles_duplicate_statuses_and_conflicts_fail() {
482        assert!(
483            AssertionSupersession::new(
484                uuid7(1),
485                uuid7(2),
486                uuid7(2),
487                uuid7(3),
488                uuid7(4),
489                uuid7(5),
490                1,
491            )
492            .is_err()
493        );
494        assert!(
495            AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1), relation(2, 11, 10, 2),])
496                .is_err()
497        );
498        let mut duplicate_status = relation(2, 11, 12, 2);
499        duplicate_status.status_event_uuid = uuid7(41);
500        assert!(
501            AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1), duplicate_status,])
502                .is_err()
503        );
504        let base = AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1)]).unwrap();
505        assert_eq!(base.merge(&base).unwrap(), base);
506        let conflict = AssertionSupersessionLedger::new(vec![relation(1, 10, 12, 1)]).unwrap();
507        assert!(matches!(
508            base.merge(&conflict),
509            Err(KnowledgeError::Conflict("supersession_uuid"))
510        ));
511    }
512}