Skip to main content

car_sync/
relay.rs

1//! Relay transport — the account-scoped op-stream devices push/pull against
2//! (slice B3 of `docs/proposals/multi-device-sync.md`, §"Transport" +
3//! §"Stragglers and the single coherence knob" + §"Sync protocol surface").
4//!
5//! The [`Relay`] trait is the proposal's surface — `push(ops)`,
6//! `pull(since_frontier) → {ops, latest_checkpoint_ptr}`, `ack(frontier)`,
7//! `checkpoint_put/get`, `roster()` — as a **library trait** with two
8//! reference implementations: [`InMemoryRelay`] (tests, in-process
9//! coordination) and [`FsRelay`] (a shared directory — the realistic
10//! single-user two-Mac loopback). The network daemon surface (`sync.*`
11//! JSON-RPC + the Parslee-hosted backend) is B6; anything that speaks this
12//! trait is that surface's contract.
13//!
14//! ## What the relay validates (and what it can't)
15//!
16//! The relay holds one **chain per device** and admits a pushed op only if
17//! it *continues* that chain exactly: contiguous `seq`, `prev` linking the
18//! relay-held head, HLC advancing (a re-push of an already-held op dedups on
19//! `op_id`; a *different* op claiming a held `seq` is a
20//! [`RelayError::Fork`] — the B1 permanent-fork hazard surfaced as a
21//! runtime error at the transport). A device pushes only its **own** chain
22//! ([`RelayError::ForeignOps`]). In B3 payloads are cleartext so the relay
23//! can run this full verification; under B6's E2E encryption the same
24//! checks still work — `op_id`/`hlc`/`seq`/`prev` stay cleartext metadata
25//! by design ("the relay can route and dedup on `op_id` and `hlc`").
26//! Device *identity* remains asserted, not authenticated, until B6 signing.
27//!
28//! ## Stable frontier, eviction horizon `H`, and GC
29//!
30//! Straight from the proposal's stragglers section:
31//!
32//! - **Stable frontier** = `min(acked)` over **active (non-evicted) roster
33//!   devices** — `None` (nothing droppable) while any active device has
34//!   never acked, mirroring `compact::AckTable`'s refusal semantics.
35//! - **Eviction**: a device silent (no push/pull/ack) longer than
36//!   [`RelayConfig::eviction_horizon_ms`] is marked
37//!   [`DeviceStatus::Evicted`] on the roster; its ack no longer holds the
38//!   frontier. An evicted device may still push/pull/ack (its re-entry path
39//!   is cold bootstrap — `checkpoint_get` + `pull(since checkpoint
40//!   frontier)` + `resume_anchored`, see `crate::session`), and it is
41//!   **reinstated** when it acks at/above the current stable frontier
42//!   (i.e. it has provably caught up) — or whenever nothing is
43//!   GC-eligible anyway (`stable frontier == None`).
44//! - **GC** ([`Relay::gc`]): an op is droppable only when **both** (a) its
45//!   HLC is at/below the stable frontier AND (b) a stored checkpoint
46//!   **covers** it (`checkpoint.frontier[device].seq >= op.seq`) — "ops
47//!   below the stable frontier are GC-eligible relay-side only after a
48//!   covering checkpoint exists". Both conditions are per-device chain
49//!   *prefixes* (HLC-monotone chains; frontier seqs), so GC always drops a
50//!   prefix and the retained chain stays gap-free; the relay keeps the last
51//!   dropped op's `(seq, op_id, hlc)` as the chain anchor for continuity
52//!   checks and remembers the drop floor so a `pull` whose `since` frontier
53//!   reaches into truncated space fails loudly
54//!   ([`RelayError::FrontierTruncated`]) instead of silently serving a
55//!   gapped log — the signal that sends the puller to cold bootstrap.
56//!
57//! **Deliberate deviation from the proposal, binding on B6:** the proposal's
58//! third `H` rule ("max op age the relay accepts = `H`; older ops are
59//! rejected") exists to keep accepted ops out of *time-truncated* space
60//! (`hlc.wall < now − H`). This relay's truncation is not time-based — it
61//! is per-device-seq + covering-checkpoint, under which a returning
62//! straggler's late ops are structurally *outside* truncated space (its own
63//! chain frontier is behind them) and converge losslessly once pushed,
64//! which is strictly stronger than the proposal's "loses only writes it
65//! made while >H-offline" degenerate case. So B3 accepts old ops rather
66//! than rejecting them. If B6's hosted backend adopts wall-clock-bounded
67//! storage, it must reintroduce the age bound *and* the proposal's
68//! surface-it-to-the-user story together.
69//!
70//! ## Checkpoints
71//!
72//! `checkpoint_put` runs TWO checks, not one. `Checkpoint::verify` proves
73//! internal self-consistency (both hashes recompute) — but that alone lets
74//! a checkpoint built from a *different* chain merely CLAIMING a device's
75//! name walk in and, via the coverage claim, make GC drop that device's
76//! real ops (the kernel-review data-loss defect). So `checkpoint_put` also
77//! **cross-checks the frontier against the relay-held chains**
78//! ([`RelayState::validate_frontier`]): every frontier entry must name an op
79//! the relay actually holds/held for that device at that seq (or, for a
80//! seq already GC'd, be consistent with the remembered dropped head). A
81//! device the relay has never seen a chain for cannot be validated. Only
82//! validated checkpoints are stored, so `gc` — which counts stored
83//! checkpoints as coverage — counts only validated ones, and the
84//! bare-seq coverage test is then sound (a validated frontier head at seq S
85//! means the contiguous hash-linked prefix 0..S is the device's real
86//! chain). `op_id`/`seq`/`prev` stay cleartext metadata under B6 E2E, so
87//! this cross-check survives encryption. Storage **dedups on
88//! `checkpoint_hash` — the whole-record content address, never
89//! `state_hash`** (binding contract from B4: two frontiers can fold to one
90//! state; keying on the state would keep the wrong checkpoint and fork
91//! chains on resume). The "latest" pointer served by `pull`/`checkpoint_get`
92//! advances only to a checkpoint whose frontier **dominates** the current
93//! latest (covers at least every device/seq it covers), so a stale or
94//! concurrent upload can never regress the bootstrap pointer.
95
96use crate::checkpoint::{Checkpoint, CheckpointError, FrontierEntry};
97use crate::oplog::{Hlc, OpRecord, WallClock};
98use serde::{Deserialize, Serialize};
99use std::collections::BTreeMap;
100use std::fmt;
101use std::fs::{self, File, OpenOptions};
102use std::io::Write;
103use std::path::{Path, PathBuf};
104
105/// A pull cursor: device_id → highest `seq` the puller already holds for
106/// that device. Seq-based (not HLC-based) on purpose: a straggler's late
107/// ops carry *old* HLCs but *new* seqs, so a seq frontier still delivers
108/// them to every peer — an HLC cursor would silently skip them.
109pub type Frontier = BTreeMap<String, u64>;
110
111/// The frontier of an op-set: max seq per device.
112pub fn frontier_of(ops: &[OpRecord]) -> Frontier {
113    let mut frontier = Frontier::new();
114    for op in ops {
115        let entry = frontier.entry(op.device_id.clone()).or_insert(op.seq);
116        if op.seq > *entry {
117            *entry = op.seq;
118        }
119    }
120    frontier
121}
122
123/// The pull cursor a checkpoint's coverage corresponds to — the cold
124/// bootstrap's `pull(since = F)`.
125pub fn checkpoint_frontier(checkpoint: &Checkpoint) -> Frontier {
126    checkpoint
127        .frontier
128        .iter()
129        .map(|(device, entry)| (device.clone(), entry.seq))
130        .collect()
131}
132
133/// Roster liveness state — the proposal's "devices+last_seen" with the
134/// eviction verdict made explicit.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum DeviceStatus {
138    Active,
139    /// Silent past the horizon `H`: its ack no longer holds the stable
140    /// frontier; re-entry is cold bootstrap, reinstated on a caught-up ack.
141    Evicted,
142}
143
144/// One device registry entry — `roster()`'s row.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RosterEntry {
147    pub device_id: String,
148    /// The stamp the device joined at (relay wall reading, counter 0).
149    pub added_at: Hlc,
150    /// Relay wall-clock ms of the device's last push/pull/ack/register.
151    pub last_seen_ms: u64,
152    /// The device's acked fold frontier (monotone-only), `None` until it
153    /// first acks — which holds the stable frontier at `None` (safe).
154    pub acked: Option<Hlc>,
155    pub status: DeviceStatus,
156}
157
158/// Relay policy knobs.
159#[derive(Debug, Clone, Default)]
160pub struct RelayConfig {
161    /// The stragglers horizon `H`: a device silent longer than this is
162    /// marked [`DeviceStatus::Evicted`]. `None` (the default) never evicts
163    /// — the safe, GC-pinning posture a caller must opt out of.
164    pub eviction_horizon_ms: Option<u64>,
165}
166
167/// What a push did. Not transactional: on an error mid-batch the already
168/// accepted prefix stays (retry-safe — a re-push of it dedups).
169#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub struct PushOutcome {
171    /// Ops newly admitted to the device's relay-held chain.
172    pub accepted: usize,
173    /// Ops the relay already held (retransmission — `op_id` identical) or
174    /// that fall below its GC floor for the device (already covered by a
175    /// checkpoint).
176    pub deduped: usize,
177}
178
179/// What a pull returned — the proposal's `{ops, latest_checkpoint_ptr}`.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct PullResult {
182    /// Every retained op above the `since` frontier, in canonical
183    /// `(hlc, op_id)` order.
184    pub ops: Vec<OpRecord>,
185    /// `checkpoint_hash` of the current latest checkpoint, if any — the
186    /// cold-bootstrap pointer.
187    pub latest_checkpoint: Option<String>,
188}
189
190/// What an ack did.
191#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
192pub struct AckOutcome {
193    /// The acked frontier advanced (monotone-only, like `AckTable::ack`).
194    pub advanced: bool,
195    /// An evicted device proved it caught up and is active again.
196    pub reinstated: bool,
197}
198
199/// What GC dropped, per device.
200#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub struct GcReport {
202    pub dropped: BTreeMap<String, usize>,
203}
204
205impl GcReport {
206    pub fn total(&self) -> usize {
207        self.dropped.values().sum()
208    }
209}
210
211/// A relay operation failure.
212#[derive(Debug)]
213pub enum RelayError {
214    /// A pushed op fails self-verification (id/device mismatch) or does not
215    /// link the relay-held chain head.
216    Chain {
217        device_id: String,
218        detail: String,
219    },
220    /// A different op claims a `seq` the relay already holds — the
221    /// permanent chain fork, refused at the transport.
222    Fork {
223        device_id: String,
224        seq: u64,
225    },
226    /// A pushed op skips ahead of the relay-held chain (ops must arrive
227    /// contiguously).
228    Gap {
229        device_id: String,
230        expected: u64,
231        found: u64,
232    },
233    /// A device may only push its own chain.
234    ForeignOps {
235        device_id: String,
236        op_device: String,
237    },
238    /// The pull's `since` frontier reaches into GC'd space — the puller
239    /// cannot be served a gapless log and must cold-bootstrap from the
240    /// latest checkpoint (`checkpoint_get` + `pull(since = checkpoint
241    /// frontier)` + `resume_anchored`).
242    FrontierTruncated {
243        device_id: String,
244        dropped_below: u64,
245    },
246    /// The uploaded checkpoint does not verify.
247    Checkpoint(CheckpointError),
248    /// The uploaded checkpoint is internally self-consistent
249    /// ([`Checkpoint::verify`] passed) but its frontier claims a chain
250    /// position that does **not** match the relay-held chain for a device —
251    /// a checkpoint built from a *different* chain merely claiming the
252    /// device's name (a restored-from-backup re-mint, or two accounts
253    /// colliding on a device id in a shared `FsRelay` folder). Rejected so
254    /// it can never become GC coverage evidence and drop the device's real
255    /// ops. (`op_id`/`seq`/`prev` stay cleartext metadata under B6 E2E, so
256    /// this cross-check survives encryption.)
257    CheckpointFrontierUnverified {
258        device_id: String,
259        seq: u64,
260        detail: String,
261    },
262    Io(std::io::Error),
263}
264
265impl fmt::Display for RelayError {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            RelayError::Chain { device_id, detail } => {
269                write!(f, "relay push rejected for {device_id}: {detail}")
270            }
271            RelayError::Fork { device_id, seq } => write!(
272                f,
273                "relay push rejected: a different op already holds {device_id} seq {seq} — \
274                 device chain fork"
275            ),
276            RelayError::Gap {
277                device_id,
278                expected,
279                found,
280            } => write!(
281                f,
282                "relay push rejected for {device_id}: seq gap (relay expects {expected}, \
283                 got {found}) — push contiguously"
284            ),
285            RelayError::ForeignOps {
286                device_id,
287                op_device,
288            } => write!(
289                f,
290                "relay push rejected: device {device_id} pushed an op emitted by {op_device} — \
291                 a device pushes only its own chain"
292            ),
293            RelayError::FrontierTruncated {
294                device_id,
295                dropped_below,
296            } => write!(
297                f,
298                "pull frontier reaches into GC'd space (device {device_id}: ops below seq \
299                 {dropped_below} were truncated) — cold-bootstrap from the latest checkpoint"
300            ),
301            RelayError::Checkpoint(e) => write!(f, "relay checkpoint rejected: {e}"),
302            RelayError::CheckpointFrontierUnverified {
303                device_id,
304                seq,
305                detail,
306            } => write!(
307                f,
308                "relay checkpoint rejected: frontier for device {device_id} at seq {seq} does \
309                 not match the relay-held chain ({detail}) — forged or foreign checkpoint, \
310                 refusing to store it (it would become GC coverage for ops it does not cover)"
311            ),
312            RelayError::Io(e) => write!(f, "relay io error: {e}"),
313        }
314    }
315}
316
317impl std::error::Error for RelayError {}
318
319/// The relay contract — see the module docs. All methods take `&mut self`
320/// because every contact updates roster liveness (and runs the eviction
321/// sweep) even on logically-read-only calls.
322pub trait Relay {
323    /// Enroll (or touch) a device on the roster. Idempotent; push/pull/ack
324    /// auto-register on first contact.
325    fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError>;
326    /// Admit `device_id`'s own journal-durable ops (in seq order) onto its
327    /// relay-held chain. Contract (B1, binding): the caller transmits only
328    /// ops that are already journal-durable on the device.
329    fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError>;
330    /// Every retained op above `since` (per-device seq cursor) + the latest
331    /// checkpoint pointer.
332    fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError>;
333    /// Record a device's fold frontier. Contract (B4, binding): the device
334    /// acks only what it has DURABLY folded (journaled), never merely
335    /// received.
336    fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError>;
337    /// Store a device-computed checkpoint. Verified, deduped on
338    /// `checkpoint_hash` (whole-record content address — contract from B4).
339    /// Returns `false` when the identical checkpoint was already stored.
340    fn checkpoint_put(
341        &mut self,
342        device_id: &str,
343        checkpoint: &Checkpoint,
344    ) -> Result<bool, RelayError>;
345    /// The latest stored checkpoint (dominance-monotone pointer), if any.
346    fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError>;
347    /// The device registry.
348    fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError>;
349    /// `min(acked)` over active roster devices; `None` while any active
350    /// device has never acked (nothing is droppable then).
351    fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError>;
352    /// Drop every op that is BOTH at/below the stable frontier AND covered
353    /// by a stored checkpoint. Never drops anything else.
354    fn gc(&mut self) -> Result<GcReport, RelayError>;
355}
356
357/// One device's relay-held chain.
358#[derive(Debug, Clone, Default, Serialize, Deserialize)]
359struct DeviceChain {
360    /// Retained ops by seq (contiguous by construction).
361    ops: BTreeMap<u64, OpRecord>,
362    /// Seqs below this were GC'd (0 = nothing dropped).
363    dropped_below: u64,
364    /// The last GC'd op's `(seq, op_id, hlc)` — the chain-continuity anchor
365    /// for the first retained/pushed op after a GC.
366    dropped_head: Option<(u64, String, Hlc)>,
367}
368
369impl DeviceChain {
370    /// The chain head the next pushed op must link: the last retained op,
371    /// else the last GC'd op.
372    fn head(&self) -> Option<(u64, &str, &Hlc)> {
373        self.ops
374            .iter()
375            .next_back()
376            .map(|(seq, op)| (*seq, op.op_id.as_str(), &op.hlc))
377            .or_else(|| {
378                self.dropped_head
379                    .as_ref()
380                    .map(|(seq, id, hlc)| (*seq, id.as_str(), hlc))
381            })
382    }
383
384    fn next_seq(&self) -> u64 {
385        self.head().map(|(seq, _, _)| seq + 1).unwrap_or(0)
386    }
387}
388
389/// The relay's pure state machine — shared verbatim by [`InMemoryRelay`]
390/// (held in memory) and [`FsRelay`] (loaded/persisted around every call).
391#[derive(Debug, Default, Serialize, Deserialize)]
392struct RelayState {
393    roster: BTreeMap<String, RosterEntry>,
394    chains: BTreeMap<String, DeviceChain>,
395    /// Content-addressed checkpoint store (dedup key = `checkpoint_hash`).
396    /// `FsRelay` persists these as their own `<hash>.checkpoint.json`
397    /// files (re-verified on load), not inside the state file.
398    #[serde(skip)]
399    checkpoints: BTreeMap<String, Checkpoint>,
400    /// `checkpoint_hash` of the dominance-latest checkpoint.
401    latest_checkpoint: Option<String>,
402}
403
404/// Does checkpoint `a`'s frontier dominate `b`'s (cover at least every
405/// device/seq `b` covers)? The relay's latest-pointer advance rule and the
406/// session's rebase-regression guard.
407pub(crate) fn frontier_dominates(a: &Checkpoint, b: &Checkpoint) -> bool {
408    b.frontier.iter().all(|(device, entry)| {
409        a.frontier
410            .get(device)
411            .is_some_and(|ae: &FrontierEntry| ae.seq >= entry.seq)
412    })
413}
414
415impl RelayState {
416    /// Touch (or enroll) a device and run the eviction sweep. Order
417    /// matters: the caller is touched first so it can never evict itself
418    /// mid-call.
419    fn touch_and_sweep(&mut self, device_id: &str, now_ms: u64, config: &RelayConfig) {
420        let entry = self
421            .roster
422            .entry(device_id.to_string())
423            .or_insert_with(|| RosterEntry {
424                device_id: device_id.to_string(),
425                added_at: Hlc {
426                    wall_ms: now_ms,
427                    counter: 0,
428                    device_id: device_id.to_string(),
429                },
430                last_seen_ms: now_ms,
431                acked: None,
432                status: DeviceStatus::Active,
433            });
434        entry.last_seen_ms = now_ms;
435        if let Some(horizon) = config.eviction_horizon_ms {
436            for entry in self.roster.values_mut() {
437                if entry.status == DeviceStatus::Active
438                    && now_ms.saturating_sub(entry.last_seen_ms) > horizon
439                {
440                    entry.status = DeviceStatus::Evicted;
441                }
442            }
443        }
444    }
445
446    fn stable_frontier(&self) -> Option<Hlc> {
447        let active: Vec<&RosterEntry> = self
448            .roster
449            .values()
450            .filter(|e| e.status == DeviceStatus::Active)
451            .collect();
452        if active.is_empty() || active.iter().any(|e| e.acked.is_none()) {
453            return None;
454        }
455        active.iter().filter_map(|e| e.acked.clone()).min()
456    }
457
458    fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
459        let mut outcome = PushOutcome::default();
460        // Process in seq order regardless of slice order.
461        let mut sorted: Vec<&OpRecord> = ops.iter().collect();
462        sorted.sort_by_key(|op| op.seq);
463        for op in sorted {
464            if op.device_id != device_id {
465                return Err(RelayError::ForeignOps {
466                    device_id: device_id.to_string(),
467                    op_device: op.device_id.clone(),
468                });
469            }
470            if !op.id_valid() {
471                return Err(RelayError::Chain {
472                    device_id: device_id.to_string(),
473                    detail: format!("op {}: stored op_id does not match content", op.op_id),
474                });
475            }
476            if op.hlc.device_id != op.device_id {
477                return Err(RelayError::Chain {
478                    device_id: device_id.to_string(),
479                    detail: format!("op {}: hlc.device_id != device_id", op.op_id),
480                });
481            }
482            let chain = self.chains.entry(device_id.to_string()).or_default();
483            if op.seq < chain.dropped_below {
484                // The GC'd prefix: a device re-pushing its own already
485                // checkpoint-covered ops (crash lost its push cursor).
486                outcome.deduped += 1;
487                continue;
488            }
489            if let Some(existing) = chain.ops.get(&op.seq) {
490                if existing.op_id == op.op_id {
491                    outcome.deduped += 1;
492                    continue;
493                }
494                return Err(RelayError::Fork {
495                    device_id: device_id.to_string(),
496                    seq: op.seq,
497                });
498            }
499            let expected = chain.next_seq();
500            if op.seq != expected {
501                return Err(RelayError::Gap {
502                    device_id: device_id.to_string(),
503                    expected,
504                    found: op.seq,
505                });
506            }
507            match chain.head() {
508                Some((_, head_id, head_hlc)) => {
509                    if op.prev.as_deref() != Some(head_id) {
510                        return Err(RelayError::Chain {
511                            device_id: device_id.to_string(),
512                            detail: format!(
513                                "op {}: prev does not link the relay-held head {head_id}",
514                                op.op_id
515                            ),
516                        });
517                    }
518                    if op.hlc <= *head_hlc {
519                        return Err(RelayError::Chain {
520                            device_id: device_id.to_string(),
521                            detail: format!(
522                                "op {}: hlc does not advance past the relay-held head",
523                                op.op_id
524                            ),
525                        });
526                    }
527                }
528                None => {
529                    if op.prev.is_some() {
530                        return Err(RelayError::Chain {
531                            device_id: device_id.to_string(),
532                            detail: format!("op {}: seq 0 must have no prev", op.op_id),
533                        });
534                    }
535                }
536            }
537            chain.ops.insert(op.seq, op.clone());
538            outcome.accepted += 1;
539        }
540        Ok(outcome)
541    }
542
543    fn pull(&self, since: &Frontier) -> Result<PullResult, RelayError> {
544        let mut ops = Vec::new();
545        for (device_id, chain) in &self.chains {
546            let start = since.get(device_id).map(|held| held + 1).unwrap_or(0);
547            if start < chain.dropped_below {
548                return Err(RelayError::FrontierTruncated {
549                    device_id: device_id.clone(),
550                    dropped_below: chain.dropped_below,
551                });
552            }
553            ops.extend(chain.ops.range(start..).map(|(_, op)| op.clone()));
554        }
555        ops.sort_by(|a, b| (&a.hlc, &a.op_id).cmp(&(&b.hlc, &b.op_id)));
556        Ok(PullResult {
557            ops,
558            latest_checkpoint: self.latest_checkpoint.clone(),
559        })
560    }
561
562    fn ack(&mut self, device_id: &str, frontier: Hlc) -> AckOutcome {
563        // Frontier over the OTHER active devices — the bar a returning
564        // straggler must clear to be reinstated.
565        let others_frontier = {
566            let others: Vec<&RosterEntry> = self
567                .roster
568                .values()
569                .filter(|e| e.status == DeviceStatus::Active && e.device_id != device_id)
570                .collect();
571            if others.is_empty() || others.iter().any(|e| e.acked.is_none()) {
572                None
573            } else {
574                others.iter().filter_map(|e| e.acked.clone()).min()
575            }
576        };
577        let entry = self.roster.get_mut(device_id).expect("touched before ack");
578        let advanced = match &entry.acked {
579            Some(current) if frontier <= *current => false,
580            _ => {
581                entry.acked = Some(frontier);
582                true
583            }
584        };
585        let mut reinstated = false;
586        if entry.status == DeviceStatus::Evicted {
587            // Reinstate when the device has provably caught up: its acked
588            // frontier reaches the active stable frontier (or nothing is
589            // GC-eligible anyway). Its stale ack must never drag GC back.
590            let caught_up = match (&entry.acked, &others_frontier) {
591                (Some(acked), Some(frontier)) => acked >= frontier,
592                (Some(_), None) => true,
593                (None, _) => false,
594            };
595            if caught_up {
596                entry.status = DeviceStatus::Active;
597                reinstated = true;
598            }
599        }
600        AckOutcome {
601            advanced,
602            reinstated,
603        }
604    }
605
606    /// Cross-check a checkpoint's frontier against the relay-held chains —
607    /// the defense `Checkpoint::verify` (internal self-consistency) cannot
608    /// give. Every frontier entry must name an op the relay actually
609    /// holds/held for that device at that seq: the whole prefix a checkpoint
610    /// claims to cover has to be the device's REAL chain, or GC would drop
611    /// real ops on a forged coverage claim (kernel-review data-loss defect).
612    /// A device the relay has never seen a chain for cannot be validated.
613    fn validate_frontier(&self, checkpoint: &Checkpoint) -> Result<(), RelayError> {
614        for (device_id, entry) in &checkpoint.frontier {
615            let unverified = |detail: &str| RelayError::CheckpointFrontierUnverified {
616                device_id: device_id.clone(),
617                seq: entry.seq,
618                detail: detail.to_string(),
619            };
620            let Some(chain) = self.chains.get(device_id) else {
621                return Err(unverified("relay holds no chain for this device"));
622            };
623            if entry.seq >= chain.dropped_below {
624                // Still-retained region: the relay must hold exactly this op.
625                match chain.ops.get(&entry.seq) {
626                    Some(op) if op.op_id == entry.head => {}
627                    Some(_) => {
628                        return Err(unverified(
629                            "frontier head does not match the relay-held op at this seq",
630                        ))
631                    }
632                    None => {
633                        return Err(unverified(
634                            "relay holds no op at the claimed frontier seq (claims coverage \
635                             beyond its chain head)",
636                        ))
637                    }
638                }
639            } else {
640                // Already-GC'd region: verify against the remembered dropped
641                // head where the seq lands on it; a deeper seq was truncated
642                // by a prior *validated* checkpoint, so accept it (it covers
643                // only already-dropped ops — no new coverage, no new risk).
644                match &chain.dropped_head {
645                    Some((seq, id, _)) if *seq == entry.seq => {
646                        if id != &entry.head {
647                            return Err(unverified(
648                                "frontier head does not match the relay's GC'd dropped head",
649                            ));
650                        }
651                    }
652                    Some((seq, _, _)) if entry.seq < *seq => {}
653                    _ => {
654                        return Err(unverified(
655                            "claimed frontier seq is below the relay's GC floor with no \
656                             matching record",
657                        ))
658                    }
659                }
660            }
661        }
662        Ok(())
663    }
664
665    fn checkpoint_put(&mut self, checkpoint: &Checkpoint) -> Result<bool, RelayError> {
666        checkpoint.verify().map_err(RelayError::Checkpoint)?;
667        // Cross-check the frontier against the relay-held chains BEFORE the
668        // checkpoint can become GC coverage — self-consistency (verify) is
669        // not enough (kernel-review data-loss defect). Only validated
670        // checkpoints enter the store, so `gc` counting stored checkpoints
671        // as coverage counts only validated ones.
672        self.validate_frontier(checkpoint)?;
673        // Dedup on the WHOLE-RECORD content address, never state_hash (B4
674        // contract: two frontiers can fold to one state).
675        let stored = if self.checkpoints.contains_key(&checkpoint.checkpoint_hash) {
676            false
677        } else {
678            self.checkpoints
679                .insert(checkpoint.checkpoint_hash.clone(), checkpoint.clone());
680            true
681        };
682        let advance = match self
683            .latest_checkpoint
684            .as_ref()
685            .and_then(|hash| self.checkpoints.get(hash))
686        {
687            Some(current) => {
688                checkpoint.checkpoint_hash != current.checkpoint_hash
689                    && frontier_dominates(checkpoint, current)
690            }
691            None => true,
692        };
693        if advance {
694            self.latest_checkpoint = Some(checkpoint.checkpoint_hash.clone());
695        }
696        Ok(stored)
697    }
698
699    fn checkpoint_get(&self) -> Option<Checkpoint> {
700        self.latest_checkpoint
701            .as_ref()
702            .and_then(|hash| self.checkpoints.get(hash))
703            .cloned()
704    }
705
706    fn gc(&mut self) -> GcReport {
707        let mut report = GcReport::default();
708        let Some(frontier) = self.stable_frontier() else {
709            return report;
710        };
711        // Max covered seq per device across all (validated — every stored
712        // checkpoint passed `validate_frontier`) checkpoints, computed once:
713        // O(checkpoints × devices), not O(ops × checkpoints) per device.
714        let mut max_covered: BTreeMap<String, u64> = BTreeMap::new();
715        for ckpt in self.checkpoints.values() {
716            for (device, entry) in &ckpt.frontier {
717                let slot = max_covered.entry(device.clone()).or_insert(entry.seq);
718                if entry.seq > *slot {
719                    *slot = entry.seq;
720                }
721            }
722        }
723        for (device_id, chain) in &mut self.chains {
724            // Both droppability conditions are chain prefixes; walk from the
725            // bottom and stop at the first op failing either.
726            let covered_through = max_covered.get(device_id).copied();
727            let mut droppable: Vec<u64> = Vec::new();
728            for (seq, op) in &chain.ops {
729                let below_frontier = op.hlc <= frontier;
730                let covered = covered_through.is_some_and(|through| through >= *seq);
731                if below_frontier && covered {
732                    droppable.push(*seq);
733                } else {
734                    break;
735                }
736            }
737            for seq in &droppable {
738                let op = chain.ops.remove(seq).expect("collected from the map");
739                chain.dropped_below = seq + 1;
740                chain.dropped_head = Some((*seq, op.op_id, op.hlc));
741            }
742            if !droppable.is_empty() {
743                report.dropped.insert(device_id.clone(), droppable.len());
744            }
745        }
746        report
747    }
748}
749
750/// The in-process reference relay: [`RelayState`] + an injected wall clock.
751pub struct InMemoryRelay {
752    state: RelayState,
753    config: RelayConfig,
754    wall: WallClock,
755}
756
757impl fmt::Debug for InMemoryRelay {
758    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
759        f.debug_struct("InMemoryRelay")
760            .field("state", &self.state)
761            .field("config", &self.config)
762            .finish_non_exhaustive()
763    }
764}
765
766impl InMemoryRelay {
767    pub fn new(config: RelayConfig, wall: WallClock) -> Self {
768        Self {
769            state: RelayState::default(),
770            config,
771            wall,
772        }
773    }
774}
775
776impl Relay for InMemoryRelay {
777    fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError> {
778        let now = (self.wall)();
779        self.state.touch_and_sweep(device_id, now, &self.config);
780        Ok(self.state.roster[device_id].clone())
781    }
782
783    fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
784        let now = (self.wall)();
785        self.state.touch_and_sweep(device_id, now, &self.config);
786        self.state.push(device_id, ops)
787    }
788
789    fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError> {
790        let now = (self.wall)();
791        self.state.touch_and_sweep(device_id, now, &self.config);
792        self.state.pull(since)
793    }
794
795    fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
796        let now = (self.wall)();
797        self.state.touch_and_sweep(device_id, now, &self.config);
798        Ok(self.state.ack(device_id, frontier))
799    }
800
801    fn checkpoint_put(
802        &mut self,
803        device_id: &str,
804        checkpoint: &Checkpoint,
805    ) -> Result<bool, RelayError> {
806        let now = (self.wall)();
807        self.state.touch_and_sweep(device_id, now, &self.config);
808        self.state.checkpoint_put(checkpoint)
809    }
810
811    fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
812        Ok(self.state.checkpoint_get())
813    }
814
815    fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
816        Ok(self.state.roster.values().cloned().collect())
817    }
818
819    fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
820        Ok(self.state.stable_frontier())
821    }
822
823    fn gc(&mut self) -> Result<GcReport, RelayError> {
824        Ok(self.state.gc())
825    }
826}
827
828/// The filesystem loopback relay: two (or more) `DeviceLog`s syncing
829/// through a **shared directory** — the realistic single-user two-Mac case
830/// (a shared volume, an external disk, a user-managed synced folder).
831///
832/// Layout under `dir`:
833/// - `relay.lock` — exclusive advisory lock held around every call (the
834///   `OplogJournal`/`car-registry` protocol, but *blocking*: concurrent
835///   callers queue rather than fail);
836/// - `relay-state.json` — roster + chains + latest-checkpoint pointer
837///   (temp + atomic rename per mutation);
838/// - `checkpoints/<checkpoint_hash>.checkpoint.json` — content-addressed
839///   checkpoint files via `Checkpoint::save`/`load`. Checkpoint files are
840///   **immutable** (the name IS the whole-record content address), so each
841///   is `Checkpoint::load`-verified **once** — on first sight by this
842///   handle — and then served from an in-memory cache; it is never
843///   re-hashed or re-`fsync`'d on subsequent (including read-only) calls.
844///   A tampering attacker in the shared folder is caught by the *next
845///   process* to open the relay (a fresh handle with a cold cache
846///   re-verifies), which is the actual two-Mac threat model; a handle
847///   never serves content it did not verify.
848///
849/// Semantics are identical to [`InMemoryRelay`] by construction — both
850/// drive the same [`RelayState`] core; this type only adds durability and
851/// cross-process mutual exclusion.
852pub struct FsRelay {
853    dir: PathBuf,
854    config: RelayConfig,
855    wall: WallClock,
856    /// Verified-once checkpoint cache (hash → checkpoint). Immutable
857    /// content-addressed files never need re-verification within a handle's
858    /// lifetime — the fix for O(all checkpoints × state) re-hash per call.
859    checkpoint_cache: BTreeMap<String, Checkpoint>,
860}
861
862impl fmt::Debug for FsRelay {
863    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
864        f.debug_struct("FsRelay")
865            .field("dir", &self.dir)
866            .field("config", &self.config)
867            .finish_non_exhaustive()
868    }
869}
870
871impl FsRelay {
872    pub fn open(dir: &Path, config: RelayConfig, wall: WallClock) -> std::io::Result<Self> {
873        fs::create_dir_all(dir.join("checkpoints"))?;
874        Ok(Self {
875            dir: dir.to_path_buf(),
876            config,
877            wall,
878            checkpoint_cache: BTreeMap::new(),
879        })
880    }
881
882    fn state_path(&self) -> PathBuf {
883        self.dir.join("relay-state.json")
884    }
885
886    fn checkpoints_dir(&self) -> PathBuf {
887        self.dir.join("checkpoints")
888    }
889
890    /// Run `f` over the loaded state under the exclusive lock, then persist
891    /// — even when `f` returns a business error (`push` keeps its accepted
892    /// prefix, matching the in-memory semantics).
893    fn with_state<T>(
894        &mut self,
895        f: impl FnOnce(&mut RelayState, u64, &RelayConfig) -> Result<T, RelayError>,
896    ) -> Result<T, RelayError> {
897        let lock = OpenOptions::new()
898            .read(true)
899            .write(true)
900            .create(true)
901            .truncate(false)
902            .open(self.dir.join("relay.lock"))
903            .map_err(RelayError::Io)?;
904        lock.lock().map_err(RelayError::Io)?; // blocking; released on drop
905
906        let mut state: RelayState = match fs::read_to_string(self.state_path()) {
907            Ok(raw) => {
908                serde_json::from_str(&raw).map_err(|e| RelayError::Io(std::io::Error::other(e)))?
909            }
910            Err(e) if e.kind() == std::io::ErrorKind::NotFound => RelayState::default(),
911            Err(e) => return Err(RelayError::Io(e)),
912        };
913        // Checkpoint files are immutable content addresses: `Checkpoint::load`
914        // (re-verify both hashes + the file-name address) runs ONCE per
915        // file, on first sight by this handle; a cache hit serves the
916        // already-verified record without re-hashing the whole state.
917        for entry in fs::read_dir(self.checkpoints_dir()).map_err(RelayError::Io)? {
918            let path = entry.map_err(RelayError::Io)?.path();
919            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
920                continue;
921            };
922            let Some(hash) = name.strip_suffix(".checkpoint.json") else {
923                continue;
924            };
925            let ckpt = match self.checkpoint_cache.get(hash) {
926                Some(cached) => cached.clone(),
927                None => {
928                    let ckpt = Checkpoint::load(&path).map_err(RelayError::Checkpoint)?;
929                    self.checkpoint_cache
930                        .insert(ckpt.checkpoint_hash.clone(), ckpt.clone());
931                    ckpt
932                }
933            };
934            state.checkpoints.insert(ckpt.checkpoint_hash.clone(), ckpt);
935        }
936
937        let now = (self.wall)();
938        let result = f(&mut state, now, &self.config);
939
940        // Persist: checkpoint files first (content-addressed, immutable),
941        // then the state file naming them (temp + atomic rename) — the same
942        // durable-referent-first ordering as compact_and_truncate. Skip the
943        // fsync-heavy `save` for any checkpoint already on disk (the name IS
944        // the content, so an existing file is byte-identical) — the fix for
945        // re-fsyncing every checkpoint on every call, including read-only
946        // ones.
947        for ckpt in state.checkpoints.values() {
948            let path = self.checkpoints_dir().join(ckpt.file_name());
949            if !path.exists() {
950                ckpt.save(&self.checkpoints_dir()).map_err(RelayError::Io)?;
951            }
952            self.checkpoint_cache
953                .entry(ckpt.checkpoint_hash.clone())
954                .or_insert_with(|| ckpt.clone());
955        }
956        let tmp = self.dir.join("relay-state.json.tmp");
957        {
958            let mut file = File::create(&tmp).map_err(RelayError::Io)?;
959            file.write_all(
960                serde_json::to_string(&state)
961                    .map_err(|e| RelayError::Io(std::io::Error::other(e)))?
962                    .as_bytes(),
963            )
964            .map_err(RelayError::Io)?;
965            file.sync_all().map_err(RelayError::Io)?;
966        }
967        fs::rename(&tmp, self.state_path()).map_err(RelayError::Io)?;
968        result
969    }
970}
971
972impl Relay for FsRelay {
973    fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError> {
974        self.with_state(|state, now, config| {
975            state.touch_and_sweep(device_id, now, config);
976            Ok(state.roster[device_id].clone())
977        })
978    }
979
980    fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
981        self.with_state(|state, now, config| {
982            state.touch_and_sweep(device_id, now, config);
983            state.push(device_id, ops)
984        })
985    }
986
987    fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError> {
988        self.with_state(|state, now, config| {
989            state.touch_and_sweep(device_id, now, config);
990            state.pull(since)
991        })
992    }
993
994    fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
995        self.with_state(|state, now, config| {
996            state.touch_and_sweep(device_id, now, config);
997            Ok(state.ack(device_id, frontier))
998        })
999    }
1000
1001    fn checkpoint_put(
1002        &mut self,
1003        device_id: &str,
1004        checkpoint: &Checkpoint,
1005    ) -> Result<bool, RelayError> {
1006        self.with_state(|state, now, config| {
1007            state.touch_and_sweep(device_id, now, config);
1008            state.checkpoint_put(checkpoint)
1009        })
1010    }
1011
1012    fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
1013        self.with_state(|state, _, _| Ok(state.checkpoint_get()))
1014    }
1015
1016    fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
1017        self.with_state(|state, _, _| Ok(state.roster.values().cloned().collect()))
1018    }
1019
1020    fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
1021        self.with_state(|state, _, _| Ok(state.stable_frontier()))
1022    }
1023
1024    fn gc(&mut self) -> Result<GcReport, RelayError> {
1025        self.with_state(|state, _, _| Ok(state.gc()))
1026    }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031    use super::*;
1032    use crate::oplog::{DeviceLog, Scope, Surface};
1033    use std::sync::atomic::{AtomicU64, Ordering};
1034    use std::sync::Arc;
1035
1036    fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
1037        let t = Arc::new(AtomicU64::new(0));
1038        let reader = t.clone();
1039        (t, Arc::new(move || reader.load(Ordering::SeqCst)))
1040    }
1041
1042    fn mem_relay() -> InMemoryRelay {
1043        InMemoryRelay::new(RelayConfig::default(), Arc::new(|| 0))
1044    }
1045
1046    fn ops_for(device: &str, n: usize) -> (DeviceLog, Vec<OpRecord>) {
1047        let mut log = DeviceLog::new(device);
1048        let ops = (0..n)
1049            .map(|i| {
1050                log.append(
1051                    Scope::Personal,
1052                    Surface::Knowledge,
1053                    serde_json::json!({"id": format!("{device}-f{i}")}),
1054                )
1055            })
1056            .collect();
1057        (log, ops)
1058    }
1059
1060    #[test]
1061    fn push_validates_the_chain_and_dedups_retransmission() {
1062        let mut relay = mem_relay();
1063        let (mut log, ops) = ops_for("a", 3);
1064
1065        let outcome = relay.push("a", &ops).unwrap();
1066        assert_eq!(
1067            outcome,
1068            PushOutcome {
1069                accepted: 3,
1070                deduped: 0
1071            }
1072        );
1073
1074        // Retransmission (crash lost the push cursor): pure dedup.
1075        let again = relay.push("a", &ops).unwrap();
1076        assert_eq!(
1077            again,
1078            PushOutcome {
1079                accepted: 0,
1080                deduped: 3
1081            }
1082        );
1083
1084        // Continuation accepted.
1085        let next = log.append(
1086            Scope::Personal,
1087            Surface::Knowledge,
1088            serde_json::json!({"id": "x"}),
1089        );
1090        assert_eq!(relay.push("a", &[next]).unwrap().accepted, 1);
1091
1092        // A gap (skipping a seq) is rejected.
1093        log.append(
1094            Scope::Personal,
1095            Surface::Knowledge,
1096            serde_json::json!({"id": "skipped"}),
1097        );
1098        let ahead = log.append(
1099            Scope::Personal,
1100            Surface::Knowledge,
1101            serde_json::json!({"id": "y"}),
1102        );
1103        assert!(matches!(
1104            relay.push("a", &[ahead]),
1105            Err(RelayError::Gap {
1106                expected: 4,
1107                found: 5,
1108                ..
1109            })
1110        ));
1111
1112        // A fork (different op at a held seq) is rejected.
1113        let mut forked = DeviceLog::new("a");
1114        let f0 = forked.append(
1115            Scope::Personal,
1116            Surface::Knowledge,
1117            serde_json::json!({"id": "evil"}),
1118        );
1119        assert!(matches!(
1120            relay.push("a", &[f0]),
1121            Err(RelayError::Fork { seq: 0, .. })
1122        ));
1123
1124        // Foreign ops are rejected.
1125        let (_, b_ops) = ops_for("b", 1);
1126        assert!(matches!(
1127            relay.push("a", &b_ops),
1128            Err(RelayError::ForeignOps { .. })
1129        ));
1130
1131        // A tampered op is rejected.
1132        let mut tampered = ops[0].clone();
1133        tampered.payload = serde_json::json!({"forged": true});
1134        assert!(matches!(
1135            relay.push("b", &[tampered]),
1136            Err(RelayError::ForeignOps { .. })
1137        ));
1138        let mut own_tampered = ops[0].clone();
1139        own_tampered.payload = serde_json::json!({"id": "a-f0", "forged": true});
1140        assert!(matches!(
1141            relay.push("a", &[own_tampered]),
1142            Err(RelayError::Chain { .. })
1143        ));
1144    }
1145
1146    #[test]
1147    fn pull_is_a_seq_cursor_and_serves_canonical_order() {
1148        let mut relay = mem_relay();
1149        let (_, a_ops) = ops_for("a", 3);
1150        let (_, b_ops) = ops_for("b", 2);
1151        relay.push("a", &a_ops).unwrap();
1152        relay.push("b", &b_ops).unwrap();
1153
1154        // Fresh puller: everything.
1155        let all = relay.pull("c", &Frontier::new()).unwrap();
1156        assert_eq!(all.ops.len(), 5);
1157        assert!(all.latest_checkpoint.is_none());
1158
1159        // Cursor past a's seq 1 and all of b: only a's tail comes back.
1160        let mut since = Frontier::new();
1161        since.insert("a".into(), 1);
1162        since.insert("b".into(), 1);
1163        let tail = relay.pull("c", &since).unwrap();
1164        assert_eq!(tail.ops.len(), 1);
1165        assert_eq!(tail.ops[0].seq, 2);
1166        assert_eq!(tail.ops[0].device_id, "a");
1167    }
1168
1169    #[test]
1170    fn stable_frontier_requires_every_active_device_acked() {
1171        let mut relay = mem_relay();
1172        let (_, a_ops) = ops_for("a", 2);
1173        relay.push("a", &a_ops).unwrap();
1174        relay.register("b").unwrap();
1175
1176        // b never acked → no frontier (nothing droppable) — the AckTable
1177        // refusal semantics, relay-side.
1178        relay.ack("a", a_ops[1].hlc.clone()).unwrap();
1179        assert_eq!(relay.stable_frontier().unwrap(), None);
1180
1181        relay.ack("b", a_ops[0].hlc.clone()).unwrap();
1182        assert_eq!(
1183            relay.stable_frontier().unwrap(),
1184            Some(a_ops[0].hlc.clone()),
1185            "min(acked)"
1186        );
1187
1188        // Monotone-only ack: a replayed lower ack cannot regress it.
1189        let outcome = relay.ack("b", a_ops[0].hlc.clone()).unwrap();
1190        assert!(!outcome.advanced);
1191        let outcome = relay.ack("b", a_ops[1].hlc.clone()).unwrap();
1192        assert!(outcome.advanced);
1193        assert_eq!(relay.stable_frontier().unwrap(), Some(a_ops[1].hlc.clone()));
1194    }
1195
1196    #[test]
1197    fn gc_requires_both_frontier_and_covering_checkpoint() {
1198        let mut relay = mem_relay();
1199        let (_, ops) = ops_for("a", 4);
1200        relay.push("a", &ops).unwrap();
1201        relay.ack("a", ops[3].hlc.clone()).unwrap();
1202
1203        // Everything is below the stable frontier, but NO covering
1204        // checkpoint exists → nothing drops.
1205        assert_eq!(relay.gc().unwrap().total(), 0);
1206
1207        // Checkpoint covering the first two ops → exactly those drop.
1208        let ckpt = Checkpoint::from_ops(&ops[..2]).unwrap();
1209        assert!(relay.checkpoint_put("a", &ckpt).unwrap());
1210        let report = relay.gc().unwrap();
1211        assert_eq!(report.dropped["a"], 2);
1212
1213        // Ops above the frontier never drop: push more, don't ack.
1214        let mut log = DeviceLog::resume("a", &ops).unwrap();
1215        let newer = log.append(
1216            Scope::Personal,
1217            Surface::Knowledge,
1218            serde_json::json!({"id": "n"}),
1219        );
1220        relay.push("a", std::slice::from_ref(&newer)).unwrap();
1221        let full_ckpt = {
1222            let mut all = ops.clone();
1223            all.push(newer);
1224            Checkpoint::from_ops(&all).unwrap()
1225        };
1226        relay.checkpoint_put("a", &full_ckpt).unwrap();
1227        // Covered by a checkpoint, but seq 4 is above the acked frontier →
1228        // only seqs 2..=3 drop.
1229        let report = relay.gc().unwrap();
1230        assert_eq!(report.dropped["a"], 2);
1231        let survivors = relay.pull("b", &Frontier::new());
1232        // A fresh pull now reaches into truncated space → cold bootstrap.
1233        assert!(matches!(
1234            survivors,
1235            Err(RelayError::FrontierTruncated {
1236                dropped_below: 4,
1237                ..
1238            })
1239        ));
1240        // But a caught-up cursor is served the retained tail.
1241        let mut since = Frontier::new();
1242        since.insert("a".into(), 3);
1243        assert_eq!(relay.pull("b", &since).unwrap().ops.len(), 1);
1244    }
1245
1246    #[test]
1247    fn gc_preserves_chain_continuity_for_later_pushes() {
1248        let mut relay = mem_relay();
1249        let (mut log, ops) = ops_for("a", 3);
1250        relay.push("a", &ops).unwrap();
1251        relay.ack("a", ops[2].hlc.clone()).unwrap();
1252        let ckpt = Checkpoint::from_ops(&ops).unwrap();
1253        relay.checkpoint_put("a", &ckpt).unwrap();
1254        assert_eq!(relay.gc().unwrap().dropped["a"], 3);
1255
1256        // The whole chain is GC'd; a continuation still push-verifies
1257        // against the remembered dropped head…
1258        let next = log.append(
1259            Scope::Personal,
1260            Surface::Knowledge,
1261            serde_json::json!({"id": "n"}),
1262        );
1263        assert_eq!(relay.push("a", &[next]).unwrap().accepted, 1);
1264        // …and a re-push of the GC'd prefix dedups instead of forking.
1265        assert_eq!(
1266            relay.push("a", &ops).unwrap(),
1267            PushOutcome {
1268                accepted: 0,
1269                deduped: 3
1270            }
1271        );
1272    }
1273
1274    #[test]
1275    fn forged_checkpoint_frontier_is_rejected_and_never_becomes_gc_coverage() {
1276        // Kernel-review DATA-LOSS repro (through the public API): a
1277        // self-consistent checkpoint built from a DIFFERENT chain that
1278        // merely CLAIMS device "a"'s name must not walk in via checkpoint_put
1279        // (verify() only checks internal hashes) and let GC drop a's REAL
1280        // ops on the coverage claim.
1281        let mut relay = mem_relay();
1282
1283        // a's REAL chain, pushed + acked.
1284        let mut a = DeviceLog::new("a");
1285        let real: Vec<OpRecord> = (0..3)
1286            .map(|i| {
1287                a.append(
1288                    Scope::Personal,
1289                    Surface::Knowledge,
1290                    serde_json::json!({"id": format!("real-{i}")}),
1291                )
1292            })
1293            .collect();
1294        relay.push("a", &real).unwrap();
1295        relay.ack("a", real[2].hlc.clone()).unwrap();
1296
1297        // A DIFFERENT chain claiming device_id "a" (another account's shared
1298        // relay dir, a restored-from-backup re-mint). Internally valid.
1299        let mut fake = DeviceLog::new("a");
1300        let other: Vec<OpRecord> = (0..3)
1301            .map(|i| {
1302                fake.append(
1303                    Scope::Personal,
1304                    Surface::Knowledge,
1305                    serde_json::json!({"id": format!("other-{i}")}),
1306                )
1307            })
1308            .collect();
1309        let forged = Checkpoint::from_ops(&other).unwrap();
1310
1311        // The relay REJECTS it — its frontier head does not match the
1312        // relay-held op at that seq.
1313        assert!(matches!(
1314            relay.checkpoint_put("a", &forged),
1315            Err(RelayError::CheckpointFrontierUnverified { device_id, .. }) if device_id == "a"
1316        ));
1317
1318        // Nothing stored → GC has no coverage → a's real ops are NOT dropped.
1319        // (All probing calls below use "a" as the caller so no extra
1320        // never-acked device is auto-registered — which would pin the stable
1321        // frontier to None and mask the final GC assertion.)
1322        assert_eq!(
1323            relay.gc().unwrap().total(),
1324            0,
1325            "no forged coverage, no data loss"
1326        );
1327        assert!(relay.checkpoint_get().unwrap().is_none());
1328        // a's real ops are all still served.
1329        let served = relay.pull("a", &Frontier::new()).unwrap();
1330        assert_eq!(served.ops.len(), 3);
1331        assert!(served
1332            .ops
1333            .iter()
1334            .all(|op| op.payload["id"].as_str().unwrap().starts_with("real-")));
1335
1336        // A checkpoint claiming coverage BEYOND the relay's chain head is
1337        // rejected too (not just a head mismatch).
1338        let mut a2 = DeviceLog::resume("a", &real).unwrap();
1339        let mut ahead = real.clone();
1340        ahead.push(a2.append(
1341            Scope::Personal,
1342            Surface::Knowledge,
1343            serde_json::json!({"id": "real-3"}),
1344        ));
1345        let claims_beyond = Checkpoint::from_ops(&ahead).unwrap(); // relay never got real-3
1346        assert!(matches!(
1347            relay.checkpoint_put("a", &claims_beyond),
1348            Err(RelayError::CheckpointFrontierUnverified { seq: 3, .. })
1349        ));
1350
1351        // A checkpoint claiming a device the relay has never seen is rejected
1352        // (the frontier device "c" is validated regardless of the caller).
1353        let mut c = DeviceLog::new("c");
1354        let c_ops: Vec<OpRecord> = (0..2)
1355            .map(|i| {
1356                c.append(
1357                    Scope::Personal,
1358                    Surface::Knowledge,
1359                    serde_json::json!({"id": format!("c{i}")}),
1360                )
1361            })
1362            .collect();
1363        let unknown_dev = Checkpoint::from_ops(&c_ops).unwrap();
1364        assert!(matches!(
1365            relay.checkpoint_put("a", &unknown_dev),
1366            Err(RelayError::CheckpointFrontierUnverified { device_id, .. }) if device_id == "c"
1367        ));
1368
1369        // The HONEST checkpoint over a's real chain is accepted and DOES
1370        // become coverage — the fix rejects forgeries, not legitimacy.
1371        let honest = Checkpoint::from_ops(&real[..2]).unwrap();
1372        assert!(relay.checkpoint_put("a", &honest).unwrap());
1373        assert_eq!(relay.gc().unwrap().dropped["a"], 2);
1374    }
1375
1376    #[test]
1377    fn checkpoint_dedup_keys_on_whole_record_address_not_state_hash() {
1378        // The B4 contract, enforced at the relay: two checkpoints folding
1379        // to the SAME state under DIFFERENT frontiers are BOTH stored.
1380        // (Knowledge is content-keyed so two devices' identical facts dedup to
1381        // one folded state — a conversation turn would not, being an event
1382        // stream keyed by op_id.)
1383        let mut a = DeviceLog::new("a");
1384        let mut b = DeviceLog::new("b");
1385        let fact = serde_json::json!({"id": "f1", "body": "hi"});
1386        let oa = a.append(Scope::Personal, Surface::Knowledge, fact.clone());
1387        let ob = b.append(Scope::Personal, Surface::Knowledge, fact);
1388        let just_a = Checkpoint::from_ops(std::slice::from_ref(&oa)).unwrap();
1389        let both = Checkpoint::from_ops(&[oa.clone(), ob.clone()]).unwrap();
1390        assert_eq!(
1391            just_a.state_hash, both.state_hash,
1392            "the cross-device dedup collision"
1393        );
1394
1395        let mut relay = mem_relay();
1396        // The relay must hold the real chains a checkpoint claims to cover —
1397        // checkpoint_put now cross-checks the frontier (data-loss fix).
1398        relay.push("a", std::slice::from_ref(&oa)).unwrap();
1399        relay.push("b", std::slice::from_ref(&ob)).unwrap();
1400        assert!(relay.checkpoint_put("a", &just_a).unwrap());
1401        assert!(
1402            relay.checkpoint_put("b", &both).unwrap(),
1403            "same state_hash is NOT a dedup"
1404        );
1405        assert!(
1406            !relay.checkpoint_put("a", &just_a).unwrap(),
1407            "same checkpoint_hash IS"
1408        );
1409
1410        // The latest pointer sits on the dominating frontier and a stale
1411        // re-put cannot regress it.
1412        assert_eq!(
1413            relay.checkpoint_get().unwrap().unwrap().checkpoint_hash,
1414            both.checkpoint_hash
1415        );
1416        relay.checkpoint_put("a", &just_a).unwrap();
1417        assert_eq!(
1418            relay.checkpoint_get().unwrap().unwrap().checkpoint_hash,
1419            both.checkpoint_hash,
1420            "dominance-monotone pointer"
1421        );
1422
1423        // A tampered checkpoint is refused.
1424        let mut forged = both.clone();
1425        forged.state.logs.clear();
1426        assert!(matches!(
1427            relay.checkpoint_put("b", &forged),
1428            Err(RelayError::Checkpoint(CheckpointError::HashMismatch { .. }))
1429        ));
1430    }
1431
1432    #[test]
1433    fn eviction_unpins_the_frontier_and_ack_reinstates() {
1434        let (t, wall) = manual_clock();
1435        let mut relay = InMemoryRelay::new(
1436            RelayConfig {
1437                eviction_horizon_ms: Some(1_000),
1438            },
1439            wall,
1440        );
1441        let (_, a_ops) = ops_for("a", 2);
1442        relay.push("a", &a_ops).unwrap();
1443
1444        t.store(100, Ordering::SeqCst);
1445        relay.ack("a", a_ops[1].hlc.clone()).unwrap();
1446        relay.ack("c", a_ops[0].hlc.clone()).unwrap(); // straggler acks early…
1447        assert_eq!(relay.stable_frontier().unwrap(), Some(a_ops[0].hlc.clone()));
1448
1449        // …then goes silent past H. Any other contact sweeps it out.
1450        t.store(2_000, Ordering::SeqCst);
1451        relay.register("a").unwrap();
1452        let roster: BTreeMap<String, RosterEntry> = relay
1453            .roster()
1454            .unwrap()
1455            .into_iter()
1456            .map(|e| (e.device_id.clone(), e))
1457            .collect();
1458        assert_eq!(roster["c"].status, DeviceStatus::Evicted);
1459        assert_eq!(roster["a"].status, DeviceStatus::Active);
1460        assert_eq!(
1461            relay.stable_frontier().unwrap(),
1462            Some(a_ops[1].hlc.clone()),
1463            "the evicted device's ack no longer holds the frontier"
1464        );
1465
1466        // A stale ack from the returning straggler does NOT reinstate (it
1467        // would drag GC eligibility back)…
1468        let outcome = relay.ack("c", a_ops[0].hlc.clone()).unwrap();
1469        assert!(!outcome.advanced && !outcome.reinstated);
1470        assert_eq!(relay.stable_frontier().unwrap(), Some(a_ops[1].hlc.clone()));
1471
1472        // …a caught-up ack does.
1473        let outcome = relay.ack("c", a_ops[1].hlc.clone()).unwrap();
1474        assert!(outcome.advanced && outcome.reinstated);
1475        let roster: BTreeMap<String, RosterEntry> = relay
1476            .roster()
1477            .unwrap()
1478            .into_iter()
1479            .map(|e| (e.device_id.clone(), e))
1480            .collect();
1481        assert_eq!(roster["c"].status, DeviceStatus::Active);
1482    }
1483
1484    #[test]
1485    fn fs_relay_matches_in_memory_semantics_and_persists() {
1486        let dir = tempfile::tempdir().unwrap();
1487        let (_, ops) = ops_for("a", 3);
1488        {
1489            let mut relay =
1490                FsRelay::open(dir.path(), RelayConfig::default(), Arc::new(|| 7)).unwrap();
1491            assert_eq!(relay.push("a", &ops).unwrap().accepted, 3);
1492            relay.ack("a", ops[2].hlc.clone()).unwrap();
1493            let ckpt = Checkpoint::from_ops(&ops[..2]).unwrap();
1494            assert!(relay.checkpoint_put("a", &ckpt).unwrap());
1495        }
1496        // A second handle (fresh process) sees the same durable state.
1497        let mut relay = FsRelay::open(dir.path(), RelayConfig::default(), Arc::new(|| 8)).unwrap();
1498        assert_eq!(relay.stable_frontier().unwrap(), Some(ops[2].hlc.clone()));
1499        // The pull auto-registers b — an active never-acked device drops
1500        // the stable frontier to None (nothing droppable) until b acks.
1501        assert_eq!(relay.pull("b", &Frontier::new()).unwrap().ops, ops);
1502        assert_eq!(relay.stable_frontier().unwrap(), None);
1503        relay.ack("b", ops[2].hlc.clone()).unwrap();
1504        assert_eq!(
1505            relay.push("a", &ops).unwrap(),
1506            PushOutcome {
1507                accepted: 0,
1508                deduped: 3
1509            }
1510        );
1511        let report = relay.gc().unwrap();
1512        assert_eq!(report.dropped["a"], 2);
1513        let roster = relay.roster().unwrap();
1514        assert_eq!(roster.len(), 2);
1515        assert_eq!(
1516            roster[0].added_at.wall_ms, 7,
1517            "roster added_at survives restart"
1518        );
1519
1520        // The checkpoint round-trips through its content-addressed file
1521        // (Checkpoint::load re-verified it).
1522        let ckpt = relay.checkpoint_get().unwrap().unwrap();
1523        assert_eq!(ckpt.frontier["a"].seq, 1);
1524
1525        // A tampered on-disk checkpoint file is refused by the next process
1526        // to open the relay (a fresh handle with a cold cache re-verifies —
1527        // the two-Mac shared-folder threat model). The current handle keeps
1528        // serving the version it already verified into its cache (it never
1529        // serves unverified content), which is why the check uses a fresh
1530        // handle.
1531        let path = dir
1532            .path()
1533            .join("checkpoints")
1534            .join(format!("{}.checkpoint.json", ckpt.checkpoint_hash));
1535        let raw = fs::read_to_string(&path).unwrap();
1536        fs::write(&path, raw.replace("\"a-f0\"", "\"a-f0-forged\"")).unwrap();
1537        let mut fresh = FsRelay::open(dir.path(), RelayConfig::default(), Arc::new(|| 9)).unwrap();
1538        assert!(matches!(
1539            fresh.checkpoint_get(),
1540            Err(RelayError::Checkpoint(_))
1541        ));
1542    }
1543}