Skip to main content

car_sync/
session.rs

1//! The device-side sync session — the pump that drives a [`DeviceLog`] +
2//! [`OplogJournal`] pair against a [`Relay`] (slice B3 of
3//! `docs/proposals/multi-device-sync.md`).
4//!
5//! [`SyncSession::pump`] is one reconciliation round, sequenced so the
6//! binding contracts from B1/B4 hold **by construction**, not by caller
7//! discipline:
8//!
9//! 1. **Push** — only ops read from (or appended through) the journal are
10//!    ever handed to the relay, and the journal is **fsync'd**
11//!    ([`OplogJournal::sync`]) before the push, so **an op is journal-durable
12//!    before it is transmitted** (B1 MUST — `flush` is only the page cache;
13//!    a power loss in the writeback window would otherwise lose a
14//!    transmitted op and re-mint its seq into a permanent Fork). A crash
15//!    that loses the push cursor is harmless — a re-push dedups relay-side
16//!    on `op_id`.
17//! 2. **Pull** — `pull(since = my per-device seq frontier)`, then
18//!    **verify before fold** (B1 MUST): the union of held + pulled ops
19//!    must pass [`verify_log`] (or [`verify_anchored`] against the
20//!    session's base checkpoint) before anything is folded or journaled.
21//! 3. **Fold durably, then ack** — verified remote ops are appended to the
22//!    journal, **advancing `self.ops` in lockstep** (each op enters
23//!    `self.ops` the instant its append succeeds, so a mid-loop failure
24//!    leaves `self.ops == journal` and the retry re-pull filters the
25//!    already-journaled ops instead of duplicating them — a duplicate line
26//!    would `DuplicateSeq`-brick the next open). The journal is then fsync'd
27//!    and only then is `ack` sent. The ack value is *derived from the
28//!    journal-held ops* — there is no API to ack anything else, so **acking
29//!    merely-received (un-journaled) state is impossible by construction**
30//!    (B4 MUST). A crash between the fold and the ack leaves the relay's ack
31//!    table behind — the safe direction: GC can't drop what we haven't
32//!    acked, and the next pump re-acks.
33//!
34//! Idempotence: re-running `pump` after any crash point re-pushes
35//! (relay dedups by `op_id`), re-pulls (already-held ops are filtered by
36//! `op_id`; re-folding is a no-op), and re-acks (monotone) — the whole
37//! round is retry-safe.
38//!
39//! **Cold bootstrap / straggler re-entry** ([`SyncSession::bootstrap`] /
40//! [`SyncSession::rebase`]): when the relay has GC'd past the session's
41//! frontier (`RelayError::FrontierTruncated`) or the device is brand new,
42//! the path is exactly the proposal's — `checkpoint_get()` → `pull(since =
43//! checkpoint frontier)` → verify → journal rewritten as checkpoint-anchored
44//! tail (`truncate_to`, stamping the truncation marker so the naive
45//! `load`/`resume` stays fenced — B4 contract 5) → **`resume_anchored`**,
46//! never `DeviceLog::resume`. Local ops **not covered** by the checkpoint —
47//! including a returning straggler's never-pushed writes — are carried into
48//! the rebased tail and pushed on the next pump: re-entry is lossless (see
49//! the relay module docs for why this is safe under seq-based frontiers).
50
51use crate::checkpoint::{resume_anchored, AnchorError, Checkpoint, CheckpointError};
52use crate::fold::{fold_onto, state_hash, FoldedRecord, SyncState};
53use crate::journal::OplogJournal;
54use crate::lease::{Intent, IntentStatus, LeaseCoordinator, LeaseError};
55use crate::oplog::{verify_log, ChainError, DeviceLog, Hlc, OpRecord, Scope, Surface, WallClock};
56use crate::relay::{checkpoint_frontier, frontier_of, Frontier, Relay, RelayError};
57use serde_json::Value;
58use std::fmt;
59use std::path::{Path, PathBuf};
60
61/// A sync-session failure.
62#[derive(Debug)]
63pub enum SessionError {
64    Io(std::io::Error),
65    Chain(ChainError),
66    Anchor(AnchorError),
67    Relay(RelayError),
68    Checkpoint(CheckpointError),
69    /// The journal carries a truncation marker naming a checkpoint that is
70    /// not present in the session's checkpoint directory — resume is
71    /// impossible without it (fetch it from the relay and retry).
72    MissingCheckpoint { checkpoint_hash: String },
73    /// The relay served a "latest" checkpoint that does not cover the
74    /// session's current base — rebasing onto it would silently lose state.
75    CheckpointRegression { held: String, offered: String },
76    /// A lease-coordinator call failed (B5) — used by the best-effort local
77    /// gate [`SyncSession::record_intent_if_current`].
78    Lease(LeaseError),
79}
80
81impl fmt::Display for SessionError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            SessionError::Io(e) => write!(f, "sync session io error: {e}"),
85            SessionError::Chain(e) => write!(f, "sync session chain error: {e}"),
86            SessionError::Anchor(e) => write!(f, "sync session anchor error: {e}"),
87            SessionError::Relay(e) => write!(f, "sync session relay error: {e}"),
88            SessionError::Checkpoint(e) => write!(f, "sync session checkpoint error: {e}"),
89            SessionError::MissingCheckpoint { checkpoint_hash } => write!(
90                f,
91                "journal is truncated below checkpoint {checkpoint_hash}, which is not in the \
92                 session checkpoint directory — fetch it (relay checkpoint_get) and retry"
93            ),
94            SessionError::CheckpointRegression { held, offered } => write!(
95                f,
96                "relay's latest checkpoint {offered} does not cover the session's base {held} — \
97                 refusing to rebase onto it (state would be lost)"
98            ),
99            SessionError::Lease(e) => write!(f, "sync session lease error: {e}"),
100        }
101    }
102}
103
104impl std::error::Error for SessionError {}
105
106impl From<std::io::Error> for SessionError {
107    fn from(e: std::io::Error) -> Self {
108        SessionError::Io(e)
109    }
110}
111impl From<ChainError> for SessionError {
112    fn from(e: ChainError) -> Self {
113        SessionError::Chain(e)
114    }
115}
116impl From<AnchorError> for SessionError {
117    fn from(e: AnchorError) -> Self {
118        SessionError::Anchor(e)
119    }
120}
121impl From<RelayError> for SessionError {
122    fn from(e: RelayError) -> Self {
123        SessionError::Relay(e)
124    }
125}
126impl From<CheckpointError> for SessionError {
127    fn from(e: CheckpointError) -> Self {
128        SessionError::Checkpoint(e)
129    }
130}
131impl From<LeaseError> for SessionError {
132    fn from(e: LeaseError) -> Self {
133        SessionError::Lease(e)
134    }
135}
136
137/// What one [`SyncSession::pump`] round did.
138#[derive(Debug, Clone, Default, PartialEq)]
139pub struct PumpReport {
140    /// Own ops newly admitted by the relay.
141    pub pushed: usize,
142    /// Own ops the relay already held.
143    pub push_deduped: usize,
144    /// Remote ops pulled, verified, journaled, and folded this round.
145    pub folded: usize,
146    /// The fold frontier acked (derived from journal-held ops), if any.
147    pub acked: Option<Hlc>,
148}
149
150/// A device's live sync endpoint: its append chain, its durable journal,
151/// and the pump. See the module docs for the contract sequencing.
152pub struct SyncSession {
153    device_id: String,
154    device: DeviceLog,
155    journal: OplogJournal,
156    checkpoint_dir: PathBuf,
157    wall: WallClock,
158    /// Every op the journal holds (the tail, when `base` is set).
159    ops: Vec<OpRecord>,
160    /// The checkpoint the journal is anchored on, when truncated.
161    base: Option<Checkpoint>,
162    /// In-memory push cursor (own max seq pushed). Deliberately NOT
163    /// persisted: losing it in a crash only causes a deduped re-push.
164    pushed_through: Option<u64>,
165}
166
167impl fmt::Debug for SyncSession {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("SyncSession")
170            .field("device_id", &self.device_id)
171            .field("ops", &self.ops.len())
172            .field("base", &self.base.as_ref().map(|c| &c.checkpoint_hash))
173            .field("pushed_through", &self.pushed_through)
174            .finish_non_exhaustive()
175    }
176}
177
178impl SyncSession {
179    /// Open a session over an existing (possibly empty, possibly truncated)
180    /// journal. A truncated journal resumes **only** through its covering
181    /// checkpoint (`resume_anchored` — B4 contract): the checkpoint file
182    /// must be present in `checkpoint_dir` under its content address.
183    pub fn open(
184        device_id: impl Into<String>,
185        journal_path: &Path,
186        checkpoint_dir: &Path,
187        wall: WallClock,
188    ) -> Result<Self, SessionError> {
189        let device_id = device_id.into();
190        let (marker, ops) = OplogJournal::load_with_marker(journal_path)?;
191        let journal = OplogJournal::open(journal_path)?;
192        let (device, base) = match marker {
193            Some(marker) => {
194                let path = checkpoint_dir
195                    .join(format!("{}.checkpoint.json", marker.checkpoint_hash));
196                if !path.exists() {
197                    return Err(SessionError::MissingCheckpoint {
198                        checkpoint_hash: marker.checkpoint_hash,
199                    });
200                }
201                let checkpoint = Checkpoint::load(&path)?;
202                let device = resume_anchored(device_id.clone(), &checkpoint, &ops)?;
203                (device, Some(checkpoint))
204            }
205            None => (DeviceLog::resume(device_id.clone(), &ops)?, None),
206        };
207        let mut session = Self {
208            device_id,
209            device,
210            journal,
211            checkpoint_dir: checkpoint_dir.to_path_buf(),
212            wall,
213            ops,
214            base,
215            pushed_through: None,
216        };
217        session.device.set_wall_clock(session.wall.clone());
218        Ok(session)
219    }
220
221    /// Open + immediately [`SyncSession::rebase`] onto the relay's latest
222    /// checkpoint — the cold-device / returning-straggler entry point
223    /// (proposal §"Cold / new device bootstrap"). With no relay checkpoint
224    /// (young account) this degrades to a plain open; the first `pump`
225    /// replays from genesis.
226    pub fn bootstrap(
227        device_id: impl Into<String>,
228        journal_path: &Path,
229        checkpoint_dir: &Path,
230        relay: &mut dyn Relay,
231        wall: WallClock,
232    ) -> Result<Self, SessionError> {
233        let mut session = Self::open(device_id, journal_path, checkpoint_dir, wall)?;
234        session.rebase(relay)?;
235        Ok(session)
236    }
237
238    /// Record a local mutation: stamp (hybrid clock), **journal (flushed)**,
239    /// then hold for push — the op is journal-durable before it can ever be
240    /// transmitted (B1 MUST). If the journal write fails the device chain
241    /// is rolled back (rebuilt from the durable ops), so the failed op can
242    /// never leave a hole for the next append to chain onto.
243    pub fn append(
244        &mut self,
245        scope: Scope,
246        surface: Surface,
247        payload: Value,
248    ) -> Result<OpRecord, SessionError> {
249        let op = self.device.append(scope, surface, payload);
250        if let Err(e) = self.journal.append(&op) {
251            self.rebuild_device()?;
252            return Err(SessionError::Io(e));
253        }
254        self.ops.push(op.clone());
255        Ok(op)
256    }
257
258    /// The fence-independent **committed-run idempotency oracle** (B5) over
259    /// this session's folded state (checkpoint base + journal tail): "has
260    /// `run_id` already committed for `agent_id`?" Keep-all and immune to epoch
261    /// bumps AND compaction, so it is the CORRECT idempotency lookup — unlike
262    /// [`crate::fold::SyncState::intent`], which is the fenced "who holds now"
263    /// view and can read `None`/pending for a run that actually committed. A B6
264    /// dispatch fence performs exactly this read before an external side effect.
265    pub fn committed_run(&self, agent_id: &str, run_id: &str) -> Option<FoldedRecord> {
266        self.state().committed_run(agent_id, run_id).cloned()
267    }
268
269    /// Record a leased execution [`Intent`] (B5) — journal-durable exactly
270    /// like any [`SyncSession::append`], so it is durable before it can be
271    /// transmitted. The **partitioned / ungated** path: a zombie that cannot
272    /// reach the coordinator still records here, and the fold converges the
273    /// ledger deterministically.
274    ///
275    /// **Terminal guard (C3):** a committed run is terminal. A `pending`/`failed`
276    /// write for a run already committed (per the fence-independent
277    /// [`SyncSession::committed_run`] oracle) is a **no-op** (`Ok(None)`), so a
278    /// failed-over holder writing `pending` before checking cannot revert the
279    /// committed ledger.
280    ///
281    /// This converges the **ledger**; it is NOT the exactly-once execution gate.
282    /// The B6 dispatch fence — a linearizable "am I still epoch N?" plus this
283    /// committed-oracle read **before** the external effect — is what makes
284    /// execution single-shot.
285    pub fn record_intent(
286        &mut self,
287        scope: Scope,
288        intent: &Intent,
289    ) -> Result<Option<OpRecord>, SessionError> {
290        if intent.status != IntentStatus::Committed
291            && self.committed_run(&intent.agent_id, &intent.run_id).is_some()
292        {
293            return Ok(None); // terminal: the run already committed — do not revert
294        }
295        Ok(Some(self.append(scope, Surface::Intent, intent.payload())?))
296    }
297
298    /// Record a leased [`Intent`] **only if the local epoch is still current**
299    /// — the best-effort local gate: a linearizable coordinator read confirms
300    /// this device still holds the lease at `intent.epoch` before the op is
301    /// journaled. Returns `Ok(None)` when the coordinator says this device is
302    /// no longer the holder at that epoch (skipping a write the fold would fence)
303    /// or when the run is already committed (the [`SyncSession::record_intent`]
304    /// terminal guard).
305    ///
306    /// This is a *liveness optimization*, NOT the safety gate: a partitioned
307    /// zombie that cannot reach the coordinator falls back to
308    /// [`SyncSession::record_intent`]. Exactly-once execution is the B6 dispatch
309    /// fence (this check races a pause-after-check — the Kleppmann residual).
310    pub fn record_intent_if_current(
311        &mut self,
312        scope: Scope,
313        intent: &Intent,
314        coordinator: &mut dyn LeaseCoordinator,
315    ) -> Result<Option<OpRecord>, SessionError> {
316        let current = coordinator.current(&intent.agent_id)?;
317        let still_ours = current
318            .as_ref()
319            .is_some_and(|l| l.holder == self.device_id && l.epoch == intent.epoch);
320        if !still_ours {
321            return Ok(None); // not the current holder — don't even write it
322        }
323        self.record_intent(scope, intent) // terminal-guarded
324    }
325
326    /// Rebuild the append chain from what is actually durable — the
327    /// journal-held ops (+ base anchor).
328    fn rebuild_device(&mut self) -> Result<(), SessionError> {
329        let mut device = match &self.base {
330            Some(checkpoint) => resume_anchored(self.device_id.clone(), checkpoint, &self.ops)?,
331            None => DeviceLog::resume(self.device_id.clone(), &self.ops)?,
332        };
333        device.set_wall_clock(self.wall.clone());
334        self.device = device;
335        Ok(())
336    }
337
338    /// The per-device seq cursor of everything this session holds
339    /// (checkpoint coverage + journal tail).
340    fn held_frontier(&self) -> Frontier {
341        let mut frontier = self
342            .base
343            .as_ref()
344            .map(checkpoint_frontier)
345            .unwrap_or_default();
346        for (device, seq) in frontier_of(&self.ops) {
347            let entry = frontier.entry(device).or_insert(seq);
348            if seq > *entry {
349                *entry = seq;
350            }
351        }
352        frontier
353    }
354
355    /// The fold frontier this session may ack: the max HLC across
356    /// journal-held ops and the base checkpoint's coverage — derived from
357    /// durable state ONLY, which is what makes ack-before-fold impossible.
358    fn ack_frontier(&self) -> Option<Hlc> {
359        let from_ops = self.ops.iter().map(|op| &op.hlc).max();
360        let from_base = self
361            .base
362            .as_ref()
363            .and_then(|c| c.frontier.values().map(|e| &e.hlc).max());
364        [from_ops, from_base].into_iter().flatten().max().cloned()
365    }
366
367    /// One reconciliation round: push journal-durable own ops → pull →
368    /// verify → journal the folds → ack. See the module docs for the
369    /// contract sequencing; retry-safe at every crash point.
370    ///
371    /// Returns [`RelayError::FrontierTruncated`] (wrapped) when the relay
372    /// has GC'd past this session's frontier — call
373    /// [`SyncSession::rebase`] and pump again.
374    pub fn pump(&mut self, relay: &mut dyn Relay) -> Result<PumpReport, SessionError> {
375        let mut report = PumpReport::default();
376
377        // 1. Push own journal-durable ops the relay may not have.
378        let mut own: Vec<OpRecord> = self
379            .ops
380            .iter()
381            .filter(|op| {
382                op.device_id == self.device_id
383                    && self.pushed_through.is_none_or(|through| op.seq > through)
384            })
385            .cloned()
386            .collect();
387        own.sort_by_key(|op| op.seq);
388        if !own.is_empty() {
389            // Journal-durable BEFORE transmit (B1 MUST): the per-op appends
390            // only reached the OS page cache (flush, not fsync). One batch
391            // barrier here makes the whole own-op tail stable before any of
392            // it leaves the device — a power loss in the writeback window
393            // otherwise loses a transmitted op and re-mints its seq (a
394            // permanent Fork). fsync once, not per append.
395            self.journal.sync()?;
396            let outcome = relay.push(&self.device_id, &own)?;
397            report.pushed = outcome.accepted;
398            report.push_deduped = outcome.deduped;
399            self.pushed_through = own.last().map(|op| op.seq);
400        }
401
402        // 2. Pull everything above what we hold.
403        let pulled = relay.pull(&self.device_id, &self.held_frontier())?;
404
405        // 3. Filter already-held (idempotent re-pull) and VERIFY BEFORE FOLD.
406        let held: std::collections::BTreeSet<&str> =
407            self.ops.iter().map(|op| op.op_id.as_str()).collect();
408        let new_ops: Vec<OpRecord> = pulled
409            .ops
410            .into_iter()
411            .filter(|op| !held.contains(op.op_id.as_str()))
412            .collect();
413        if !new_ops.is_empty() {
414            let mut candidate = self.ops.clone();
415            candidate.extend(new_ops.iter().cloned());
416            match &self.base {
417                Some(checkpoint) => crate::checkpoint::verify_anchored(checkpoint, &candidate)?,
418                None => verify_log(&candidate)?,
419            }
420
421            // 4. Journal the folds — advancing self.ops IN LOCKSTEP with the
422            // journal. Recording each op into self.ops the instant its
423            // append succeeds is load-bearing for retry-safety: a mid-loop
424            // append failure then leaves self.ops == the journal, so the
425            // retry pump filters the already-journaled ops out of the
426            // re-pull instead of appending them a second time (a duplicate
427            // journal line = DuplicateSeq on the next open = a bricked
428            // device). `candidate` is discarded on failure.
429            drop(candidate);
430            for op in &new_ops {
431                self.journal.append(op)?;
432                self.ops.push(op.clone());
433                self.device.observe(&op.hlc);
434                report.folded += 1;
435            }
436            // Durable fold BEFORE ack (B4 MUST: ack asserts durably-folded
437            // state). One batch fsync over the folds just journaled.
438            self.journal.sync()?;
439        }
440
441        // 5. Ack — derived from journal-held state only, after it is
442        // durable. (B4 MUST: ack asserts durably-folded state.)
443        if let Some(frontier) = self.ack_frontier() {
444            relay.ack(&self.device_id, frontier.clone())?;
445            report.acked = Some(frontier);
446        }
447        Ok(report)
448    }
449
450    /// Re-anchor this session on the relay's latest checkpoint — the cold
451    /// bootstrap / post-eviction re-entry move. Returns `true` when a
452    /// rebase happened.
453    ///
454    /// Sequencing (each step durable before the next depends on it):
455    /// `checkpoint_get` → `pull(since = checkpoint frontier)` → merge in
456    /// every locally-held op the checkpoint does NOT cover (a returning
457    /// straggler's unpushed writes survive) → `verify_anchored` → save the
458    /// checkpoint into the session checkpoint dir → `truncate_to` (journal
459    /// rewritten as the anchored tail, truncation marker stamped) →
460    /// `resume_anchored`. The push cursor resets so the next `pump`
461    /// re-offers every own op in the tail (relay dedups the already-pushed).
462    pub fn rebase(&mut self, relay: &mut dyn Relay) -> Result<bool, SessionError> {
463        let Some(checkpoint) = relay.checkpoint_get()? else {
464            return Ok(false); // young account: genesis replay via pump
465        };
466        if let Some(base) = &self.base {
467            if base.checkpoint_hash == checkpoint.checkpoint_hash {
468                return Ok(false); // already anchored here
469            }
470            if !crate::relay::frontier_dominates(&checkpoint, base) {
471                return Err(SessionError::CheckpointRegression {
472                    held: base.checkpoint_hash.clone(),
473                    offered: checkpoint.checkpoint_hash.clone(),
474                });
475            }
476        }
477
478        let pulled = relay.pull(&self.device_id, &checkpoint_frontier(&checkpoint))?;
479
480        // Keep every held op the checkpoint does not cover — own unpushed
481        // writes AND foreign tails we already folded — deduped against the
482        // pull by op_id.
483        let mut tail: Vec<OpRecord> = pulled.ops;
484        let mut seen: std::collections::BTreeSet<String> =
485            tail.iter().map(|op| op.op_id.clone()).collect();
486        for op in &self.ops {
487            let covered = checkpoint
488                .frontier
489                .get(&op.device_id)
490                .is_some_and(|entry| op.seq <= entry.seq);
491            if !covered && seen.insert(op.op_id.clone()) {
492                tail.push(op.clone());
493            }
494        }
495        tail.sort_by(|a, b| (&a.hlc, &a.op_id).cmp(&(&b.hlc, &b.op_id)));
496
497        // Verify BEFORE any durable rewrite; then checkpoint durable FIRST,
498        // then the journal truncation that names it (B4 crash ordering).
499        crate::checkpoint::verify_anchored(&checkpoint, &tail)?;
500        checkpoint.save(&self.checkpoint_dir)?;
501        self.journal.truncate_to(&tail, &checkpoint.checkpoint_hash)?;
502
503        let mut device = resume_anchored(self.device_id.clone(), &checkpoint, &tail)?;
504        device.set_wall_clock(self.wall.clone());
505        self.device = device;
506        self.ops = tail;
507        self.base = Some(checkpoint);
508        self.pushed_through = None;
509        Ok(true)
510    }
511
512    /// Compute a checkpoint at the relay's stable frontier from this
513    /// session's held ops and upload it — the device-computed snapshot the
514    /// proposal requires under E2E ("the relay holds ciphertext and cannot
515    /// fold"). Call **after** a `pump` (so held == relay-known and own ops
516    /// are pushed). Returns the uploaded checkpoint, or `None` when there
517    /// is no stable frontier, nothing below it, or this session is itself
518    /// anchored on a checkpoint (recompaction over a base is the same
519    /// later slice B4 deferred).
520    pub fn publish_checkpoint(
521        &mut self,
522        relay: &mut dyn Relay,
523    ) -> Result<Option<Checkpoint>, SessionError> {
524        if self.base.is_some() {
525            return Ok(None);
526        }
527        let Some(frontier) = relay.stable_frontier()? else {
528            return Ok(None);
529        };
530        let below: Vec<OpRecord> = self
531            .ops
532            .iter()
533            .filter(|op| op.hlc <= frontier)
534            .cloned()
535            .collect();
536        if below.is_empty() {
537            return Ok(None);
538        }
539        let checkpoint = Checkpoint::from_ops(&below)?;
540        relay.checkpoint_put(&self.device_id, &checkpoint)?;
541        Ok(Some(checkpoint))
542    }
543
544    /// The materialized state: `fold_onto(base checkpoint, journal tail)`.
545    pub fn state(&self) -> SyncState {
546        let base = self
547            .base
548            .as_ref()
549            .map(|c| c.state.clone())
550            .unwrap_or_default();
551        fold_onto(&base, &self.ops)
552    }
553
554    /// [`state_hash`] of [`SyncSession::state`] — the divergence invariant
555    /// two synced devices must agree on.
556    pub fn state_hash(&self) -> String {
557        state_hash(&self.state())
558    }
559
560    pub fn device_id(&self) -> &str {
561        &self.device_id
562    }
563
564    /// The journal-held ops (the anchored tail, when a base is set).
565    pub fn ops(&self) -> &[OpRecord] {
566        &self.ops
567    }
568
569    /// The checkpoint this session's journal is anchored on, if truncated.
570    pub fn base(&self) -> Option<&Checkpoint> {
571        self.base.as_ref()
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use crate::relay::{AckOutcome, GcReport, InMemoryRelay, PullResult, PushOutcome, RelayConfig, RosterEntry};
579    use serde_json::json;
580    use std::sync::atomic::{AtomicBool, Ordering};
581    use std::sync::Arc;
582
583    fn zero_wall() -> WallClock {
584        Arc::new(|| 0)
585    }
586
587    fn mem_relay() -> InMemoryRelay {
588        InMemoryRelay::new(RelayConfig::default(), zero_wall())
589    }
590
591    struct Dirs {
592        _tmp: tempfile::TempDir,
593        journal: std::path::PathBuf,
594        ckpts: std::path::PathBuf,
595    }
596
597    fn dirs() -> Dirs {
598        let tmp = tempfile::tempdir().unwrap();
599        let journal = tmp.path().join("oplog.jsonl");
600        let ckpts = tmp.path().join("checkpoints");
601        Dirs { _tmp: tmp, journal, ckpts }
602    }
603
604    fn open(device: &str, d: &Dirs) -> SyncSession {
605        SyncSession::open(device, &d.journal, &d.ckpts, zero_wall()).unwrap()
606    }
607
608    #[test]
609    fn two_devices_converge_through_the_relay_across_all_tiers() {
610        let mut relay = mem_relay();
611        let (da, db) = (dirs(), dirs());
612        let mut a = open("mac-a", &da);
613        let mut b = open("mac-b", &db);
614
615        // Concurrent writes on every fold tier, including an LWW conflict
616        // and a routing observation multiset.
617        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1", "v": 1})).unwrap();
618        a.append(Scope::Personal, Surface::Declagent, json!({"id": "milo", "owner": "a"})).unwrap();
619        a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})).unwrap();
620        b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2", "v": 2})).unwrap();
621        b.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})).unwrap();
622
623        a.pump(&mut relay).unwrap();
624        let rb = b.pump(&mut relay).unwrap();
625        assert_eq!(rb.folded, 3, "b folded a's three ops");
626        // b now writes causally AFTER folding a's registry record.
627        b.append(Scope::Personal, Surface::Declagent, json!({"id": "milo", "owner": "b"})).unwrap();
628        b.pump(&mut relay).unwrap();
629        let ra = a.pump(&mut relay).unwrap();
630        assert_eq!(ra.folded, 3);
631
632        assert_eq!(a.state_hash(), b.state_hash(), "divergence invariant: same hash");
633        let state = a.state();
634        assert_eq!(
635            state.registries[&Surface::Declagent.tag()]["id:milo"].payload["owner"],
636            json!("b"),
637            "LWW resolved by the hybrid clock's causal order"
638        );
639        assert_eq!(
640            state.log_entries(&Surface::Routing.tag()).len(),
641            2,
642            "the observation multiset survived transport"
643        );
644
645        // Idempotence: an extra pump on both sides is a complete no-op.
646        let ra = a.pump(&mut relay).unwrap();
647        let rb = b.pump(&mut relay).unwrap();
648        assert_eq!((ra.pushed, ra.folded), (0, 0));
649        assert_eq!((rb.pushed, rb.folded), (0, 0));
650        assert_eq!(a.state_hash(), b.state_hash());
651    }
652
653    #[test]
654    fn op_is_journal_durable_before_it_is_ever_transmitted() {
655        // Contract 1 (B1 MUST): append journals+flushes; the crash window
656        // between append and pump loses NOTHING and re-mints NO seq.
657        let mut relay = mem_relay();
658        let d = dirs();
659        {
660            let mut a = open("mac-a", &d);
661            a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
662            // "crash" before any pump: session dropped, nothing transmitted.
663        }
664        let mut a = open("mac-a", &d);
665        // The op survived in the journal; resume did not re-mint its seq.
666        let next = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
667        assert_eq!(next.seq, 1);
668        a.pump(&mut relay).unwrap();
669        assert_eq!(relay.pull("x", &Frontier::new()).unwrap().ops.len(), 2);
670        verify_log(a.ops()).unwrap();
671    }
672
673    #[test]
674    fn ack_is_derived_from_journal_held_state_only() {
675        // Contract 2 (B4 MUST), shown by construction: the acked frontier
676        // equals the max HLC of what is ON DISK in the journal — never of
677        // anything merely received.
678        let mut relay = mem_relay();
679        let (da, db) = (dirs(), dirs());
680        let mut a = open("mac-a", &da);
681        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
682        a.pump(&mut relay).unwrap();
683
684        let mut b = open("mac-b", &db);
685        let report = b.pump(&mut relay).unwrap();
686        let acked = report.acked.unwrap();
687
688        // Reload b's journal from disk: the ack is exactly its max stamp.
689        drop(b);
690        let on_disk = OplogJournal::load(&db.journal).unwrap();
691        assert_eq!(acked, on_disk.iter().map(|op| op.hlc.clone()).max().unwrap());
692        let roster: std::collections::BTreeMap<String, RosterEntry> = relay
693            .roster()
694            .unwrap()
695            .into_iter()
696            .map(|e| (e.device_id.clone(), e))
697            .collect();
698        assert_eq!(roster["mac-b"].acked.as_ref(), Some(&acked));
699    }
700
701    /// A relay wrapper that fails `ack` while the flag is set — the
702    /// crash/partition at the worst point of the pump (after the durable
703    /// fold, before the ack).
704    struct FlakyAckRelay<'a> {
705        inner: &'a mut dyn Relay,
706        fail_ack: Arc<AtomicBool>,
707    }
708
709    impl Relay for FlakyAckRelay<'_> {
710        fn register(&mut self, d: &str) -> Result<RosterEntry, RelayError> {
711            self.inner.register(d)
712        }
713        fn push(&mut self, d: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
714            self.inner.push(d, ops)
715        }
716        fn pull(&mut self, d: &str, since: &Frontier) -> Result<PullResult, RelayError> {
717            self.inner.pull(d, since)
718        }
719        fn ack(&mut self, d: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
720            if self.fail_ack.load(Ordering::SeqCst) {
721                return Err(RelayError::Io(std::io::Error::other("network down")));
722            }
723            self.inner.ack(d, frontier)
724        }
725        fn checkpoint_put(&mut self, d: &str, c: &Checkpoint) -> Result<bool, RelayError> {
726            self.inner.checkpoint_put(d, c)
727        }
728        fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
729            self.inner.checkpoint_get()
730        }
731        fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
732            self.inner.roster()
733        }
734        fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
735            self.inner.stable_frontier()
736        }
737        fn gc(&mut self) -> Result<GcReport, RelayError> {
738            self.inner.gc()
739        }
740    }
741
742    #[test]
743    fn crash_mid_pump_is_idempotent_at_every_step() {
744        let mut relay = mem_relay();
745        let (da, db) = (dirs(), dirs());
746        let mut a = open("mac-a", &da);
747        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
748        a.pump(&mut relay).unwrap();
749
750        // --- Crash point 1: pulled but nothing device-side happened yet
751        // (transport delivered bytes; the process died before verify/fold).
752        // Nothing durable changed, the relay ack table is untouched → a
753        // fresh pump redoes everything.
754        let _ = relay.pull("mac-b", &Frontier::new()).unwrap();
755        let roster: std::collections::BTreeMap<String, RosterEntry> = relay
756            .roster()
757            .unwrap()
758            .into_iter()
759            .map(|e| (e.device_id.clone(), e))
760            .collect();
761        assert_eq!(roster["mac-b"].acked, None, "merely-received is never acked");
762
763        // --- Crash point 2: fold journaled durably, ack lost (network died
764        // between the fsync and the ack).
765        let fail = Arc::new(AtomicBool::new(true));
766        let mut b = open("mac-b", &db);
767        b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
768        {
769            let mut flaky = FlakyAckRelay { inner: &mut relay, fail_ack: fail.clone() };
770            let err = b.pump(&mut flaky).unwrap_err();
771            assert!(matches!(err, SessionError::Relay(RelayError::Io(_))));
772        }
773        // The fold IS durable (journal has a's op)…
774        let on_disk = OplogJournal::load(&db.journal).unwrap();
775        assert_eq!(on_disk.len(), 2);
776        // …but the ack never landed — the SAFE direction: GC can't drop
777        // what b hasn't acked.
778        let roster: std::collections::BTreeMap<String, RosterEntry> = relay
779            .roster()
780            .unwrap()
781            .into_iter()
782            .map(|e| (e.device_id.clone(), e))
783            .collect();
784        assert_eq!(roster["mac-b"].acked, None);
785
786        // --- Crash point 3: session lost entirely (push cursor gone).
787        // Reopen from the journal and re-pump: re-push dedups by op_id,
788        // re-pull folds nothing new, the ack finally lands.
789        drop(b);
790        fail.store(false, Ordering::SeqCst);
791        let mut b = open("mac-b", &db);
792        let report = b.pump(&mut relay).unwrap();
793        assert_eq!(report.folded, 0, "re-pull re-fold is a no-op by op_id dedup");
794        assert_eq!(report.pushed, 0, "re-push deduped relay-side");
795        assert_eq!(report.push_deduped, 1);
796        assert!(report.acked.is_some());
797
798        a.pump(&mut relay).unwrap();
799        assert_eq!(a.state_hash(), b.state_hash(), "convergence after every crash point");
800    }
801
802    #[test]
803    fn cold_bootstrap_goes_through_resume_anchored_and_stays_fenced() {
804        // Contract 5: a cold device bootstraps checkpoint-first; its
805        // journal carries the truncation marker, so the naive
806        // load()/DeviceLog::resume path stays a runtime error.
807        let mut relay = mem_relay();
808        let da = dirs();
809        let mut a = open("mac-a", &da);
810        for i in 0..4 {
811            a.append(Scope::Personal, Surface::Knowledge, json!({"id": format!("f{i}"), "timestamp": i})).unwrap();
812        }
813        a.pump(&mut relay).unwrap();
814        let ckpt = a.publish_checkpoint(&mut relay).unwrap().unwrap();
815        relay.gc().unwrap();
816
817        // Fresh device: bootstrap = checkpoint_get + pull(since ckpt
818        // frontier) + resume_anchored.
819        let db = dirs();
820        let mut b = SyncSession::bootstrap("mac-b", &db.journal, &db.ckpts, &mut relay, zero_wall())
821            .unwrap();
822        assert_eq!(b.base().unwrap().checkpoint_hash, ckpt.checkpoint_hash);
823        b.pump(&mut relay).unwrap();
824        assert_eq!(b.state_hash(), a.state_hash());
825
826        // The fences hold on the bootstrapped journal.
827        let err = OplogJournal::load(&db.journal).unwrap_err();
828        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
829        let (marker, tail) = OplogJournal::load_with_marker(&db.journal).unwrap();
830        assert_eq!(marker.unwrap().checkpoint_hash, ckpt.checkpoint_hash);
831        // b keeps working across a restart (open() resumes anchored)…
832        drop(b);
833        let mut b = open("mac-b", &db);
834        let op = b.append(Scope::Personal, Surface::Knowledge, json!({"id": "from-b"})).unwrap();
835        assert!(op.hlc > tail.iter().map(|o| o.hlc.clone()).max().unwrap_or(Hlc {
836            wall_ms: 0,
837            counter: 0,
838            device_id: String::new()
839        }));
840        b.pump(&mut relay).unwrap();
841        a.pump(&mut relay).unwrap();
842        assert_eq!(a.state_hash(), b.state_hash());
843    }
844
845    #[test]
846    fn partial_fold_failure_is_retry_safe_and_never_bricks_the_journal() {
847        // Kernel-review BRICKED-DEVICE repro (mechanism, not just the
848        // duplicated-line consequence): a mid-fold-loop journal append
849        // failure must leave self.ops == the journal, so the retry filters
850        // the already-journaled ops out of the re-pull instead of writing
851        // them a SECOND time (a duplicate line = DuplicateSeq on the next
852        // open = a permanently bricked device with no recovery API).
853        let mut relay = mem_relay();
854        let (da, db) = (dirs(), dirs());
855        let mut a = open("mac-a", &da);
856        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
857        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
858        a.pump(&mut relay).unwrap();
859
860        let mut b = open("mac-b", &db);
861        // Force the SECOND fold append to fail (ENOSPC-class).
862        b.journal.fail_append_after = Some(1);
863        let err = b.pump(&mut relay).unwrap_err();
864        assert!(matches!(err, SessionError::Io(_)), "got {err:?}");
865
866        // Lockstep invariant: self.ops holds exactly what the journal holds
867        // (the one op that appended before the failure), never more.
868        assert_eq!(b.ops().len(), 1, "only the successfully-journaled fold is in self.ops");
869        let on_disk = OplogJournal::load(&db.journal).unwrap();
870        assert_eq!(on_disk.len(), 1);
871        assert_eq!(on_disk[0].op_id, b.ops()[0].op_id);
872
873        // Retry: the seam auto-cleared, so this pump folds the remaining op.
874        // Crucially it does NOT re-journal the first fold (filtered by op_id
875        // from self.ops) — no duplicate line.
876        let report = b.pump(&mut relay).unwrap();
877        assert_eq!(report.folded, 1, "only the un-journaled op is folded on retry");
878        let on_disk = OplogJournal::load(&db.journal).unwrap();
879        assert_eq!(on_disk.len(), 2, "no duplicate line — the journal is not bricked");
880        verify_log(&on_disk).expect("no DuplicateSeq: the journal opens cleanly");
881
882        // The device is not bricked: it reopens and converges.
883        drop(b);
884        let b = open("mac-b", &db);
885        assert_eq!(a.state_hash(), b.state_hash());
886    }
887
888    #[test]
889    fn append_failure_rolls_the_chain_back() {
890        // Force a journal append failure by dropping the journal file's
891        // directory out from under it is not portable; instead exercise the
892        // rebuild path directly: rebuild_device must reproduce the exact
893        // chain position after arbitrary appends.
894        let d = dirs();
895        let mut a = open("mac-a", &d);
896        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})).unwrap();
897        a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})).unwrap();
898        let before = a.ops().to_vec();
899        a.rebuild_device().unwrap();
900        let next = a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f3"})).unwrap();
901        assert_eq!(next.seq, 2);
902        assert_eq!(next.prev.as_deref(), Some(before[1].op_id.as_str()));
903        let mut all = before;
904        all.push(next);
905        verify_log(&all).unwrap();
906    }
907
908    // ------------------------------------------------------------------
909    // B5: execution lease + fencing, end-to-end through the relay.
910    // ------------------------------------------------------------------
911
912    #[test]
913    fn partition_both_write_intents_converge_to_the_higher_epoch_winner() {
914        // The full arc: mac-a holds the lease (epoch 1) and fires a scheduled
915        // run; it pauses past its TTL; mac-b STEALS the lease (epoch 2) and
916        // fires the SAME run (same B7 deterministic run_id); mac-a, partitioned
917        // from the coordinator, wrongly still believes it holds epoch 1 and
918        // ALSO commits the run (a real zombie). After convergence the fold
919        // fences the zombie deterministically — one ledger record, the
920        // higher-epoch (mac-b) commit wins, even though the zombie's op has a
921        // LATER HLC. No double-execution; both devices agree.
922        use crate::lease::{InMemoryLeaseCoordinator, IntentStatus};
923        use std::sync::atomic::AtomicU64;
924
925        let coord_t = Arc::new(AtomicU64::new(0));
926        let reader = coord_t.clone();
927        let coord_wall: WallClock = Arc::new(move || reader.load(Ordering::SeqCst));
928        let mut coord = InMemoryLeaseCoordinator::new(coord_wall);
929
930        let mut relay = mem_relay();
931        let (da, db) = (dirs(), dirs());
932        let mut a = open("mac-a", &da);
933        let mut b = open("mac-b", &db);
934        let run = car_proto::deterministic_run_id("milo", "3am digest", "occurrence-1");
935
936        // 1. mac-a acquires (epoch 1) and records the run's pending intent
937        //    through the GATED path (coordinator confirms it still holds).
938        let lease_a = coord.acquire("milo", "mac-a", 100).unwrap();
939        assert_eq!(lease_a.epoch, 1);
940        let recorded = a
941            .record_intent_if_current(
942                Scope::Personal,
943                &Intent::new("milo", &run, 1, IntentStatus::Pending),
944                &mut coord,
945            )
946            .unwrap();
947        assert!(recorded.is_some(), "mac-a holds the lease → intent recorded");
948        a.pump(&mut relay).unwrap();
949
950        // 2. mac-b folds a's state, then — a's lid closed past the TTL —
951        //    STEALS the lease (epoch 2) and commits the same run.
952        b.pump(&mut relay).unwrap();
953        coord_t.store(200, Ordering::SeqCst); // past mac-a's 100ms TTL
954        let lease_b = coord.acquire("milo", "mac-b", 100).unwrap();
955        assert_eq!((lease_b.epoch, lease_b.holder.as_str()), (2, "mac-b"));
956        b.record_intent_if_current(
957            Scope::Personal,
958            &Intent::new("milo", &run, 2, IntentStatus::Pending),
959            &mut coord,
960        )
961        .unwrap()
962        .expect("mac-b holds epoch 2");
963        let b_commit = b
964            .record_intent_if_current(
965                Scope::Personal,
966                &Intent::new("milo", &run, 2, IntentStatus::Committed),
967                &mut coord,
968            )
969            .unwrap()
970            .expect("mac-b holds epoch 2");
971        b.pump(&mut relay).unwrap();
972
973        // 3. mac-a is PARTITIONED from the coordinator (never learns it lost)
974        //    but still syncs with the relay. It folds b's ops, then — a genuine
975        //    double-execution artifact — also commits the run via the UNGATED
976        //    path, stamping a LATER HLC than b's committed op. (A committed
977        //    write is allowed even for an already-committed run — idempotent;
978        //    the fold converges it. The terminal guard only no-ops a *pending*
979        //    write for an already-committed run — see the C3 session test.)
980        a.pump(&mut relay).unwrap();
981        let a_zombie = a
982            .record_intent(
983                Scope::Personal,
984                &Intent::new("milo", &run, 1, IntentStatus::Committed),
985            )
986            .unwrap()
987            .expect("a committed write is recorded (not terminal-guarded)");
988        assert!(a_zombie.hlc > b_commit.hlc, "the zombie's write is later in HLC");
989        a.pump(&mut relay).unwrap();
990        b.pump(&mut relay).unwrap();
991
992        // 4. Converge: both agree; the epoch-2 (mac-b) commit is the ledger
993        //    winner despite the zombie's later HLC (fencing beats the clock),
994        //    and the fence-independent oracle agrees.
995        assert_eq!(a.state_hash(), b.state_hash(), "divergence invariant holds");
996        let state = a.state();
997        assert_eq!(state.intents["milo"].runs.len(), 1, "one who-holds record");
998        assert_eq!(state.fencing_epoch("milo"), Some(2));
999        let winner = state.intent("milo", &run).expect("run present");
1000        assert_eq!(winner.op_id, b_commit.op_id, "higher-epoch commit wins despite lower HLC");
1001        let decoded = Intent::from_payload(&winner.payload).unwrap();
1002        assert_eq!((decoded.epoch, decoded.status), (2, IntentStatus::Committed));
1003        // The durable idempotency oracle also resolves to the epoch-2 commit,
1004        // on both devices — the read a B6 dispatch fence would perform.
1005        assert_eq!(a.committed_run("milo", &run).unwrap().op_id, b_commit.op_id);
1006        assert_eq!(b.committed_run("milo", &run).unwrap().op_id, b_commit.op_id);
1007    }
1008
1009    #[test]
1010    fn record_intent_if_current_gates_out_a_lost_holder() {
1011        // The best-effort local gate when the coordinator IS reachable: once
1012        // mac-a no longer holds the lease, the gated write records nothing
1013        // (saving an op the fold would only fence). This is a liveness
1014        // optimization — the sound gate remains the fold's epoch fence.
1015        use crate::lease::{InMemoryLeaseCoordinator, IntentStatus};
1016        let mut coord = InMemoryLeaseCoordinator::new(zero_wall());
1017        let d = dirs();
1018        let mut a = open("mac-a", &d);
1019        let run = "run-x";
1020
1021        coord.acquire("milo", "mac-a", 100).unwrap();
1022        let recorded = a
1023            .record_intent_if_current(
1024                Scope::Personal,
1025                &Intent::new("milo", run, 1, IntentStatus::Pending),
1026                &mut coord,
1027            )
1028            .unwrap();
1029        assert!(recorded.is_some());
1030        assert_eq!(a.ops().len(), 1);
1031
1032        // mac-a releases; mac-b acquires epoch 2. The coordinator now reports
1033        // mac-b as the holder → the gate refuses mac-a's write.
1034        coord.release("milo", "mac-a", 1).unwrap();
1035        coord.acquire("milo", "mac-b", 100).unwrap();
1036        let gated = a
1037            .record_intent_if_current(
1038                Scope::Personal,
1039                &Intent::new("milo", run, 1, IntentStatus::Committed),
1040                &mut coord,
1041            )
1042            .unwrap();
1043        assert!(gated.is_none(), "coordinator says mac-a lost → not recorded");
1044        assert_eq!(a.ops().len(), 1, "nothing new journaled");
1045    }
1046
1047    #[test]
1048    fn c3_record_intent_no_ops_a_pending_for_an_already_committed_run() {
1049        // C3 write-side guard: once a run has committed (per the
1050        // fence-independent oracle), record_intent NO-OPs a later
1051        // pending/failed write for it — a failed-over holder that writes
1052        // `pending` before checking cannot revert the committed ledger. This is
1053        // the ungated path (no coordinator); the guard is a local oracle read.
1054        let mut relay = mem_relay();
1055        let (da, db) = (dirs(), dirs());
1056        let mut a = open("mac-a", &da);
1057        let mut b = open("mac-b", &db);
1058        let run = "run-nightly";
1059
1060        // mac-a commits the run at epoch 1 and syncs it to mac-b.
1061        a.record_intent(Scope::Personal, &Intent::new("milo", run, 1, IntentStatus::Committed))
1062            .unwrap()
1063            .expect("first commit recorded");
1064        a.pump(&mut relay).unwrap();
1065        b.pump(&mut relay).unwrap();
1066        assert!(b.committed_run("milo", run).is_some(), "mac-b folded the commit");
1067
1068        // mac-b fails over to epoch 2 and — before checking — tries to write the
1069        // run PENDING. The terminal guard no-ops it (the run already committed).
1070        let before = b.ops().len();
1071        let attempt = b
1072            .record_intent(Scope::Personal, &Intent::new("milo", run, 2, IntentStatus::Pending))
1073            .unwrap();
1074        assert!(attempt.is_none(), "pending for an already-committed run is a no-op");
1075        assert_eq!(b.ops().len(), before, "nothing journaled");
1076
1077        // The ledger stays committed on both sides after further sync.
1078        b.pump(&mut relay).unwrap();
1079        a.pump(&mut relay).unwrap();
1080        assert!(a.committed_run("milo", run).is_some());
1081        let decoded = Intent::from_payload(&b.state().intent("milo", run).unwrap().payload).unwrap();
1082        assert_eq!(decoded.status, IntentStatus::Committed, "not reverted to pending");
1083    }
1084}