Skip to main content

silk/
entry.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use crate::clock::LamportClock;
5use crate::ontology::{Ontology, OntologyExtension};
6
7/// Property value — supports the types needed for graph node/edge properties.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(untagged)]
10pub enum Value {
11    Null,
12    Bool(bool),
13    Int(i64),
14    Float(f64),
15    String(String),
16    List(Vec<Value>),
17    Map(BTreeMap<String, Value>),
18}
19
20/// Graph operations — the payload of each Merkle-DAG entry.
21///
22/// `DefineOntology` must be the first (genesis) entry. All subsequent
23/// operations are validated against the ontology it defines.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "op")]
26pub enum GraphOp {
27    /// Genesis entry — defines the initial ontology (extendable via R-03 ExtendOntology).
28    /// Must be the first entry in the DAG (next = []).
29    #[serde(rename = "define_ontology")]
30    DefineOntology { ontology: Ontology },
31    #[serde(rename = "add_node")]
32    AddNode {
33        node_id: String,
34        node_type: String,
35        #[serde(default)]
36        subtype: Option<String>,
37        label: String,
38        #[serde(default)]
39        properties: BTreeMap<String, Value>,
40    },
41    #[serde(rename = "add_edge")]
42    AddEdge {
43        edge_id: String,
44        edge_type: String,
45        source_id: String,
46        target_id: String,
47        #[serde(default)]
48        properties: BTreeMap<String, Value>,
49    },
50    #[serde(rename = "update_property")]
51    UpdateProperty {
52        entity_id: String,
53        key: String,
54        value: Value,
55    },
56    #[serde(rename = "remove_node")]
57    RemoveNode { node_id: String },
58    #[serde(rename = "remove_edge")]
59    RemoveEdge { edge_id: String },
60    /// R-03: Extend the ontology with new types/properties (monotonic only).
61    #[serde(rename = "extend_ontology")]
62    ExtendOntology { extension: OntologyExtension },
63    /// Reserved: schema transform lenses for cross-ontology projection.
64    /// Opaque transforms field — interpretation is application-defined.
65    /// Reserved now to avoid future wire format break.
66    #[serde(rename = "define_lens")]
67    DefineLens { transforms: Vec<u8> },
68    /// R-08: Checkpoint entry — summarizes all prior state.
69    /// Contains synthetic ops that reconstruct the full graph when replayed.
70    /// After compaction, this becomes the new genesis (next=[]).
71    #[serde(rename = "checkpoint")]
72    Checkpoint {
73        /// Synthetic ops that reconstruct the graph state
74        ops: Vec<GraphOp>,
75        /// Per-op clocks: (physical_ms, logical) for each op.
76        /// Bug 6 fix: preserves per-entity clock metadata for correct LWW after compaction.
77        #[serde(default)]
78        op_clocks: Vec<(u64, u32)>,
79        /// Physical timestamp when compaction was performed
80        compacted_at_physical_ms: u64,
81        /// Logical timestamp when compaction was performed
82        compacted_at_logical: u32,
83    },
84}
85
86/// A 32-byte BLAKE3 hash, used as the content address for entries.
87pub type Hash = [u8; 32];
88
89/// A single entry in the Merkle-DAG operation log.
90///
91/// Each entry is content-addressed: `hash = BLAKE3(msgpack(signable_content))`.
92/// The hash covers the payload, causal links, and clock — NOT the hash itself.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Entry {
95    /// BLAKE3 hash of the signable content (payload + next + refs + clock + author)
96    pub hash: Hash,
97    /// The graph mutation (or genesis ontology definition)
98    pub payload: GraphOp,
99    /// Causal predecessors — hashes of the DAG heads at time of write
100    pub next: Vec<Hash>,
101    /// Reserved. Currently unused (always empty). Part of the hash computation
102    /// for wire format stability. May be used for skip-list traversal in future versions.
103    #[serde(default)]
104    pub refs: Vec<Hash>,
105    /// Lamport clock at time of creation
106    pub clock: LamportClock,
107    /// Author instance identifier
108    pub author: String,
109    /// D-027: ed25519 signature over the hash bytes (64 bytes). None for unsigned (pre-v0.3) entries.
110    #[serde(default)]
111    pub signature: Option<Vec<u8>>,
112    /// BLAKE3 hash of the resolved ontology at time of entry creation.
113    /// None for pre-migration entries. Not included in content hash (metadata only).
114    #[serde(default)]
115    pub ontology_hash: Option<Hash>,
116}
117
118/// The portion of an Entry that gets hashed. Signature is NOT included
119/// (the signature covers the hash, not vice versa).
120#[derive(Serialize)]
121struct SignableContent<'a> {
122    payload: &'a GraphOp,
123    next: &'a Vec<Hash>,
124    refs: &'a Vec<Hash>,
125    clock: &'a LamportClock,
126    author: &'a str,
127}
128
129impl Entry {
130    /// Create a new unsigned entry with computed BLAKE3 hash.
131    pub fn new(
132        payload: GraphOp,
133        next: Vec<Hash>,
134        refs: Vec<Hash>,
135        clock: LamportClock,
136        author: impl Into<String>,
137    ) -> Self {
138        let author = author.into();
139        let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
140        Self {
141            hash,
142            payload,
143            next,
144            refs,
145            clock,
146            author,
147            signature: None,
148            ontology_hash: None,
149        }
150    }
151
152    /// D-027: Create a new signed entry. Computes hash, then signs it with ed25519.
153    #[cfg(feature = "signing")]
154    pub fn new_signed(
155        payload: GraphOp,
156        next: Vec<Hash>,
157        refs: Vec<Hash>,
158        clock: LamportClock,
159        author: impl Into<String>,
160        signing_key: &ed25519_dalek::SigningKey,
161    ) -> Self {
162        use ed25519_dalek::Signer;
163        let author = author.into();
164        let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
165        let sig = signing_key.sign(&hash);
166        Self {
167            hash,
168            payload,
169            next,
170            refs,
171            clock,
172            author,
173            signature: Some(sig.to_bytes().to_vec()),
174            ontology_hash: None,
175        }
176    }
177
178    /// D-027: Verify the ed25519 signature on this entry against a public key.
179    /// Returns true if signature is valid, false if invalid.
180    /// Returns true if no signature is present (unsigned entry — backward compatible).
181    #[cfg(feature = "signing")]
182    pub fn verify_signature(&self, public_key: &ed25519_dalek::VerifyingKey) -> bool {
183        use ed25519_dalek::Verifier;
184        match &self.signature {
185            Some(sig_bytes) => {
186                if sig_bytes.len() != 64 {
187                    return false;
188                }
189                let mut sig_array = [0u8; 64];
190                sig_array.copy_from_slice(sig_bytes);
191                let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
192                public_key.verify(&self.hash, &sig).is_ok()
193            }
194            None => true, // unsigned entries accepted (migration mode)
195        }
196    }
197
198    /// Check whether this entry has a signature.
199    pub fn is_signed(&self) -> bool {
200        self.signature.is_some()
201    }
202
203    /// Compute the BLAKE3 hash of the signable content.
204    fn compute_hash(
205        payload: &GraphOp,
206        next: &Vec<Hash>,
207        refs: &Vec<Hash>,
208        clock: &LamportClock,
209        author: &str,
210    ) -> Hash {
211        let signable = SignableContent {
212            payload,
213            next,
214            refs,
215            clock,
216            author,
217        };
218        // Safety: rmp_serde serialization of #[derive(Serialize)] structs with known
219        // types (String, i64, bool, Vec, BTreeMap) cannot fail. Same pattern as sled/redb.
220        let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
221        *blake3::hash(&bytes).as_bytes()
222    }
223
224    /// Verify that the stored hash matches the content.
225    pub fn verify_hash(&self) -> bool {
226        let computed = Self::compute_hash(
227            &self.payload,
228            &self.next,
229            &self.refs,
230            &self.clock,
231            &self.author,
232        );
233        self.hash == computed
234    }
235
236    /// Serialize the entry to MessagePack bytes.
237    ///
238    /// Uses `expect()` because msgpack serialization of `#[derive(Serialize)]` structs
239    /// with known types cannot fail in practice. Converting to `Result` would add API
240    /// complexity for a failure mode that doesn't exist.
241    pub fn to_bytes(&self) -> Vec<u8> {
242        rmp_serde::to_vec(self).expect("entry serialization should not fail")
243    }
244
245    /// Deserialize an entry from MessagePack bytes.
246    pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
247        rmp_serde::from_slice(bytes)
248    }
249
250    /// Return the hash as a hex string (for display/debugging).
251    pub fn hash_hex(&self) -> String {
252        hex::encode(self.hash)
253    }
254}
255
256/// Encode a hash as hex string. Utility for display.
257pub fn hash_hex(hash: &Hash) -> String {
258    hex::encode(hash)
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::ontology::{EdgeTypeDef, NodeTypeDef, PropertyDef, ValueType};
265
266    fn sample_ontology() -> Ontology {
267        Ontology {
268            node_types: BTreeMap::from([
269                (
270                    "entity".into(),
271                    NodeTypeDef {
272                        description: None,
273                        properties: BTreeMap::from([
274                            (
275                                "ip".into(),
276                                PropertyDef {
277                                    value_type: ValueType::String,
278                                    required: false,
279                                    description: None,
280                                    constraints: None,
281                                },
282                            ),
283                            (
284                                "port".into(),
285                                PropertyDef {
286                                    value_type: ValueType::Int,
287                                    required: false,
288                                    description: None,
289                                    constraints: None,
290                                },
291                            ),
292                        ]),
293                        subtypes: None,
294                        parent_type: None,
295                    },
296                ),
297                (
298                    "signal".into(),
299                    NodeTypeDef {
300                        description: None,
301                        properties: BTreeMap::new(),
302                        subtypes: None,
303                        parent_type: None,
304                    },
305                ),
306            ]),
307            edge_types: BTreeMap::from([(
308                "RUNS_ON".into(),
309                EdgeTypeDef {
310                    description: None,
311                    source_types: vec!["entity".into()],
312                    target_types: vec!["entity".into()],
313                    properties: BTreeMap::new(),
314                },
315            )]),
316        }
317    }
318
319    fn sample_op() -> GraphOp {
320        GraphOp::AddNode {
321            node_id: "server-1".into(),
322            node_type: "entity".into(),
323            label: "Production Server".into(),
324            properties: BTreeMap::from([
325                ("ip".into(), Value::String("10.0.0.1".into())),
326                ("port".into(), Value::Int(8080)),
327            ]),
328            subtype: None,
329        }
330    }
331
332    fn sample_clock() -> LamportClock {
333        LamportClock::with_values("inst-a", 1, 0)
334    }
335
336    #[test]
337    fn entry_hash_deterministic() {
338        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
339        let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
340        assert_eq!(e1.hash, e2.hash);
341    }
342
343    #[test]
344    fn entry_hash_changes_on_mutation() {
345        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
346        let different_op = GraphOp::AddNode {
347            node_id: "server-2".into(),
348            node_type: "entity".into(),
349            label: "Other Server".into(),
350            properties: BTreeMap::new(),
351            subtype: None,
352        };
353        let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
354        assert_ne!(e1.hash, e2.hash);
355    }
356
357    #[test]
358    fn entry_hash_changes_with_different_author() {
359        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
360        let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
361        assert_ne!(e1.hash, e2.hash);
362    }
363
364    #[test]
365    fn entry_hash_changes_with_different_clock() {
366        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
367        let mut clock2 = sample_clock();
368        clock2.physical_ms = 99;
369        let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
370        assert_ne!(e1.hash, e2.hash);
371    }
372
373    #[test]
374    fn entry_hash_changes_with_different_next() {
375        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
376        let e2 = Entry::new(
377            sample_op(),
378            vec![[0u8; 32]],
379            vec![],
380            sample_clock(),
381            "inst-a",
382        );
383        assert_ne!(e1.hash, e2.hash);
384    }
385
386    #[test]
387    fn entry_verify_hash_valid() {
388        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
389        assert!(entry.verify_hash());
390    }
391
392    #[test]
393    fn entry_verify_hash_reject_tampered() {
394        let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
395        entry.author = "evil-node".into();
396        assert!(!entry.verify_hash());
397    }
398
399    #[test]
400    fn entry_roundtrip_msgpack() {
401        let entry = Entry::new(
402            sample_op(),
403            vec![[1u8; 32]],
404            vec![[2u8; 32]],
405            sample_clock(),
406            "inst-a",
407        );
408        let bytes = entry.to_bytes();
409        let decoded = Entry::from_bytes(&bytes).unwrap();
410        assert_eq!(entry, decoded);
411    }
412
413    #[test]
414    fn entry_next_links_causal() {
415        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
416        let e2 = Entry::new(
417            GraphOp::RemoveNode {
418                node_id: "server-1".into(),
419            },
420            vec![e1.hash],
421            vec![],
422            LamportClock::with_values("inst-a", 2, 0),
423            "inst-a",
424        );
425        assert_eq!(e2.next, vec![e1.hash]);
426        assert!(e2.verify_hash());
427    }
428
429    #[test]
430    fn graphop_all_variants_serialize() {
431        let ops = vec![
432            GraphOp::DefineOntology {
433                ontology: sample_ontology(),
434            },
435            sample_op(),
436            GraphOp::AddEdge {
437                edge_id: "e1".into(),
438                edge_type: "RUNS_ON".into(),
439                source_id: "svc-1".into(),
440                target_id: "server-1".into(),
441                properties: BTreeMap::new(),
442            },
443            GraphOp::UpdateProperty {
444                entity_id: "server-1".into(),
445                key: "cpu".into(),
446                value: Value::Float(85.5),
447            },
448            GraphOp::RemoveNode {
449                node_id: "server-1".into(),
450            },
451            GraphOp::RemoveEdge {
452                edge_id: "e1".into(),
453            },
454            GraphOp::ExtendOntology {
455                extension: crate::ontology::OntologyExtension {
456                    node_types: BTreeMap::from([(
457                        "metric".into(),
458                        NodeTypeDef {
459                            description: Some("A metric observation".into()),
460                            properties: BTreeMap::new(),
461                            subtypes: None,
462                            parent_type: None,
463                        },
464                    )]),
465                    edge_types: BTreeMap::new(),
466                    node_type_updates: BTreeMap::new(),
467                },
468            },
469            GraphOp::Checkpoint {
470                ops: vec![
471                    GraphOp::DefineOntology {
472                        ontology: sample_ontology(),
473                    },
474                    GraphOp::AddNode {
475                        node_id: "n1".into(),
476                        node_type: "entity".into(),
477                        subtype: None,
478                        label: "Node 1".into(),
479                        properties: BTreeMap::new(),
480                    },
481                ],
482                op_clocks: vec![(1, 0), (2, 0)],
483                compacted_at_physical_ms: 1000,
484                compacted_at_logical: 5,
485            },
486        ];
487        for op in ops {
488            let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
489            let bytes = entry.to_bytes();
490            let decoded = Entry::from_bytes(&bytes).unwrap();
491            assert_eq!(entry, decoded);
492        }
493    }
494
495    #[test]
496    fn genesis_entry_contains_ontology() {
497        let ont = sample_ontology();
498        let genesis = Entry::new(
499            GraphOp::DefineOntology {
500                ontology: ont.clone(),
501            },
502            vec![],
503            vec![],
504            LamportClock::new("inst-a"),
505            "inst-a",
506        );
507        match &genesis.payload {
508            GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
509            _ => panic!("genesis should be DefineOntology"),
510        }
511        assert!(genesis.next.is_empty(), "genesis has no predecessors");
512        assert!(genesis.verify_hash());
513    }
514
515    #[test]
516    fn value_all_variants_roundtrip() {
517        let values = vec![
518            Value::Null,
519            Value::Bool(true),
520            Value::Int(42),
521            Value::Float(3.14),
522            Value::String("hello".into()),
523            Value::List(vec![Value::Int(1), Value::String("two".into())]),
524            Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
525        ];
526        for val in values {
527            let bytes = rmp_serde::to_vec(&val).unwrap();
528            let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
529            assert_eq!(val, decoded);
530        }
531    }
532
533    #[test]
534    fn hash_hex_format() {
535        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
536        let hex = entry.hash_hex();
537        assert_eq!(hex.len(), 64);
538        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
539    }
540
541    #[test]
542    fn unsigned_entry_has_no_signature() {
543        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
544        assert!(!entry.is_signed());
545        assert!(entry.signature.is_none());
546    }
547
548    #[test]
549    fn unsigned_entry_roundtrip_preserves_none_signature() {
550        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
551        let bytes = entry.to_bytes();
552        let decoded = Entry::from_bytes(&bytes).unwrap();
553        assert_eq!(decoded.signature, None);
554        assert!(decoded.verify_hash());
555    }
556
557    #[cfg(feature = "signing")]
558    mod signing_tests {
559        use super::*;
560
561        fn test_keypair() -> ed25519_dalek::SigningKey {
562            use rand::rngs::OsRng;
563            ed25519_dalek::SigningKey::generate(&mut OsRng)
564        }
565
566        #[test]
567        fn signed_entry_roundtrip() {
568            let key = test_keypair();
569            let entry =
570                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
571
572            assert!(entry.is_signed());
573            assert!(entry.verify_hash());
574
575            let public = key.verifying_key();
576            assert!(entry.verify_signature(&public));
577        }
578
579        #[test]
580        fn signed_entry_serialization_roundtrip() {
581            let key = test_keypair();
582            let entry =
583                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
584
585            let bytes = entry.to_bytes();
586            let decoded = Entry::from_bytes(&bytes).unwrap();
587
588            assert!(decoded.is_signed());
589            assert!(decoded.verify_hash());
590            assert!(decoded.verify_signature(&key.verifying_key()));
591        }
592
593        #[test]
594        fn wrong_key_fails_verification() {
595            let key1 = test_keypair();
596            let key2 = test_keypair();
597
598            let entry =
599                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
600
601            // Correct key verifies
602            assert!(entry.verify_signature(&key1.verifying_key()));
603            // Wrong key fails
604            assert!(!entry.verify_signature(&key2.verifying_key()));
605        }
606
607        #[test]
608        fn tampered_hash_fails_both_checks() {
609            let key = test_keypair();
610            let mut entry =
611                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
612
613            // Tamper with the hash
614            entry.hash[0] ^= 0xFF;
615
616            assert!(!entry.verify_hash());
617            assert!(!entry.verify_signature(&key.verifying_key()));
618        }
619
620        #[test]
621        fn unsigned_entry_passes_signature_check() {
622            // D-027 backward compat: unsigned entries are accepted
623            let key = test_keypair();
624            let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
625
626            assert!(!entry.is_signed());
627            assert!(entry.verify_signature(&key.verifying_key())); // returns true (no sig = ok)
628        }
629    }
630
631    // -- Value JSON round-trip tests (Review 4, Issue #1) --
632
633    #[test]
634    fn value_int_json_roundtrip_preserves_type() {
635        let val = Value::Int(1);
636        let json = serde_json::to_string(&val).unwrap();
637        let back: Value = serde_json::from_str(&json).unwrap();
638        assert_eq!(
639            back,
640            Value::Int(1),
641            "Int(1) -> JSON -> back should stay Int, got {:?}",
642            back
643        );
644    }
645
646    #[test]
647    fn value_float_json_roundtrip_preserves_type() {
648        let val = Value::Float(1.0);
649        let json = serde_json::to_string(&val).unwrap();
650        let back: Value = serde_json::from_str(&json).unwrap();
651        assert_eq!(
652            back,
653            Value::Float(1.0),
654            "Float(1.0) -> JSON -> back should stay Float, got {:?}",
655            back
656        );
657    }
658
659    #[test]
660    fn value_float_json_includes_decimal() {
661        // serde_json must serialize 1.0_f64 as "1.0" (not "1")
662        // to ensure untagged deserialization picks Float, not Int
663        let json = serde_json::to_string(&Value::Float(1.0)).unwrap();
664        assert!(
665            json.contains('.'),
666            "Float(1.0) must serialize with decimal point, got: {}",
667            json
668        );
669    }
670
671    #[test]
672    fn graphop_with_mixed_values_json_roundtrip() {
673        let mut props = BTreeMap::new();
674        props.insert("count".into(), Value::Int(42));
675        props.insert("ratio".into(), Value::Float(1.0));
676        props.insert("name".into(), Value::String("test".into()));
677
678        let op = GraphOp::UpdateProperty {
679            entity_id: "e1".into(),
680            key: "data".into(),
681            value: Value::Map(props),
682        };
683
684        let json = serde_json::to_string(&op).unwrap();
685        let back: GraphOp = serde_json::from_str(&json).unwrap();
686
687        // Verify the round-tripped op produces the same hash
688        let entry1 = Entry::new(op, vec![], vec![], sample_clock(), "a");
689        let entry2 = Entry::new(back, vec![], vec![], sample_clock(), "a");
690        assert_eq!(
691            entry1.hash, entry2.hash,
692            "JSON round-trip changed the hash!"
693        );
694    }
695
696    // -- ontology_hash field --
697
698    #[test]
699    fn entry_ontology_hash_defaults_to_none() {
700        let entry = Entry::new(
701            GraphOp::AddNode {
702                node_id: "n1".into(),
703                node_type: "entity".into(),
704                subtype: None,
705                label: "n1".into(),
706                properties: BTreeMap::new(),
707            },
708            vec![],
709            vec![],
710            sample_clock(),
711            "author",
712        );
713        assert!(entry.ontology_hash.is_none());
714    }
715
716    #[test]
717    fn entry_ontology_hash_survives_roundtrip() {
718        let mut entry = Entry::new(
719            GraphOp::AddNode {
720                node_id: "n1".into(),
721                node_type: "entity".into(),
722                subtype: None,
723                label: "n1".into(),
724                properties: BTreeMap::new(),
725            },
726            vec![],
727            vec![],
728            sample_clock(),
729            "author",
730        );
731        entry.ontology_hash = Some([42u8; 32]);
732
733        let bytes = entry.to_bytes();
734        let restored = Entry::from_bytes(&bytes).unwrap();
735        assert_eq!(restored.ontology_hash, Some([42u8; 32]));
736    }
737
738    #[test]
739    fn entry_ontology_hash_not_in_content_hash() {
740        // Two entries identical except for ontology_hash must have the same hash
741        let mut a = Entry::new(
742            GraphOp::AddNode {
743                node_id: "n1".into(),
744                node_type: "entity".into(),
745                subtype: None,
746                label: "n1".into(),
747                properties: BTreeMap::new(),
748            },
749            vec![],
750            vec![],
751            sample_clock(),
752            "author",
753        );
754        let b = a.clone();
755        a.ontology_hash = Some([99u8; 32]);
756
757        // Hashes are computed at creation time, before ontology_hash is set.
758        // Both must be identical (ontology_hash is metadata, not identity).
759        assert_eq!(a.hash, b.hash);
760    }
761
762    #[test]
763    fn old_entry_without_ontology_hash_deserializes() {
764        // Simulate a pre-migration entry (no ontology_hash field)
765        let entry = Entry::new(
766            GraphOp::AddNode {
767                node_id: "n1".into(),
768                node_type: "entity".into(),
769                subtype: None,
770                label: "n1".into(),
771                properties: BTreeMap::new(),
772            },
773            vec![],
774            vec![],
775            sample_clock(),
776            "author",
777        );
778        // Serialize without ontology_hash (it's None, serde skips it)
779        let bytes = entry.to_bytes();
780        let restored = Entry::from_bytes(&bytes).unwrap();
781        assert!(restored.ontology_hash.is_none());
782        assert!(restored.verify_hash());
783    }
784
785    // -- DefineLens variant --
786
787    #[test]
788    fn define_lens_roundtrips() {
789        let op = GraphOp::DefineLens {
790            transforms: vec![1, 2, 3, 4],
791        };
792        let entry = Entry::new(op.clone(), vec![], vec![], sample_clock(), "author");
793        let bytes = entry.to_bytes();
794        let restored = Entry::from_bytes(&bytes).unwrap();
795        assert_eq!(restored.payload, op);
796        assert!(restored.verify_hash());
797    }
798}