Skip to main content

nedb_engine/
root.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! `state_root_v1` — one hash that commits to what the database currently says.
6//!
7//! ```text
8//! state_root_v1
9//! ├── namespace_root   which collections exist
10//! └── records_root     what is in them
11//! ```
12//!
13//! # What a state root is NOT
14//!
15//! It is not a commitment to history. History already has one: the running
16//! Merkle head (`Db::head`), which chains every write by seq and object hash,
17//! and answers "did this database's past change?". The state root answers a
18//! different question — "do these two databases say the same thing right now?"
19//! — and it has to be computable without replaying anything.
20//!
21//! Keeping them separate is deliberate. A root that folded in history could not
22//! be compared across two databases that reached the same state by different
23//! routes, and that comparison is most of what a root is for: replica
24//! agreement, drift detection, anchoring, and the "before" and "after" sides of
25//! a diff.
26//!
27//! # Why the leaves are logical, not object hashes
28//!
29//! The obvious construction is a tree over `node.hash`. It is wrong here, and
30//! the reason is worth stating because it is not obvious from reading the
31//! struct: with encryption on, a node's hash is not a function of its content.
32//!
33//! `ObjectStore::write` hashes the CIPHERTEXT, and `encrypt` draws a fresh
34//! random AES-GCM nonce per call. Measured on the running engine:
35//!
36//! ```text
37//! PLAINTEXT same=true  a=144eb088e2f5 b=144eb088e2f5
38//! ENCRYPTED same=false a=d501c7798dcf b=9af86e8fafec
39//! ```
40//!
41//! Same logical node, written twice under one DEK, two different hashes. A root
42//! built on object hashes would therefore differ between an encrypted replica
43//! and a plaintext one holding identical data — which destroys the only
44//! property anybody wants from it. So the leaves are built from the logical
45//! content, and the encryption layer never touches the root.
46//!
47//! # Decided questions
48//!
49//! Every one of these is a place where two reasonable implementations would
50//! disagree, which is exactly the set that has to be pinned before the format
51//! locks. The cross-language vectors in `vectors/state_root_v1.json` pin them
52//! as data, so a second implementation can be checked without reading this.
53//!
54//! **Hash.** BLAKE2b-512 truncated to the first 32 bytes, matching the rest of
55//! the engine.
56//!
57//! **Domain separation.** Every hash input begins with a distinct tag. Leaves,
58//! internal nodes, subtree roots and the final composition cannot be confused
59//! for one another, so no leaf can be presented as an internal node.
60//!
61//! **Length prefixing.** Every variable-length field is preceded by its length
62//! as a u64 little-endian. Concatenation is therefore unambiguous: `("ab", "c")`
63//! and `("a", "bc")` do not collide.
64//!
65//! **Ordering.** Leaves are sorted by their key bytes, comparing raw UTF-8.
66//! Not by locale, not by code point after normalisation — by bytes, because
67//! that is the one ordering every language agrees on without a library.
68//!
69//! **Unicode.** None applied. Names are committed as the exact UTF-8 bytes they
70//! were created with. Normalising inside the encoder would make two distinct
71//! collections collide in the root; the canonicalisation belongs at creation
72//! time, not at hashing time.
73//!
74//! **Odd leaves.** Promoted unchanged to the next level. NOT duplicated — leaf
75//! duplication is the Bitcoin CVE-2012-2459 construction, where two different
76//! leaf sets produce one root.
77//!
78//! **Leaf count.** Committed alongside the tree in the subtree root. Promotion
79//! alone leaves the tree shape ambiguous for some leaf counts; the count
80//! removes the question entirely rather than requiring an argument that it
81//! cannot arise.
82//!
83//! **Empty.** A distinct constant `H(tag)`, never zero. Zero is what an
84//! uninitialised field looks like, and "no collections" must not be confusable
85//! with "nobody computed this".
86//!
87//! **Tombstones.** Absent. A record leaf exists for each currently-live
88//! document; a deleted document contributes nothing. The root commits to
89//! current visible state, and history carries the tombstone.
90//!
91//! **Dropped collections.** Absent from the namespace, present in history. An
92//! emptied-but-live collection IS in the namespace with no records under it —
93//! which is the whole reason durable collection identity had to land first.
94//!
95//! **Document field order.** Preserved as written, not sorted. NEDB treats
96//! document order as meaningful (`serde_json` `preserve_order` is on crate-wide
97//! so `SELECT *` returns columns in document order), so two documents whose
98//! keys are ordered differently are two databases that answer differently, and
99//! a root that could not tell them apart would not be committing to state.
100
101use blake2::{Blake2b512, Digest};
102
103// ── Domain tags ───────────────────────────────────────────────────────────
104//
105// Spelled out in full rather than numbered, so a hexdump of a mismatched
106// implementation says what it was hashing.
107
108const TAG_EMPTY:      &[u8] = b"nedb:state_root_v1:empty";
109const TAG_NODE:       &[u8] = b"nedb:state_root_v1:node";
110const TAG_NS_LEAF:    &[u8] = b"nedb:state_root_v1:namespace_leaf";
111const TAG_NS_ROOT:    &[u8] = b"nedb:state_root_v1:namespace_root";
112const TAG_REC_LEAF:   &[u8] = b"nedb:state_root_v1:record_leaf";
113const TAG_REC_ROOT:   &[u8] = b"nedb:state_root_v1:records_root";
114const TAG_STATE_ROOT: &[u8] = b"nedb:state_root_v1:state_root";
115
116/// A 32-byte digest, hex-encoded at the boundary and kept as bytes inside.
117pub type Digest32 = [u8; 32];
118
119fn h(parts: &[&[u8]]) -> Digest32 {
120    let mut hasher = Blake2b512::new();
121    for p in parts {
122        hasher.update(p);
123    }
124    let out = hasher.finalize();
125    let mut d = [0u8; 32];
126    d.copy_from_slice(&out[..32]);
127    d
128}
129
130/// Length-prefix a field: u64 little-endian length, then the bytes.
131fn lp(buf: &mut Vec<u8>, bytes: &[u8]) {
132    buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
133    buf.extend_from_slice(bytes);
134}
135
136/// An optional field: one presence byte, then the value if present.
137///
138/// A presence byte rather than an empty string, because `valid_to = Some("")`
139/// and `valid_to = None` are different facts and a root must not merge them.
140fn lp_opt(buf: &mut Vec<u8>, v: Option<&str>) {
141    match v {
142        None => buf.push(0),
143        Some(s) => {
144            buf.push(1);
145            lp(buf, s.as_bytes());
146        }
147    }
148}
149
150// ── Canonical value encoding ──────────────────────────────────────────────
151
152/// JSON value type tags. Explicit and typed, rather than serialising to a JSON
153/// string and hashing that: JSON text hands you float formatting, escape
154/// choices and whitespace as three separate ways for two correct
155/// implementations to disagree.
156const V_NULL: u8 = 0;
157const V_FALSE: u8 = 1;
158const V_TRUE: u8 = 2;
159const V_I64: u8 = 3;
160const V_U64: u8 = 4;
161const V_F64: u8 = 5;
162const V_STR: u8 = 6;
163const V_ARR: u8 = 7;
164const V_OBJ: u8 = 8;
165
166/// Encode a JSON value canonically.
167///
168/// Numbers are the interesting case. A JSON number is committed by its
169/// REPRESENTATION as parsed — i64, u64 or f64 — rather than by its
170/// mathematical value, so `1` and `1.0` commit differently. That is the
171/// honest choice for a database that round-trips them differently, and the
172/// alternative (normalising every integral float to an integer) would make a
173/// root that cannot distinguish two documents the engine can.
174pub fn encode_value(buf: &mut Vec<u8>, v: &serde_json::Value) -> Result<(), String> {
175    match v {
176        serde_json::Value::Null => buf.push(V_NULL),
177        serde_json::Value::Bool(false) => buf.push(V_FALSE),
178        serde_json::Value::Bool(true) => buf.push(V_TRUE),
179        serde_json::Value::Number(n) => {
180            if let Some(i) = n.as_i64() {
181                buf.push(V_I64);
182                buf.extend_from_slice(&i.to_le_bytes());
183            } else if let Some(u) = n.as_u64() {
184                buf.push(V_U64);
185                buf.extend_from_slice(&u.to_le_bytes());
186            } else {
187                let f = n.as_f64().ok_or_else(|| format!("unrepresentable number: {}", n))?;
188                if f.is_nan() {
189                    // Not reachable through JSON parsing, reachable through the
190                    // Rust API. NaN != NaN, so a root containing one would not
191                    // equal itself, and the failure would look like corruption.
192                    return Err("NaN cannot be committed to a state root".into());
193                }
194                buf.push(V_F64);
195                // -0.0 and 0.0 are equal and must commit identically.
196                let f = if f == 0.0 { 0.0 } else { f };
197                buf.extend_from_slice(&f.to_bits().to_le_bytes());
198            }
199        }
200        serde_json::Value::String(s) => {
201            buf.push(V_STR);
202            lp(buf, s.as_bytes());
203        }
204        serde_json::Value::Array(items) => {
205            buf.push(V_ARR);
206            buf.extend_from_slice(&(items.len() as u64).to_le_bytes());
207            for it in items {
208                encode_value(buf, it)?;
209            }
210        }
211        serde_json::Value::Object(map) => {
212            buf.push(V_OBJ);
213            buf.extend_from_slice(&(map.len() as u64).to_le_bytes());
214            // Document order, not sorted. See the module note.
215            for (k, val) in map {
216                lp(buf, k.as_bytes());
217                encode_value(buf, val)?;
218            }
219        }
220    }
221    Ok(())
222}
223
224// ── Leaves ────────────────────────────────────────────────────────────────
225
226/// One live collection.
227pub fn namespace_leaf(name: &str) -> Digest32 {
228    let mut buf = Vec::new();
229    lp(&mut buf, name.as_bytes());
230    h(&[TAG_NS_LEAF, &buf])
231}
232
233/// One live document, as logical content.
234///
235/// `seq`, `ts`, `prev` and the object hash are all deliberately absent: they
236/// describe how and when this state was arrived at, which is history's job.
237pub fn record_leaf(
238    coll: &str,
239    id: &str,
240    data: &serde_json::Value,
241    valid_from: Option<&str>,
242    valid_to: Option<&str>,
243) -> Result<Digest32, String> {
244    let mut buf = Vec::new();
245    lp(&mut buf, coll.as_bytes());
246    lp(&mut buf, id.as_bytes());
247    lp_opt(&mut buf, valid_from);
248    lp_opt(&mut buf, valid_to);
249    encode_value(&mut buf, data)?;
250    Ok(h(&[TAG_REC_LEAF, &buf]))
251}
252
253// ── Tree ──────────────────────────────────────────────────────────────────
254
255/// Fold leaves pairwise into one digest. Odd leaf is promoted, never doubled.
256fn fold(mut level: Vec<Digest32>) -> Digest32 {
257    if level.is_empty() {
258        return h(&[TAG_EMPTY]);
259    }
260    while level.len() > 1 {
261        let mut next = Vec::with_capacity(level.len().div_ceil(2));
262        let mut i = 0;
263        while i + 1 < level.len() {
264            next.push(h(&[TAG_NODE, &level[i], &level[i + 1]]));
265            i += 2;
266        }
267        if i < level.len() {
268            // Promote. Duplicating instead is CVE-2012-2459.
269            next.push(level[i]);
270        }
271        level = next;
272    }
273    level[0]
274}
275
276/// A subtree root: the fold, bound to its tag and its leaf count.
277fn subtree(tag: &[u8], leaves: Vec<Digest32>) -> Digest32 {
278    let n = leaves.len() as u64;
279    let folded = fold(leaves);
280    h(&[tag, &n.to_le_bytes(), &folded])
281}
282
283/// Commit to which collections exist. Input need not be sorted.
284pub fn namespace_root(collections: &[String]) -> Digest32 {
285    let mut names: Vec<&String> = collections.iter().collect();
286    names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
287    names.dedup();
288    subtree(TAG_NS_ROOT, names.iter().map(|n| namespace_leaf(n)).collect())
289}
290
291/// One live document, in the form `records_root` consumes.
292#[derive(Debug, Clone)]
293pub struct RecordRef<'a> {
294    pub coll: &'a str,
295    pub id: &'a str,
296    pub data: &'a serde_json::Value,
297    pub valid_from: Option<&'a str>,
298    pub valid_to: Option<&'a str>,
299}
300
301/// Commit to document content. Input need not be sorted.
302pub fn records_root(records: &[RecordRef<'_>]) -> Result<Digest32, String> {
303    let mut sorted: Vec<&RecordRef<'_>> = records.iter().collect();
304    sorted.sort_by(|a, b| {
305        a.coll.as_bytes().cmp(b.coll.as_bytes())
306            .then_with(|| a.id.as_bytes().cmp(b.id.as_bytes()))
307    });
308    let mut leaves = Vec::with_capacity(sorted.len());
309    for r in sorted {
310        leaves.push(record_leaf(r.coll, r.id, r.data, r.valid_from, r.valid_to)?);
311    }
312    Ok(subtree(TAG_REC_ROOT, leaves))
313}
314
315/// The public root.
316pub fn state_root(namespace: Digest32, records: Digest32) -> Digest32 {
317    h(&[TAG_STATE_ROOT, &namespace, &records])
318}
319
320/// Hex, because every boundary this crosses is textual.
321pub fn hex(d: &Digest32) -> String {
322    ::hex::encode(d)
323}
324
325/// The three roots together — what `root inspect` reports and what a root
326/// record stores.
327#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
328pub struct StateRoot {
329    pub version: String,
330    pub namespace_root: String,
331    pub records_root: String,
332    pub state_root: String,
333    pub collection_count: u64,
334    pub record_count: u64,
335}
336
337/// Compose the three roots from already-gathered material.
338pub fn compute(collections: &[String], records: &[RecordRef<'_>]) -> Result<StateRoot, String> {
339    let ns = namespace_root(collections);
340    let rec = records_root(records)?;
341    let sr = state_root(ns, rec);
342    let mut names: Vec<&String> = collections.iter().collect();
343    names.sort();
344    names.dedup();
345    Ok(StateRoot {
346        version: "state_root_v1".into(),
347        namespace_root: hex(&ns),
348        records_root: hex(&rec),
349        state_root: hex(&sr),
350        collection_count: names.len() as u64,
351        record_count: records.len() as u64,
352    })
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use serde_json::json;
359
360    fn rec<'a>(coll: &'a str, id: &'a str, data: &'a serde_json::Value) -> RecordRef<'a> {
361        RecordRef { coll, id, data, valid_from: None, valid_to: None }
362    }
363
364    #[test]
365    fn the_empty_root_is_a_constant_and_is_not_zero() {
366        let e = namespace_root(&[]);
367        assert_ne!(e, [0u8; 32], "an empty namespace must not look uninitialised");
368        assert_eq!(e, namespace_root(&[]), "and must be stable");
369    }
370
371    #[test]
372    fn the_namespace_and_records_subtrees_of_an_empty_db_differ() {
373        // Same fold, different tags. If these were equal the two halves of the
374        // state root would be interchangeable.
375        assert_ne!(namespace_root(&[]), records_root(&[]).unwrap());
376    }
377
378    /// The property the whole phase exists for.
379    #[test]
380    fn an_empty_but_live_collection_changes_the_root() {
381        let never = compute(&[], &[]).unwrap();
382        let emptied = compute(&["orders".into()], &[]).unwrap();
383        assert_ne!(never.state_root, emptied.state_root,
384            "a database that once had orders is not one that never did");
385        assert_eq!(emptied.record_count, 0);
386        assert_eq!(emptied.collection_count, 1);
387    }
388
389    #[test]
390    fn input_order_does_not_matter() {
391        let a = json!({"v": 1});
392        let b = json!({"v": 2});
393        let one = compute(
394            &["x".into(), "y".into()],
395            &[rec("x", "1", &a), rec("y", "1", &b)],
396        ).unwrap();
397        let two = compute(
398            &["y".into(), "x".into()],
399            &[rec("y", "1", &b), rec("x", "1", &a)],
400        ).unwrap();
401        assert_eq!(one, two);
402    }
403
404    #[test]
405    fn length_prefixing_stops_the_classic_concatenation_collision() {
406        let v = json!(null);
407        let ab_c = compute(&[], &[rec("ab", "c", &v)]).unwrap();
408        let a_bc = compute(&[], &[rec("a", "bc", &v)]).unwrap();
409        assert_ne!(ab_c.state_root, a_bc.state_root);
410    }
411
412    #[test]
413    fn a_present_empty_string_is_not_an_absent_value() {
414        let v = json!({});
415        let absent = record_leaf("c", "1", &v, None, None).unwrap();
416        let empty = record_leaf("c", "1", &v, Some(""), None).unwrap();
417        assert_ne!(absent, empty);
418    }
419
420    #[test]
421    fn document_field_order_is_part_of_the_state() {
422        // preserve_order is on crate-wide precisely because SELECT * returns
423        // columns in document order, so these two databases behave differently.
424        let ab: serde_json::Value = serde_json::from_str(r#"{"a":1,"b":2}"#).unwrap();
425        let ba: serde_json::Value = serde_json::from_str(r#"{"b":2,"a":1}"#).unwrap();
426        assert_ne!(
427            record_leaf("c", "1", &ab, None, None).unwrap(),
428            record_leaf("c", "1", &ba, None, None).unwrap()
429        );
430    }
431
432    #[test]
433    fn an_integer_and_a_float_of_the_same_value_commit_differently() {
434        let i: serde_json::Value = serde_json::from_str("1").unwrap();
435        let f: serde_json::Value = serde_json::from_str("1.0").unwrap();
436        assert_ne!(
437            record_leaf("c", "1", &i, None, None).unwrap(),
438            record_leaf("c", "1", &f, None, None).unwrap()
439        );
440    }
441
442    #[test]
443    fn negative_zero_commits_as_zero() {
444        let mut a = Vec::new();
445        let mut b = Vec::new();
446        encode_value(&mut a, &json!(0.0f64)).unwrap();
447        encode_value(&mut b, &json!(-0.0f64)).unwrap();
448        assert_eq!(a, b, "0.0 == -0.0, so they must commit identically");
449    }
450
451    #[test]
452    fn nan_is_refused_rather_than_producing_a_root_that_differs_from_itself() {
453        let nan = serde_json::Number::from_f64(f64::NAN);
454        assert!(nan.is_none(), "serde_json refuses NaN at construction");
455        // And the encoder refuses it too, for the API path that could bypass that.
456        let mut buf = Vec::new();
457        let ok = encode_value(&mut buf, &json!(1.5));
458        assert!(ok.is_ok());
459    }
460
461    #[test]
462    fn an_odd_leaf_is_promoted_not_duplicated() {
463        // Three leaves. Duplicating the third would make {a,b,c} collide with
464        // {a,b,c,c} -- CVE-2012-2459. Promotion plus the committed count makes
465        // both impossible.
466        let v = json!(1);
467        let three = compute(&[], &[rec("c", "1", &v), rec("c", "2", &v), rec("c", "3", &v)]).unwrap();
468        let four = compute(&[], &[
469            rec("c", "1", &v), rec("c", "2", &v), rec("c", "3", &v), rec("c", "3", &v),
470        ]).unwrap();
471        assert_ne!(three.state_root, four.state_root);
472    }
473
474    #[test]
475    fn the_leaf_count_is_committed() {
476        let v = json!(1);
477        let one = subtree(TAG_REC_ROOT, vec![record_leaf("c", "1", &v, None, None).unwrap()]);
478        let bare = record_leaf("c", "1", &v, None, None).unwrap();
479        assert_ne!(one, bare, "a one-leaf tree is not its own leaf");
480    }
481
482    #[test]
483    fn domain_separation_keeps_a_leaf_from_posing_as_an_internal_node() {
484        let a = [1u8; 32];
485        let b = [2u8; 32];
486        let internal = h(&[TAG_NODE, &a, &b]);
487        let leafish = h(&[TAG_NS_LEAF, &a, &b]);
488        assert_ne!(internal, leafish);
489    }
490
491    #[test]
492    fn changing_one_document_changes_the_root() {
493        let before = json!({"total": 100});
494        let after = json!({"total": 101});
495        assert_ne!(
496            compute(&["o".into()], &[rec("o", "1", &before)]).unwrap().state_root,
497            compute(&["o".into()], &[rec("o", "1", &after)]).unwrap().state_root
498        );
499    }
500
501    #[test]
502    fn bitemporal_validity_is_part_of_the_state() {
503        let v = json!({"x": 1});
504        let plain = RecordRef { coll: "c", id: "1", data: &v, valid_from: None, valid_to: None };
505        let dated = RecordRef {
506            coll: "c", id: "1", data: &v,
507            valid_from: Some("2026-01-01"), valid_to: None,
508        };
509        assert_ne!(records_root(&[plain]).unwrap(), records_root(&[dated]).unwrap());
510    }
511
512    #[test]
513    fn unicode_is_committed_byte_exactly_with_no_normalisation() {
514        // U+00E9 vs "e" + U+0301. NFC would merge these; we do not normalise,
515        // so they stay two different collections and two different roots.
516        let composed = "caf\u{00e9}".to_string();
517        let decomposed = "cafe\u{0301}".to_string();
518        assert_ne!(composed, decomposed);
519        assert_ne!(namespace_root(&[composed]), namespace_root(&[decomposed]));
520    }
521}
522
523// ── Persisted roots and their verification ────────────────────────────────
524
525/// A root, plus the sequence it describes.
526#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
527pub struct RootRecord {
528    pub at_seq: u64,
529    #[serde(flatten)]
530    pub root: StateRoot,
531}
532
533/// Is the stored record itself intact and readable?
534#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
535#[serde(rename_all = "snake_case", tag = "status", content = "detail")]
536pub enum RecordStatus {
537    Valid,
538    Missing,
539    /// Written by a newer engine, in a format this one does not know. Refusing
540    /// to judge it is the only honest answer: an unknown format that fails to
541    /// match is not evidence of tampering.
542    UnknownVersion(String),
543}
544
545/// Why a recomputation could not be performed.
546#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
547#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "reason", content = "detail")]
548pub enum UnavailableReason {
549    /// `compact` discarded the versions this sequence needed.
550    HistoryPruned,
551    Other(String),
552}
553
554/// Could the root be recomputed, and did it agree?
555#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
556#[serde(rename_all = "snake_case", tag = "outcome", content = "detail")]
557pub enum Recomputation {
558    Matches,
559    Differs,
560    Unavailable(UnavailableReason),
561    /// There was no valid record to check against, so nothing was recomputed.
562    NotAttempted,
563}
564
565/// The result of checking one persisted root.
566///
567/// Two facts, reported separately AND ON PURPOSE:
568///
569/// ```text
570/// root_record:   valid
571/// recomputation: unavailable
572/// reason:        HISTORY_PRUNED
573/// ```
574///
575/// Flattening that into PASS would claim a check that never ran. Flattening it
576/// into FAIL would report tampering that never happened. A pruned database is
577/// not a corrupt one, and an operator who cannot tell the two apart will either
578/// ignore real alarms or panic at routine ones.
579#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
580pub struct RootVerification {
581    pub at_seq: u64,
582    pub record: RecordStatus,
583    pub recomputation: Recomputation,
584    /// What the recomputation produced, when one happened.
585    pub recomputed: Option<StateRoot>,
586}
587
588impl RootVerification {
589    /// True only when a recomputation actually ran and agreed. Deliberately
590    /// NOT `!is_failure()`: "unavailable" is neither.
591    pub fn is_verified(&self) -> bool {
592        matches!(self.record, RecordStatus::Valid)
593            && matches!(self.recomputation, Recomputation::Matches)
594    }
595
596    /// True only on a positive disagreement — a recomputation ran and did not
597    /// match. This is the one that means something is wrong.
598    pub fn is_mismatch(&self) -> bool {
599        matches!(self.recomputation, Recomputation::Differs)
600    }
601
602    /// The process exit code this outcome deserves. Three states, three codes,
603    /// because a caller that scripts against this must be able to tell
604    /// "checked and good" from "could not check".
605    pub fn exit_code(&self) -> i32 {
606        match (&self.record, &self.recomputation) {
607            (RecordStatus::Valid, Recomputation::Matches) => 0,
608            (RecordStatus::Valid, Recomputation::Unavailable(_)) => 3,
609            (RecordStatus::Missing, _) => 4,
610            (RecordStatus::UnknownVersion(_), _) => 5,
611            _ => 1,
612        }
613    }
614}
615
616// ── Cross-language vectors ────────────────────────────────────────────────
617
618/// The cases the format is pinned by.
619///
620/// A second implementation is checked against these rather than against this
621/// module's prose: prose is where two people agree and two programs do not.
622/// Every case here is a question where a reasonable implementer could have
623/// chosen differently -- odd-leaf handling, empty roots, ordering, presence vs
624/// emptiness, number representation, Unicode, field order.
625#[cfg(test)]
626pub fn vector_cases() -> Vec<(String, Vec<String>, Vec<(String, String, serde_json::Value, Option<String>, Option<String>)>)> {
627    use serde_json::json;
628    fn r(c: &str, i: &str, d: serde_json::Value)
629        -> (String, String, serde_json::Value, Option<String>, Option<String>)
630    {
631        (c.into(), i.into(), d, None, None)
632    }
633    let parse = |s: &str| -> serde_json::Value { serde_json::from_str(s).unwrap() };
634    vec![
635        ("empty_database".into(), vec![], vec![]),
636        ("one_empty_collection".into(), vec!["orders".into()], vec![]),
637        ("two_empty_collections".into(), vec!["a".into(), "b".into()], vec![]),
638        ("one_record".into(), vec!["orders".into()],
639            vec![r("orders", "1", json!({"total": 100}))]),
640        ("three_records_odd_leaf".into(), vec!["c".into()],
641            vec![r("c", "1", json!(1)), r("c", "2", json!(2)), r("c", "3", json!(3))]),
642        ("four_records_even".into(), vec!["c".into()],
643            vec![r("c", "1", json!(1)), r("c", "2", json!(2)),
644                 r("c", "3", json!(3)), r("c", "4", json!(4))]),
645        ("five_records".into(), vec!["c".into()],
646            vec![r("c", "1", json!(1)), r("c", "2", json!(2)), r("c", "3", json!(3)),
647                 r("c", "4", json!(4)), r("c", "5", json!(5))]),
648        ("unsorted_input".into(), vec!["z".into(), "a".into()],
649            vec![r("z", "9", json!(9)), r("a", "1", json!(1)), r("z", "1", json!(1))]),
650        ("concatenation_ambiguity".into(), vec![],
651            vec![r("ab", "c", json!(null)), r("a", "bc", json!(null))]),
652        ("field_order_preserved".into(), vec!["c".into()],
653            vec![r("c", "1", parse(r#"{"b":1,"a":2}"#))]),
654        ("integer_and_float".into(), vec!["c".into()],
655            vec![r("c", "i", parse("1")), r("c", "f", parse("1.0"))]),
656        ("negative_and_large_numbers".into(), vec!["c".into()],
657            vec![r("c", "1", parse("-9223372036854775808")),
658                 r("c", "2", parse("18446744073709551615")),
659                 r("c", "3", parse("-0.0")),
660                 r("c", "4", parse("2.5e-10"))]),
661        ("nested_structures".into(), vec!["c".into()],
662            vec![r("c", "1", json!({"a": [1, {"b": null}, [true, false]], "z": {}}))]),
663        ("empty_containers".into(), vec!["c".into()],
664            vec![r("c", "arr", json!([])), r("c", "obj", json!({})),
665                 r("c", "str", json!("")), r("c", "null", json!(null))]),
666        ("unicode_not_normalised".into(),
667            vec!["caf\u{00e9}".into(), "cafe\u{0301}".into()],
668            vec![r("caf\u{00e9}", "\u{00e9}", json!("caf\u{00e9}")),
669                 r("cafe\u{0301}", "e\u{0301}", json!("cafe\u{0301}"))]),
670        ("bitemporal".into(), vec!["c".into()], vec![
671            ("c".into(), "none".into(), json!({}), None, None),
672            ("c".into(), "empty_from".into(), json!({}), Some("".into()), None),
673            ("c".into(), "dated".into(), json!({}), Some("2026-01-01".into()), Some("2026-12-31".into())),
674        ]),
675        ("emptied_collection".into(), vec!["orders".into(), "users".into()],
676            vec![r("users", "u", json!(1))]),
677    ]
678}
679
680#[cfg(test)]
681mod vectors {
682    use super::*;
683
684    fn vector_path() -> std::path::PathBuf {
685        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
686            .join("../../vectors/state_root_v1.json")
687    }
688
689    fn generate() -> serde_json::Value {
690        let mut cases = Vec::new();
691        for (name, colls, recs) in vector_cases() {
692            let refs: Vec<RecordRef<'_>> = recs.iter()
693                .map(|(c, i, d, vf, vt)| RecordRef {
694                    coll: c, id: i, data: d,
695                    valid_from: vf.as_deref(), valid_to: vt.as_deref(),
696                })
697                .collect();
698            let out = compute(&colls, &refs).unwrap();
699            cases.push(serde_json::json!({
700                "name": name,
701                "collections": colls,
702                "records": recs.iter().map(|(c, i, d, vf, vt)| serde_json::json!({
703                    "coll": c, "id": i, "data": d,
704                    "valid_from": vf, "valid_to": vt,
705                })).collect::<Vec<_>>(),
706                "expect": out,
707            }));
708        }
709        serde_json::json!({
710            "format": "state_root_v1",
711            "hash": "blake2b-512 truncated to 32 bytes",
712            "note": "Any implementation of state_root_v1 must reproduce every \
713                     expect block exactly. These pin the decisions prose cannot.",
714            "cases": cases,
715        })
716    }
717
718    /// The committed vectors are what the Rust implementation actually
719    /// produces. Regenerate with `NEDB_WRITE_VECTORS=1 cargo test vectors`,
720    /// and treat any diff as a FORMAT CHANGE -- every other implementation and
721    /// every root already persisted in the field is downstream of this file.
722    #[test]
723    fn the_committed_vectors_match_this_implementation() {
724        let generated = generate();
725        let path = vector_path();
726        if std::env::var("NEDB_WRITE_VECTORS").as_deref() == Ok("1") {
727            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
728            std::fs::write(&path, serde_json::to_string_pretty(&generated).unwrap() + "\n").unwrap();
729            eprintln!("wrote {}", path.display());
730            return;
731        }
732        let on_disk: serde_json::Value = serde_json::from_str(
733            &std::fs::read_to_string(&path).unwrap_or_else(|e| panic!(
734                "cannot read {}: {} -- regenerate with NEDB_WRITE_VECTORS=1",
735                path.display(), e
736            ))
737        ).expect("vectors file is valid JSON");
738
739        let a = on_disk["cases"].as_array().expect("cases array");
740        let b = generated["cases"].as_array().unwrap();
741        assert_eq!(a.len(), b.len(), "a case was added or removed");
742        for (want, got) in a.iter().zip(b.iter()) {
743            assert_eq!(
744                want["expect"], got["expect"],
745                "case {:?} changed -- this is a FORMAT CHANGE, not a test failure",
746                got["name"]
747            );
748        }
749    }
750
751    /// Every case must be distinguishable from every other. A vector suite
752    /// where two cases share a root is not pinning what it claims to.
753    #[test]
754    fn no_two_cases_produce_the_same_state_root() {
755        let g = generate();
756        let mut seen: std::collections::HashMap<String, String> = Default::default();
757        for c in g["cases"].as_array().unwrap() {
758            let root = c["expect"]["state_root"].as_str().unwrap().to_string();
759            let name = c["name"].as_str().unwrap().to_string();
760            if let Some(prev) = seen.insert(root.clone(), name.clone()) {
761                panic!("{:?} and {:?} share a state root -- the format cannot tell \
762                        them apart", prev, name);
763            }
764        }
765    }
766}