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)]
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}
113
114/// Entries serialize as a POSITIONAL msgpack array — no field names on the
115/// wire or on disk — so the field count is part of the format.
116///
117/// S4 removed a ninth-hour `ontology_hash` field that was never populated in
118/// production. Deserialization therefore accepts both shapes: the current
119/// 7-element form and the legacy 8-element form, whose trailing element is
120/// consumed and discarded. Consuming it matters: entries are read as a
121/// `Vec<Entry>`, so leaving a stray element in the stream would corrupt every
122/// entry after it.
123///
124/// This is one-directional. A build with this shim reads stores and peers
125/// written by older builds; an older build cannot read the 7-element entries
126/// this one writes.
127impl<'de> Deserialize<'de> for Entry {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: serde::Deserializer<'de>,
131    {
132        struct EntryVisitor;
133
134        impl<'de> serde::de::Visitor<'de> for EntryVisitor {
135            type Value = Entry;
136
137            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
138                f.write_str("an Entry as a 7-element sequence (or legacy 8-element)")
139            }
140
141            fn visit_seq<A>(self, mut seq: A) -> Result<Entry, A::Error>
142            where
143                A: serde::de::SeqAccess<'de>,
144            {
145                use serde::de::Error as _;
146                let hash = seq
147                    .next_element()?
148                    .ok_or_else(|| A::Error::invalid_length(0, &"8 fields"))?;
149                let payload = seq
150                    .next_element()?
151                    .ok_or_else(|| A::Error::invalid_length(1, &"8 fields"))?;
152                let next = seq
153                    .next_element()?
154                    .ok_or_else(|| A::Error::invalid_length(2, &"8 fields"))?;
155                let refs = seq.next_element()?.unwrap_or_default();
156                let clock = seq
157                    .next_element()?
158                    .ok_or_else(|| A::Error::invalid_length(4, &"8 fields"))?;
159                let author = seq
160                    .next_element()?
161                    .ok_or_else(|| A::Error::invalid_length(5, &"8 fields"))?;
162                let signature = seq.next_element()?.unwrap_or(None);
163                // Legacy trailing `ontology_hash`. Always None in production;
164                // consumed so the surrounding stream stays aligned.
165                let _legacy: Option<Option<Hash>> = seq.next_element()?;
166
167                Ok(Entry {
168                    hash,
169                    payload,
170                    next,
171                    refs,
172                    clock,
173                    author,
174                    signature,
175                })
176            }
177        }
178
179        deserializer.deserialize_seq(EntryVisitor)
180    }
181}
182
183/// The portion of an Entry that gets hashed. Signature is NOT included
184/// (the signature covers the hash, not vice versa).
185#[derive(Serialize)]
186struct SignableContent<'a> {
187    payload: &'a GraphOp,
188    next: &'a Vec<Hash>,
189    refs: &'a Vec<Hash>,
190    clock: &'a LamportClock,
191    author: &'a str,
192}
193
194impl Entry {
195    /// Create a new unsigned entry with computed BLAKE3 hash.
196    pub fn new(
197        payload: GraphOp,
198        next: Vec<Hash>,
199        refs: Vec<Hash>,
200        clock: LamportClock,
201        author: impl Into<String>,
202    ) -> Self {
203        let author = author.into();
204        let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
205        Self {
206            hash,
207            payload,
208            next,
209            refs,
210            clock,
211            author,
212            signature: None,
213        }
214    }
215
216    /// D-027: Create a new signed entry. Computes hash, then signs it with ed25519.
217    #[cfg(feature = "signing")]
218    pub fn new_signed(
219        payload: GraphOp,
220        next: Vec<Hash>,
221        refs: Vec<Hash>,
222        clock: LamportClock,
223        author: impl Into<String>,
224        signing_key: &ed25519_dalek::SigningKey,
225    ) -> Self {
226        use ed25519_dalek::Signer;
227        let author = author.into();
228        let hash = Self::compute_hash(&payload, &next, &refs, &clock, &author);
229        let sig = signing_key.sign(&hash);
230        Self {
231            hash,
232            payload,
233            next,
234            refs,
235            clock,
236            author,
237            signature: Some(sig.to_bytes().to_vec()),
238        }
239    }
240
241    /// D-027: Verify the ed25519 signature on this entry against a public key.
242    /// Returns true if signature is valid, false if invalid.
243    /// Returns true if no signature is present (unsigned entry — backward compatible).
244    #[cfg(feature = "signing")]
245    pub fn verify_signature(&self, public_key: &ed25519_dalek::VerifyingKey) -> bool {
246        use ed25519_dalek::Verifier;
247        match &self.signature {
248            Some(sig_bytes) => {
249                if sig_bytes.len() != 64 {
250                    return false;
251                }
252                let mut sig_array = [0u8; 64];
253                sig_array.copy_from_slice(sig_bytes);
254                let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
255                public_key.verify(&self.hash, &sig).is_ok()
256            }
257            None => true, // unsigned entries accepted (migration mode)
258        }
259    }
260
261    /// Check whether this entry has a signature.
262    pub fn is_signed(&self) -> bool {
263        self.signature.is_some()
264    }
265
266    /// Compute the BLAKE3 hash of the signable content.
267    fn compute_hash(
268        payload: &GraphOp,
269        next: &Vec<Hash>,
270        refs: &Vec<Hash>,
271        clock: &LamportClock,
272        author: &str,
273    ) -> Hash {
274        let signable = SignableContent {
275            payload,
276            next,
277            refs,
278            clock,
279            author,
280        };
281        // Safety: rmp_serde serialization of #[derive(Serialize)] structs with known
282        // types (String, i64, bool, Vec, BTreeMap) cannot fail. Same pattern as sled/redb.
283        let bytes = rmp_serde::to_vec(&signable).expect("serialization should not fail");
284        *blake3::hash(&bytes).as_bytes()
285    }
286
287    /// Verify that the stored hash matches the content.
288    pub fn verify_hash(&self) -> bool {
289        let computed = Self::compute_hash(
290            &self.payload,
291            &self.next,
292            &self.refs,
293            &self.clock,
294            &self.author,
295        );
296        self.hash == computed
297    }
298
299    /// Serialize the entry to MessagePack bytes.
300    ///
301    /// Uses `expect()` because msgpack serialization of `#[derive(Serialize)]` structs
302    /// with known types cannot fail in practice. Converting to `Result` would add API
303    /// complexity for a failure mode that doesn't exist.
304    pub fn to_bytes(&self) -> Vec<u8> {
305        rmp_serde::to_vec(self).expect("entry serialization should not fail")
306    }
307
308    /// Deserialize an entry from MessagePack bytes.
309    pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
310        rmp_serde::from_slice(bytes)
311    }
312
313    /// Return the hash as a hex string (for display/debugging).
314    pub fn hash_hex(&self) -> String {
315        hex::encode(self.hash)
316    }
317}
318
319/// Encode a hash as hex string. Utility for display.
320pub fn hash_hex(hash: &Hash) -> String {
321    hex::encode(hash)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::ontology::{EdgeTypeDef, NodeTypeDef, PropertyDef, ValueType};
328
329    fn sample_ontology() -> Ontology {
330        Ontology {
331            node_types: BTreeMap::from([
332                (
333                    "entity".into(),
334                    NodeTypeDef {
335                        description: None,
336                        properties: BTreeMap::from([
337                            (
338                                "ip".into(),
339                                PropertyDef {
340                                    value_type: ValueType::String,
341                                    required: false,
342                                    description: None,
343                                    constraints: None,
344                                },
345                            ),
346                            (
347                                "port".into(),
348                                PropertyDef {
349                                    value_type: ValueType::Int,
350                                    required: false,
351                                    description: None,
352                                    constraints: None,
353                                },
354                            ),
355                        ]),
356                        subtypes: None,
357                        parent_type: None,
358                    },
359                ),
360                (
361                    "signal".into(),
362                    NodeTypeDef {
363                        description: None,
364                        properties: BTreeMap::new(),
365                        subtypes: None,
366                        parent_type: None,
367                    },
368                ),
369            ]),
370            edge_types: BTreeMap::from([(
371                "RUNS_ON".into(),
372                EdgeTypeDef {
373                    description: None,
374                    source_types: vec!["entity".into()],
375                    target_types: vec!["entity".into()],
376                    properties: BTreeMap::new(),
377                },
378            )]),
379        }
380    }
381
382    fn sample_op() -> GraphOp {
383        GraphOp::AddNode {
384            node_id: "server-1".into(),
385            node_type: "entity".into(),
386            label: "Production Server".into(),
387            properties: BTreeMap::from([
388                ("ip".into(), Value::String("10.0.0.1".into())),
389                ("port".into(), Value::Int(8080)),
390            ]),
391            subtype: None,
392        }
393    }
394
395    fn sample_clock() -> LamportClock {
396        LamportClock::with_values("inst-a", 1, 0)
397    }
398
399    fn sample_entry() -> Entry {
400        Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a")
401    }
402
403    #[test]
404    fn entry_hash_deterministic() {
405        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
406        let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
407        assert_eq!(e1.hash, e2.hash);
408    }
409
410    #[test]
411    fn entry_hash_changes_on_mutation() {
412        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
413        let different_op = GraphOp::AddNode {
414            node_id: "server-2".into(),
415            node_type: "entity".into(),
416            label: "Other Server".into(),
417            properties: BTreeMap::new(),
418            subtype: None,
419        };
420        let e2 = Entry::new(different_op, vec![], vec![], sample_clock(), "inst-a");
421        assert_ne!(e1.hash, e2.hash);
422    }
423
424    #[test]
425    fn entry_hash_changes_with_different_author() {
426        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
427        let e2 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-b");
428        assert_ne!(e1.hash, e2.hash);
429    }
430
431    #[test]
432    fn entry_hash_changes_with_different_clock() {
433        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
434        let mut clock2 = sample_clock();
435        clock2.physical_ms = 99;
436        let e2 = Entry::new(sample_op(), vec![], vec![], clock2, "inst-a");
437        assert_ne!(e1.hash, e2.hash);
438    }
439
440    #[test]
441    fn entry_hash_changes_with_different_next() {
442        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
443        let e2 = Entry::new(
444            sample_op(),
445            vec![[0u8; 32]],
446            vec![],
447            sample_clock(),
448            "inst-a",
449        );
450        assert_ne!(e1.hash, e2.hash);
451    }
452
453    #[test]
454    fn entry_verify_hash_valid() {
455        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
456        assert!(entry.verify_hash());
457    }
458
459    #[test]
460    fn entry_verify_hash_reject_tampered() {
461        let mut entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
462        entry.author = "evil-node".into();
463        assert!(!entry.verify_hash());
464    }
465
466    #[test]
467    fn entry_roundtrip_msgpack() {
468        let entry = Entry::new(
469            sample_op(),
470            vec![[1u8; 32]],
471            vec![[2u8; 32]],
472            sample_clock(),
473            "inst-a",
474        );
475        let bytes = entry.to_bytes();
476        let decoded = Entry::from_bytes(&bytes).unwrap();
477        assert_eq!(entry, decoded);
478    }
479
480    #[test]
481    fn entry_next_links_causal() {
482        let e1 = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
483        let e2 = Entry::new(
484            GraphOp::RemoveNode {
485                node_id: "server-1".into(),
486            },
487            vec![e1.hash],
488            vec![],
489            LamportClock::with_values("inst-a", 2, 0),
490            "inst-a",
491        );
492        assert_eq!(e2.next, vec![e1.hash]);
493        assert!(e2.verify_hash());
494    }
495
496    #[test]
497    fn graphop_all_variants_serialize() {
498        let ops = vec![
499            GraphOp::DefineOntology {
500                ontology: sample_ontology(),
501            },
502            sample_op(),
503            GraphOp::AddEdge {
504                edge_id: "e1".into(),
505                edge_type: "RUNS_ON".into(),
506                source_id: "svc-1".into(),
507                target_id: "server-1".into(),
508                properties: BTreeMap::new(),
509            },
510            GraphOp::UpdateProperty {
511                entity_id: "server-1".into(),
512                key: "cpu".into(),
513                value: Value::Float(85.5),
514            },
515            GraphOp::RemoveNode {
516                node_id: "server-1".into(),
517            },
518            GraphOp::RemoveEdge {
519                edge_id: "e1".into(),
520            },
521            GraphOp::ExtendOntology {
522                extension: crate::ontology::OntologyExtension {
523                    node_types: BTreeMap::from([(
524                        "metric".into(),
525                        NodeTypeDef {
526                            description: Some("A metric observation".into()),
527                            properties: BTreeMap::new(),
528                            subtypes: None,
529                            parent_type: None,
530                        },
531                    )]),
532                    edge_types: BTreeMap::new(),
533                    node_type_updates: BTreeMap::new(),
534                    edge_type_updates: BTreeMap::new(),
535                },
536            },
537            GraphOp::Checkpoint {
538                ops: vec![
539                    GraphOp::DefineOntology {
540                        ontology: sample_ontology(),
541                    },
542                    GraphOp::AddNode {
543                        node_id: "n1".into(),
544                        node_type: "entity".into(),
545                        subtype: None,
546                        label: "Node 1".into(),
547                        properties: BTreeMap::new(),
548                    },
549                ],
550                op_clocks: vec![(1, 0), (2, 0)],
551                compacted_at_physical_ms: 1000,
552                compacted_at_logical: 5,
553            },
554        ];
555        for op in ops {
556            let entry = Entry::new(op, vec![], vec![], sample_clock(), "inst-a");
557            let bytes = entry.to_bytes();
558            let decoded = Entry::from_bytes(&bytes).unwrap();
559            assert_eq!(entry, decoded);
560        }
561    }
562
563    #[test]
564    fn genesis_entry_contains_ontology() {
565        let ont = sample_ontology();
566        let genesis = Entry::new(
567            GraphOp::DefineOntology {
568                ontology: ont.clone(),
569            },
570            vec![],
571            vec![],
572            LamportClock::new("inst-a"),
573            "inst-a",
574        );
575        match &genesis.payload {
576            GraphOp::DefineOntology { ontology } => assert_eq!(ontology, &ont),
577            _ => panic!("genesis should be DefineOntology"),
578        }
579        assert!(genesis.next.is_empty(), "genesis has no predecessors");
580        assert!(genesis.verify_hash());
581    }
582
583    #[test]
584    fn value_all_variants_roundtrip() {
585        let values = vec![
586            Value::Null,
587            Value::Bool(true),
588            Value::Int(42),
589            Value::Float(3.14),
590            Value::String("hello".into()),
591            Value::List(vec![Value::Int(1), Value::String("two".into())]),
592            Value::Map(BTreeMap::from([("key".into(), Value::Bool(false))])),
593        ];
594        for val in values {
595            let bytes = rmp_serde::to_vec(&val).unwrap();
596            let decoded: Value = rmp_serde::from_slice(&bytes).unwrap();
597            assert_eq!(val, decoded);
598        }
599    }
600
601    #[test]
602    fn hash_hex_format() {
603        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
604        let hex = entry.hash_hex();
605        assert_eq!(hex.len(), 64);
606        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
607    }
608
609    #[test]
610    fn unsigned_entry_has_no_signature() {
611        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
612        assert!(!entry.is_signed());
613        assert!(entry.signature.is_none());
614    }
615
616    #[test]
617    fn unsigned_entry_roundtrip_preserves_none_signature() {
618        let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
619        let bytes = entry.to_bytes();
620        let decoded = Entry::from_bytes(&bytes).unwrap();
621        assert_eq!(decoded.signature, None);
622        assert!(decoded.verify_hash());
623    }
624
625    #[cfg(feature = "signing")]
626    mod signing_tests {
627        use super::*;
628
629        fn test_keypair() -> ed25519_dalek::SigningKey {
630            use rand::rngs::OsRng;
631            ed25519_dalek::SigningKey::generate(&mut OsRng)
632        }
633
634        #[test]
635        fn signed_entry_roundtrip() {
636            let key = test_keypair();
637            let entry =
638                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
639
640            assert!(entry.is_signed());
641            assert!(entry.verify_hash());
642
643            let public = key.verifying_key();
644            assert!(entry.verify_signature(&public));
645        }
646
647        #[test]
648        fn signed_entry_serialization_roundtrip() {
649            let key = test_keypair();
650            let entry =
651                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
652
653            let bytes = entry.to_bytes();
654            let decoded = Entry::from_bytes(&bytes).unwrap();
655
656            assert!(decoded.is_signed());
657            assert!(decoded.verify_hash());
658            assert!(decoded.verify_signature(&key.verifying_key()));
659        }
660
661        #[test]
662        fn wrong_key_fails_verification() {
663            let key1 = test_keypair();
664            let key2 = test_keypair();
665
666            let entry =
667                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key1);
668
669            // Correct key verifies
670            assert!(entry.verify_signature(&key1.verifying_key()));
671            // Wrong key fails
672            assert!(!entry.verify_signature(&key2.verifying_key()));
673        }
674
675        #[test]
676        fn tampered_hash_fails_both_checks() {
677            let key = test_keypair();
678            let mut entry =
679                Entry::new_signed(sample_op(), vec![], vec![], sample_clock(), "inst-a", &key);
680
681            // Tamper with the hash
682            entry.hash[0] ^= 0xFF;
683
684            assert!(!entry.verify_hash());
685            assert!(!entry.verify_signature(&key.verifying_key()));
686        }
687
688        #[test]
689        fn unsigned_entry_passes_signature_check() {
690            // D-027 backward compat: unsigned entries are accepted
691            let key = test_keypair();
692            let entry = Entry::new(sample_op(), vec![], vec![], sample_clock(), "inst-a");
693
694            assert!(!entry.is_signed());
695            assert!(entry.verify_signature(&key.verifying_key())); // returns true (no sig = ok)
696        }
697    }
698
699    // -- Value JSON round-trip tests (Review 4, Issue #1) --
700
701    #[test]
702    fn value_int_json_roundtrip_preserves_type() {
703        let val = Value::Int(1);
704        let json = serde_json::to_string(&val).unwrap();
705        let back: Value = serde_json::from_str(&json).unwrap();
706        assert_eq!(
707            back,
708            Value::Int(1),
709            "Int(1) -> JSON -> back should stay Int, got {:?}",
710            back
711        );
712    }
713
714    #[test]
715    fn value_float_json_roundtrip_preserves_type() {
716        let val = Value::Float(1.0);
717        let json = serde_json::to_string(&val).unwrap();
718        let back: Value = serde_json::from_str(&json).unwrap();
719        assert_eq!(
720            back,
721            Value::Float(1.0),
722            "Float(1.0) -> JSON -> back should stay Float, got {:?}",
723            back
724        );
725    }
726
727    #[test]
728    fn value_float_json_includes_decimal() {
729        // serde_json must serialize 1.0_f64 as "1.0" (not "1")
730        // to ensure untagged deserialization picks Float, not Int
731        let json = serde_json::to_string(&Value::Float(1.0)).unwrap();
732        assert!(
733            json.contains('.'),
734            "Float(1.0) must serialize with decimal point, got: {}",
735            json
736        );
737    }
738
739    #[test]
740    fn graphop_with_mixed_values_json_roundtrip() {
741        let mut props = BTreeMap::new();
742        props.insert("count".into(), Value::Int(42));
743        props.insert("ratio".into(), Value::Float(1.0));
744        props.insert("name".into(), Value::String("test".into()));
745
746        let op = GraphOp::UpdateProperty {
747            entity_id: "e1".into(),
748            key: "data".into(),
749            value: Value::Map(props),
750        };
751
752        let json = serde_json::to_string(&op).unwrap();
753        let back: GraphOp = serde_json::from_str(&json).unwrap();
754
755        // Verify the round-tripped op produces the same hash
756        let entry1 = Entry::new(op, vec![], vec![], sample_clock(), "a");
757        let entry2 = Entry::new(back, vec![], vec![], sample_clock(), "a");
758        assert_eq!(
759            entry1.hash, entry2.hash,
760            "JSON round-trip changed the hash!"
761        );
762    }
763
764    // -- Wire format compatibility (S4) --
765
766    /// A legacy 8-element entry, as written by every build up to 0.2.7, must
767    /// still deserialize after `ontology_hash` was removed. This is the eta
768    /// case: an existing redb store opened by a newer binary.
769    #[test]
770    fn legacy_eight_element_entry_still_deserializes() {
771        let entry = sample_entry();
772        // Rebuild the legacy shape by hand: the same seven fields plus the
773        // trailing ontology_hash that used to follow. Tuples serialize as
774        // positional arrays, which is exactly the old struct encoding.
775        let legacy = (
776            entry.hash,
777            entry.payload.clone(),
778            entry.next.clone(),
779            entry.refs.clone(),
780            entry.clock.clone(),
781            entry.author.clone(),
782            entry.signature.clone(),
783            None::<Hash>,
784        );
785        let bytes = rmp_serde::to_vec(&legacy).unwrap();
786        assert_eq!(bytes[0], 0x98, "legacy fixture is not 8 elements");
787
788        let restored = Entry::from_bytes(&bytes).expect("legacy entry must load");
789        assert_eq!(restored, entry);
790        assert!(restored.verify_hash());
791    }
792
793    /// The stream must stay aligned: a stray trailing element would corrupt
794    /// every entry after it, since entries are read as a `Vec<Entry>`.
795    #[test]
796    fn legacy_entries_in_a_sequence_stay_aligned() {
797        let entry = sample_entry();
798        let legacy = |e: &Entry| {
799            (
800                e.hash,
801                e.payload.clone(),
802                e.next.clone(),
803                e.refs.clone(),
804                e.clock.clone(),
805                e.author.clone(),
806                e.signature.clone(),
807                None::<Hash>,
808            )
809        };
810        let bytes = rmp_serde::to_vec(&vec![legacy(&entry), legacy(&entry)]).unwrap();
811        let restored: Vec<Entry> = rmp_serde::from_slice(&bytes).expect("legacy vec must load");
812        assert_eq!(restored.len(), 2);
813        assert!(restored.iter().all(|e| e.verify_hash()));
814    }
815
816    /// Entries serialize as a POSITIONAL msgpack array — no field names on the
817    /// wire or on disk. Adding or removing a field therefore changes the arity
818    /// of every entry ever written, breaking stored redb data and any peer on
819    /// an older build. That is a protocol decision, never a cleanup.
820    ///
821    /// This test exists because S4's "delete the dead field" read as tidying
822    /// and was in fact format-load-bearing.
823    #[test]
824    fn entry_wire_format_is_a_positional_array_of_seven() {
825        let bytes = sample_entry().to_bytes();
826
827        // msgpack fixarray marker: 0x90 | len. Seven fields => 0x97.
828        assert_eq!(
829            bytes[0], 0x97,
830            "Entry is no longer a 7-element positional array. Changing the \
831             field count breaks every persisted store and every peer running \
832             an older build; add a deserialization shim for the old arity \
833             (as S4 did for the 8-element form) before changing this."
834        );
835        // No field names present, confirming positional encoding: there is
836        // nowhere for an added or removed field to hide.
837        for name in [b"payload".as_slice(), b"signature".as_slice()] {
838            assert!(
839                !bytes.windows(name.len()).any(|w| w == name),
840                "expected positional encoding, found a field name on the wire"
841            );
842        }
843    }
844
845    // -- DefineLens variant --
846
847    #[test]
848    fn define_lens_roundtrips() {
849        let op = GraphOp::DefineLens {
850            transforms: vec![1, 2, 3, 4],
851        };
852        let entry = Entry::new(op.clone(), vec![], vec![], sample_clock(), "author");
853        let bytes = entry.to_bytes();
854        let restored = Entry::from_bytes(&bytes).unwrap();
855        assert_eq!(restored.payload, op);
856        assert!(restored.verify_hash());
857    }
858}