Skip to main content

ringkernel_core/
provenance.rs

1//! PROV-O provenance attribution for message envelopes.
2//!
3//! Per `docs/superpowers/specs/2026-04-17-v1.1-vyngraph-gaps.md` section 3.4,
4//! this module provides optional per-message attribution metadata for NSAI
5//! (neuro-symbolic AI) reasoning chains such as VynGraph audit workloads.
6//!
7//! ## Overview
8//!
9//! The full W3C PROV-O vocabulary is large; v1.1 supports the eight core
10//! relations that cover 95%+ of NSAI reasoning chains:
11//!
12//! | Relation           | Types                | Example                                         |
13//! |--------------------|----------------------|-------------------------------------------------|
14//! | `wasAttributedTo`  | Entity   -> Agent    | fraud alert attributed to `fraud_triangle`      |
15//! | `wasGeneratedBy`   | Entity   -> Activity | consolidated balance generated by consolidation |
16//! | `wasDerivedFrom`   | Entity   -> Entity   | source-trace lineage for audit evidence         |
17//! | `used`             | Activity -> Entity   | SoD check used journal entries                  |
18//! | `wasInformedBy`    | Activity -> Activity | going-concern informed by cash-flow analysis    |
19//! | `wasAssociatedWith`| Activity -> Agent    | LLM generation associated with auditor prompt   |
20//! | `actedOnBehalfOf`  | Agent    -> Agent    | assistant acted on behalf of partner            |
21//! | `Plan` subtype     | n/a                  | audit programs as plans                         |
22//!
23//! Qualified relations (`qualifiedAttribution`, etc.) are deferred to v1.5 -
24//! HLC already provides the temporal qualification v1.1 needs.
25//!
26//! ## Design
27//!
28//! - Zero cost when unused: `MessageEnvelope::provenance` is `Option<_>`.
29//! - 4 inline relation slots cover the common case; overflow spills to an
30//!   off-band record referenced by `overflow_ref`.
31//! - `Plan` is an Entity subclass (matches PROV-O spec) tracked via `plan_id`.
32//! - All identifiers are `u64`; the caller (VynGraph) owns the ID namespace.
33//!
34//! ## Example
35//!
36//! ```
37//! use ringkernel_core::provenance::{
38//!     ProvNodeType, ProvRelationKind, ProvenanceBuilder,
39//! };
40//! use ringkernel_core::hlc::HlcTimestamp;
41//!
42//! // IDs are u64 — caller (e.g., VynGraph) owns the ID namespace
43//! const FRAUD_ALERT_ID: u64 = 0xDEAD_BEEF_0001;
44//! const FRAUD_TRIANGLE_AGENT: u64 = 0xA6E0_7751_0002;
45//! const GAAP_RUN_ACTIVITY: u64 = 0xAC71_7170_0003;
46//!
47//! let hdr = ProvenanceBuilder::new(ProvNodeType::Entity, FRAUD_ALERT_ID)
48//!     .with_timestamp(HlcTimestamp::now(1))
49//!     .with_relation(ProvRelationKind::WasAttributedTo, FRAUD_TRIANGLE_AGENT)
50//!     .with_relation(ProvRelationKind::WasGeneratedBy, GAAP_RUN_ACTIVITY)
51//!     .build()
52//!     .unwrap();
53//! assert_eq!(hdr.relation_count(), 2);
54//! ```
55
56use crate::hlc::HlcTimestamp;
57
58/// Maximum number of relations that fit inline (without overflow spill).
59///
60/// Chosen to cover 95%+ of NSAI reasoning steps (most have 1-3 PROV edges).
61pub const INLINE_RELATION_SLOTS: usize = 4;
62
63/// Maximum chain depth when walking `wasDerivedFrom` / `wasInformedBy`
64/// relations during validation. Guards against pathological graphs.
65pub const MAX_CHAIN_DEPTH: usize = 256;
66
67/// Classification of a PROV-O node.
68///
69/// Per the W3C PROV-O data model, a node is one of three top-level kinds
70/// (Entity, Activity, Agent). `Plan` is a Plan-subclass of Entity tracked
71/// explicitly so we can recognize audit programs without an extra lookup.
72#[derive(
73    Debug, Clone, Copy, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize,
74)]
75#[archive(compare(PartialEq))]
76#[repr(u8)]
77pub enum ProvNodeType {
78    /// A PROV Entity: data item, artifact, fact.
79    Entity = 0,
80    /// A PROV Activity: computation, reasoning step, consolidation run.
81    Activity = 1,
82    /// A PROV Agent: an auditor, an analytic, an LLM.
83    Agent = 2,
84    /// A Plan (Entity subclass) - e.g. an audit program.
85    Plan = 3,
86}
87
88impl ProvNodeType {
89    /// Try to parse from the wire `u8` discriminant.
90    pub const fn from_u8(value: u8) -> Option<Self> {
91        match value {
92            0 => Some(Self::Entity),
93            1 => Some(Self::Activity),
94            2 => Some(Self::Agent),
95            3 => Some(Self::Plan),
96            _ => None,
97        }
98    }
99
100    /// Convert to the `u8` discriminant.
101    pub const fn as_u8(self) -> u8 {
102        self as u8
103    }
104
105    /// Whether this node is Entity-like (Entity or Plan).
106    pub const fn is_entity(self) -> bool {
107        matches!(self, Self::Entity | Self::Plan)
108    }
109}
110
111/// PROV-O relation kind (edge label).
112///
113/// `None = 0` indicates an empty slot in [`ProvenanceHeader::relations`].
114/// This makes the zero-value representation safe and lets arrays of
115/// relations be default-initialised cheaply.
116#[derive(
117    Debug,
118    Clone,
119    Copy,
120    PartialEq,
121    Eq,
122    Hash,
123    Default,
124    rkyv::Archive,
125    rkyv::Serialize,
126    rkyv::Deserialize,
127)]
128#[archive(compare(PartialEq))]
129#[repr(u8)]
130pub enum ProvRelationKind {
131    /// Empty slot - no relation.
132    #[default]
133    None = 0,
134    /// Entity was attributed to Agent.
135    WasAttributedTo = 1,
136    /// Entity was generated by Activity.
137    WasGeneratedBy = 2,
138    /// Entity was derived from another Entity.
139    WasDerivedFrom = 3,
140    /// Activity used an Entity.
141    Used = 4,
142    /// Activity was informed by another Activity.
143    WasInformedBy = 5,
144    /// Activity was associated with an Agent.
145    WasAssociatedWith = 6,
146    /// Agent acted on behalf of another Agent.
147    ActedOnBehalfOf = 7,
148}
149
150impl ProvRelationKind {
151    /// Try to parse from the wire `u8` discriminant.
152    pub const fn from_u8(value: u8) -> Option<Self> {
153        match value {
154            0 => Some(Self::None),
155            1 => Some(Self::WasAttributedTo),
156            2 => Some(Self::WasGeneratedBy),
157            3 => Some(Self::WasDerivedFrom),
158            4 => Some(Self::Used),
159            5 => Some(Self::WasInformedBy),
160            6 => Some(Self::WasAssociatedWith),
161            7 => Some(Self::ActedOnBehalfOf),
162            _ => None,
163        }
164    }
165
166    /// Convert to the `u8` discriminant.
167    pub const fn as_u8(self) -> u8 {
168        self as u8
169    }
170
171    /// Whether this slot carries a real relation (kind != None).
172    pub const fn is_some(self) -> bool {
173        !matches!(self, Self::None)
174    }
175
176    /// Expected PROV-O source node type for this relation's subject.
177    ///
178    /// Useful for type-check helpers before adding a relation to a builder.
179    pub const fn expected_source_type(self) -> Option<ProvNodeType> {
180        match self {
181            Self::None => None,
182            Self::WasAttributedTo | Self::WasGeneratedBy | Self::WasDerivedFrom => {
183                Some(ProvNodeType::Entity)
184            }
185            Self::Used | Self::WasInformedBy | Self::WasAssociatedWith => {
186                Some(ProvNodeType::Activity)
187            }
188            Self::ActedOnBehalfOf => Some(ProvNodeType::Agent),
189        }
190    }
191}
192
193/// A single PROV-O relation edge (kind + target node ID).
194///
195/// Compact 16-byte representation (after alignment padding) packed into the
196/// inline relation array of [`ProvenanceHeader`].
197#[derive(
198    Debug, Clone, Copy, PartialEq, Eq, Default, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize,
199)]
200#[repr(C)]
201pub struct ProvRelation {
202    /// Relation label (`None` marks an empty slot).
203    pub kind: ProvRelationKind,
204    /// Target PROV node ID. Meaningless when `kind == None`.
205    pub target_id: u64,
206}
207
208impl ProvRelation {
209    /// An empty relation slot.
210    pub const EMPTY: Self = Self {
211        kind: ProvRelationKind::None,
212        target_id: 0,
213    };
214
215    /// Build a new relation.
216    pub const fn new(kind: ProvRelationKind, target_id: u64) -> Self {
217        Self { kind, target_id }
218    }
219
220    /// Whether this slot is populated.
221    pub const fn is_some(&self) -> bool {
222        self.kind.is_some()
223    }
224}
225
226/// PROV-O attribution metadata attached to a message envelope.
227///
228/// - Absent: `MessageEnvelope::provenance == None` - 1 byte overhead.
229/// - Present: ~`size_of::<ProvenanceHeader>()` bytes inline (verified by test
230///   [`tests::test_provenance_header_size`]). The spec notes a ~88 byte
231///   target; actual size depends on natural alignment and is asserted in the
232///   test module.
233/// - With >4 relations: one extra cache line referenced by `overflow_ref`
234///   (caller owns that off-band record).
235#[derive(Debug, Clone, Copy, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
236#[repr(C)]
237pub struct ProvenanceHeader {
238    /// Classification of the node this header describes.
239    pub node_type: ProvNodeType,
240
241    /// Globally unique PROV node ID (Entity/Activity/Agent/Plan).
242    pub node_id: u64,
243
244    /// Inline PROV relations. Empty slots have `kind == ProvRelationKind::None`.
245    /// When more than 4 relations are needed, callers set `overflow_ref`.
246    pub relations: [ProvRelation; INLINE_RELATION_SLOTS],
247
248    /// Optional off-band pointer to an extended relations record. `None`
249    /// when all relations fit inline.
250    pub overflow_ref: Option<u64>,
251
252    /// HLC timestamp of the PROV event (generation / attribution time).
253    pub prov_timestamp: HlcTimestamp,
254
255    /// Optional Plan ID when this activity is executed under a Plan.
256    pub plan_id: Option<u64>,
257}
258
259impl ProvenanceHeader {
260    /// Construct a header with all empty relation slots.
261    pub const fn new(node_type: ProvNodeType, node_id: u64) -> Self {
262        Self {
263            node_type,
264            node_id,
265            relations: [ProvRelation::EMPTY; INLINE_RELATION_SLOTS],
266            overflow_ref: None,
267            prov_timestamp: HlcTimestamp::zero(),
268            plan_id: None,
269        }
270    }
271
272    /// Number of populated inline relations.
273    pub fn relation_count(&self) -> usize {
274        self.relations.iter().filter(|r| r.is_some()).count()
275    }
276
277    /// Whether any overflow relations are referenced.
278    pub const fn has_overflow(&self) -> bool {
279        self.overflow_ref.is_some()
280    }
281
282    /// Iterate over the populated inline relations.
283    pub fn iter_relations(&self) -> impl Iterator<Item = &ProvRelation> {
284        self.relations.iter().filter(|r| r.is_some())
285    }
286
287    /// Find the first relation of a given kind, if present inline.
288    pub fn find_relation(&self, kind: ProvRelationKind) -> Option<&ProvRelation> {
289        self.relations.iter().find(|r| r.kind == kind)
290    }
291
292    /// Validate structural invariants that are cheap to check.
293    ///
294    /// - `node_id` must be non-zero (0 is reserved as "unset").
295    /// - At most one self-loop is allowed (would indicate a cycle).
296    /// - Relation kinds must be consistent with the declared `node_type` when
297    ///   they have an expected source type.
298    pub fn validate(&self) -> Result<(), ProvenanceError> {
299        if self.node_id == 0 {
300            return Err(ProvenanceError::InvalidNodeId);
301        }
302
303        for rel in self.iter_relations() {
304            if rel.target_id == self.node_id {
305                return Err(ProvenanceError::SelfLoop {
306                    node_id: self.node_id,
307                });
308            }
309            if let Some(expected) = rel.kind.expected_source_type() {
310                if expected != self.node_type
311                    && !(expected == ProvNodeType::Entity && self.node_type == ProvNodeType::Plan)
312                {
313                    return Err(ProvenanceError::KindTypeMismatch {
314                        kind: rel.kind,
315                        node_type: self.node_type,
316                    });
317                }
318            }
319        }
320
321        Ok(())
322    }
323}
324
325impl Default for ProvenanceHeader {
326    fn default() -> Self {
327        Self::new(ProvNodeType::Entity, 0)
328    }
329}
330
331/// Builder for [`ProvenanceHeader`].
332///
333/// Collects relations in a `Vec` so callers don't have to think about inline
334/// vs overflow slots. On `build`, the first `INLINE_RELATION_SLOTS` relations
335/// are packed inline; any remainder is signalled by setting `overflow_ref`.
336///
337/// Overflow record storage is the caller's responsibility; the builder just
338/// records the pointer.
339#[derive(Debug, Clone)]
340pub struct ProvenanceBuilder {
341    node_type: ProvNodeType,
342    node_id: u64,
343    relations: Vec<ProvRelation>,
344    overflow_ref: Option<u64>,
345    prov_timestamp: HlcTimestamp,
346    plan_id: Option<u64>,
347}
348
349impl ProvenanceBuilder {
350    /// Start a new builder for a given PROV node.
351    pub fn new(node_type: ProvNodeType, node_id: u64) -> Self {
352        Self {
353            node_type,
354            node_id,
355            relations: Vec::new(),
356            overflow_ref: None,
357            prov_timestamp: HlcTimestamp::zero(),
358            plan_id: None,
359        }
360    }
361
362    /// Set the HLC timestamp for the PROV event.
363    pub fn with_timestamp(mut self, ts: HlcTimestamp) -> Self {
364        self.prov_timestamp = ts;
365        self
366    }
367
368    /// Set the Plan ID this activity follows (if any).
369    pub fn with_plan(mut self, plan_id: u64) -> Self {
370        self.plan_id = Some(plan_id);
371        self
372    }
373
374    /// Add a PROV-O relation edge. `None` kind is rejected by
375    /// [`ProvenanceBuilder::build`].
376    pub fn with_relation(mut self, kind: ProvRelationKind, target_id: u64) -> Self {
377        self.relations.push(ProvRelation::new(kind, target_id));
378        self
379    }
380
381    /// Explicitly set the overflow record pointer. Callers typically let
382    /// `build` set this automatically when more than 4 relations are added.
383    pub fn with_overflow_ref(mut self, overflow_ref: u64) -> Self {
384        self.overflow_ref = Some(overflow_ref);
385        self
386    }
387
388    /// Finalise the builder and validate the result.
389    ///
390    /// If more than [`INLINE_RELATION_SLOTS`] relations were added and
391    /// [`Self::with_overflow_ref`] was not called explicitly,
392    /// [`ProvenanceError::OverflowNotSet`] is returned - callers must make
393    /// the overflow record accessible through an out-of-band ID.
394    pub fn build(self) -> Result<ProvenanceHeader, ProvenanceError> {
395        // Reject explicit `None` kind entries - they'd collide with empty slots.
396        for rel in &self.relations {
397            if rel.kind == ProvRelationKind::None {
398                return Err(ProvenanceError::InvalidRelationKind);
399            }
400        }
401
402        let mut header = ProvenanceHeader::new(self.node_type, self.node_id);
403        header.prov_timestamp = self.prov_timestamp;
404        header.plan_id = self.plan_id;
405        header.overflow_ref = self.overflow_ref;
406
407        let inline_take = self.relations.len().min(INLINE_RELATION_SLOTS);
408        for (slot, rel) in header.relations[..inline_take]
409            .iter_mut()
410            .zip(self.relations.iter().take(inline_take))
411        {
412            *slot = *rel;
413        }
414
415        if self.relations.len() > INLINE_RELATION_SLOTS && self.overflow_ref.is_none() {
416            return Err(ProvenanceError::OverflowNotSet {
417                excess: self.relations.len() - INLINE_RELATION_SLOTS,
418            });
419        }
420
421        header.validate()?;
422        Ok(header)
423    }
424}
425
426/// Errors raised when constructing or validating provenance headers.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum ProvenanceError {
429    /// `node_id == 0` - reserved for "unset".
430    InvalidNodeId,
431    /// A relation's kind was set to `None`, which marks empty slots.
432    InvalidRelationKind,
433    /// The source `node_type` does not match the relation's expected type.
434    KindTypeMismatch {
435        /// The offending relation kind.
436        kind: ProvRelationKind,
437        /// The declared node type.
438        node_type: ProvNodeType,
439    },
440    /// A relation pointed back at the node itself (direct cycle).
441    SelfLoop {
442        /// The node ID whose relation looped back.
443        node_id: u64,
444    },
445    /// A chain traversal exceeded [`MAX_CHAIN_DEPTH`].
446    ChainTooDeep {
447        /// The depth at which traversal aborted.
448        depth: usize,
449    },
450    /// A cycle was detected while traversing a chain.
451    CycleDetected {
452        /// The node ID where the cycle closed.
453        at_node: u64,
454    },
455    /// More than 4 relations were added without setting `overflow_ref`.
456    OverflowNotSet {
457        /// Number of relations beyond the inline capacity.
458        excess: usize,
459    },
460}
461
462impl std::fmt::Display for ProvenanceError {
463    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        match self {
465            Self::InvalidNodeId => write!(f, "provenance node_id must be non-zero"),
466            Self::InvalidRelationKind => {
467                write!(f, "relation kind None is reserved for empty slots")
468            }
469            Self::KindTypeMismatch { kind, node_type } => write!(
470                f,
471                "relation kind {:?} is not valid from node type {:?}",
472                kind, node_type
473            ),
474            Self::SelfLoop { node_id } => {
475                write!(f, "relation targets the source node itself ({})", node_id)
476            }
477            Self::ChainTooDeep { depth } => {
478                write!(f, "provenance chain exceeded max depth ({})", depth)
479            }
480            Self::CycleDetected { at_node } => {
481                write!(f, "provenance chain cycle detected at node {}", at_node)
482            }
483            Self::OverflowNotSet { excess } => write!(
484                f,
485                "{} relations overflowed inline slots; call with_overflow_ref",
486                excess
487            ),
488        }
489    }
490}
491
492impl std::error::Error for ProvenanceError {}
493
494/// Walk a derivation / informing chain to validate bounded depth and the
495/// absence of cycles.
496///
497/// The caller supplies a closure that resolves each node ID to its (optional)
498/// predecessor in the chain. The walk bails out with
499/// [`ProvenanceError::CycleDetected`] if a node is revisited and with
500/// [`ProvenanceError::ChainTooDeep`] after [`MAX_CHAIN_DEPTH`] hops.
501pub fn validate_chain<F>(start: u64, mut next: F) -> Result<usize, ProvenanceError>
502where
503    F: FnMut(u64) -> Option<u64>,
504{
505    let mut seen: Vec<u64> = Vec::with_capacity(16);
506    let mut cursor = Some(start);
507    let mut depth = 0usize;
508
509    while let Some(node) = cursor {
510        if seen.contains(&node) {
511            return Err(ProvenanceError::CycleDetected { at_node: node });
512        }
513        seen.push(node);
514        depth += 1;
515        if depth > MAX_CHAIN_DEPTH {
516            return Err(ProvenanceError::ChainTooDeep { depth });
517        }
518        cursor = next(node);
519    }
520    Ok(depth)
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    // ------------------------------------------------------------------
528    // Type-level / representation tests
529    // ------------------------------------------------------------------
530
531    #[test]
532    fn test_provenance_header_size() {
533        // The spec quotes ~88 bytes as a target; we report the natural size
534        // after alignment. This assertion is here to flag unexpected layout
535        // drift during future changes.
536        let size = std::mem::size_of::<ProvenanceHeader>();
537        assert!(
538            size <= 160,
539            "ProvenanceHeader size {} exceeds reasonable envelope budget",
540            size
541        );
542        // Log for visibility when running cargo test -- --nocapture.
543        println!("ProvenanceHeader size: {} bytes", size);
544    }
545
546    #[test]
547    fn test_prov_node_type_roundtrip() {
548        for kind in [
549            ProvNodeType::Entity,
550            ProvNodeType::Activity,
551            ProvNodeType::Agent,
552            ProvNodeType::Plan,
553        ] {
554            assert_eq!(ProvNodeType::from_u8(kind.as_u8()), Some(kind));
555        }
556        assert_eq!(ProvNodeType::from_u8(99), None);
557    }
558
559    #[test]
560    fn test_prov_relation_kind_roundtrip() {
561        use ProvRelationKind::*;
562        for kind in [
563            None,
564            WasAttributedTo,
565            WasGeneratedBy,
566            WasDerivedFrom,
567            Used,
568            WasInformedBy,
569            WasAssociatedWith,
570            ActedOnBehalfOf,
571        ] {
572            assert_eq!(ProvRelationKind::from_u8(kind.as_u8()), Some(kind));
573        }
574        assert_eq!(ProvRelationKind::from_u8(200), Option::None);
575    }
576
577    #[test]
578    fn test_expected_source_type() {
579        use ProvRelationKind::*;
580        assert_eq!(
581            WasAttributedTo.expected_source_type(),
582            Some(ProvNodeType::Entity)
583        );
584        assert_eq!(Used.expected_source_type(), Some(ProvNodeType::Activity));
585        assert_eq!(
586            ActedOnBehalfOf.expected_source_type(),
587            Some(ProvNodeType::Agent)
588        );
589        assert_eq!(None.expected_source_type(), Option::None);
590    }
591
592    // ------------------------------------------------------------------
593    // Builder tests
594    // ------------------------------------------------------------------
595
596    #[test]
597    fn test_builder_basic() {
598        let hdr = ProvenanceBuilder::new(ProvNodeType::Entity, 0xDEADBEEF)
599            .with_relation(ProvRelationKind::WasAttributedTo, 0xA1)
600            .with_relation(ProvRelationKind::WasGeneratedBy, 0xA2)
601            .build()
602            .expect("valid provenance");
603
604        assert_eq!(hdr.node_type, ProvNodeType::Entity);
605        assert_eq!(hdr.node_id, 0xDEADBEEF);
606        assert_eq!(hdr.relation_count(), 2);
607        assert!(!hdr.has_overflow());
608        assert_eq!(
609            hdr.find_relation(ProvRelationKind::WasAttributedTo)
610                .map(|r| r.target_id),
611            Some(0xA1)
612        );
613    }
614
615    #[test]
616    fn test_builder_with_timestamp_and_plan() {
617        let ts = HlcTimestamp::new(1234, 5, 1);
618        let hdr = ProvenanceBuilder::new(ProvNodeType::Activity, 42)
619            .with_timestamp(ts)
620            .with_plan(99)
621            .with_relation(ProvRelationKind::WasAssociatedWith, 7)
622            .build()
623            .unwrap();
624        assert_eq!(hdr.prov_timestamp, ts);
625        assert_eq!(hdr.plan_id, Some(99));
626    }
627
628    #[test]
629    fn test_builder_fills_inline_slots_sequentially() {
630        let hdr = ProvenanceBuilder::new(ProvNodeType::Entity, 1)
631            .with_relation(ProvRelationKind::WasDerivedFrom, 10)
632            .with_relation(ProvRelationKind::WasDerivedFrom, 11)
633            .with_relation(ProvRelationKind::WasDerivedFrom, 12)
634            .with_relation(ProvRelationKind::WasDerivedFrom, 13)
635            .build()
636            .unwrap();
637
638        assert_eq!(hdr.relation_count(), 4);
639        assert!(!hdr.has_overflow());
640        for (i, r) in hdr.iter_relations().enumerate() {
641            assert_eq!(r.target_id, 10 + i as u64);
642        }
643    }
644
645    #[test]
646    fn test_builder_overflow_without_ref_rejected() {
647        let err = ProvenanceBuilder::new(ProvNodeType::Entity, 1)
648            .with_relation(ProvRelationKind::WasDerivedFrom, 10)
649            .with_relation(ProvRelationKind::WasDerivedFrom, 11)
650            .with_relation(ProvRelationKind::WasDerivedFrom, 12)
651            .with_relation(ProvRelationKind::WasDerivedFrom, 13)
652            .with_relation(ProvRelationKind::WasDerivedFrom, 14)
653            .build()
654            .unwrap_err();
655        assert_eq!(err, ProvenanceError::OverflowNotSet { excess: 1 });
656    }
657
658    #[test]
659    fn test_builder_overflow_with_ref_succeeds() {
660        let hdr = ProvenanceBuilder::new(ProvNodeType::Entity, 1)
661            .with_relation(ProvRelationKind::WasDerivedFrom, 10)
662            .with_relation(ProvRelationKind::WasDerivedFrom, 11)
663            .with_relation(ProvRelationKind::WasDerivedFrom, 12)
664            .with_relation(ProvRelationKind::WasDerivedFrom, 13)
665            .with_relation(ProvRelationKind::WasDerivedFrom, 14)
666            .with_relation(ProvRelationKind::WasDerivedFrom, 15)
667            .with_overflow_ref(0xBEEF)
668            .build()
669            .unwrap();
670        assert!(hdr.has_overflow());
671        assert_eq!(hdr.overflow_ref, Some(0xBEEF));
672        // Only 4 relations fit inline; the rest live in the overflow record.
673        assert_eq!(hdr.relation_count(), INLINE_RELATION_SLOTS);
674    }
675
676    #[test]
677    fn test_builder_rejects_none_relation() {
678        let err = ProvenanceBuilder::new(ProvNodeType::Entity, 1)
679            .with_relation(ProvRelationKind::None, 42)
680            .build()
681            .unwrap_err();
682        assert_eq!(err, ProvenanceError::InvalidRelationKind);
683    }
684
685    #[test]
686    fn test_builder_rejects_zero_node_id() {
687        let err = ProvenanceBuilder::new(ProvNodeType::Entity, 0)
688            .build()
689            .unwrap_err();
690        assert_eq!(err, ProvenanceError::InvalidNodeId);
691    }
692
693    #[test]
694    fn test_builder_rejects_self_loop() {
695        let err = ProvenanceBuilder::new(ProvNodeType::Entity, 7)
696            .with_relation(ProvRelationKind::WasDerivedFrom, 7)
697            .build()
698            .unwrap_err();
699        assert_eq!(err, ProvenanceError::SelfLoop { node_id: 7 });
700    }
701
702    #[test]
703    fn test_builder_rejects_kind_type_mismatch() {
704        // `Used` is Activity -> Entity; declaring from Entity is invalid.
705        let err = ProvenanceBuilder::new(ProvNodeType::Entity, 1)
706            .with_relation(ProvRelationKind::Used, 2)
707            .build()
708            .unwrap_err();
709        assert_eq!(
710            err,
711            ProvenanceError::KindTypeMismatch {
712                kind: ProvRelationKind::Used,
713                node_type: ProvNodeType::Entity
714            }
715        );
716    }
717
718    #[test]
719    fn test_plan_accepts_entity_relations() {
720        // Plans are a subclass of Entity, so Entity relations must work.
721        let hdr = ProvenanceBuilder::new(ProvNodeType::Plan, 100)
722            .with_relation(ProvRelationKind::WasAttributedTo, 200)
723            .build()
724            .unwrap();
725        assert_eq!(hdr.node_type, ProvNodeType::Plan);
726    }
727
728    // ------------------------------------------------------------------
729    // Chain validation tests
730    // ------------------------------------------------------------------
731
732    #[test]
733    fn test_validate_chain_simple() {
734        // 1 -> 2 -> 3 -> end
735        let parents = |n: u64| match n {
736            1 => Some(2),
737            2 => Some(3),
738            _ => None,
739        };
740        assert_eq!(validate_chain(1, parents).unwrap(), 3);
741    }
742
743    #[test]
744    fn test_validate_chain_detects_cycle() {
745        // 1 -> 2 -> 1 cycle
746        let parents = |n: u64| match n {
747            1 => Some(2),
748            2 => Some(1),
749            _ => None,
750        };
751        let err = validate_chain(1, parents).unwrap_err();
752        assert_eq!(err, ProvenanceError::CycleDetected { at_node: 1 });
753    }
754
755    #[test]
756    fn test_validate_chain_depth_bounded() {
757        // Unbounded linear chain should trip the depth guard.
758        let parents = |n: u64| Some(n + 1);
759        let err = validate_chain(1, parents).unwrap_err();
760        assert!(matches!(err, ProvenanceError::ChainTooDeep { .. }));
761    }
762
763    // ------------------------------------------------------------------
764    // Header helpers
765    // ------------------------------------------------------------------
766
767    #[test]
768    fn test_header_iter_relations_skips_empty() {
769        let mut hdr = ProvenanceHeader::new(ProvNodeType::Entity, 1);
770        hdr.relations[0] = ProvRelation::new(ProvRelationKind::WasAttributedTo, 10);
771        hdr.relations[2] = ProvRelation::new(ProvRelationKind::WasGeneratedBy, 20);
772        let targets: Vec<u64> = hdr.iter_relations().map(|r| r.target_id).collect();
773        assert_eq!(targets, vec![10, 20]);
774    }
775
776    #[test]
777    fn test_header_validate_rejects_zero_node() {
778        let hdr = ProvenanceHeader::new(ProvNodeType::Entity, 0);
779        assert_eq!(hdr.validate().unwrap_err(), ProvenanceError::InvalidNodeId);
780    }
781
782    #[test]
783    fn test_default_header_has_zero_node() {
784        // Default exists for rkyv-friendliness; callers must fill node_id.
785        let hdr = ProvenanceHeader::default();
786        assert_eq!(hdr.node_id, 0);
787        assert_eq!(hdr.relation_count(), 0);
788    }
789}