car_sync/lib.rs
1//! Multi-device sync core for CAR — the oplog + deterministic fold (slice B1
2//! of `docs/proposals/multi-device-sync.md`).
3//!
4//! The proposal's frame: **sync events, not files.** Every state-changing
5//! operation is appended to a content-addressed, append-only, replica-tagged
6//! [`oplog::OpRecord`] log; sync is "send me the ops I don't have"; and each
7//! device [`fold::fold`]s the full op-set into materialized state
8//! **deterministically** — commutative, associative, and idempotent over the
9//! op-set (CRDT properties), so two laptops writing simultaneously converge
10//! the moment they exchange ops.
11//!
12//! What this slice ships (pure, library-only — no network, no daemon wiring):
13//!
14//! - [`oplog`] — [`oplog::OpRecord`] exactly as the proposal specs it
15//! (`op_id` content-derived, `hlc {wall_ms, counter, device_id}`,
16//! `scope: Personal | Shared{org}`, the eight-variant `surface` enum,
17//! surface-specific `payload`), plus the per-device `seq`/`prev` hash-chain
18//! linkage that makes a device's log **order-verifiable**
19//! ([`oplog::verify_log`]) and the [`oplog::DeviceLog`] writer that stamps
20//! [`oplog::Hlc`] values from the hybrid clock (see the B3 note below).
21//! - [`fold`] — [`fold::fold`]`(ops) -> `[`fold::SyncState`] under the
22//! proposal's per-surface fold rules: **grow-only union by stable ID** for
23//! the log tier, **LWW-register per record ordered by HLC** for the
24//! registry tier, and ordered-observation [`fold::SyncState::replay`] for
25//! the path-dependent routing tier ("sync the observations, not the
26//! result" — the EMA apply is injected, execution stays out of the crate).
27//! [`fold::state_hash`] is the divergence-detection invariant ("same
28//! frontier ⇒ same snapshot hash"), and
29//! [`fold::registry_as_lww`] projects a folded registry onto
30//! `car_state::crdt::LwwMap` so the fold provably agrees with the shipped
31//! `crdt_merge` primitives where the domains overlap.
32//! - [`journal`] — durable JSONL persistence for the log in the
33//! `car-eventlog` journal idiom: append-only, torn-line tolerant on load,
34//! plus B4's [`journal::OplogJournal::truncate_to`] (atomic
35//! temp+rename rewrite under the existing advisory lock, stamping a
36//! [`journal::TruncationMarker`] that fences the naive
37//! `load`+`resume` path into a runtime error — a truncated tail resumes
38//! only through [`checkpoint::resume_anchored`]).
39//! - [`checkpoint`] (B4) — [`checkpoint::Checkpoint`]: a serialized fold at
40//! a frontier — per-device `{seq, hlc, head}` frontier entries, covered
41//! scopes, the [`fold::SyncState`] snapshot, [`fold::state_hash`] as the
42//! divergence invariant, and a whole-record `checkpoint_hash` (frontier +
43//! scopes + state) as the content address / file name — so "same file ⇒
44//! same checkpoint" holds even when two frontiers fold to one deduped
45//! state, and a tampered frontier is rejected on load.
46//! [`checkpoint::verify_anchored`] proves a truncated tail continues the
47//! checkpoint's recorded chain heads (the checkpoint IS the anchored
48//! head); [`checkpoint::resume_anchored`] resumes a device chain past a
49//! truncation without forking. [`fold::fold_onto`] is the consumption
50//! primitive: `fold_onto(checkpoint.state, tail) == fold(full log)`.
51//! - [`compact`] (B4) — per-surface retention
52//! ([`compact::RetentionPolicy::proposal_default`]: conversations last-N,
53//! runs 50/agent + 30 days, trajectories last-D, knowledge/skills/routing
54//! keep-all — every dropped id-bearing entry leaves a minimal tombstone
55//! stub so `supersedes` references resolve even when they arrive after
56//! compaction, and event-stream trims are rejected), the monotone-only
57//! [`compact::AckTable`] fold-frontier bookkeeping (an ack asserts
58//! durably-folded state — MUST, binding on B3), and
59//! [`compact::compact_and_truncate`] enforcing the crash-ordering
60//! invariant **checkpoint durable FIRST, then truncate** — acknowledged
61//! data is never lost, and compaction refuses to drop anything above ANY
62//! device's acked frontier.
63//!
64//! Determinism discipline (the proposal's "free property" depends on it):
65//! all folded state lives in `BTreeMap`s — no `HashMap` iteration order, no
66//! wall-clock reads anywhere in the fold/retention path (the age rules'
67//! reference instant defaults to [`compact::as_of_from_ops`], pure over the
68//! below-frontier ops).
69//!
70//! B3 adds the missing middle — how ops actually travel:
71//!
72//! - [`oplog::HlcClock`] — the **real hybrid logical clock**: `{wall_ms,
73//! counter}` state with the standard send/receive rules (max of local
74//! wall and everything witnessed; counter ticks on ties), monotone under
75//! clock skew, regression, and same-millisecond bursts. It replaces B1's
76//! pure-Lamport stamp source behind the SAME wire shape, exactly as
77//! promised — [`DeviceLog::new`] still defaults to the degenerate
78//! logical (always-0 wall) mode, and wall readings are **injectable**
79//! ([`oplog::WallClock`]; [`oplog::system_clock`] is the one opt-in
80//! place system time exists in this crate).
81//! - [`relay`] — the [`relay::Relay`] trait (`push` / `pull(since seq
82//! frontier) → {ops, latest_checkpoint_ptr}` / `ack` /
83//! `checkpoint_put/get` / `roster`) with two reference implementations:
84//! [`relay::InMemoryRelay`] and the shared-directory
85//! [`relay::FsRelay`] loopback (the single-user two-Mac case). The relay
86//! admits only ops that *continue* a device's relay-held chain (fork =
87//! runtime error), computes the **stable frontier** = `min(acked)` over
88//! non-evicted roster devices, marks a device silent past the horizon
89//! `H` [`relay::DeviceStatus::Evicted`] (its ack no longer pins GC;
90//! reinstated on a caught-up ack), and GC-drops an op **only** when it
91//! is both at/below the stable frontier AND covered by a stored
92//! checkpoint — checkpoints dedup on `checkpoint_hash`, the whole-record
93//! content address, never `state_hash` (the B4 contract).
94//! - [`session`] — [`session::SyncSession`], the device-side pump holding
95//! the B1/B4 contracts **by construction**: append journals (flushed)
96//! before an op is pushable (journal-durable before transmit); pulls are
97//! verified before folding; folds are journaled before the ack, whose
98//! value is *derived from journal-held ops only* (acking merely-received
99//! state is impossible). Retry-safe at every crash point (`op_id` dedup
100//! both ways). Cold bootstrap / straggler re-entry is
101//! [`session::SyncSession::bootstrap`]/[`session::SyncSession::rebase`]:
102//! `checkpoint_get` + `pull(since = checkpoint frontier)` +
103//! [`checkpoint::resume_anchored`] — never `DeviceLog::resume` — with
104//! locally-held uncovered ops (a returning straggler's unpushed writes)
105//! carried across the rebase and pushed after.
106//!
107//! B5 adds **execution lease + fencing** — single-leader *execution* layered
108//! on top of the leaderless *replication* above:
109//!
110//! - [`lease`] — the [`lease::LeaseCoordinator`] trait: a **linearizable**
111//! compare-and-swap register per agent (exactly one holder at a time; a new
112//! acquire after TTL-expiry or release bumps the monotone `epoch` = the
113//! fencing token). It is deliberately **separate** from
114//! [`relay::Relay`] — an eventually-consistent relay structurally cannot
115//! host a lease (no consensus). [`lease::InMemoryLeaseCoordinator`] is the
116//! honest in-process reference (`Arc<Mutex>` CAS is genuinely linearizable
117//! in one process); a distributed backend is B6. The lease register holds
118//! only non-sensitive metadata, so it never breaches the E2E guarantee on
119//! the actual agent data (the proposal's data/control-plane split).
120//! - **Fencing as a fold property, over two views** — the leased
121//! [`oplog::Surface::Intent`] surface ([`fold::FoldTier::Leased`]) carries
122//! the `epoch`, and the fold yields (a) [`fold::SyncState::committed_run`],
123//! the **fence-independent, keep-all idempotency ORACLE** (survives epoch
124//! bumps AND compaction — the correct "did this run already execute?"
125//! lookup), and (b) [`fold::SyncState::intent`], the "who holds now" view
126//! where *pending* intents are per-agent fenced (a stale zombie's pending
127//! loses **deterministically, order-independently, without a wall-clock
128//! race** — fencing beats HLC) while committed/failed are terminal-immune.
129//! Idempotency keys on the B7 `car_proto::deterministic_run_id`. **This
130//! converges the ledger and provides the durable oracle; it is NOT
131//! exactly-once execution** — that is B6's dispatch fence (a linearizable
132//! "still epoch N?" plus the oracle read, before the external effect). See
133//! [`lease`] and [`session::SyncSession::record_intent`] (terminal-guarded).
134//!
135//! B2 adds **transcript resume** — the conversation surface as an ordered,
136//! role-threaded projection of the oplog:
137//!
138//! - [`conversation`] — [`fold::SyncState::transcript`] folds the
139//! [`oplog::Surface::Conversation`] entries for one `conversation_id` into a
140//! causally-ordered `Vec<`[`conversation::Turn`]`>` (the crate's canonical
141//! `(hlc, op_id)` order — two devices talking to the same agent concurrently
142//! interleave deterministically), and [`fold::SyncState::resume_messages`]
143//! returns the **repaired, provider-valid** [`car_inference_types::Message`]
144//! sequence car-inference's multi-turn path replays to continue the
145//! conversation — the verbatim conversation-resume API
146//! `docs/solutions/conversation-persistence-removed-in-0.25.md` says does not
147//! exist today. A conversation turn is an **event stream keyed by `op_id`**
148//! (op identity IS turn identity — the kernel-review correction: content
149//! keying silently dropped two genuine same-timestamp turns), so a resent op
150//! dedups but two distinct authorings never collapse; it differs from routing
151//! only in being an *independent* multiset (no path-dependent replay), so it
152//! tolerates `LastN` retention. Because HLC order is deterministic but says
153//! nothing about *concurrent* turns, `resume_messages` runs a repair (coalesce
154//! adjacent same-role turns, drop orphan/dangling tool exchanges) so the
155//! `Message` sequence is never provider-invalid — the "runtime validates"
156//! thesis applied to the projection. The 0.25 *compaction-vs-store
157//! incoherence* cannot recur: the oplog is the one source of truth and the
158//! transcript is a projection of the same folded state B4's checkpoint
159//! serializes. Built on the shared `car-inference-types` crate, so a
160//! `Message` shape change is a compile error here, not a runtime break in B6.
161//!
162//! Later slices: rerouting today's file write paths through the oplog and the
163//! daemon/memgine adoption of transcript resume (B6), the `sync.*` WS/FFI
164//! surface + E2E encryption + checkpoint/op signing + per-scope streams + the
165//! **distributed lease coordinator** (B6 — the network backend speaks the
166//! [`relay::Relay`] and [`lease::LeaseCoordinator`] contracts).
167
168pub mod checkpoint;
169pub mod compact;
170pub mod conversation;
171pub mod crypto;
172pub mod fence;
173pub mod fold;
174pub mod journal;
175pub mod lease;
176pub mod net_relay;
177pub mod oplog;
178pub mod partition;
179pub mod relay;
180pub mod session;
181
182pub use checkpoint::{
183 resume_anchored, verify_anchored, AnchorError, Checkpoint, CheckpointError, FrontierEntry,
184};
185pub use compact::{
186 apply_retention, as_of_from_ops, compact_and_truncate, is_tombstone, plan_compaction, AckTable,
187 CompactError, CompactionOutcome, CompactionPlan, RetentionPolicy, RetentionReport,
188 RetentionRule, RUNS_MAX_AGE_MS, RUNS_MAX_PER_AGENT,
189};
190pub use conversation::{Role, Turn, DEFAULT_CONVERSATION};
191pub use crypto::{
192 derive_key, encryption_audience, CryptoError, DerivedKeyProvider, Envelope, LocalKeyCipher,
193 PayloadCipher, SyncKeyProvider, ALG_CHACHA20POLY1305,
194};
195pub use fence::{check_dispatch, FenceDecision};
196pub use fold::{
197 fold, fold_onto, hlc_version, registry_as_lww, state_hash, FoldTier, FoldedRecord, IntentAgent,
198 SyncState,
199};
200pub use journal::{OplogJournal, TruncationMarker};
201pub use lease::{
202 InMemoryLeaseCoordinator, Intent, IntentStatus, Lease, LeaseCoordinator, LeaseError,
203};
204pub use net_relay::{
205 LeaseWire, LoopbackTransport, NetworkLeaseCoordinator, NetworkRelay, SyncTransport,
206 TransportError,
207};
208pub use oplog::{
209 canonical_json, logical_clock, system_clock, verify_log, ChainError, DeviceLog, Hlc, HlcClock,
210 OpRecord, Scope, Surface, WallClock,
211};
212pub use partition::{
213 is_portable, policy_for, portable_domains, SurfacePolicy, SyncClass, SURFACE_POLICIES,
214};
215pub use relay::{
216 checkpoint_frontier, frontier_of, AckOutcome, DeviceStatus, Frontier, FsRelay, GcReport,
217 InMemoryRelay, PullResult, PushOutcome, Relay, RelayConfig, RelayError, RosterEntry,
218};
219pub use session::{PumpReport, SessionError, SyncSession};
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use serde_json::json;
225
226 /// Build a two-device op-set exercising both fold tiers:
227 /// grow-only Knowledge facts + LWW Declagent registry records.
228 fn two_device_ops() -> Vec<OpRecord> {
229 let mut a = DeviceLog::new("device-a");
230 let mut b = DeviceLog::new("device-b");
231
232 let mut ops = vec![
233 a.append(
234 Scope::Personal,
235 Surface::Knowledge,
236 json!({"id": "fact-1", "body": "the sky is blue"}),
237 ),
238 a.append(
239 Scope::Personal,
240 Surface::Declagent,
241 json!({"id": "agent-1", "name": "milo", "rev": "a1"}),
242 ),
243 b.append(
244 Scope::Personal,
245 Surface::Knowledge,
246 json!({"id": "fact-2", "body": "water is wet"}),
247 ),
248 ];
249 // b observes a's ops (lamport receive rule) then overwrites agent-1.
250 for op in &ops {
251 b.observe(&op.hlc);
252 }
253 ops.push(b.append(
254 Scope::Personal,
255 Surface::Declagent,
256 json!({"id": "agent-1", "name": "milo", "rev": "b2"}),
257 ));
258 ops.push(a.append(
259 Scope::Personal,
260 Surface::Conversation,
261 json!({"speaker": "user", "text": "hi", "timestamp": 1}),
262 ));
263 ops
264 }
265
266 /// Heap's algorithm — every permutation of `items`, no rand dependency.
267 fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
268 fn heap<T: Clone>(k: usize, arr: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
269 if k == 1 {
270 out.push(arr.clone());
271 return;
272 }
273 for i in 0..k {
274 heap(k - 1, arr, out);
275 if k.is_multiple_of(2) {
276 arr.swap(i, k - 1);
277 } else {
278 arr.swap(0, k - 1);
279 }
280 }
281 }
282 let mut arr = items.to_vec();
283 let mut out = Vec::new();
284 heap(arr.len(), &mut arr, &mut out);
285 out
286 }
287
288 #[test]
289 fn fold_is_permutation_invariant() {
290 // The core CRDT law: same op-SET in any order → the same state and
291 // the same state hash. All 120 permutations of a 5-op set.
292 let ops = two_device_ops();
293 let baseline = fold(&ops);
294 let baseline_hash = state_hash(&baseline);
295 for perm in permutations(&ops) {
296 let folded = fold(&perm);
297 assert_eq!(folded, baseline, "fold must be order-independent");
298 assert_eq!(state_hash(&folded), baseline_hash);
299 }
300 }
301
302 #[test]
303 fn fold_is_idempotent_over_duplicated_ops() {
304 // Re-delivered ops (relay retransmission) dedup on op_id: folding the
305 // set twice-concatenated equals folding it once.
306 let ops = two_device_ops();
307 let mut doubled = ops.clone();
308 doubled.extend(ops.iter().cloned());
309 assert_eq!(fold(&doubled), fold(&ops));
310 // Re-folding the identical set is stable (idempotent re-fold).
311 assert_eq!(fold(&ops), fold(&ops));
312 }
313
314 #[test]
315 fn divergent_replica_union_matches_crdt_merge() {
316 // The overlap contract with the shipped car-state CRDT primitives:
317 // folding the UNION of two devices' ops must resolve a registry to
318 // exactly the state crdt_merge produces from the per-device exports.
319 let ops = two_device_ops();
320 let a_ops: Vec<OpRecord> = ops
321 .iter()
322 .filter(|o| o.device_id == "device-a")
323 .cloned()
324 .collect();
325 let b_ops: Vec<OpRecord> = ops
326 .iter()
327 .filter(|o| o.device_id == "device-b")
328 .cloned()
329 .collect();
330
331 let union_lww = registry_as_lww(&fold(&ops), &Surface::Declagent.tag());
332 let a_lww = registry_as_lww(&fold(&a_ops), &Surface::Declagent.tag());
333 let b_lww = registry_as_lww(&fold(&b_ops), &Surface::Declagent.tag());
334
335 let merged_ab = car_state::crdt::merge_maps(&a_lww, &b_lww);
336 let merged_ba = car_state::crdt::merge_maps(&b_lww, &a_lww);
337 assert_eq!(merged_ab, union_lww, "fold(union) == crdt_merge(exports)");
338 assert_eq!(merged_ba, union_lww, "in either merge order");
339
340 // And the winner is b's later write (b observed a first — higher HLC).
341 assert_eq!(union_lww["id:agent-1"].value["rev"], json!("b2"));
342 assert_eq!(union_lww["id:agent-1"].replica, "device-b");
343 }
344
345 #[test]
346 fn journal_round_trip_load_fold_verify() {
347 let dir = tempfile::tempdir().unwrap();
348 let path = dir.path().join("oplog.jsonl");
349 let ops = two_device_ops();
350 {
351 let mut journal = OplogJournal::open(&path).unwrap();
352 for op in &ops {
353 journal.append(op).unwrap();
354 }
355 }
356 let loaded = OplogJournal::load(&path).unwrap();
357 assert_eq!(loaded, ops);
358 verify_log(&loaded).expect("loaded log must chain-verify");
359 assert_eq!(state_hash(&fold(&loaded)), state_hash(&fold(&ops)));
360 }
361
362 /// Build a two-device op-set touching EVERY surface tier, with a valid
363 /// frontier cut at `split` (every op before it is HLC-≤ every device's
364 /// ack): grow-only entities, an LWW registry record overwritten across
365 /// the cut, and a routing observation multiset spanning the cut
366 /// (including a byte-identical repeat — the multiset trap).
367 fn all_surface_ops() -> (Vec<OpRecord>, usize) {
368 let mut a = DeviceLog::new("dev-a");
369 let mut b = DeviceLog::new("dev-b");
370 let mut ops = vec![
371 a.append(
372 Scope::Personal,
373 Surface::Knowledge,
374 json!({"id": "f1", "timestamp": 10}),
375 ),
376 a.append(Scope::Personal, Surface::Skill, json!({"id": "s1"})),
377 a.append(
378 Scope::Personal,
379 Surface::Conversation,
380 json!({"speaker": "u", "text": "hi", "timestamp": 11}),
381 ),
382 a.append(
383 Scope::Personal,
384 Surface::Run,
385 json!({"id": "r1", "agent_id": "milo", "timestamp": 12}),
386 ),
387 a.append(
388 Scope::Personal,
389 Surface::Trajectory,
390 json!({"id": "t1", "timestamp": 13}),
391 ),
392 a.append(
393 Scope::Personal,
394 Surface::Declagent,
395 json!({"id": "agent-1", "rev": "a"}),
396 ),
397 a.append(
398 Scope::Shared { org: "acme".into() },
399 Surface::Registry {
400 kind: "agents".into(),
401 },
402 json!({"id": "reg-1", "v": 1}),
403 ),
404 a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})),
405 a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})), // byte-identical repeat
406 ];
407 for op in &ops {
408 b.observe(&op.hlc);
409 }
410 ops.push(b.append(
411 Scope::Personal,
412 Surface::Knowledge,
413 json!({"id": "f2", "timestamp": 20}),
414 ));
415 let split = ops.len();
416 // Tail: every tier mutates again, above the frontier.
417 ops.push(b.append(
418 Scope::Personal,
419 Surface::Conversation,
420 json!({"speaker": "a", "text": "yo", "timestamp": 21}),
421 ));
422 ops.push(b.append(
423 Scope::Personal,
424 Surface::Declagent,
425 json!({"id": "agent-1", "rev": "b"}),
426 )); // LWW across the cut
427 ops.push(b.append(Scope::Personal, Surface::Routing, json!({"sample": 0.0})));
428 ops.push(b.append(
429 Scope::Personal,
430 Surface::Run,
431 json!({"id": "r2", "agent_id": "milo", "timestamp": 22}),
432 ));
433 for op in &ops[split..] {
434 a.observe(&op.hlc);
435 }
436 ops.push(a.append(
437 Scope::Personal,
438 Surface::Knowledge,
439 json!({"id": "f1", "timestamp": 99}),
440 )); // grow-only collision across the cut
441 (ops, split)
442 }
443
444 fn acks_at(ops: &[OpRecord], split: usize) -> AckTable {
445 // Every device acks the max HLC of the prefix ("I have folded
446 // everything at or below this stamp"), so the stable frontier is
447 // exactly the cut.
448 let frontier = ops[..split].iter().map(|o| o.hlc.clone()).max().unwrap();
449 let mut acks = AckTable::new();
450 for op in ops {
451 acks.ack(op.device_id.clone(), frontier.clone());
452 }
453 acks
454 }
455
456 #[test]
457 fn compaction_equivalence_fold_full_equals_checkpoint_plus_tail() {
458 // THE invariant that makes compaction safe, per surface:
459 // fold(full log) == fold_onto(checkpoint.state, retained tail),
460 // byte-identical state AND state_hash — including the routing
461 // observation MULTISET and its order-sensitive replay.
462 let (ops, split) = all_surface_ops();
463 let acks = acks_at(&ops, split);
464 let plan = plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None).unwrap();
465 assert_eq!(plan.dropped_ops, split);
466 assert_eq!(plan.retained_ops.len(), ops.len() - split);
467
468 let full = fold(&ops);
469 let reconstructed = fold_onto(&plan.checkpoint.state, &plan.retained_ops);
470 assert_eq!(reconstructed, full);
471 assert_eq!(state_hash(&reconstructed), state_hash(&full));
472
473 // Per-surface spot checks across the cut:
474 assert_eq!(
475 reconstructed.registries[&Surface::Declagent.tag()]["id:agent-1"].payload["rev"],
476 json!("b"),
477 "LWW: the tail's later write wins over the checkpointed one"
478 );
479 assert_eq!(
480 reconstructed.logs[&Surface::Knowledge.tag()]["id:f1"].payload["timestamp"],
481 json!(10),
482 "grow-only: the checkpointed earliest writer keeps the slot"
483 );
484 assert_eq!(
485 reconstructed.log_entries(&Surface::Routing.tag()).len(),
486 3,
487 "multiset: 2 checkpointed observations (incl. the repeat) + 1 tail"
488 );
489 let ema =
490 |s: f64, rec: &FoldedRecord| 0.7 * s + 0.3 * rec.payload["sample"].as_f64().unwrap();
491 assert_eq!(
492 reconstructed.replay(&Surface::Routing.tag(), 0.5_f64, ema),
493 full.replay(&Surface::Routing.tag(), 0.5_f64, ema),
494 "order-sensitive replay agrees across the compaction"
495 );
496
497 // And the composition is verifiable: the checkpoint anchors the tail.
498 verify_anchored(&plan.checkpoint, &plan.retained_ops).unwrap();
499 }
500
501 #[test]
502 fn retention_coherence_local_compaction_equals_global() {
503 // The proposal's "local compaction is just an eager application of
504 // the same retention the checkpoint applies globally — the two can
505 // never disagree": retention(fold_onto(retained ckpt, tail)) ==
506 // retention(fold(full)).
507 let (ops, split) = all_surface_ops();
508 let acks = acks_at(&ops, split);
509 let policy = RetentionPolicy::proposal_default(1, u64::MAX);
510 let as_of = 1_000u64;
511 let plan = plan_compaction(&ops, &acks, &policy, Some(as_of)).unwrap();
512 assert_eq!(
513 plan.as_of_ms, as_of,
514 "explicit as_of wins over the derived default"
515 );
516
517 let (global, _) = apply_retention(&fold(&ops), &policy, as_of).unwrap();
518 let (local, _) = apply_retention(
519 &fold_onto(&plan.checkpoint.state, &plan.retained_ops),
520 &policy,
521 as_of,
522 )
523 .unwrap();
524 assert_eq!(local, global);
525 assert_eq!(state_hash(&local), state_hash(&global));
526 // The retained checkpoint really did trim: only the newest turn
527 // survives conversations' last-1 rule (turns are content-hash-keyed
528 // — no id — so the trimmed one drops without a stub).
529 assert_eq!(
530 plan.checkpoint.state.logs[&Surface::Conversation.tag()].len(),
531 1
532 );
533 }
534
535 #[test]
536 fn retention_coherence_survives_cross_frontier_supersedes() {
537 // Kernel-review repro: LastN{1} knowledge drops f1 at compaction
538 // time; a LATER tail op f3 supersedes f1. Under
539 // preserve-only-what's-referenced-now, the global fold retained f1
540 // (it sees f3's reference) while the compacted device could not —
541 // divergence under identical policy + as_of. Universal tombstone
542 // stubs close the time hole: both sides hold the same f1 stub.
543 let mut a = DeviceLog::new("a");
544 let mut b = DeviceLog::new("b");
545 let mut ops = vec![
546 a.append(
547 Scope::Personal,
548 Surface::Knowledge,
549 json!({"id": "f1", "timestamp": 1}),
550 ),
551 a.append(
552 Scope::Personal,
553 Surface::Knowledge,
554 json!({"id": "f2", "timestamp": 2}),
555 ),
556 ];
557 let split = ops.len();
558 for op in &ops {
559 b.observe(&op.hlc);
560 }
561 ops.push(b.append(
562 Scope::Personal,
563 Surface::Knowledge,
564 json!({"id": "f3", "timestamp": 3, "supersedes": "f1"}),
565 ));
566
567 let acks = acks_at(&ops, split);
568 let mut policy = RetentionPolicy::keep_all();
569 policy
570 .rules
571 .insert("knowledge".to_string(), RetentionRule::LastN { n: 1 });
572 let plan = plan_compaction(&ops, &acks, &policy, Some(10)).unwrap();
573 assert_eq!(plan.dropped_ops, split);
574 let tag = Surface::Knowledge.tag();
575 // The checkpoint stubbed f1 BEFORE anything referenced it…
576 assert!(is_tombstone(&plan.checkpoint.state.logs[&tag]["id:f1"]));
577
578 // …and the coherence equivalence holds ACROSS the late reference.
579 let (global, _) = apply_retention(&fold(&ops), &policy, 10).unwrap();
580 let (local, _) = apply_retention(
581 &fold_onto(&plan.checkpoint.state, &plan.retained_ops),
582 &policy,
583 10,
584 )
585 .unwrap();
586 assert_eq!(
587 local, global,
588 "no divergence despite the cross-frontier supersedes"
589 );
590 assert_eq!(state_hash(&local), state_hash(&global));
591 // f3 is live, its supersedes target resolves against the f1 stub on
592 // BOTH sides — a tombstone, not a hole.
593 assert_eq!(
594 global.logs[&tag]["id:f3"].payload["supersedes"],
595 json!("f1")
596 );
597 assert!(is_tombstone(&global.logs[&tag]["id:f1"]));
598 assert!(is_tombstone(&local.logs[&tag]["id:f1"]));
599 }
600
601 #[test]
602 fn crash_ordering_checkpoint_durable_first_then_truncate() {
603 // Simulate a crash between the two durable steps and prove no
604 // acknowledged data can be lost at any point.
605 let dir = tempfile::tempdir().unwrap();
606 let journal_path = dir.path().join("oplog.jsonl");
607 let ckpt_dir = dir.path().join("checkpoints");
608 let (ops, split) = all_surface_ops();
609 let acks = acks_at(&ops, split);
610 let policy = RetentionPolicy::keep_all();
611
612 {
613 let mut journal = OplogJournal::open(&journal_path).unwrap();
614 for op in &ops {
615 journal.append(op).unwrap();
616 }
617
618 // Step 1+2: plan and persist the checkpoint… then "crash"
619 // before truncation (we simply don't truncate).
620 let plan = plan_compaction(
621 &OplogJournal::load(&journal_path).unwrap(),
622 &acks,
623 &policy,
624 None,
625 )
626 .unwrap();
627 let ckpt_path = plan.checkpoint.save(&ckpt_dir).unwrap();
628
629 // Post-"crash" state: the journal is UNTOUCHED (full data,
630 // no truncation marker — the normal load path still works),
631 // and the checkpoint is valid but redundant. Nothing lost.
632 let survived = OplogJournal::load(&journal_path).unwrap();
633 assert_eq!(survived, ops, "journal intact after crash-before-truncate");
634 let ckpt = Checkpoint::load(&ckpt_path).unwrap();
635 assert_eq!(fold_onto(&ckpt.state, &plan.retained_ops), fold(&ops));
636 } // journal lock released — "process died"
637
638 // "Restart": rerun the whole compaction. Idempotent — the same
639 // frontier recomputes the same content-addressed checkpoint file —
640 // and now the truncation completes.
641 let mut journal = OplogJournal::open(&journal_path).unwrap();
642 let outcome = compact_and_truncate(&mut journal, &ckpt_dir, &acks, &policy, None).unwrap();
643 assert_eq!(outcome.plan.dropped_ops, split);
644
645 // The truncated journal is marked: the naive load path is a runtime
646 // error, and the marker names the covering checkpoint.
647 assert!(
648 OplogJournal::load(&journal_path).is_err(),
649 "naive load is fenced"
650 );
651 let (marker, tail) = OplogJournal::load_with_marker(&journal_path).unwrap();
652 assert_eq!(
653 marker.unwrap().checkpoint_hash,
654 outcome.plan.checkpoint.checkpoint_hash,
655 "marker names the covering checkpoint"
656 );
657 assert_eq!(tail, ops[split..].to_vec());
658 verify_log(&tail).expect("truncated journal verifies on its own (anchored non-zero start)");
659 let ckpt = Checkpoint::load(&outcome.checkpoint_path.unwrap()).unwrap();
660 verify_anchored(&ckpt, &tail).unwrap();
661 assert_eq!(
662 fold_onto(&ckpt.state, &tail),
663 fold(&ops),
664 "nothing acknowledged was lost"
665 );
666
667 // Exactly one checkpoint file exists (the rerun deduped on content).
668 let count = std::fs::read_dir(&ckpt_dir).unwrap().count();
669 assert_eq!(count, 1);
670 }
671
672 #[test]
673 fn truncated_journal_resumes_and_keeps_verifying_end_to_end() {
674 let dir = tempfile::tempdir().unwrap();
675 let journal_path = dir.path().join("oplog.jsonl");
676 let ckpt_dir = dir.path().join("checkpoints");
677 let (ops, split) = all_surface_ops();
678 let acks = acks_at(&ops, split);
679
680 let mut journal = OplogJournal::open(&journal_path).unwrap();
681 for op in &ops {
682 journal.append(op).unwrap();
683 }
684 let outcome = compact_and_truncate(
685 &mut journal,
686 &ckpt_dir,
687 &acks,
688 &RetentionPolicy::keep_all(),
689 None,
690 )
691 .unwrap();
692 let ckpt = Checkpoint::load(&outcome.checkpoint_path.unwrap()).unwrap();
693
694 // Life goes on after truncation: resume the device chain from
695 // checkpoint + tail (never from seq 0 — DeviceLog::resume is fenced
696 // and refuses the truncated tail at runtime), append, journal,
697 // reload.
698 let (_, tail) = OplogJournal::load_with_marker(&journal_path).unwrap();
699 assert!(matches!(
700 DeviceLog::resume("dev-a", &tail),
701 Err(ChainError::TruncatedChain { .. })
702 ));
703 let mut dev_a = resume_anchored("dev-a", &ckpt, &tail).unwrap();
704 let next = dev_a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-new"}));
705 journal.append(&next).unwrap();
706
707 let (marker, reloaded) = OplogJournal::load_with_marker(&journal_path).unwrap();
708 assert!(marker.is_some(), "marker survives post-truncation appends");
709 verify_anchored(&ckpt, &reloaded).expect("checkpoint anchors the growing truncated log");
710 let full_plus = {
711 let mut v = ops.clone();
712 v.push(next);
713 v
714 };
715 assert_eq!(fold_onto(&ckpt.state, &reloaded), fold(&full_plus));
716 }
717
718 // ------------------------------------------------------------------
719 // B3 integration: relay transport + roster + eviction + re-entry.
720 // ------------------------------------------------------------------
721
722 use crate::session::SyncSession;
723 use std::sync::atomic::{AtomicU64, Ordering};
724 use std::sync::Arc;
725
726 fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
727 let t = Arc::new(AtomicU64::new(0));
728 let reader = t.clone();
729 (t, Arc::new(move || reader.load(Ordering::SeqCst)))
730 }
731
732 fn session(device: &str, root: &std::path::Path, wall: WallClock) -> SyncSession {
733 SyncSession::open(
734 device,
735 &root.join(device).join("oplog.jsonl"),
736 &root.join(device).join("checkpoints"),
737 wall,
738 )
739 .unwrap()
740 }
741
742 #[test]
743 fn two_macs_converge_through_the_filesystem_loopback_relay() {
744 // The realistic single-user case: two DeviceLogs syncing through a
745 // shared directory, real HLC wall clocks with skew between them.
746 let tmp = tempfile::tempdir().unwrap();
747 let relay_dir = tmp.path().join("shared-relay");
748 let (ta, wall_a) = manual_clock();
749 let (tb, wall_b) = manual_clock();
750 let (tr, wall_r) = manual_clock();
751 ta.store(1_000, Ordering::SeqCst);
752 tb.store(940, Ordering::SeqCst); // 60ms of skew
753 tr.store(970, Ordering::SeqCst);
754
755 // Each Mac holds its own FsRelay handle on the shared dir — state
756 // travels through the files, never through shared memory.
757 let mut relay_a =
758 FsRelay::open(&relay_dir, RelayConfig::default(), wall_r.clone()).unwrap();
759 let mut relay_b = FsRelay::open(&relay_dir, RelayConfig::default(), wall_r).unwrap();
760 let mut a = session("mac-a", tmp.path(), wall_a);
761 let mut b = session("mac-b", tmp.path(), wall_b);
762
763 a.append(
764 Scope::Personal,
765 Surface::Knowledge,
766 json!({"id": "f1", "v": 1}),
767 )
768 .unwrap();
769 a.append(
770 Scope::Personal,
771 Surface::Declagent,
772 json!({"id": "milo", "owner": "a"}),
773 )
774 .unwrap();
775 b.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0}))
776 .unwrap();
777 b.append(
778 Scope::Personal,
779 Surface::Conversation,
780 json!({"speaker": "u", "text": "hi", "timestamp": 5}),
781 )
782 .unwrap();
783
784 a.pump(&mut relay_a).unwrap();
785 b.pump(&mut relay_b).unwrap();
786 // b writes after folding a's record; despite b's slower wall clock
787 // the HLC orders it causally after (receive rule).
788 b.append(
789 Scope::Personal,
790 Surface::Declagent,
791 json!({"id": "milo", "owner": "b"}),
792 )
793 .unwrap();
794 b.pump(&mut relay_b).unwrap();
795 a.pump(&mut relay_a).unwrap();
796
797 assert_eq!(a.state_hash(), b.state_hash());
798 assert_eq!(
799 a.state().registries[&Surface::Declagent.tag()]["id:milo"].payload["owner"],
800 json!("b"),
801 "causality beats wall-clock skew"
802 );
803 // The journals themselves verify end-to-end.
804 verify_log(a.ops()).unwrap();
805 verify_log(b.ops()).unwrap();
806 }
807
808 #[test]
809 fn straggler_eviction_and_lossless_cold_reentry() {
810 // The full stragglers arc from the proposal: three devices, c goes
811 // dark holding UNPUSHED local writes → evicted at the horizon →
812 // frontier unpinned → checkpoint + relay GC → c returns, its pull
813 // is FrontierTruncated → cold re-entry (checkpoint_get + pull since
814 // the checkpoint frontier + resume_anchored) carrying its unpushed
815 // ops → it pushes them (chain-valid) → everyone converges.
816 let tmp = tempfile::tempdir().unwrap();
817 let (t, wall) = manual_clock();
818 let mut relay = InMemoryRelay::new(
819 RelayConfig {
820 eviction_horizon_ms: Some(1_000),
821 },
822 wall.clone(),
823 );
824 let mut a = session("dev-a", tmp.path(), wall.clone());
825 let mut b = session("dev-b", tmp.path(), wall.clone());
826 let mut c = session("dev-c", tmp.path(), wall.clone());
827
828 // t=100: everyone writes, pumps, acks.
829 t.store(100, Ordering::SeqCst);
830 a.append(
831 Scope::Personal,
832 Surface::Knowledge,
833 json!({"id": "a1", "timestamp": 100}),
834 )
835 .unwrap();
836 c.append(
837 Scope::Personal,
838 Surface::Knowledge,
839 json!({"id": "c1", "timestamp": 100}),
840 )
841 .unwrap();
842 a.pump(&mut relay).unwrap();
843 c.pump(&mut relay).unwrap();
844 b.pump(&mut relay).unwrap();
845 a.pump(&mut relay).unwrap();
846 c.pump(&mut relay).unwrap();
847 assert_eq!(a.state_hash(), c.state_hash());
848
849 // t=500: c writes LOCALLY (journal-durable, never pushed) and goes
850 // dark.
851 t.store(500, Ordering::SeqCst);
852 let unpushed = c
853 .append(
854 Scope::Personal,
855 Surface::Knowledge,
856 json!({"id": "c-dark", "timestamp": 500}),
857 )
858 .unwrap();
859
860 // t=800..2000: a and b keep working; c stays silent.
861 t.store(800, Ordering::SeqCst);
862 a.append(
863 Scope::Personal,
864 Surface::Knowledge,
865 json!({"id": "a2", "timestamp": 800}),
866 )
867 .unwrap();
868 a.pump(&mut relay).unwrap();
869 b.pump(&mut relay).unwrap();
870 a.pump(&mut relay).unwrap();
871
872 // c's stale ack pins the frontier while it is still active.
873 let pinned = relay.stable_frontier().unwrap().unwrap();
874
875 // t=2000: past the horizon (last seen 500) — the sweep on any
876 // contact evicts c and the frontier advances past its stale ack.
877 t.store(2_000, Ordering::SeqCst);
878 a.pump(&mut relay).unwrap();
879 b.pump(&mut relay).unwrap();
880 let roster: std::collections::BTreeMap<String, RosterEntry> = relay
881 .roster()
882 .unwrap()
883 .into_iter()
884 .map(|e| (e.device_id.clone(), e))
885 .collect();
886 assert_eq!(roster["dev-c"].status, DeviceStatus::Evicted);
887 let unpinned = relay.stable_frontier().unwrap().unwrap();
888 assert!(
889 unpinned > pinned,
890 "the evicted device's ack no longer holds the frontier"
891 );
892
893 // a checkpoints at the stable frontier and the relay GCs. Before a
894 // covering checkpoint exists, NOTHING drops even below the frontier.
895 assert_eq!(
896 relay.gc().unwrap().total(),
897 0,
898 "no covering checkpoint → no GC"
899 );
900 let ckpt = a.publish_checkpoint(&mut relay).unwrap().unwrap();
901 let report = relay.gc().unwrap();
902 assert!(report.total() > 0, "covered + below-frontier ops now drop");
903 // Ops above the stable frontier never drop, covered or not.
904 let mut since = Frontier::new();
905 for (device, entry) in &ckpt.frontier {
906 since.insert(device.clone(), entry.seq);
907 }
908 for op in relay.pull("dev-a", &since).unwrap().ops {
909 assert!(
910 op.hlc > unpinned
911 || ckpt
912 .frontier
913 .get(&op.device_id)
914 .is_none_or(|e| op.seq > e.seq)
915 );
916 }
917
918 // t=3000: c returns. Its normal pump hits truncated space → the
919 // cold bootstrap signal. NOTE the pump pushes BEFORE it pulls, so
920 // even this failed round already delivered c's journal-durable
921 // unpushed op to the relay (chain-valid against c's GC'd chain
922 // anchor) — contract 1 makes that safe at any time.
923 t.store(3_000, Ordering::SeqCst);
924 let err = c.pump(&mut relay).unwrap_err();
925 assert!(
926 matches!(
927 err,
928 SessionError::Relay(RelayError::FrontierTruncated { .. })
929 ),
930 "got {err:?}"
931 );
932 assert!(
933 relay
934 .pull("dev-a", &{
935 let mut f = since.clone();
936 f.insert("dev-c".to_string(), 0);
937 f
938 })
939 .unwrap()
940 .ops
941 .iter()
942 .any(|op| op.op_id == unpushed.op_id),
943 "the failed pump's push half already landed the unpushed op"
944 );
945
946 // Cold re-entry: rebase onto the checkpoint. The unpushed local op
947 // SURVIVES the rebase (uncovered by the checkpoint frontier)…
948 assert!(c.rebase(&mut relay).unwrap());
949 assert_eq!(c.base().unwrap().checkpoint_hash, ckpt.checkpoint_hash);
950 assert!(
951 c.ops().iter().any(|op| op.op_id == unpushed.op_id),
952 "the straggler's unpushed write survives cold re-entry"
953 );
954 // …and the naive resume path is fenced on c's rebased journal.
955 let c_journal = tmp.path().join("dev-c").join("oplog.jsonl");
956 assert!(
957 OplogJournal::load(&c_journal).is_err(),
958 "truncation marker fences load()"
959 );
960
961 // c pumps: its unpushed op is re-offered (the push cursor reset on
962 // rebase) and dedups against the failed round's delivery — pushed
963 // exactly once overall — then c acks at the new frontier →
964 // reinstated.
965 let report = c.pump(&mut relay).unwrap();
966 assert_eq!(
967 (report.pushed, report.push_deduped),
968 (0, 1),
969 "the unpushed op reached the relay exactly once"
970 );
971 let roster: std::collections::BTreeMap<String, RosterEntry> = relay
972 .roster()
973 .unwrap()
974 .into_iter()
975 .map(|e| (e.device_id.clone(), e))
976 .collect();
977 assert_eq!(
978 roster["dev-c"].status,
979 DeviceStatus::Active,
980 "caught-up ack reinstates"
981 );
982
983 // c's late op has an OLD hlc (below the stable frontier) but is not
984 // GC-eligible: no checkpoint covers its seq yet.
985 assert!(unpushed.hlc < relay.stable_frontier().unwrap().unwrap());
986 assert_eq!(
987 relay.gc().unwrap().total(),
988 0,
989 "late op is safe until a checkpoint covers it"
990 );
991
992 // Everyone pulls c's late write and converges — lossless re-entry.
993 a.pump(&mut relay).unwrap();
994 b.pump(&mut relay).unwrap();
995 assert_eq!(a.state_hash(), b.state_hash());
996 assert_eq!(a.state_hash(), c.state_hash());
997 assert!(a.state().logs[&Surface::Knowledge.tag()].contains_key("id:c-dark"));
998
999 // Chain validity end to end: every journal still proves itself.
1000 verify_log(a.ops()).unwrap();
1001 verify_log(b.ops()).unwrap();
1002 verify_anchored(c.base().unwrap(), c.ops()).unwrap();
1003 }
1004
1005 #[test]
1006 fn replay_over_permuted_opsets_is_deterministic() {
1007 // The routing rule ("sync the observations, not the result"): an
1008 // order-sensitive injected fold (EMA-like) over the hlc-ordered
1009 // observation stream yields the same value from any delivery order.
1010 let mut a = DeviceLog::new("dev-a");
1011 let mut b = DeviceLog::new("dev-b");
1012 let mut ops = vec![
1013 a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})),
1014 a.append(Scope::Personal, Surface::Routing, json!({"sample": 0.0})),
1015 ];
1016 for op in &ops {
1017 b.observe(&op.hlc);
1018 }
1019 ops.push(b.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})));
1020
1021 let ema = |state: f64, rec: &FoldedRecord| {
1022 0.7 * state + 0.3 * rec.payload["sample"].as_f64().unwrap()
1023 };
1024 let folded = fold(&ops);
1025 // Multiset guard: the third observation is byte-identical to the
1026 // first and must still be a distinct event (this test previously
1027 // passed while silently losing it).
1028 assert_eq!(folded.log_entries(&Surface::Routing.tag()).len(), 3);
1029 let baseline = folded.replay(&Surface::Routing.tag(), 0.5_f64, ema);
1030 // 0.5 →(1.0) 0.65 →(0.0) 0.455 →(1.0) 0.6185
1031 assert!((baseline - 0.6185).abs() < 1e-12, "got {baseline}");
1032 for perm in permutations(&ops) {
1033 assert_eq!(
1034 fold(&perm).replay(&Surface::Routing.tag(), 0.5_f64, ema),
1035 baseline
1036 );
1037 }
1038 }
1039}