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