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