Skip to main content

car_sync/
checkpoint.rs

1//! Checkpoints — a serialized fold at a frontier (slice B4 of
2//! `docs/proposals/multi-device-sync.md`, §"Snapshots: bounding the oplog").
3//!
4//! A [`Checkpoint`] is exactly what the proposal specs: "a serialized
5//! **folded state at a frontier F**, content-addressed" — the
6//! [`crate::fold::SyncState`] a device folded from every op at or below the
7//! frontier, plus the bookkeeping a *truncated* log needs to stay
8//! verifiable:
9//!
10//! - **`frontier`** — per device, the `{seq, hlc, head}` of the last op the
11//!   checkpoint covers. Because [`crate::oplog::verify_log`] enforces
12//!   HLC-monotone chains, "hlc ≤ F" is always a per-device chain *prefix*,
13//!   so the frontier is a clean cut.
14//! - **`head`** — the covered chain tail's `op_id`. After truncation the
15//!   first retained op's `prev` must link to it: the checkpoint IS the
16//!   anchored chain head, which upgrades the A9-style truncation-honesty
17//!   story (a truncated log + its checkpoint prove that nothing was dropped
18//!   *silently* — the cut is signed into the anchor). [`verify_anchored`]
19//!   checks the whole composition; [`crate::oplog::verify_log`] alone
20//!   already accepts chains that don't start at `seq 0` (designed for this).
21//! - **`state_hash`** — [`crate::fold::state_hash`] of the stored state, the
22//!   proposal's divergence invariant ("same frontier ⇒ same snapshot hash").
23//! - **`checkpoint_hash`** — the **content address**, covering the WHOLE
24//!   record (frontier + scopes + state), not just the state. This is
25//!   load-bearing: the fold DEDUPS (e.g. the same conversation turn emitted
26//!   by two devices), so two checkpoints with *different frontiers* can fold
27//!   to the *same* `SyncState` — a state-only address would give them one
28//!   file name, the relay (B3) would dedup-keep the wrong one, and
29//!   [`resume_anchored`] would seed `next_seq` from the wrong frontier — a
30//!   permanent duplicate-seq chain fork. Addressing the whole record makes
31//!   "same file ⇒ same checkpoint" true (and gives B6 a correct thing to
32//!   sign); "same frontier ⇒ same file" still holds because the fold is
33//!   deterministic.
34//!
35//! **Scopes, honestly:** the proposal tracks frontiers per scope, but B1's
36//! `DeviceLog` stamps ONE `seq`/`prev` chain across all scopes (personal and
37//! shared ops interleave in a single device chain), so a per-scope
38//! truncation would punch unverifiable holes in the chain. B4 therefore
39//! checkpoints a device log **whole-chain** and records the [`Checkpoint::scopes`]
40//! it covers; true per-scope frontiers arrive when B3/B6 split the relay
41//! streams (and with them the chains) by scope.
42//!
43//! Durability discipline: [`Checkpoint::save`] writes temp + atomic rename
44//! (fsync'd file, best-effort fsync'd dir); [`Checkpoint::load`] re-derives
45//! BOTH hashes from the stored content — `state_hash` from the state,
46//! `checkpoint_hash` from the whole record — and cross-checks the file name
47//! against the content address, **rejecting any mismatch loudly**
48//! ([`CheckpointError::HashMismatch`] / [`CheckpointError::ContentMismatch`]
49//! / [`CheckpointError::AddressMismatch`]) — a tampered or bit-rotted
50//! snapshot (including a tampered *frontier*) never folds and never anchors.
51
52use crate::fold::{fold, state_hash, SyncState};
53use crate::oplog::{canonical_json, verify_log, ChainError, DeviceLog, Hlc, OpRecord};
54use serde::{Deserialize, Serialize};
55use sha2::{Digest, Sha256};
56use std::collections::{BTreeMap, BTreeSet};
57use std::fmt;
58use std::fs::{self, File};
59use std::io::Write;
60use std::path::{Path, PathBuf};
61
62/// Per-device frontier bookkeeping: the last op the checkpoint covers for
63/// one device — its chain position (`seq`), stamp (`hlc`), and `op_id`
64/// (`head`, the anchor a truncated log's first retained `prev` links to).
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct FrontierEntry {
67    pub seq: u64,
68    pub hlc: Hlc,
69    pub head: String,
70}
71
72/// A serialized fold at a frontier — see the module docs for the design.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Checkpoint {
75    /// device_id → last covered op (`{seq, hlc, head}`). A device absent
76    /// here has NO ops below the frontier (its full chain is retained).
77    pub frontier: BTreeMap<String, FrontierEntry>,
78    /// Sorted, deduped scope tags covered ([`crate::oplog::Scope::tag`]) —
79    /// whole-chain checkpointing, see module docs.
80    pub scopes: Vec<String>,
81    /// [`state_hash`] of `state` — the divergence invariant ("same frontier
82    /// ⇒ same snapshot hash"). Recomputed and enforced on
83    /// [`Checkpoint::load`].
84    pub state_hash: String,
85    /// Whole-record content address covering frontier + scopes + state (see
86    /// module docs for why state-only addressing forks chains). The file
87    /// name; recomputed and enforced on [`Checkpoint::load`].
88    pub checkpoint_hash: String,
89    /// The folded state at the frontier (possibly retention-compacted by
90    /// [`crate::compact::plan_compaction`]).
91    pub state: SyncState,
92}
93
94impl Checkpoint {
95    /// Assemble a checkpoint from its parts, stamping both hashes — the one
96    /// construction path, so a `Checkpoint` value is coherent by build.
97    pub fn assemble(
98        frontier: BTreeMap<String, FrontierEntry>,
99        scopes: Vec<String>,
100        state: SyncState,
101    ) -> Self {
102        let hash = state_hash(&state);
103        let mut checkpoint = Self {
104            frontier,
105            scopes,
106            state_hash: hash,
107            checkpoint_hash: String::new(),
108            state,
109        };
110        checkpoint.checkpoint_hash = checkpoint.content_hash();
111        checkpoint
112    }
113
114    /// Checkpoint an op-set: verify it (the B1 verify-before-fold
115    /// contract), fold it, and record the per-device frontier + scopes.
116    /// The state is the **exact** fold — retention is applied separately by
117    /// [`crate::compact::plan_compaction`], so the equivalence
118    /// `fold_onto(checkpoint.state, tail) == fold(full log)` holds exactly.
119    pub fn from_ops(ops: &[OpRecord]) -> Result<Self, ChainError> {
120        verify_log(ops)?;
121        let state = fold(ops);
122        let mut frontier: BTreeMap<String, FrontierEntry> = BTreeMap::new();
123        let mut scopes: BTreeSet<String> = BTreeSet::new();
124        for op in ops {
125            scopes.insert(op.scope.tag());
126            let replace = match frontier.get(&op.device_id) {
127                Some(existing) => op.seq > existing.seq,
128                None => true,
129            };
130            if replace {
131                frontier.insert(
132                    op.device_id.clone(),
133                    FrontierEntry {
134                        seq: op.seq,
135                        hlc: op.hlc.clone(),
136                        head: op.op_id.clone(),
137                    },
138                );
139            }
140        }
141        Ok(Self::assemble(frontier, scopes.into_iter().collect(), state))
142    }
143
144    /// Recompute the whole-record content address from the checkpoint's
145    /// fields (everything except `checkpoint_hash` itself, over the
146    /// canonical serialization). Covers the frontier and scopes, not just
147    /// the state — see the module docs for the chain-fork this prevents.
148    pub fn content_hash(&self) -> String {
149        let mut value = serde_json::to_value(self).expect("Checkpoint serializes");
150        if let Some(obj) = value.as_object_mut() {
151            obj.remove("checkpoint_hash");
152        }
153        let mut hasher = Sha256::new();
154        hasher.update(canonical_json(&value).as_bytes());
155        let digest = hasher.finalize();
156        let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
157        format!("ckpt-{hex}")
158    }
159
160    /// Recompute BOTH hashes and reject any mismatch — the load-verify half
161    /// of the durability discipline (also callable on an in-memory
162    /// checkpoint received from a peer). `state_hash` proves the state;
163    /// `checkpoint_hash` proves the whole record, so a tampered *frontier*
164    /// is caught too.
165    pub fn verify(&self) -> Result<(), CheckpointError> {
166        let actual_state = state_hash(&self.state);
167        if actual_state != self.state_hash {
168            return Err(CheckpointError::HashMismatch {
169                expected: self.state_hash.clone(),
170                actual: actual_state,
171            });
172        }
173        let actual_content = self.content_hash();
174        if actual_content != self.checkpoint_hash {
175            return Err(CheckpointError::ContentMismatch {
176                expected: self.checkpoint_hash.clone(),
177                actual: actual_content,
178            });
179        }
180        Ok(())
181    }
182
183    /// Content-addressed file name: `<checkpoint_hash>.checkpoint.json`.
184    /// Same frontier + same retention ⇒ same record ⇒ same file (the relay
185    /// dedup the proposal leans on) — and, because the address covers the
186    /// whole record, same file ⇒ same checkpoint.
187    pub fn file_name(&self) -> String {
188        format!("{}.checkpoint.json", self.checkpoint_hash)
189    }
190
191    /// Durably write the checkpoint into `dir` (created if needed):
192    /// temp file → fsync → atomic rename → best-effort dir fsync. Returns
193    /// the final path. Writing the same checkpoint twice is idempotent
194    /// (content-addressed name, rename-over-identical).
195    pub fn save(&self, dir: &Path) -> std::io::Result<PathBuf> {
196        fs::create_dir_all(dir)?;
197        let final_path = dir.join(self.file_name());
198        let tmp_path = dir.join(format!("{}.tmp", self.file_name()));
199        {
200            let mut tmp = File::create(&tmp_path)?;
201            tmp.write_all(
202                serde_json::to_string(self)
203                    .map_err(std::io::Error::other)?
204                    .as_bytes(),
205            )?;
206            tmp.sync_all()?;
207        }
208        fs::rename(&tmp_path, &final_path)?;
209        // Make the rename itself durable where the platform allows it.
210        #[cfg(unix)]
211        {
212            let _ = File::open(dir).and_then(|d| d.sync_all());
213        }
214        Ok(final_path)
215    }
216
217    /// Load a checkpoint and **verify it**: both stored hashes must
218    /// recompute from the stored content ([`Checkpoint::verify`]), and the
219    /// file's name must BE the content address — a renamed or
220    /// wrongly-addressed file is rejected ([`CheckpointError::AddressMismatch`])
221    /// so a store can never serve checkpoint X under checkpoint Y's name.
222    /// Never fold a snapshot that doesn't prove itself.
223    pub fn load(path: &Path) -> Result<Self, CheckpointError> {
224        let raw = fs::read_to_string(path).map_err(CheckpointError::Io)?;
225        let checkpoint: Checkpoint =
226            serde_json::from_str(&raw).map_err(CheckpointError::Parse)?;
227        checkpoint.verify()?;
228        let expected = checkpoint.file_name();
229        let found = path
230            .file_name()
231            .map(|n| n.to_string_lossy().into_owned())
232            .unwrap_or_default();
233        if found != expected {
234            return Err(CheckpointError::AddressMismatch { expected, found });
235        }
236        Ok(checkpoint)
237    }
238}
239
240/// A checkpoint load/verify failure.
241#[derive(Debug)]
242pub enum CheckpointError {
243    Io(std::io::Error),
244    Parse(serde_json::Error),
245    /// The stored `state_hash` does not recompute from the stored state —
246    /// tampering, corruption, or a fold-determinism bug. Never fold it.
247    HashMismatch { expected: String, actual: String },
248    /// The stored `checkpoint_hash` does not recompute from the whole
249    /// record — a tampered frontier/scopes (or a forged address). Never
250    /// fold or anchor on it.
251    ContentMismatch { expected: String, actual: String },
252    /// The file's name is not the record's content address — a renamed,
253    /// swapped, or wrongly-stored checkpoint file.
254    AddressMismatch { expected: String, found: String },
255}
256
257impl fmt::Display for CheckpointError {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            CheckpointError::Io(e) => write!(f, "checkpoint io error: {e}"),
261            CheckpointError::Parse(e) => write!(f, "checkpoint parse error: {e}"),
262            CheckpointError::HashMismatch { expected, actual } => write!(
263                f,
264                "checkpoint state_hash mismatch (stored {expected}, recomputed {actual}) — \
265                 tampered or corrupt snapshot, refusing to fold it"
266            ),
267            CheckpointError::ContentMismatch { expected, actual } => write!(
268                f,
269                "checkpoint content-address mismatch (stored {expected}, recomputed {actual}) — \
270                 frontier/scopes tampered or record corrupt, refusing to fold or anchor on it"
271            ),
272            CheckpointError::AddressMismatch { expected, found } => write!(
273                f,
274                "checkpoint file name {found} is not its content address {expected} — \
275                 renamed or wrongly-stored checkpoint, refusing to load it"
276            ),
277        }
278    }
279}
280
281impl std::error::Error for CheckpointError {}
282
283/// A failure composing a checkpoint with a (truncated) tail.
284#[derive(Debug)]
285pub enum AnchorError {
286    /// The checkpoint itself doesn't verify.
287    Checkpoint(CheckpointError),
288    /// The tail doesn't chain-verify on its own.
289    Chain(ChainError),
290    /// A device's first retained op doesn't continue the checkpoint's
291    /// recorded frontier (`seq`, `prev` link, or HLC advance).
292    BrokenAnchor { device_id: String, detail: String },
293    /// A device absent from the checkpoint frontier must present its chain
294    /// from `seq 0` — a nonzero start with no anchor is a silent hole.
295    UnanchoredDevice { device_id: String, first_seq: u64 },
296}
297
298impl fmt::Display for AnchorError {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        match self {
301            AnchorError::Checkpoint(e) => write!(f, "anchor checkpoint invalid: {e}"),
302            AnchorError::Chain(e) => write!(f, "anchored tail chain invalid: {e}"),
303            AnchorError::BrokenAnchor { device_id, detail } => {
304                write!(f, "device {device_id}: tail does not anchor on checkpoint ({detail})")
305            }
306            AnchorError::UnanchoredDevice { device_id, first_seq } => write!(
307                f,
308                "device {device_id}: first op has seq {first_seq} but the checkpoint \
309                 records no frontier for it — unanchored truncation"
310            ),
311        }
312    }
313}
314
315impl std::error::Error for AnchorError {}
316
317/// Verify the composition `checkpoint + retained tail` — the truncated-log
318/// analogue of [`verify_log`]:
319///
320/// 1. the checkpoint proves itself (both `state_hash` and the whole-record
321///    `checkpoint_hash` recompute — a tampered frontier can't anchor);
322/// 2. the tail chain-verifies on its own (`verify_log` — which already
323///    accepts chains not starting at `seq 0`);
324/// 3. per device, the first retained op continues the checkpoint's frontier
325///    exactly: `seq == frontier.seq + 1`, `prev == frontier.head`, and
326///    `hlc > frontier.hlc`;
327/// 4. a device with **no** frontier entry must start at `seq 0` (nothing of
328///    it was truncated).
329///
330/// A pass means the pair carries the same integrity guarantee the full log
331/// carried — the checkpoint IS the anchored head of every truncated chain.
332pub fn verify_anchored(checkpoint: &Checkpoint, tail: &[OpRecord]) -> Result<(), AnchorError> {
333    checkpoint.verify().map_err(AnchorError::Checkpoint)?;
334    verify_log(tail).map_err(AnchorError::Chain)?;
335
336    // First retained op per device (min seq).
337    let mut first: BTreeMap<&str, &OpRecord> = BTreeMap::new();
338    for op in tail {
339        let replace = match first.get(op.device_id.as_str()) {
340            Some(existing) => op.seq < existing.seq,
341            None => true,
342        };
343        if replace {
344            first.insert(&op.device_id, op);
345        }
346    }
347
348    for (device_id, op) in first {
349        match checkpoint.frontier.get(device_id) {
350            Some(anchor) => {
351                if op.seq != anchor.seq + 1 {
352                    return Err(AnchorError::BrokenAnchor {
353                        device_id: device_id.to_string(),
354                        detail: format!(
355                            "first retained seq {} does not continue frontier seq {}",
356                            op.seq, anchor.seq
357                        ),
358                    });
359                }
360                if op.prev.as_deref() != Some(anchor.head.as_str()) {
361                    return Err(AnchorError::BrokenAnchor {
362                        device_id: device_id.to_string(),
363                        detail: format!(
364                            "first retained op's prev does not link to frontier head {}",
365                            anchor.head
366                        ),
367                    });
368                }
369                if op.hlc <= anchor.hlc {
370                    return Err(AnchorError::BrokenAnchor {
371                        device_id: device_id.to_string(),
372                        detail: "first retained op's hlc does not advance past the frontier"
373                            .to_string(),
374                    });
375                }
376            }
377            None => {
378                if op.seq != 0 {
379                    return Err(AnchorError::UnanchoredDevice {
380                        device_id: device_id.to_string(),
381                        first_seq: op.seq,
382                    });
383                }
384            }
385        }
386    }
387    Ok(())
388}
389
390/// Resume a device's append chain from a checkpoint + retained tail — the
391/// truncated-journal sibling of [`DeviceLog::resume`]. Without this, a
392/// device whose ops were ALL below the frontier would resume at `seq 0` and
393/// permanently fork its own chain (the same hazard `DeviceLog::resume`'s
394/// journal-durable-before-transmit contract guards). Verifies the
395/// composition first, seeds `seq`/`prev` from the frontier anchor, and
396/// advances the hybrid clock past every stamp the checkpoint or tail
397/// covers (the frontier holds each device's max covered HLC, so it bounds
398/// everything folded into the state). The resumed log defaults to the
399/// logical (always-0) wall source — attach the real one with
400/// [`DeviceLog::set_wall_clock`].
401pub fn resume_anchored(
402    device_id: impl Into<String>,
403    checkpoint: &Checkpoint,
404    tail: &[OpRecord],
405) -> Result<DeviceLog, AnchorError> {
406    verify_anchored(checkpoint, tail)?;
407    let device_id = device_id.into();
408    let mut log = DeviceLog::new(device_id.clone());
409    for entry in checkpoint.frontier.values() {
410        log.clock.observe(&entry.hlc);
411    }
412    if let Some(anchor) = checkpoint.frontier.get(&device_id) {
413        log.next_seq = anchor.seq + 1;
414        log.prev = Some(anchor.head.clone());
415    }
416    for op in tail {
417        log.clock.observe(&op.hlc);
418        if op.device_id == device_id && op.seq >= log.next_seq {
419            log.next_seq = op.seq + 1;
420            log.prev = Some(op.op_id.clone());
421        }
422    }
423    Ok(log)
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::fold::fold_onto;
430    use crate::oplog::{Scope, Surface};
431    use serde_json::json;
432
433    /// Two devices, mixed scopes/surfaces; returns (all ops, split index) —
434    /// ops[..split] is a valid per-device chain-prefix cut.
435    fn ops_with_cut() -> (Vec<OpRecord>, usize) {
436        let mut a = DeviceLog::new("dev-a");
437        let mut b = DeviceLog::new("dev-b");
438        let mut ops = vec![
439            a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1", "v": 1})),
440            a.append(
441                Scope::Shared { org: "acme".into() },
442                Surface::Declagent,
443                json!({"id": "agent-1", "rev": "a"}),
444            ),
445        ];
446        for op in &ops {
447            b.observe(&op.hlc);
448        }
449        ops.push(b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2", "v": 2})));
450        let split = ops.len();
451        ops.push(a.append(Scope::Personal, Surface::Conversation, json!({"speaker": "u", "text": "hi", "timestamp": 10})));
452        for op in &ops[split..] {
453            b.observe(&op.hlc);
454        }
455        ops.push(b.append(
456            Scope::Shared { org: "acme".into() },
457            Surface::Declagent,
458            json!({"id": "agent-1", "rev": "b"}),
459        ));
460        (ops, split)
461    }
462
463    #[test]
464    fn from_ops_records_frontier_heads_and_scopes() {
465        let (ops, split) = ops_with_cut();
466        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
467        assert_eq!(ckpt.scopes, vec!["personal".to_string(), "shared:acme".to_string()]);
468        assert_eq!(ckpt.frontier.len(), 2);
469        assert_eq!(ckpt.frontier["dev-a"].seq, 1);
470        assert_eq!(ckpt.frontier["dev-a"].head, ops[1].op_id);
471        assert_eq!(ckpt.frontier["dev-b"].seq, 0);
472        assert_eq!(ckpt.frontier["dev-b"].head, ops[2].op_id);
473        assert_eq!(ckpt.state_hash, state_hash(&fold(&ops[..split])));
474    }
475
476    #[test]
477    fn from_ops_refuses_an_invalid_log() {
478        let (mut ops, _) = ops_with_cut();
479        ops[0].payload = json!({"forged": true}); // id no longer matches
480        assert!(matches!(Checkpoint::from_ops(&ops), Err(ChainError::IdMismatch { .. })));
481    }
482
483    #[test]
484    fn checkpoint_save_load_round_trips_content_addressed() {
485        let dir = tempfile::tempdir().unwrap();
486        let (ops, split) = ops_with_cut();
487        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
488
489        let path = ckpt.save(dir.path()).unwrap();
490        assert_eq!(
491            path.file_name().unwrap().to_str().unwrap(),
492            format!("{}.checkpoint.json", ckpt.checkpoint_hash),
493            "file name is the WHOLE-RECORD content address"
494        );
495        let loaded = Checkpoint::load(&path).unwrap();
496        assert_eq!(loaded, ckpt);
497
498        // Saving again is idempotent (same content → same file).
499        let again = ckpt.save(dir.path()).unwrap();
500        assert_eq!(again, path);
501        // No stray temp file left behind.
502        let names: Vec<String> = fs::read_dir(dir.path())
503            .unwrap()
504            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
505            .collect();
506        assert_eq!(names.len(), 1, "only the final checkpoint file exists: {names:?}");
507    }
508
509    #[test]
510    fn tampered_checkpoint_is_rejected_on_load() {
511        let dir = tempfile::tempdir().unwrap();
512        let (ops, split) = ops_with_cut();
513        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
514        let path = ckpt.save(dir.path()).unwrap();
515
516        // Flip a payload value inside the stored state without recomputing
517        // the hash — the load must fail loudly, not fold garbage.
518        let raw = fs::read_to_string(&path).unwrap();
519        let tampered = raw.replace("\"v\":1", "\"v\":999");
520        assert_ne!(raw, tampered, "tamper target must exist in the serialized state");
521        fs::write(&path, tampered).unwrap();
522        assert!(matches!(
523            Checkpoint::load(&path),
524            Err(CheckpointError::HashMismatch { .. })
525        ));
526
527        // In-memory tamper too.
528        let mut forged = ckpt.clone();
529        forged.state.logs.clear();
530        assert!(matches!(forged.verify(), Err(CheckpointError::HashMismatch { .. })));
531    }
532
533    #[test]
534    fn tampered_frontier_is_rejected_on_load_and_verify() {
535        // Kernel-review defect (reproduced): with a state-only hash, a
536        // stored file's FRONTIER could be edited and still pass verify().
537        // The whole-record content address closes that.
538        let dir = tempfile::tempdir().unwrap();
539        let (ops, split) = ops_with_cut();
540        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
541        let path = ckpt.save(dir.path()).unwrap();
542
543        // On-disk frontier tamper: bump dev-a's anchor seq. state_hash still
544        // recomputes (state untouched) — the content address must catch it.
545        let mut value: serde_json::Value =
546            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
547        value["frontier"]["dev-a"]["seq"] = json!(7);
548        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
549        assert!(matches!(
550            Checkpoint::load(&path),
551            Err(CheckpointError::ContentMismatch { .. })
552        ));
553
554        // In-memory frontier tamper is equally rejected — and therefore
555        // can never anchor or seed a resume.
556        let mut forged = ckpt.clone();
557        forged.frontier.get_mut("dev-a").unwrap().seq = 7;
558        assert!(matches!(forged.verify(), Err(CheckpointError::ContentMismatch { .. })));
559        assert!(matches!(
560            verify_anchored(&forged, &ops[split..]),
561            Err(AnchorError::Checkpoint(CheckpointError::ContentMismatch { .. }))
562        ));
563
564        // A renamed file is not its own address — rejected.
565        let good = ckpt.save(dir.path()).unwrap();
566        let renamed = dir.path().join("latest.checkpoint.json");
567        fs::rename(&good, &renamed).unwrap();
568        assert!(matches!(
569            Checkpoint::load(&renamed),
570            Err(CheckpointError::AddressMismatch { .. })
571        ));
572    }
573
574    #[test]
575    fn different_frontiers_never_share_a_content_address() {
576        // Kernel-review defect (reproduced): the fold dedups a
577        // byte-identical logical-entity fact emitted by two devices, so
578        // from_ops([oa]) and from_ops([oa, ob]) fold to the SAME SyncState
579        // (same state_hash) with DIFFERENT frontiers. Under state-only
580        // addressing they shared one file name — the relay would dedup-keep
581        // the wrong one, verify_anchored would fail, and resume_anchored would
582        // seed next_seq from the wrong frontier (a permanent duplicate-seq
583        // chain fork). The whole-record address keeps them distinct.
584        // (Knowledge is content-keyed and dedups cross-device — unlike a
585        // conversation turn, which is an op_id-keyed event stream: see the
586        // conversation module CRIT-2 tests.)
587        let mut a = DeviceLog::new("a");
588        let mut b = DeviceLog::new("b");
589        let fact = json!({"id": "f1", "body": "hi"});
590        let oa = a.append(Scope::Personal, Surface::Knowledge, fact.clone());
591        let ob = b.append(Scope::Personal, Surface::Knowledge, fact);
592
593        let just_a = Checkpoint::from_ops(std::slice::from_ref(&oa)).unwrap();
594        let both = Checkpoint::from_ops(&[oa, ob]).unwrap();
595
596        assert_eq!(just_a.state, both.state, "cross-device dedup: identical folded state");
597        assert_eq!(just_a.state_hash, both.state_hash, "state hash agrees (divergence invariant)");
598        assert_ne!(just_a.frontier, both.frontier, "but the frontiers differ");
599        assert_ne!(
600            just_a.checkpoint_hash, both.checkpoint_hash,
601            "so the content addresses MUST differ"
602        );
603        assert_ne!(just_a.file_name(), both.file_name(), "…and so must the file names");
604
605        // Saving both into one dir stores two files — no false dedup.
606        let dir = tempfile::tempdir().unwrap();
607        let p1 = just_a.save(dir.path()).unwrap();
608        let p2 = both.save(dir.path()).unwrap();
609        assert_ne!(p1, p2);
610        assert_eq!(Checkpoint::load(&p1).unwrap(), just_a);
611        assert_eq!(Checkpoint::load(&p2).unwrap(), both);
612    }
613
614    #[test]
615    fn verify_anchored_accepts_the_truncated_composition() {
616        let (ops, split) = ops_with_cut();
617        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
618        let tail = &ops[split..];
619        verify_log(tail).expect("verify_log alone accepts a non-zero-seq chain");
620        verify_anchored(&ckpt, tail).expect("checkpoint anchors the truncated tail");
621        // And the composition folds to the full state.
622        assert_eq!(fold_onto(&ckpt.state, tail), fold(&ops));
623    }
624
625    #[test]
626    fn verify_anchored_rejects_breaks() {
627        let (ops, split) = ops_with_cut();
628        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
629        let tail: Vec<OpRecord> = ops[split..].to_vec();
630
631        // A hole right after the cut: drop dev-a's first retained op. Its
632        // next op (none here) — instead drop dev-b's anchor continuation:
633        // dev-b's retained op is seq 1; removing it leaves only dev-a's,
634        // which still anchors — so test by skipping dev-a's op while keeping
635        // a later dev-a op. Simplest real break: shift the tail by one op
636        // for a device that has more than one retained op.
637        let mut a_extra = resume_anchored("dev-a", &ckpt, &tail).unwrap();
638        let extra = a_extra.append(Scope::Personal, Surface::Knowledge, json!({"id": "f9"}));
639        let mut with_extra = tail.clone();
640        with_extra.push(extra.clone());
641        verify_anchored(&ckpt, &with_extra).unwrap();
642
643        // Drop dev-a's FIRST retained op but keep the later one → the chain
644        // now starts past the anchor: verify_log itself can't see the hole
645        // (it only checks contiguity between present ops)… but the anchor
646        // check does.
647        let holed: Vec<OpRecord> = with_extra
648            .iter()
649            .filter(|o| o.op_id != tail[0].op_id)
650            .cloned()
651            .collect();
652        assert!(matches!(
653            verify_anchored(&ckpt, &holed),
654            Err(AnchorError::BrokenAnchor { .. })
655        ));
656
657        // A device with no frontier entry must start at seq 0.
658        let mut stranger = DeviceLog::new("dev-c");
659        stranger.append(Scope::Personal, Surface::Knowledge, json!({"id": "s0"}));
660        let s1 = stranger.append(Scope::Personal, Surface::Knowledge, json!({"id": "s1"}));
661        let mut with_stranger = tail.clone();
662        with_stranger.push(s1); // seq 1, but s0 is missing and no anchor exists
663        assert!(matches!(
664            verify_anchored(&ckpt, &with_stranger),
665            Err(AnchorError::UnanchoredDevice { first_seq: 1, .. })
666        ));
667
668        // A tampered checkpoint refuses to anchor anything.
669        let mut forged = ckpt.clone();
670        forged.state.logs.clear();
671        assert!(matches!(
672            verify_anchored(&forged, &tail),
673            Err(AnchorError::Checkpoint(CheckpointError::HashMismatch { .. }))
674        ));
675    }
676
677    #[test]
678    fn resume_anchored_continues_chains_after_truncation() {
679        let (ops, split) = ops_with_cut();
680        let ckpt = Checkpoint::from_ops(&ops[..split]).unwrap();
681        let tail: Vec<OpRecord> = ops[split..].to_vec();
682
683        // dev-a has a retained op → resumes past it.
684        let mut a = resume_anchored("dev-a", &ckpt, &tail).unwrap();
685        let next_a = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "na"}));
686        assert_eq!(next_a.seq, 3);
687        assert_eq!(next_a.prev.as_deref(), Some(tail[0].op_id.as_str()));
688
689        // The critical case: a device whose ops were ALL truncated must
690        // resume from the checkpoint anchor, not fork at seq 0.
691        let ckpt_all = Checkpoint::from_ops(&ops).unwrap();
692        let mut b = resume_anchored("dev-b", &ckpt_all, &[]).unwrap();
693        let next_b = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "nb"}));
694        assert_eq!(next_b.seq, 2, "continues past the checkpointed chain");
695        assert_eq!(next_b.prev.as_deref(), Some(ckpt_all.frontier["dev-b"].head.as_str()));
696        assert!(
697            next_b.hlc > ckpt_all.frontier["dev-a"].hlc && next_b.hlc > ckpt_all.frontier["dev-b"].hlc,
698            "lamport advanced past everything the checkpoint covers"
699        );
700
701        // The composed log (anchored tail + new appends) still verifies.
702        let mut composed = tail.clone();
703        composed.push(next_a);
704        verify_anchored(&ckpt, &composed).unwrap();
705    }
706}