1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use crate::clock::LamportClock;
5use crate::ontology::{Ontology, OntologyExtension};
6
7#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "op")]
26pub enum GraphOp {
27 #[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 #[serde(rename = "extend_ontology")]
62 ExtendOntology { extension: OntologyExtension },
63 #[serde(rename = "define_lens")]
67 DefineLens { transforms: Vec<u8> },
68 #[serde(rename = "checkpoint")]
72 Checkpoint {
73 ops: Vec<GraphOp>,
75 #[serde(default)]
78 op_clocks: Vec<(u64, u32)>,
79 compacted_at_physical_ms: u64,
81 compacted_at_logical: u32,
83 },
84}
85
86pub type Hash = [u8; 32];
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Entry {
95 pub hash: Hash,
97 pub payload: GraphOp,
99 pub next: Vec<Hash>,
101 #[serde(default)]
104 pub refs: Vec<Hash>,
105 pub clock: LamportClock,
107 pub author: String,
109 #[serde(default)]
111 pub signature: Option<Vec<u8>>,
112 #[serde(default)]
115 pub ontology_hash: Option<Hash>,
116}
117
118#[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 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 #[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 #[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, }
196 }
197
198 pub fn is_signed(&self) -> bool {
200 self.signature.is_some()
201 }
202
203 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 let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
221 *blake3::hash(&bytes).as_bytes()
222 }
223
224 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 pub fn to_bytes(&self) -> Vec<u8> {
242 rmp_serde::to_vec(self).expect("entry serialization should not fail")
243 }
244
245 pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
247 rmp_serde::from_slice(bytes)
248 }
249
250 pub fn hash_hex(&self) -> String {
252 hex::encode(self.hash)
253 }
254}
255
256pub 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 assert!(entry.verify_signature(&key1.verifying_key()));
603 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 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 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())); }
629 }
630
631 #[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 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 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 #[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 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 assert_eq!(a.hash, b.hash);
760 }
761
762 #[test]
763 fn old_entry_without_ontology_hash_deserializes() {
764 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 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 #[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}