Skip to main content

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 org_key_directory;
179pub mod org_key_provider;
180pub mod org_key_resolver;
181pub mod org_rotation;
182pub mod partition;
183pub mod relay;
184pub mod session;
185
186pub use checkpoint::{
187    resume_anchored, verify_anchored, AnchorError, Checkpoint, CheckpointError, FrontierEntry,
188};
189pub use compact::{
190    apply_retention, as_of_from_ops, compact_and_truncate, is_tombstone, plan_compaction, AckTable,
191    CompactError, CompactionOutcome, CompactionPlan, RetentionPolicy, RetentionReport,
192    RetentionRule, RUNS_MAX_AGE_MS, RUNS_MAX_PER_AGENT,
193};
194pub use conversation::{Role, Turn, DEFAULT_CONVERSATION};
195pub use crypto::{
196    derive_ed25519_identity, derive_key, derive_x25519_identity, ed25519_verifying,
197    encryption_audience, generate_org_key, parse_ed25519_verifying, parse_x25519_pub,
198    require_canonical_org, unwrap_org_key, wrap_org_key, x25519_public, CryptoError,
199    DerivedKeyProvider, Envelope, KdfProfile, LocalKeyCipher, PayloadCipher, StretchedMaster,
200    SyncKeyProvider, WrappedOrgKey, ALG_CHACHA20POLY1305, ALG_ORG_KEY_WRAP,
201};
202// Re-exported because the org-key wrap/unwrap public API takes/returns these
203// dalek key types (so downstream doesn't version-match an ed25519-dalek dep).
204pub use ed25519_dalek::{SigningKey as OrgSigningKey, VerifyingKey as OrgVerifyingKey};
205pub use fence::{check_dispatch, FenceDecision};
206pub use fold::{
207    fold, fold_onto, hlc_version, registry_as_lww, state_hash, FoldTier, FoldedRecord, IntentAgent,
208    SyncState,
209};
210pub use journal::{OplogJournal, TruncationMarker};
211pub use lease::{
212    InMemoryLeaseCoordinator, Intent, IntentStatus, Lease, LeaseCoordinator, LeaseError,
213};
214pub use net_relay::{
215    LeaseWire, LoopbackTransport, NetworkLeaseCoordinator, NetworkOrgKeyDirectory, NetworkRelay,
216    OrgKeyTransport, SyncTransport, TransportError,
217};
218pub use oplog::{
219    canonical_json, logical_clock, system_clock, verify_log, ChainError, DeviceLog, Hlc, HlcClock,
220    OpRecord, Scope, Surface, WallClock,
221};
222pub use org_key_directory::{
223    FsOrgKeyDirectory, InMemoryOrgKeyDirectory, MemberPublicKey, OrgKeyDirectory,
224    OrgKeyDirectoryError, OrgKeyDirectoryState,
225};
226pub use org_key_provider::OrgAwareKeyProvider;
227pub use org_key_resolver::{resolve_org_root, ResolvedOrgRoot};
228pub use org_rotation::{
229    is_authoritative, provision_org_members, resolve_all_org_roots, rotate_org_key,
230    sign_rotation_floor, verify_rotation_floor, ProvisionReport, RotationError, RotationFloor,
231};
232pub use partition::{
233    is_portable, policy_for, portable_domains, SurfacePolicy, SyncClass, SURFACE_POLICIES,
234};
235pub use relay::{
236    checkpoint_frontier, frontier_of, AckOutcome, DeviceStatus, Frontier, FsRelay, GcReport,
237    InMemoryRelay, PullResult, PushOutcome, Relay, RelayConfig, RelayError, RosterEntry,
238};
239pub use session::{PumpReport, SessionError, SyncSession};
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use serde_json::json;
245
246    /// Build a two-device op-set exercising both fold tiers:
247    /// grow-only Knowledge facts + LWW Declagent registry records.
248    fn two_device_ops() -> Vec<OpRecord> {
249        let mut a = DeviceLog::new("device-a");
250        let mut b = DeviceLog::new("device-b");
251
252        let mut ops = vec![
253            a.append(
254                Scope::Personal,
255                Surface::Knowledge,
256                json!({"id": "fact-1", "body": "the sky is blue"}),
257            ),
258            a.append(
259                Scope::Personal,
260                Surface::Declagent,
261                json!({"id": "agent-1", "name": "milo", "rev": "a1"}),
262            ),
263            b.append(
264                Scope::Personal,
265                Surface::Knowledge,
266                json!({"id": "fact-2", "body": "water is wet"}),
267            ),
268        ];
269        // b observes a's ops (lamport receive rule) then overwrites agent-1.
270        for op in &ops {
271            b.observe(&op.hlc);
272        }
273        ops.push(b.append(
274            Scope::Personal,
275            Surface::Declagent,
276            json!({"id": "agent-1", "name": "milo", "rev": "b2"}),
277        ));
278        ops.push(a.append(
279            Scope::Personal,
280            Surface::Conversation,
281            json!({"speaker": "user", "text": "hi", "timestamp": 1}),
282        ));
283        ops
284    }
285
286    /// Heap's algorithm — every permutation of `items`, no rand dependency.
287    fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
288        fn heap<T: Clone>(k: usize, arr: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
289            if k == 1 {
290                out.push(arr.clone());
291                return;
292            }
293            for i in 0..k {
294                heap(k - 1, arr, out);
295                if k.is_multiple_of(2) {
296                    arr.swap(i, k - 1);
297                } else {
298                    arr.swap(0, k - 1);
299                }
300            }
301        }
302        let mut arr = items.to_vec();
303        let mut out = Vec::new();
304        heap(arr.len(), &mut arr, &mut out);
305        out
306    }
307
308    #[test]
309    fn fold_is_permutation_invariant() {
310        // The core CRDT law: same op-SET in any order → the same state and
311        // the same state hash. All 120 permutations of a 5-op set.
312        let ops = two_device_ops();
313        let baseline = fold(&ops);
314        let baseline_hash = state_hash(&baseline);
315        for perm in permutations(&ops) {
316            let folded = fold(&perm);
317            assert_eq!(folded, baseline, "fold must be order-independent");
318            assert_eq!(state_hash(&folded), baseline_hash);
319        }
320    }
321
322    #[test]
323    fn fold_is_idempotent_over_duplicated_ops() {
324        // Re-delivered ops (relay retransmission) dedup on op_id: folding the
325        // set twice-concatenated equals folding it once.
326        let ops = two_device_ops();
327        let mut doubled = ops.clone();
328        doubled.extend(ops.iter().cloned());
329        assert_eq!(fold(&doubled), fold(&ops));
330        // Re-folding the identical set is stable (idempotent re-fold).
331        assert_eq!(fold(&ops), fold(&ops));
332    }
333
334    #[test]
335    fn divergent_replica_union_matches_crdt_merge() {
336        // The overlap contract with the shipped car-state CRDT primitives:
337        // folding the UNION of two devices' ops must resolve a registry to
338        // exactly the state crdt_merge produces from the per-device exports.
339        let ops = two_device_ops();
340        let a_ops: Vec<OpRecord> = ops
341            .iter()
342            .filter(|o| o.device_id == "device-a")
343            .cloned()
344            .collect();
345        let b_ops: Vec<OpRecord> = ops
346            .iter()
347            .filter(|o| o.device_id == "device-b")
348            .cloned()
349            .collect();
350
351        let union_lww = registry_as_lww(&fold(&ops), &Surface::Declagent.tag());
352        let a_lww = registry_as_lww(&fold(&a_ops), &Surface::Declagent.tag());
353        let b_lww = registry_as_lww(&fold(&b_ops), &Surface::Declagent.tag());
354
355        let merged_ab = car_state::crdt::merge_maps(&a_lww, &b_lww);
356        let merged_ba = car_state::crdt::merge_maps(&b_lww, &a_lww);
357        assert_eq!(merged_ab, union_lww, "fold(union) == crdt_merge(exports)");
358        assert_eq!(merged_ba, union_lww, "in either merge order");
359
360        // And the winner is b's later write (b observed a first — higher HLC).
361        assert_eq!(union_lww["id:agent-1"].value["rev"], json!("b2"));
362        assert_eq!(union_lww["id:agent-1"].replica, "device-b");
363    }
364
365    #[test]
366    fn journal_round_trip_load_fold_verify() {
367        let dir = tempfile::tempdir().unwrap();
368        let path = dir.path().join("oplog.jsonl");
369        let ops = two_device_ops();
370        {
371            let mut journal = OplogJournal::open(&path).unwrap();
372            for op in &ops {
373                journal.append(op).unwrap();
374            }
375        }
376        let loaded = OplogJournal::load(&path).unwrap();
377        assert_eq!(loaded, ops);
378        verify_log(&loaded).expect("loaded log must chain-verify");
379        assert_eq!(state_hash(&fold(&loaded)), state_hash(&fold(&ops)));
380    }
381
382    /// Build a two-device op-set touching EVERY surface tier, with a valid
383    /// frontier cut at `split` (every op before it is HLC-≤ every device's
384    /// ack): grow-only entities, an LWW registry record overwritten across
385    /// the cut, and a routing observation multiset spanning the cut
386    /// (including a byte-identical repeat — the multiset trap).
387    fn all_surface_ops() -> (Vec<OpRecord>, usize) {
388        let mut a = DeviceLog::new("dev-a");
389        let mut b = DeviceLog::new("dev-b");
390        let mut ops = vec![
391            a.append(
392                Scope::Personal,
393                Surface::Knowledge,
394                json!({"id": "f1", "timestamp": 10}),
395            ),
396            a.append(Scope::Personal, Surface::Skill, json!({"id": "s1"})),
397            a.append(
398                Scope::Personal,
399                Surface::Conversation,
400                json!({"speaker": "u", "text": "hi", "timestamp": 11}),
401            ),
402            a.append(
403                Scope::Personal,
404                Surface::Run,
405                json!({"id": "r1", "agent_id": "milo", "timestamp": 12}),
406            ),
407            a.append(
408                Scope::Personal,
409                Surface::Trajectory,
410                json!({"id": "t1", "timestamp": 13}),
411            ),
412            a.append(
413                Scope::Personal,
414                Surface::Declagent,
415                json!({"id": "agent-1", "rev": "a"}),
416            ),
417            a.append(
418                Scope::Shared { org: "acme".into() },
419                Surface::Registry {
420                    kind: "agents".into(),
421                },
422                json!({"id": "reg-1", "v": 1}),
423            ),
424            a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})),
425            a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})), // byte-identical repeat
426        ];
427        for op in &ops {
428            b.observe(&op.hlc);
429        }
430        ops.push(b.append(
431            Scope::Personal,
432            Surface::Knowledge,
433            json!({"id": "f2", "timestamp": 20}),
434        ));
435        let split = ops.len();
436        // Tail: every tier mutates again, above the frontier.
437        ops.push(b.append(
438            Scope::Personal,
439            Surface::Conversation,
440            json!({"speaker": "a", "text": "yo", "timestamp": 21}),
441        ));
442        ops.push(b.append(
443            Scope::Personal,
444            Surface::Declagent,
445            json!({"id": "agent-1", "rev": "b"}),
446        )); // LWW across the cut
447        ops.push(b.append(Scope::Personal, Surface::Routing, json!({"sample": 0.0})));
448        ops.push(b.append(
449            Scope::Personal,
450            Surface::Run,
451            json!({"id": "r2", "agent_id": "milo", "timestamp": 22}),
452        ));
453        for op in &ops[split..] {
454            a.observe(&op.hlc);
455        }
456        ops.push(a.append(
457            Scope::Personal,
458            Surface::Knowledge,
459            json!({"id": "f1", "timestamp": 99}),
460        )); // grow-only collision across the cut
461        (ops, split)
462    }
463
464    fn acks_at(ops: &[OpRecord], split: usize) -> AckTable {
465        // Every device acks the max HLC of the prefix ("I have folded
466        // everything at or below this stamp"), so the stable frontier is
467        // exactly the cut.
468        let frontier = ops[..split].iter().map(|o| o.hlc.clone()).max().unwrap();
469        let mut acks = AckTable::new();
470        for op in ops {
471            acks.ack(op.device_id.clone(), frontier.clone());
472        }
473        acks
474    }
475
476    #[test]
477    fn compaction_equivalence_fold_full_equals_checkpoint_plus_tail() {
478        // THE invariant that makes compaction safe, per surface:
479        // fold(full log) == fold_onto(checkpoint.state, retained tail),
480        // byte-identical state AND state_hash — including the routing
481        // observation MULTISET and its order-sensitive replay.
482        let (ops, split) = all_surface_ops();
483        let acks = acks_at(&ops, split);
484        let plan = plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None).unwrap();
485        assert_eq!(plan.dropped_ops, split);
486        assert_eq!(plan.retained_ops.len(), ops.len() - split);
487
488        let full = fold(&ops);
489        let reconstructed = fold_onto(&plan.checkpoint.state, &plan.retained_ops);
490        assert_eq!(reconstructed, full);
491        assert_eq!(state_hash(&reconstructed), state_hash(&full));
492
493        // Per-surface spot checks across the cut:
494        assert_eq!(
495            reconstructed.registries[&Surface::Declagent.tag()]["id:agent-1"].payload["rev"],
496            json!("b"),
497            "LWW: the tail's later write wins over the checkpointed one"
498        );
499        assert_eq!(
500            reconstructed.logs[&Surface::Knowledge.tag()]["id:f1"].payload["timestamp"],
501            json!(10),
502            "grow-only: the checkpointed earliest writer keeps the slot"
503        );
504        assert_eq!(
505            reconstructed.log_entries(&Surface::Routing.tag()).len(),
506            3,
507            "multiset: 2 checkpointed observations (incl. the repeat) + 1 tail"
508        );
509        let ema =
510            |s: f64, rec: &FoldedRecord| 0.7 * s + 0.3 * rec.payload["sample"].as_f64().unwrap();
511        assert_eq!(
512            reconstructed.replay(&Surface::Routing.tag(), 0.5_f64, ema),
513            full.replay(&Surface::Routing.tag(), 0.5_f64, ema),
514            "order-sensitive replay agrees across the compaction"
515        );
516
517        // And the composition is verifiable: the checkpoint anchors the tail.
518        verify_anchored(&plan.checkpoint, &plan.retained_ops).unwrap();
519    }
520
521    #[test]
522    fn retention_coherence_local_compaction_equals_global() {
523        // The proposal's "local compaction is just an eager application of
524        // the same retention the checkpoint applies globally — the two can
525        // never disagree": retention(fold_onto(retained ckpt, tail)) ==
526        // retention(fold(full)).
527        let (ops, split) = all_surface_ops();
528        let acks = acks_at(&ops, split);
529        let policy = RetentionPolicy::proposal_default(1, u64::MAX);
530        let as_of = 1_000u64;
531        let plan = plan_compaction(&ops, &acks, &policy, Some(as_of)).unwrap();
532        assert_eq!(
533            plan.as_of_ms, as_of,
534            "explicit as_of wins over the derived default"
535        );
536
537        let (global, _) = apply_retention(&fold(&ops), &policy, as_of).unwrap();
538        let (local, _) = apply_retention(
539            &fold_onto(&plan.checkpoint.state, &plan.retained_ops),
540            &policy,
541            as_of,
542        )
543        .unwrap();
544        assert_eq!(local, global);
545        assert_eq!(state_hash(&local), state_hash(&global));
546        // The retained checkpoint really did trim: only the newest turn
547        // survives conversations' last-1 rule (turns are content-hash-keyed
548        // — no id — so the trimmed one drops without a stub).
549        assert_eq!(
550            plan.checkpoint.state.logs[&Surface::Conversation.tag()].len(),
551            1
552        );
553    }
554
555    #[test]
556    fn retention_coherence_survives_cross_frontier_supersedes() {
557        // Kernel-review repro: LastN{1} knowledge drops f1 at compaction
558        // time; a LATER tail op f3 supersedes f1. Under
559        // preserve-only-what's-referenced-now, the global fold retained f1
560        // (it sees f3's reference) while the compacted device could not —
561        // divergence under identical policy + as_of. Universal tombstone
562        // stubs close the time hole: both sides hold the same f1 stub.
563        let mut a = DeviceLog::new("a");
564        let mut b = DeviceLog::new("b");
565        let mut ops = vec![
566            a.append(
567                Scope::Personal,
568                Surface::Knowledge,
569                json!({"id": "f1", "timestamp": 1}),
570            ),
571            a.append(
572                Scope::Personal,
573                Surface::Knowledge,
574                json!({"id": "f2", "timestamp": 2}),
575            ),
576        ];
577        let split = ops.len();
578        for op in &ops {
579            b.observe(&op.hlc);
580        }
581        ops.push(b.append(
582            Scope::Personal,
583            Surface::Knowledge,
584            json!({"id": "f3", "timestamp": 3, "supersedes": "f1"}),
585        ));
586
587        let acks = acks_at(&ops, split);
588        let mut policy = RetentionPolicy::keep_all();
589        policy
590            .rules
591            .insert("knowledge".to_string(), RetentionRule::LastN { n: 1 });
592        let plan = plan_compaction(&ops, &acks, &policy, Some(10)).unwrap();
593        assert_eq!(plan.dropped_ops, split);
594        let tag = Surface::Knowledge.tag();
595        // The checkpoint stubbed f1 BEFORE anything referenced it…
596        assert!(is_tombstone(&plan.checkpoint.state.logs[&tag]["id:f1"]));
597
598        // …and the coherence equivalence holds ACROSS the late reference.
599        let (global, _) = apply_retention(&fold(&ops), &policy, 10).unwrap();
600        let (local, _) = apply_retention(
601            &fold_onto(&plan.checkpoint.state, &plan.retained_ops),
602            &policy,
603            10,
604        )
605        .unwrap();
606        assert_eq!(
607            local, global,
608            "no divergence despite the cross-frontier supersedes"
609        );
610        assert_eq!(state_hash(&local), state_hash(&global));
611        // f3 is live, its supersedes target resolves against the f1 stub on
612        // BOTH sides — a tombstone, not a hole.
613        assert_eq!(
614            global.logs[&tag]["id:f3"].payload["supersedes"],
615            json!("f1")
616        );
617        assert!(is_tombstone(&global.logs[&tag]["id:f1"]));
618        assert!(is_tombstone(&local.logs[&tag]["id:f1"]));
619    }
620
621    #[test]
622    fn crash_ordering_checkpoint_durable_first_then_truncate() {
623        // Simulate a crash between the two durable steps and prove no
624        // acknowledged data can be lost at any point.
625        let dir = tempfile::tempdir().unwrap();
626        let journal_path = dir.path().join("oplog.jsonl");
627        let ckpt_dir = dir.path().join("checkpoints");
628        let (ops, split) = all_surface_ops();
629        let acks = acks_at(&ops, split);
630        let policy = RetentionPolicy::keep_all();
631
632        {
633            let mut journal = OplogJournal::open(&journal_path).unwrap();
634            for op in &ops {
635                journal.append(op).unwrap();
636            }
637
638            // Step 1+2: plan and persist the checkpoint… then "crash"
639            // before truncation (we simply don't truncate).
640            let plan = plan_compaction(
641                &OplogJournal::load(&journal_path).unwrap(),
642                &acks,
643                &policy,
644                None,
645            )
646            .unwrap();
647            let ckpt_path = plan.checkpoint.save(&ckpt_dir).unwrap();
648
649            // Post-"crash" state: the journal is UNTOUCHED (full data,
650            // no truncation marker — the normal load path still works),
651            // and the checkpoint is valid but redundant. Nothing lost.
652            let survived = OplogJournal::load(&journal_path).unwrap();
653            assert_eq!(survived, ops, "journal intact after crash-before-truncate");
654            let ckpt = Checkpoint::load(&ckpt_path).unwrap();
655            assert_eq!(fold_onto(&ckpt.state, &plan.retained_ops), fold(&ops));
656        } // journal lock released — "process died"
657
658        // "Restart": rerun the whole compaction. Idempotent — the same
659        // frontier recomputes the same content-addressed checkpoint file —
660        // and now the truncation completes.
661        let mut journal = OplogJournal::open(&journal_path).unwrap();
662        let outcome = compact_and_truncate(&mut journal, &ckpt_dir, &acks, &policy, None).unwrap();
663        assert_eq!(outcome.plan.dropped_ops, split);
664
665        // The truncated journal is marked: the naive load path is a runtime
666        // error, and the marker names the covering checkpoint.
667        assert!(
668            OplogJournal::load(&journal_path).is_err(),
669            "naive load is fenced"
670        );
671        let (marker, tail) = OplogJournal::load_with_marker(&journal_path).unwrap();
672        assert_eq!(
673            marker.unwrap().checkpoint_hash,
674            outcome.plan.checkpoint.checkpoint_hash,
675            "marker names the covering checkpoint"
676        );
677        assert_eq!(tail, ops[split..].to_vec());
678        verify_log(&tail).expect("truncated journal verifies on its own (anchored non-zero start)");
679        let ckpt = Checkpoint::load(&outcome.checkpoint_path.unwrap()).unwrap();
680        verify_anchored(&ckpt, &tail).unwrap();
681        assert_eq!(
682            fold_onto(&ckpt.state, &tail),
683            fold(&ops),
684            "nothing acknowledged was lost"
685        );
686
687        // Exactly one checkpoint file exists (the rerun deduped on content).
688        let count = std::fs::read_dir(&ckpt_dir).unwrap().count();
689        assert_eq!(count, 1);
690    }
691
692    #[test]
693    fn truncated_journal_resumes_and_keeps_verifying_end_to_end() {
694        let dir = tempfile::tempdir().unwrap();
695        let journal_path = dir.path().join("oplog.jsonl");
696        let ckpt_dir = dir.path().join("checkpoints");
697        let (ops, split) = all_surface_ops();
698        let acks = acks_at(&ops, split);
699
700        let mut journal = OplogJournal::open(&journal_path).unwrap();
701        for op in &ops {
702            journal.append(op).unwrap();
703        }
704        let outcome = compact_and_truncate(
705            &mut journal,
706            &ckpt_dir,
707            &acks,
708            &RetentionPolicy::keep_all(),
709            None,
710        )
711        .unwrap();
712        let ckpt = Checkpoint::load(&outcome.checkpoint_path.unwrap()).unwrap();
713
714        // Life goes on after truncation: resume the device chain from
715        // checkpoint + tail (never from seq 0 — DeviceLog::resume is fenced
716        // and refuses the truncated tail at runtime), append, journal,
717        // reload.
718        let (_, tail) = OplogJournal::load_with_marker(&journal_path).unwrap();
719        assert!(matches!(
720            DeviceLog::resume("dev-a", &tail),
721            Err(ChainError::TruncatedChain { .. })
722        ));
723        let mut dev_a = resume_anchored("dev-a", &ckpt, &tail).unwrap();
724        let next = dev_a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-new"}));
725        journal.append(&next).unwrap();
726
727        let (marker, reloaded) = OplogJournal::load_with_marker(&journal_path).unwrap();
728        assert!(marker.is_some(), "marker survives post-truncation appends");
729        verify_anchored(&ckpt, &reloaded).expect("checkpoint anchors the growing truncated log");
730        let full_plus = {
731            let mut v = ops.clone();
732            v.push(next);
733            v
734        };
735        assert_eq!(fold_onto(&ckpt.state, &reloaded), fold(&full_plus));
736    }
737
738    // ------------------------------------------------------------------
739    // B3 integration: relay transport + roster + eviction + re-entry.
740    // ------------------------------------------------------------------
741
742    use crate::session::SyncSession;
743    use std::sync::atomic::{AtomicU64, Ordering};
744    use std::sync::Arc;
745
746    fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
747        let t = Arc::new(AtomicU64::new(0));
748        let reader = t.clone();
749        (t, Arc::new(move || reader.load(Ordering::SeqCst)))
750    }
751
752    fn session(device: &str, root: &std::path::Path, wall: WallClock) -> SyncSession {
753        SyncSession::open(
754            device,
755            &root.join(device).join("oplog.jsonl"),
756            &root.join(device).join("checkpoints"),
757            wall,
758        )
759        .unwrap()
760    }
761
762    #[test]
763    fn two_macs_converge_through_the_filesystem_loopback_relay() {
764        // The realistic single-user case: two DeviceLogs syncing through a
765        // shared directory, real HLC wall clocks with skew between them.
766        let tmp = tempfile::tempdir().unwrap();
767        let relay_dir = tmp.path().join("shared-relay");
768        let (ta, wall_a) = manual_clock();
769        let (tb, wall_b) = manual_clock();
770        let (tr, wall_r) = manual_clock();
771        ta.store(1_000, Ordering::SeqCst);
772        tb.store(940, Ordering::SeqCst); // 60ms of skew
773        tr.store(970, Ordering::SeqCst);
774
775        // Each Mac holds its own FsRelay handle on the shared dir — state
776        // travels through the files, never through shared memory.
777        let mut relay_a =
778            FsRelay::open(&relay_dir, RelayConfig::default(), wall_r.clone()).unwrap();
779        let mut relay_b = FsRelay::open(&relay_dir, RelayConfig::default(), wall_r).unwrap();
780        let mut a = session("mac-a", tmp.path(), wall_a);
781        let mut b = session("mac-b", tmp.path(), wall_b);
782
783        a.append(
784            Scope::Personal,
785            Surface::Knowledge,
786            json!({"id": "f1", "v": 1}),
787        )
788        .unwrap();
789        a.append(
790            Scope::Personal,
791            Surface::Declagent,
792            json!({"id": "milo", "owner": "a"}),
793        )
794        .unwrap();
795        b.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0}))
796            .unwrap();
797        b.append(
798            Scope::Personal,
799            Surface::Conversation,
800            json!({"speaker": "u", "text": "hi", "timestamp": 5}),
801        )
802        .unwrap();
803
804        a.pump(&mut relay_a).unwrap();
805        b.pump(&mut relay_b).unwrap();
806        // b writes after folding a's record; despite b's slower wall clock
807        // the HLC orders it causally after (receive rule).
808        b.append(
809            Scope::Personal,
810            Surface::Declagent,
811            json!({"id": "milo", "owner": "b"}),
812        )
813        .unwrap();
814        b.pump(&mut relay_b).unwrap();
815        a.pump(&mut relay_a).unwrap();
816
817        assert_eq!(a.state_hash(), b.state_hash());
818        assert_eq!(
819            a.state().registries[&Surface::Declagent.tag()]["id:milo"].payload["owner"],
820            json!("b"),
821            "causality beats wall-clock skew"
822        );
823        // The journals themselves verify end-to-end.
824        verify_log(a.ops()).unwrap();
825        verify_log(b.ops()).unwrap();
826    }
827
828    #[test]
829    fn straggler_eviction_and_lossless_cold_reentry() {
830        // The full stragglers arc from the proposal: three devices, c goes
831        // dark holding UNPUSHED local writes → evicted at the horizon →
832        // frontier unpinned → checkpoint + relay GC → c returns, its pull
833        // is FrontierTruncated → cold re-entry (checkpoint_get + pull since
834        // the checkpoint frontier + resume_anchored) carrying its unpushed
835        // ops → it pushes them (chain-valid) → everyone converges.
836        let tmp = tempfile::tempdir().unwrap();
837        let (t, wall) = manual_clock();
838        let mut relay = InMemoryRelay::new(
839            RelayConfig {
840                eviction_horizon_ms: Some(1_000),
841            },
842            wall.clone(),
843        );
844        let mut a = session("dev-a", tmp.path(), wall.clone());
845        let mut b = session("dev-b", tmp.path(), wall.clone());
846        let mut c = session("dev-c", tmp.path(), wall.clone());
847
848        // t=100: everyone writes, pumps, acks.
849        t.store(100, Ordering::SeqCst);
850        a.append(
851            Scope::Personal,
852            Surface::Knowledge,
853            json!({"id": "a1", "timestamp": 100}),
854        )
855        .unwrap();
856        c.append(
857            Scope::Personal,
858            Surface::Knowledge,
859            json!({"id": "c1", "timestamp": 100}),
860        )
861        .unwrap();
862        a.pump(&mut relay).unwrap();
863        c.pump(&mut relay).unwrap();
864        b.pump(&mut relay).unwrap();
865        a.pump(&mut relay).unwrap();
866        c.pump(&mut relay).unwrap();
867        assert_eq!(a.state_hash(), c.state_hash());
868
869        // t=500: c writes LOCALLY (journal-durable, never pushed) and goes
870        // dark.
871        t.store(500, Ordering::SeqCst);
872        let unpushed = c
873            .append(
874                Scope::Personal,
875                Surface::Knowledge,
876                json!({"id": "c-dark", "timestamp": 500}),
877            )
878            .unwrap();
879
880        // t=800..2000: a and b keep working; c stays silent.
881        t.store(800, Ordering::SeqCst);
882        a.append(
883            Scope::Personal,
884            Surface::Knowledge,
885            json!({"id": "a2", "timestamp": 800}),
886        )
887        .unwrap();
888        a.pump(&mut relay).unwrap();
889        b.pump(&mut relay).unwrap();
890        a.pump(&mut relay).unwrap();
891
892        // c's stale ack pins the frontier while it is still active.
893        let pinned = relay.stable_frontier().unwrap().unwrap();
894
895        // t=2000: past the horizon (last seen 500) — the sweep on any
896        // contact evicts c and the frontier advances past its stale ack.
897        t.store(2_000, Ordering::SeqCst);
898        a.pump(&mut relay).unwrap();
899        b.pump(&mut relay).unwrap();
900        let roster: std::collections::BTreeMap<String, RosterEntry> = relay
901            .roster()
902            .unwrap()
903            .into_iter()
904            .map(|e| (e.device_id.clone(), e))
905            .collect();
906        assert_eq!(roster["dev-c"].status, DeviceStatus::Evicted);
907        let unpinned = relay.stable_frontier().unwrap().unwrap();
908        assert!(
909            unpinned > pinned,
910            "the evicted device's ack no longer holds the frontier"
911        );
912
913        // a checkpoints at the stable frontier and the relay GCs. Before a
914        // covering checkpoint exists, NOTHING drops even below the frontier.
915        assert_eq!(
916            relay.gc().unwrap().total(),
917            0,
918            "no covering checkpoint → no GC"
919        );
920        let ckpt = a.publish_checkpoint(&mut relay).unwrap().unwrap();
921        let report = relay.gc().unwrap();
922        assert!(report.total() > 0, "covered + below-frontier ops now drop");
923        // Ops above the stable frontier never drop, covered or not.
924        let mut since = Frontier::new();
925        for (device, entry) in &ckpt.frontier {
926            since.insert(device.clone(), entry.seq);
927        }
928        for op in relay.pull("dev-a", &since).unwrap().ops {
929            assert!(
930                op.hlc > unpinned
931                    || ckpt
932                        .frontier
933                        .get(&op.device_id)
934                        .is_none_or(|e| op.seq > e.seq)
935            );
936        }
937
938        // t=3000: c returns. Its normal pump hits truncated space → the
939        // cold bootstrap signal. NOTE the pump pushes BEFORE it pulls, so
940        // even this failed round already delivered c's journal-durable
941        // unpushed op to the relay (chain-valid against c's GC'd chain
942        // anchor) — contract 1 makes that safe at any time.
943        t.store(3_000, Ordering::SeqCst);
944        let err = c.pump(&mut relay).unwrap_err();
945        assert!(
946            matches!(
947                err,
948                SessionError::Relay(RelayError::FrontierTruncated { .. })
949            ),
950            "got {err:?}"
951        );
952        assert!(
953            relay
954                .pull("dev-a", &{
955                    let mut f = since.clone();
956                    f.insert("dev-c".to_string(), 0);
957                    f
958                })
959                .unwrap()
960                .ops
961                .iter()
962                .any(|op| op.op_id == unpushed.op_id),
963            "the failed pump's push half already landed the unpushed op"
964        );
965
966        // Cold re-entry: rebase onto the checkpoint. The unpushed local op
967        // SURVIVES the rebase (uncovered by the checkpoint frontier)…
968        assert!(c.rebase(&mut relay).unwrap());
969        assert_eq!(c.base().unwrap().checkpoint_hash, ckpt.checkpoint_hash);
970        assert!(
971            c.ops().iter().any(|op| op.op_id == unpushed.op_id),
972            "the straggler's unpushed write survives cold re-entry"
973        );
974        // …and the naive resume path is fenced on c's rebased journal.
975        let c_journal = tmp.path().join("dev-c").join("oplog.jsonl");
976        assert!(
977            OplogJournal::load(&c_journal).is_err(),
978            "truncation marker fences load()"
979        );
980
981        // c pumps: its unpushed op is re-offered (the push cursor reset on
982        // rebase) and dedups against the failed round's delivery — pushed
983        // exactly once overall — then c acks at the new frontier →
984        // reinstated.
985        let report = c.pump(&mut relay).unwrap();
986        assert_eq!(
987            (report.pushed, report.push_deduped),
988            (0, 1),
989            "the unpushed op reached the relay exactly once"
990        );
991        let roster: std::collections::BTreeMap<String, RosterEntry> = relay
992            .roster()
993            .unwrap()
994            .into_iter()
995            .map(|e| (e.device_id.clone(), e))
996            .collect();
997        assert_eq!(
998            roster["dev-c"].status,
999            DeviceStatus::Active,
1000            "caught-up ack reinstates"
1001        );
1002
1003        // c's late op has an OLD hlc (below the stable frontier) but is not
1004        // GC-eligible: no checkpoint covers its seq yet.
1005        assert!(unpushed.hlc < relay.stable_frontier().unwrap().unwrap());
1006        assert_eq!(
1007            relay.gc().unwrap().total(),
1008            0,
1009            "late op is safe until a checkpoint covers it"
1010        );
1011
1012        // Everyone pulls c's late write and converges — lossless re-entry.
1013        a.pump(&mut relay).unwrap();
1014        b.pump(&mut relay).unwrap();
1015        assert_eq!(a.state_hash(), b.state_hash());
1016        assert_eq!(a.state_hash(), c.state_hash());
1017        assert!(a.state().logs[&Surface::Knowledge.tag()].contains_key("id:c-dark"));
1018
1019        // Chain validity end to end: every journal still proves itself.
1020        verify_log(a.ops()).unwrap();
1021        verify_log(b.ops()).unwrap();
1022        verify_anchored(c.base().unwrap(), c.ops()).unwrap();
1023    }
1024
1025    #[test]
1026    fn replay_over_permuted_opsets_is_deterministic() {
1027        // The routing rule ("sync the observations, not the result"): an
1028        // order-sensitive injected fold (EMA-like) over the hlc-ordered
1029        // observation stream yields the same value from any delivery order.
1030        let mut a = DeviceLog::new("dev-a");
1031        let mut b = DeviceLog::new("dev-b");
1032        let mut ops = vec![
1033            a.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})),
1034            a.append(Scope::Personal, Surface::Routing, json!({"sample": 0.0})),
1035        ];
1036        for op in &ops {
1037            b.observe(&op.hlc);
1038        }
1039        ops.push(b.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})));
1040
1041        let ema = |state: f64, rec: &FoldedRecord| {
1042            0.7 * state + 0.3 * rec.payload["sample"].as_f64().unwrap()
1043        };
1044        let folded = fold(&ops);
1045        // Multiset guard: the third observation is byte-identical to the
1046        // first and must still be a distinct event (this test previously
1047        // passed while silently losing it).
1048        assert_eq!(folded.log_entries(&Surface::Routing.tag()).len(), 3);
1049        let baseline = folded.replay(&Surface::Routing.tag(), 0.5_f64, ema);
1050        // 0.5 →(1.0) 0.65 →(0.0) 0.455 →(1.0) 0.6185
1051        assert!((baseline - 0.6185).abs() < 1e-12, "got {baseline}");
1052        for perm in permutations(&ops) {
1053            assert_eq!(
1054                fold(&perm).replay(&Surface::Routing.tag(), 0.5_f64, ema),
1055                baseline
1056            );
1057        }
1058    }
1059}