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    /// Skip-list references for O(log n) traversal into deeper history
97    pub refs: Vec<Hash>,
98    /// Lamport clock at time of creation
99    pub clock: LamportClock,
100    /// Author instance identifier
101    pub author: String,
102    /// D-027: ed25519 signature over the hash bytes (64 bytes). None for unsigned (pre-v0.3) entries.
103    #[serde(default)]
104    pub signature: Option<Vec<u8>>,
105}
106
107/// The portion of an Entry that gets hashed. Signature is NOT included
108/// (the signature covers the hash, not vice versa).
109#[derive(Serialize)]
110struct SignableContent<'a> {
111    payload: &'a GraphOp,
112    next: &'a Vec<Hash>,
113    refs: &'a Vec<Hash>,
114    clock: &'a LamportClock,
115    author: &'a str,
116}
117
118impl Entry {
119    /// Create a new unsigned entry with computed BLAKE3 hash.
120    pub fn new(
121        payload: GraphOp,
122        next: Vec<Hash>,
123        refs: Vec<Hash>,
124        clock: LamportClock,
125        author: impl Into<String>,
126    ) -> Self {
127        let author = author.into();
128        let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
129        Self {
130            hash,
131            payload,
132            next,
133            refs,
134            clock,
135            author,
136            signature: None,
137        }
138    }
139
140    /// D-027: Create a new signed entry. Computes hash, then signs it with ed25519.
141    #[cfg(feature = "signing")]
142    pub fn new_signed(
143        payload: GraphOp,
144        next: Vec<Hash>,
145        refs: Vec<Hash>,
146        clock: LamportClock,
147        author: impl Into<String>,
148        signing_key: &ed25519_dalek::SigningKey,
149    ) -> Self {
150        use ed25519_dalek::Signer;
151        let author = author.into();
152        let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
153        let sig = signing_key.sign(&hash);
154        Self {
155            hash,
156            payload,
157            next,
158            refs,
159            clock,
160            author,
161            signature: Some(sig.to_bytes().to_vec()),
162        }
163    }
164
165    /// D-027: Verify the ed25519 signature on this entry against a public key.
166    /// Returns true if signature is valid, false if invalid.
167    /// Returns true if no signature is present (unsigned entry — backward compatible).
168    #[cfg(feature = "signing")]
169    pub fn verify_signature(&self, public_key: &ed25519_dalek::VerifyingKey) -> bool {
170        use ed25519_dalek::Verifier;
171        match &self.signature {
172            Some(sig_bytes) => {
173                if sig_bytes.len() != 64 {
174                    return false;
175                }
176                let mut sig_array = [0u8; 64];
177                sig_array.copy_from_slice(sig_bytes);
178                let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
179                public_key.verify(&self.hash, &sig).is_ok()
180            }
181            None => true, // unsigned entries accepted (migration mode)
182        }
183    }
184
185    /// Check whether this entry has a signature.
186    pub fn is_signed(&self) -> bool {
187        self.signature.is_some()
188    }
189
190    /// Compute the BLAKE3 hash of the signable content.
191    fn compute_hash(
192        payload: &GraphOp,
193        next: &Vec<Hash>,
194        refs: &Vec<Hash>,
195        clock: &LamportClock,
196        author: &str,
197    ) -> Hash {
198        let signable = SignableContent {
199            payload,
200            next,
201            refs,
202            clock,
203            author,
204        };
205        // Safety: rmp_serde serialization of #[derive(Serialize)] structs with known
206        // types (String, i64, bool, Vec, BTreeMap) cannot fail. Same pattern as sled/redb.
207        let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
208        *blake3::hash(&bytes).as_bytes()
209    }
210
211    /// Verify that the stored hash matches the content.
212    pub fn verify_hash(&self) -> bool {
213        let computed = Self::compute_hash(
214            &self.payload,
215            &self.next,
216            &self.refs,
217            &self.clock,
218            &self.author,
219        );
220        self.hash == computed
221    }
222
223    /// Serialize the entry to MessagePack bytes.
224    ///
225    /// Uses `expect()` because msgpack serialization of `#[derive(Serialize)]` structs
226    /// with known types cannot fail in practice. Converting to `Result` would add API
227    /// complexity for a failure mode that doesn't exist.
228    pub fn to_bytes(&self) -> Vec<u8> {
229        rmp_serde::to_vec(self).expect("entry serialization should not fail")
230    }
231
232    /// Deserialize an entry from MessagePack bytes.
233    pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
234        rmp_serde::from_slice(bytes)
235    }
236
237    /// Return the hash as a hex string (for display/debugging).
238    pub fn hash_hex(&self) -> String {
239        hex::encode(self.hash)
240    }
241}
242
243/// Encode a hash as hex string. Utility for display.
244pub fn hash_hex(hash: &Hash) -> String {
245    hex::encode(hash)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::ontology::{EdgeTypeDef, NodeTypeDef, PropertyDef, ValueType};
252
253    fn sample_ontology() -> Ontology {
254        Ontology {
255            node_types: BTreeMap::from([
256                (
257                    "entity".into(),
258                    NodeTypeDef {
259                        description: None,
260                        properties: BTreeMap::from([
261                            (
262                                "ip".into(),
263                                PropertyDef {
264                                    value_type: ValueType::String,
265                                    required: false,
266                                    description: None,
267                                    constraints: None,
268                                },
269                            ),
270                            (
271                                "port".into(),
272                                PropertyDef {
273                                    value_type: ValueType::Int,
274                                    required: false,
275                                    description: None,
276                                    constraints: None,
277                                },
278                            ),
279                        ]),
280                        subtypes: None,
281                    },
282                ),
283                (
284                    "signal".into(),
285                    NodeTypeDef {
286                        description: None,
287                        properties: BTreeMap::new(),
288                        subtypes: None,
289                    },
290                ),
291            ]),
292            edge_types: BTreeMap::from([(
293                "RUNS_ON".into(),
294                EdgeTypeDef {
295                    description: None,
296                    source_types: vec!["entity".into()],
297                    target_types: vec!["entity".into()],
298                    properties: BTreeMap::new(),
299                },
300            )]),
301        }
302    }
303
304    fn sample_op() -> GraphOp {
305        GraphOp::AddNode {
306            node_id: "server-1".into(),
307            node_type: "entity".into(),
308            label: "Production Server".into(),
309            properties: BTreeMap::from([
310                ("ip".into(), Value::String("10.0.0.1".into())),
311                ("port".into(), Value::Int(8080)),
312            ]),
313            subtype: None,
314        }
315    }
316
317    fn sample_clock() -> LamportClock {
318        LamportClock::with_values("inst-a", 1, 0)
319    }
320
321    #[test]
322    fn entry_hash_deterministic() {
323        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
324        let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
325        assert_eq!(e1.hash, e2.hash);
326    }
327
328    #[test]
329    fn entry_hash_changes_on_mutation() {
330        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
331        let different_op = GraphOp::AddNode {
332            node_id: "server-2".into(),
333            node_type: "entity".into(),
334            label: "Other Server".into(),
335            properties: BTreeMap::new(),
336            subtype: None,
337        };
338        let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
339        assert_ne!(e1.hash, e2.hash);
340    }
341
342    #[test]
343    fn entry_hash_changes_with_different_author() {
344        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
345        let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
346        assert_ne!(e1.hash, e2.hash);
347    }
348
349    #[test]
350    fn entry_hash_changes_with_different_clock() {
351        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
352        let mut clock2 = sample_clock();
353        clock2.physical_ms = 99;
354        let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
355        assert_ne!(e1.hash, e2.hash);
356    }
357
358    #[test]
359    fn entry_hash_changes_with_different_next() {
360        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
361        let e2 = Entry::new(
362            sample_op(),
363            vec![[0u8; 32]],
364            vec![],
365            sample_clock(),
366            "inst-a",
367        );
368        assert_ne!(e1.hash, e2.hash);
369    }
370
371    #[test]
372    fn entry_verify_hash_valid() {
373        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
374        assert!(entry.verify_hash());
375    }
376
377    #[test]
378    fn entry_verify_hash_reject_tampered() {
379        let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
380        entry.author = "evil-node".into();
381        assert!(!entry.verify_hash());
382    }
383
384    #[test]
385    fn entry_roundtrip_msgpack() {
386        let entry = Entry::new(
387            sample_op(),
388            vec![[1u8; 32]],
389            vec![[2u8; 32]],
390            sample_clock(),
391            "inst-a",
392        );
393        let bytes = entry.to_bytes();
394        let decoded = Entry::from_bytes(&bytes).unwrap();
395        assert_eq!(entry, decoded);
396    }
397
398    #[test]
399    fn entry_next_links_causal() {
400        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
401        let e2 = Entry::new(
402            GraphOp::RemoveNode {
403                node_id: "server-1".into(),
404            },
405            vec![e1.hash],
406            vec![],
407            LamportClock::with_values("inst-a", 2, 0),
408            "inst-a",
409        );
410        assert_eq!(e2.next, vec![e1.hash]);
411        assert!(e2.verify_hash());
412    }
413
414    #[test]
415    fn graphop_all_variants_serialize() {
416        let ops = vec![
417            GraphOp::DefineOntology {
418                ontology: sample_ontology(),
419            },
420            sample_op(),
421            GraphOp::AddEdge {
422                edge_id: "e1".into(),
423                edge_type: "RUNS_ON".into(),
424                source_id: "svc-1".into(),
425                target_id: "server-1".into(),
426                properties: BTreeMap::new(),
427            },
428            GraphOp::UpdateProperty {
429                entity_id: "server-1".into(),
430                key: "cpu".into(),
431                value: Value::Float(85.5),
432            },
433            GraphOp::RemoveNode {
434                node_id: "server-1".into(),
435            },
436            GraphOp::RemoveEdge {
437                edge_id: "e1".into(),
438            },
439            GraphOp::ExtendOntology {
440                extension: crate::ontology::OntologyExtension {
441                    node_types: BTreeMap::from([(
442                        "metric".into(),
443                        NodeTypeDef {
444                            description: Some("A metric observation".into()),
445                            properties: BTreeMap::new(),
446                            subtypes: None,
447                        },
448                    )]),
449                    edge_types: BTreeMap::new(),
450                    node_type_updates: BTreeMap::new(),
451                },
452            },
453            GraphOp::Checkpoint {
454                ops: vec![
455                    GraphOp::DefineOntology {
456                        ontology: sample_ontology(),
457                    },
458                    GraphOp::AddNode {
459                        node_id: "n1".into(),
460                        node_type: "entity".into(),
461                        subtype: None,
462                        label: "Node 1".into(),
463                        properties: BTreeMap::new(),
464                    },
465                ],
466                op_clocks: vec![(1, 0), (2, 0)],
467                compacted_at_physical_ms: 1000,
468                compacted_at_logical: 5,
469            },
470        ];
471        for op in ops {
472            let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
473            let bytes = entry.to_bytes();
474            let decoded = Entry::from_bytes(&bytes).unwrap();
475            assert_eq!(entry, decoded);
476        }
477    }
478
479    #[test]
480    fn genesis_entry_contains_ontology() {
481        let ont = sample_ontology();
482        let genesis = Entry::new(
483            GraphOp::DefineOntology {
484                ontology: ont.clone(),
485            },
486            vec![],
487            vec![],
488            LamportClock::new("inst-a"),
489            "inst-a",
490        );
491        match &genesis.payload {
492            GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
493            _ => panic!("genesis should be DefineOntology"),
494        }
495        assert!(genesis.next.is_empty(), "genesis has no predecessors");
496        assert!(genesis.verify_hash());
497    }
498
499    #[test]
500    fn value_all_variants_roundtrip() {
501        let values = vec![
502            Value::Null,
503            Value::Bool(true),
504            Value::Int(42),
505            Value::Float(3.14),
506            Value::String("hello".into()),
507            Value::List(vec![Value::Int(1), Value::String("two".into())]),
508            Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
509        ];
510        for val in values {
511            let bytes = rmp_serde::to_vec(&val).unwrap();
512            let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
513            assert_eq!(val, decoded);
514        }
515    }
516
517    #[test]
518    fn hash_hex_format() {
519        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
520        let hex = entry.hash_hex();
521        assert_eq!(hex.len(), 64);
522        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
523    }
524
525    #[test]
526    fn unsigned_entry_has_no_signature() {
527        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
528        assert!(!entry.is_signed());
529        assert!(entry.signature.is_none());
530    }
531
532    #[test]
533    fn unsigned_entry_roundtrip_preserves_none_signature() {
534        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
535        let bytes = entry.to_bytes();
536        let decoded = Entry::from_bytes(&bytes).unwrap();
537        assert_eq!(decoded.signature, None);
538        assert!(decoded.verify_hash());
539    }
540
541    #[cfg(feature = "signing")]
542    mod signing_tests {
543        use super::*;
544
545        fn test_keypair() -> ed25519_dalek::SigningKey {
546            use rand::rngs::OsRng;
547            ed25519_dalek::SigningKey::generate(&mut OsRng)
548        }
549
550        #[test]
551        fn signed_entry_roundtrip() {
552            let key = test_keypair();
553            let entry =
554                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
555
556            assert!(entry.is_signed());
557            assert!(entry.verify_hash());
558
559            let public = key.verifying_key();
560            assert!(entry.verify_signature(&public));
561        }
562
563        #[test]
564        fn signed_entry_serialization_roundtrip() {
565            let key = test_keypair();
566            let entry =
567                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
568
569            let bytes = entry.to_bytes();
570            let decoded = Entry::from_bytes(&bytes).unwrap();
571
572            assert!(decoded.is_signed());
573            assert!(decoded.verify_hash());
574            assert!(decoded.verify_signature(&key.verifying_key()));
575        }
576
577        #[test]
578        fn wrong_key_fails_verification() {
579            let key1 = test_keypair();
580            let key2 = test_keypair();
581
582            let entry =
583                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
584
585            // Correct key verifies
586            assert!(entry.verify_signature(&key1.verifying_key()));
587            // Wrong key fails
588            assert!(!entry.verify_signature(&key2.verifying_key()));
589        }
590
591        #[test]
592        fn tampered_hash_fails_both_checks() {
593            let key = test_keypair();
594            let mut entry =
595                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
596
597            // Tamper with the hash
598            entry.hash[0] ^= 0xFF;
599
600            assert!(!entry.verify_hash());
601            assert!(!entry.verify_signature(&key.verifying_key()));
602        }
603
604        #[test]
605        fn unsigned_entry_passes_signature_check() {
606            // D-027 backward compat: unsigned entries are accepted
607            let key = test_keypair();
608            let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
609
610            assert!(!entry.is_signed());
611            assert!(entry.verify_signature(&key.verifying_key())); // returns true (no sig = ok)
612        }
613    }
614}