Skip to main content

car_server_core/
sync.rs

1//! Daemon-held multi-device sync + execution-lease subsystem — the `sync.*` /
2//! `lease.*` WS surface's engine (slice B6 of
3//! `docs/proposals/multi-device-sync.md`).
4//!
5//! One [`SyncSubsystem`] per daemon **is one device** in the user's sync fleet:
6//! it owns a [`car_sync::SyncSession`] (the pump over an append-only oplog +
7//! deterministic fold) against a [`car_sync::FsRelay`] rooted at
8//! `<journal_dir>/sync/relay/` — so the single-user two-device case (two Macs
9//! sharing that directory, e.g. via a synced folder, or two daemons on one host
10//! in tests) converges out of the box — plus an in-process linearizable
11//! [`car_sync::InMemoryLeaseCoordinator`] for the execution lease.
12//!
13//! The handler layer (`handler.rs`) is thin: it parses params and calls the
14//! `&mut self` methods here under the subsystem's `tokio::sync::Mutex`, which is
15//! why the convergence + fence + lease behaviour is unit-tested directly on
16//! [`SyncSubsystem`] (two instances sharing an `FsRelay` dir + a cloned
17//! coordinator) rather than only through the WS round-trip.
18//!
19//! ## State domains: wired vs. pending (honest boundary)
20//!
21//! - **Conversation — wired end-to-end.** [`SyncSubsystem::record_turn`] routes
22//!   a conversation write **through the oplog** (a `Surface::Conversation` op),
23//!   and [`SyncSubsystem::resume`] returns the repaired, provider-valid
24//!   `Vec<Message>` from [`car_sync::SyncState::resume_messages`] — so
25//!   transcript resume across devices is real, not a stub. What is **not** done:
26//!   auto-teeing the daemon's *existing internal* conversation persistence
27//!   (car-inference/memgine) into the oplog — a host uses `sync.record_turn`
28//!   explicitly. That internal reroute is the pending B2 adoption step.
29//! - **Intent ledger — wired.** [`SyncSubsystem::record_intent`] writes the
30//!   leased-execution `Surface::Intent` ledger (terminal-guarded), and
31//!   [`SyncSubsystem::fence_check`] runs the B6 dispatch fence over it.
32//! - **Any other surface — a generic tee.** [`SyncSubsystem::append`] records
33//!   an op on any [`car_sync::Surface`] (knowledge/skill/declagent/routing/…),
34//!   so a host can tee those domains into the oplog today. Rerouting the
35//!   daemon's own knowledge/registry write paths through it is the pending step.
36//!
37//! ## Encryption & distributed coordination
38//!
39//! Two subsystem flavors:
40//!
41//! - [`SyncSubsystem::open`] — the **local** default: a shared-directory
42//!   `FsRelay` + an in-process `InMemoryLeaseCoordinator`, cleartext payloads.
43//!   The single-host / synced-folder case.
44//! - [`SyncSubsystem::open_remote`] — the **Parslee-backed** path (selected when
45//!   `.car/config.toml` `[sync] backend = "parslee"`): a [`NetworkRelay`] +
46//!   distributed [`NetworkLeaseCoordinator`] over a [`car_sync::SyncTransport`]
47//!   (the real `car_parslee::ParsleeSyncTransport`, or a `LoopbackTransport`
48//!   reference server in tests), scoped to the user's Parslee identity, with op
49//!   payloads **E2E-encrypted** under a login-derived [`SyncKeyProvider`]. The
50//!   session encrypts-on-append / decrypts-before-fold, so the relay holds only
51//!   ciphertext, and the lease is a genuinely cross-device fencing register.
52//!   This is "phone + Mac after one login" and it is exercised end-to-end
53//!   (`two_remote_devices_converge_e2e_through_the_network_relay`).
54//!
55//! The cross-device **key** is login-derived: [`car_sync::DerivedKeyProvider`]
56//! `from_passphrase` is the zero-knowledge source that works today (same
57//! passphrase → same keys on every device, the server never sees it); a
58//! Parslee-issued per-user master is the alternative. Config **propagation** has
59//! a tested tee primitive ([`SyncSubsystem::tee_config`]/[`SyncSubsystem::config_get`],
60//! partition-guarded). Under E2E, checkpoint publishing is **guarded off**
61//! ([`SyncSession::publish_checkpoint`]) so no cleartext/inconsistent snapshot
62//! reaches the relay.
63//!
64//! Remaining follow-ups: the live Parslee **server** implementing the contract
65//! (cross-repo — `docs/proposals/parslee-sync-backend.md`); per-subsystem
66//! **adoption** of the config tee (each config write-path calling `tee_config`
67//! + applying `config_get` on pull); and per-scope **encrypted** checkpoint push
68//! (restores relay-side GC — until then the server owns retention).
69
70use std::path::Path;
71
72use car_sync::{
73    check_dispatch, frontier_of, system_clock, FsRelay, InMemoryLeaseCoordinator, Intent,
74    IntentStatus, LeaseCoordinator, NetworkLeaseCoordinator, NetworkRelay, Relay, RelayConfig,
75    Scope, Surface, SyncKeyProvider, SyncSession, SyncTransport, Turn, WallClock,
76};
77use serde_json::{json, Value};
78use std::sync::Arc;
79
80use crate::assistant::governance::{
81    ActionState, AssistantCheckpoint, SupervisedActionRecord, ACTION_REGISTRY_KIND,
82    CHECKPOINT_REGISTRY_KIND,
83};
84
85/// The reserved `Surface::Registry` sub-kind portable config domains tee onto
86/// (LWW per domain). One sub-surface keeps all synced config in a single folded
87/// registry the daemon reads back by domain.
88const CONFIG_KIND: &str = "config";
89
90/// Registry kind carrying each device's peer-reachable A2A endpoint.
91///
92/// This is how "find CAR on my other machine through Parslee" works without a
93/// Parslee-side change: the sync roster knows which devices exist and when they
94/// were last seen, but carries nothing dialable. Rather than add an address to
95/// the roster — which would publish a per-device endpoint to the service — each
96/// device announces its own endpoint on the **oplog**, which is end-to-end
97/// encrypted and already the mechanism by which this user's devices converge.
98/// Parslee relays the bytes and cannot read them.
99///
100/// Folds LWW per `id` (the device id), so a machine that changes address simply
101/// overwrites its own entry and every other device converges on the new one.
102const HOST_ENDPOINT_KIND: &str = "host_endpoint";
103
104/// One device's announced A2A endpoint and peer identity.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct HostEndpoint {
107    pub device_id: String,
108    pub name: String,
109    pub url: String,
110    /// Base64 ed25519 public key. Empty from a device running a build that
111    /// predates peer auth — such a peer is listed but cannot be authenticated,
112    /// which is the honest state rather than a silent downgrade to trusting it.
113    pub pubkey: String,
114}
115/// Surface tag for the assistant's synced knowledge facts (matches
116/// `Surface::Knowledge.tag()` and the write-half's `sync.append` surface).
117const KNOWLEDGE_SURFACE: &str = "knowledge";
118
119/// A daemon device's sync endpoint + lease coordinator. Held behind a
120/// `tokio::sync::Mutex` on `ServerState`; every method is `&mut self`.
121pub struct SyncSubsystem {
122    device_id: String,
123    session: SyncSession,
124    relay: Box<dyn Relay + Send>,
125    coordinator: Box<dyn LeaseCoordinator + Send>,
126}
127
128impl std::fmt::Debug for SyncSubsystem {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("SyncSubsystem")
131            .field("device_id", &self.device_id)
132            .finish_non_exhaustive()
133    }
134}
135
136impl SyncSubsystem {
137    /// Open the daemon-default subsystem rooted at `root` (`<journal_dir>/sync/`):
138    /// a persistent per-device id at `root/device-id`, this device's oplog +
139    /// checkpoints under `root/<device_id>/`, and a shared `FsRelay` at
140    /// `root/relay/`. Uses the real system wall clock and a fresh in-process
141    /// lease coordinator.
142    pub fn open(root: &Path) -> Result<Self, String> {
143        std::fs::create_dir_all(root)
144            .map_err(|e| format!("sync: create {}: {e}", root.display()))?;
145        let device_id = load_or_mint_device_id(root)?;
146        let device_dir = root.join(&device_id);
147        let relay_dir = root.join("relay");
148        let coordinator = Box::new(InMemoryLeaseCoordinator::new(system_clock()));
149        Self::open_with(
150            device_id,
151            &device_dir,
152            &relay_dir,
153            coordinator,
154            system_clock(),
155        )
156    }
157
158    /// Open with explicit paths, coordinator, and clock — the injection point
159    /// the tests use to run two devices (distinct `device_dir`s, one shared
160    /// `relay_dir`, a cloned coordinator register).
161    pub fn open_with(
162        device_id: String,
163        device_dir: &Path,
164        relay_dir: &Path,
165        coordinator: Box<dyn LeaseCoordinator + Send>,
166        wall: WallClock,
167    ) -> Result<Self, String> {
168        std::fs::create_dir_all(device_dir)
169            .map_err(|e| format!("sync: create {}: {e}", device_dir.display()))?;
170        let journal_path = device_dir.join("oplog.jsonl");
171        let checkpoint_dir = device_dir.join("checkpoints");
172        let session = SyncSession::open(
173            device_id.clone(),
174            &journal_path,
175            &checkpoint_dir,
176            wall.clone(),
177        )
178        .map_err(|e| format!("sync: open session: {e}"))?;
179        let relay = FsRelay::open(relay_dir, RelayConfig::default(), wall)
180            .map_err(|e| format!("sync: open relay {}: {e}", relay_dir.display()))?;
181        Ok(Self {
182            device_id,
183            session,
184            relay: Box::new(relay),
185            coordinator,
186        })
187    }
188
189    /// Open a **remote** (Parslee-backed) subsystem. This device's oplog +
190    /// checkpoints stay local under `root/<device_id>/`, but the relay and lease
191    /// register are the network `transport` (e.g. `ParsleeSyncTransport`, or a
192    /// `LoopbackTransport` in tests), scoped to `scope` — the user's Parslee
193    /// identity (`user:<id>` / `org:<id>`) — so every device on that login
194    /// converges through one service. Op payloads are E2E-encrypted under
195    /// `key_provider` (login-derived), so the relay only ever holds ciphertext.
196    /// The daemon selects this over [`SyncSubsystem::open`] when
197    /// `.car/config.toml` `[sync] backend = "parslee"`.
198    pub fn open_remote(
199        root: &Path,
200        transport: Arc<dyn SyncTransport>,
201        scope: impl Into<String>,
202        key_provider: Arc<dyn SyncKeyProvider>,
203    ) -> Result<Self, String> {
204        std::fs::create_dir_all(root)
205            .map_err(|e| format!("sync: create {}: {e}", root.display()))?;
206        let device_id = load_or_mint_device_id(root)?;
207        let device_dir = root.join(&device_id);
208        std::fs::create_dir_all(&device_dir)
209            .map_err(|e| format!("sync: create {}: {e}", device_dir.display()))?;
210        let scope = scope.into();
211        let journal_path = device_dir.join("oplog.jsonl");
212        let checkpoint_dir = device_dir.join("checkpoints");
213        let session = SyncSession::open(
214            device_id.clone(),
215            &journal_path,
216            &checkpoint_dir,
217            system_clock(),
218        )
219        .map_err(|e| format!("sync: open session: {e}"))?
220        .with_key_provider(key_provider);
221        let relay: Box<dyn Relay + Send> =
222            Box::new(NetworkRelay::new(transport.clone(), scope.clone()));
223        let coordinator: Box<dyn LeaseCoordinator + Send> =
224            Box::new(NetworkLeaseCoordinator::new(transport, scope));
225        Ok(Self {
226            device_id,
227            session,
228            relay,
229            coordinator,
230        })
231    }
232
233    pub fn device_id(&self) -> &str {
234        &self.device_id
235    }
236
237    // ----- sync.* -----------------------------------------------------------
238
239    /// `sync.status` — the roster, this device's journal frontier (per-device
240    /// max seq), the relay's stable frontier, and the divergence-invariant
241    /// state hash.
242    pub fn status(&mut self) -> Result<Value, String> {
243        let stable = self
244            .relay
245            .stable_frontier()
246            .map_err(|e| format!("sync: stable_frontier: {e}"))?;
247        let roster = self
248            .relay
249            .roster()
250            .map_err(|e| format!("sync: roster: {e}"))?;
251        Ok(json!({
252            "device_id": self.device_id,
253            "state_hash": self.session.state_hash(),
254            "journal_frontier": frontier_of(self.session.ops()),
255            "stable_frontier": stable,
256            "base_checkpoint": self.session.base().map(|c| c.checkpoint_hash.clone()),
257            "roster": roster,
258        }))
259    }
260
261    /// `sync.append` — record an op on any surface (the generic domain tee).
262    pub fn append(
263        &mut self,
264        scope: Scope,
265        surface: Surface,
266        payload: Value,
267    ) -> Result<Value, String> {
268        let op = self
269            .session
270            .append(scope, surface, payload)
271            .map_err(|e| format!("sync: append: {e}"))?;
272        Ok(json!({ "op_id": op.op_id, "seq": op.seq, "hlc": op.hlc }))
273    }
274
275    /// Replicate a **portable** config `domain` onto the oplog so every device
276    /// on this login converges it (LWW per domain) — how a setting changed on
277    /// the Mac reaches the phone. The **tee primitive**: the daemon's own config
278    /// write-paths (agent-permissions, messaging allowlist, …) call this after a
279    /// local write; that per-subsystem adoption is the remaining wiring. It
280    /// **refuses** a device-local domain (`car_sync::partition`) — the guard that
281    /// keeps a secret or an OS grant off the relay; `value` must already exclude
282    /// any device-local sub-field (e.g. a keychain ref stays a per-device
283    /// pointer). Safer than raw `sync.append` (which any surface can use).
284    pub fn tee_config(&mut self, domain: &str, value: Value) -> Result<Value, String> {
285        if !car_sync::is_portable(domain) {
286            return Err(format!(
287                "sync: refusing to tee device-local domain '{domain}' — only portable \
288                 policy syncs; secrets and OS grants stay on the device"
289            ));
290        }
291        self.append(
292            Scope::Personal,
293            Surface::Registry {
294                kind: CONFIG_KIND.to_string(),
295            },
296            json!({ "id": domain, "value": value }),
297        )
298    }
299
300    /// The converged (folded LWW) value of a config `domain`, or `null` if no
301    /// device has teed it yet — the read side an adopting subsystem applies
302    /// after a pull to reconcile a peer's change onto this device.
303    pub fn config_get(&self, domain: &str) -> Value {
304        self.session
305            .state()
306            .registries
307            .get(&format!("registry:{CONFIG_KIND}"))
308            .and_then(|reg| reg.get(&format!("id:{domain}")))
309            .and_then(|rec| rec.payload.get("value").cloned())
310            .unwrap_or(Value::Null)
311    }
312
313    /// Announce this device's peer-reachable A2A endpoint to the user's other
314    /// devices.
315    ///
316    /// Idempotent by construction: the entry is keyed on this device's id and
317    /// folds LWW, so re-announcing on every start overwrites rather than
318    /// accumulates. Call it whenever the A2A listener comes up — the address can
319    /// change between runs (DHCP, a new `--a2a-public-url`), and a stale entry
320    /// would send peers at an address nothing answers on.
321    pub fn publish_host_endpoint(
322        &mut self,
323        name: &str,
324        url: &str,
325        pubkey: &str,
326    ) -> Result<Value, String> {
327        let id = self.device_id().to_string();
328        self.append(
329            Scope::Personal,
330            Surface::Registry {
331                kind: HOST_ENDPOINT_KIND.to_string(),
332            },
333            json!({ "id": id, "name": name, "url": url, "pubkey": pubkey }),
334        )
335    }
336
337    /// Every *other* device's announced endpoint.
338    ///
339    /// This device is excluded: it is not its own peer, and listing it would put
340    /// a self-addressed row in every peer listing.
341    ///
342    /// The `pubkey` is what makes these peers *reachable* rather than merely
343    /// visible. It arrived over the E2E-encrypted oplog, so only a device
344    /// holding this login's key material could have published it — which is
345    /// exactly the property that lets this host trust it without an operator
346    /// comparing fingerprints by hand.
347    pub fn host_endpoints(&self) -> Vec<HostEndpoint> {
348        let me = self.device_id().to_string();
349        // Bind the state: `session.state()` returns a temporary, and the
350        // registry borrow below outlives the expression it was produced in.
351        let state = self.session.state();
352        let Some(reg) = state
353            .registries
354            .get(&format!("registry:{HOST_ENDPOINT_KIND}"))
355        else {
356            return Vec::new();
357        };
358        let mut out: Vec<HostEndpoint> = reg
359            .iter()
360            .filter_map(|(key, rec)| {
361                let device_id = key.strip_prefix("id:")?.to_string();
362                if device_id == me {
363                    return None;
364                }
365                let url = rec.payload.get("url")?.as_str()?.trim().to_string();
366                // Refuse anything that is not an absolute http(s) URL. A peer
367                // entry is dialed, so a malformed or scheme-less value is a
368                // request to connect somewhere unintended.
369                if !(url.starts_with("http://") || url.starts_with("https://")) {
370                    return None;
371                }
372                let name = rec
373                    .payload
374                    .get("name")
375                    .and_then(|v| v.as_str())
376                    .filter(|n| !n.trim().is_empty())
377                    .unwrap_or(&device_id)
378                    .to_string();
379                let pubkey = rec
380                    .payload
381                    .get("pubkey")
382                    .and_then(|v| v.as_str())
383                    .unwrap_or_default()
384                    .to_string();
385                Some(HostEndpoint {
386                    device_id,
387                    name,
388                    url,
389                    pubkey,
390                })
391            })
392            .collect();
393        out.sort_by(|a, b| a.name.cmp(&b.name));
394        out
395    }
396
397    /// The converged (folded) knowledge facts — the read side of the assistant's
398    /// synced memory. Returns each fact's `{subject, body}` payload in ASCENDING
399    /// `(hlc, op_id)` order (as `log_entries` yields them), so a reducer that
400    /// keeps the LAST occurrence per subject gets the newest value. The assistant
401    /// re-ingests these after a pull so a fact learned on one device surfaces in
402    /// recall on another. Raw (unreduced) on purpose — the caller reduces
403    /// newest-per-subject so the ordering contract stays in one place.
404    pub fn knowledge(&self) -> Vec<Value> {
405        self.session
406            .state()
407            .log_entries(KNOWLEDGE_SURFACE)
408            .into_iter()
409            .map(|rec| rec.payload.clone())
410            .collect()
411    }
412
413    /// Persist the exact model-facing supervised-assistant checkpoint in the
414    /// existing append-only oplog. LWW is per session id, while `revision`
415    /// prevents a delayed writer from replacing a newer local checkpoint.
416    pub fn assistant_checkpoint_put(
417        &mut self,
418        checkpoint: AssistantCheckpoint,
419    ) -> Result<Value, String> {
420        if checkpoint.session_id.trim().is_empty() || checkpoint.id != checkpoint.session_id {
421            return Err("assistant checkpoint id must equal its non-empty session_id".into());
422        }
423        if let Some(current) = self.assistant_checkpoint_get(&checkpoint.session_id)? {
424            if checkpoint.revision <= current.revision {
425                return Err(format!(
426                    "assistant checkpoint revision {} is not newer than {}",
427                    checkpoint.revision, current.revision
428                ));
429            }
430        }
431        let session_id = checkpoint.session_id.clone();
432        let value = serde_json::to_value(checkpoint)
433            .map_err(|e| format!("sync: serialize assistant checkpoint: {e}"))?;
434        self.append(
435            Scope::Personal,
436            Surface::Registry {
437                kind: CHECKPOINT_REGISTRY_KIND.to_string(),
438            },
439            json!({ "id": session_id, "checkpoint": value }),
440        )
441    }
442
443    /// Load the latest exact checkpoint for one supervised session.
444    pub fn assistant_checkpoint_get(
445        &self,
446        session_id: &str,
447    ) -> Result<Option<AssistantCheckpoint>, String> {
448        let state = self.session.state();
449        let value = state
450            .registries
451            .get(&format!("registry:{CHECKPOINT_REGISTRY_KIND}"))
452            .and_then(|reg| reg.get(&format!("id:{session_id}")))
453            .and_then(|rec| rec.payload.get("checkpoint"));
454        value
455            .map(|v| {
456                serde_json::from_value(v.clone())
457                    .map_err(|e| format!("sync: decode assistant checkpoint: {e}"))
458            })
459            .transpose()
460    }
461
462    /// Append one monotone supervised-action lifecycle record. This ledger is
463    /// separate from scheduled-run Intent because approval and indeterminate
464    /// dispatch are distinct safety states, not aliases for pending/failed.
465    pub fn assistant_action_put(
466        &mut self,
467        record: SupervisedActionRecord,
468    ) -> Result<Value, String> {
469        if record.id.trim().is_empty()
470            || record.id != record.scope.action_id(&record.session_id, &record.call_id)
471        {
472            return Err("assistant action id does not match its canonical scope digest".into());
473        }
474        match self.assistant_action_get(&record.id)? {
475            None if record.state != ActionState::Proposed => {
476                return Err("a supervised action must begin in proposed state".into());
477            }
478            Some(current) => {
479                let mut expected = current.clone();
480                expected.transition(record.state, record.receipt.clone())?;
481                if expected != record {
482                    return Err("supervised action mutation changed immutable scope fields".into());
483                }
484            }
485            None => {}
486        }
487        let id = record.id.clone();
488        let value = serde_json::to_value(record)
489            .map_err(|e| format!("sync: serialize supervised action: {e}"))?;
490        self.append(
491            Scope::Personal,
492            Surface::Registry {
493                kind: ACTION_REGISTRY_KIND.to_string(),
494            },
495            json!({ "id": id, "record": value }),
496        )
497    }
498
499    pub fn assistant_action_get(
500        &self,
501        action_id: &str,
502    ) -> Result<Option<SupervisedActionRecord>, String> {
503        let state = self.session.state();
504        let value = state
505            .registries
506            .get(&format!("registry:{ACTION_REGISTRY_KIND}"))
507            .and_then(|reg| reg.get(&format!("id:{action_id}")))
508            .and_then(|rec| rec.payload.get("record"));
509        value
510            .map(|v| {
511                serde_json::from_value(v.clone())
512                    .map_err(|e| format!("sync: decode supervised action: {e}"))
513            })
514            .transpose()
515    }
516
517    /// `sync.record_turn` — the Conversation domain, routed through the oplog so
518    /// `sync.resume` is real. `role` ∈ `user|assistant|tool`.
519    pub fn record_turn(
520        &mut self,
521        scope: Scope,
522        conversation_id: &str,
523        role: &str,
524        content: &str,
525        tool_calls: Vec<Value>,
526        tool_use_id: Option<&str>,
527        timestamp: u64,
528    ) -> Result<Value, String> {
529        // Fail fast rather than persist garbage into the durable oplog. The WS
530        // handler + FFI proxy used to `.unwrap_or("")` every field, so a caller
531        // that omitted/typo'd a param silently recorded a malformed turn:
532        // - an empty `conversation_id` is the scoping key `resume_messages` folds
533        //   on, so blank pools unrelated callers' turns into one anonymous thread;
534        // - a turn with no `content` AND no `tool_calls`/`tool_use_id` is an empty
535        //   record that only bloats the journal and shows up as a blank message on
536        //   resume. A tool turn legitimately has empty content (identified by
537        //   tool_use_id), so it's exempt from the content check.
538        if conversation_id.trim().is_empty() {
539            return Err("sync.record_turn requires a non-empty `conversation_id`".into());
540        }
541        if content.is_empty() && tool_calls.is_empty() && tool_use_id.is_none() {
542            return Err(
543                "sync.record_turn requires `content` (or tool_calls / tool_use_id for a tool turn)"
544                    .into(),
545            );
546        }
547        let payload = match role {
548            "assistant" => Turn::assistant_payload(conversation_id, content, tool_calls, timestamp),
549            "tool" | "tool_result" => Turn::tool_payload(
550                conversation_id,
551                tool_use_id.unwrap_or_default(),
552                content,
553                timestamp,
554            ),
555            _ => Turn::user_payload(conversation_id, content, timestamp),
556        };
557        self.append(scope, Surface::Conversation, payload)
558    }
559
560    /// `sync.record_intent` — write a leased-execution intent to the
561    /// `Surface::Intent` ledger (terminal-guarded: a `pending`/`failed` write
562    /// for an already-committed run is a no-op). This is what populates the
563    /// committed-run oracle the dispatch fence reads.
564    pub fn record_intent(&mut self, scope: Scope, intent: &Intent) -> Result<Value, String> {
565        let recorded = self
566            .session
567            .record_intent(scope, intent)
568            .map_err(|e| format!("sync: record_intent: {e}"))?;
569        Ok(match recorded {
570            Some(op) => json!({ "recorded": true, "op_id": op.op_id }),
571            None => {
572                json!({ "recorded": false, "reason": "run already committed (terminal guard)" })
573            }
574        })
575    }
576
577    /// `sync.pump` — one reconciliation round (push journal-durable own ops →
578    /// pull → verify → fold → ack). This drives push/pull/ack against the relay.
579    pub fn pump(&mut self) -> Result<Value, String> {
580        let report = self
581            .session
582            .pump(self.relay.as_mut())
583            .map_err(|e| format!("sync: pump: {e}"))?;
584        Ok(json!({
585            "pushed": report.pushed,
586            "push_deduped": report.push_deduped,
587            "folded": report.folded,
588            "acked": report.acked,
589            "state_hash": self.session.state_hash(),
590        }))
591    }
592
593    /// `sync.checkpoint` — compute + publish a device-side checkpoint at the
594    /// relay's stable frontier (the E2E-ready snapshot; the relay never folds).
595    pub fn checkpoint(&mut self) -> Result<Value, String> {
596        let published = self
597            .session
598            .publish_checkpoint(self.relay.as_mut())
599            .map_err(|e| format!("sync: publish_checkpoint: {e}"))?;
600        Ok(match published {
601            Some(c) => json!({ "published": true, "checkpoint_hash": c.checkpoint_hash }),
602            None => json!({ "published": false }),
603        })
604    }
605
606    /// `sync.rebase` — cold bootstrap / straggler re-entry: re-anchor on the
607    /// relay's latest checkpoint (carrying uncovered local ops across).
608    pub fn rebase(&mut self) -> Result<Value, String> {
609        let rebased = self
610            .session
611            .rebase(self.relay.as_mut())
612            .map_err(|e| format!("sync: rebase: {e}"))?;
613        Ok(json!({
614            "rebased": rebased,
615            "base_checkpoint": self.session.base().map(|c| c.checkpoint_hash.clone()),
616        }))
617    }
618
619    /// `sync.transcript` — the ordered, role-threaded raw transcript projection.
620    pub fn transcript(&self, conversation_id: &str) -> Value {
621        json!(self.session.state().transcript(conversation_id))
622    }
623
624    /// `sync.resume` — the repaired, provider-valid `Vec<Message>` a host
625    /// replays to continue the conversation (the verbatim resume path).
626    pub fn resume(&self, conversation_id: &str) -> Result<Value, String> {
627        serde_json::to_value(self.session.state().resume_messages(conversation_id))
628            .map_err(|e| format!("sync: serialize resume messages: {e}"))
629    }
630
631    /// `sync.fence_check` — the B6 executor dispatch fence at the point of
632    /// effect: the durable committed-run oracle read + the linearizable "am I
633    /// still epoch N?" read. Only `may_dispatch == true` authorizes the effect.
634    pub fn fence_check(
635        &mut self,
636        agent_id: &str,
637        run_id: &str,
638        epoch: u64,
639    ) -> Result<Value, String> {
640        let state = self.session.state();
641        let decision = check_dispatch(
642            self.coordinator.as_mut(),
643            &state,
644            agent_id,
645            run_id,
646            &self.device_id,
647            epoch,
648        )
649        .map_err(|e| format!("sync: fence_check: {e}"))?;
650        let may = decision.may_dispatch();
651        Ok(json!({ "decision": decision, "may_dispatch": may }))
652    }
653
654    // ----- lease.* ----------------------------------------------------------
655
656    /// `lease.acquire` — CAS-acquire the per-agent execution lease (this device
657    /// is the holder); on grant the monotone fencing `epoch` bumps.
658    pub fn lease_acquire(&mut self, agent_id: &str, ttl_ms: u64) -> Result<Value, String> {
659        let lease = self
660            .coordinator
661            .acquire(agent_id, &self.device_id, ttl_ms)
662            .map_err(|e| format!("lease: acquire: {e}"))?;
663        serde_json::to_value(lease).map_err(|e| e.to_string())
664    }
665
666    /// `lease.renew` — heartbeat the lease (no epoch bump), iff still the holder.
667    pub fn lease_renew(
668        &mut self,
669        agent_id: &str,
670        epoch: u64,
671        ttl_ms: u64,
672    ) -> Result<Value, String> {
673        let lease = self
674            .coordinator
675            .renew(agent_id, &self.device_id, epoch, ttl_ms)
676            .map_err(|e| format!("lease: renew: {e}"))?;
677        serde_json::to_value(lease).map_err(|e| e.to_string())
678    }
679
680    /// `lease.release` — clean handoff (next acquire skips the TTL wait).
681    pub fn lease_release(&mut self, agent_id: &str, epoch: u64) -> Result<Value, String> {
682        self.coordinator
683            .release(agent_id, &self.device_id, epoch)
684            .map_err(|e| format!("lease: release: {e}"))?;
685        Ok(json!({ "released": true }))
686    }
687
688    /// `lease.status` — the linearizable read of the current lease (or `null`).
689    pub fn lease_status(&mut self, agent_id: &str) -> Result<Value, String> {
690        let current = self
691            .coordinator
692            .current(agent_id)
693            .map_err(|e| format!("lease: status: {e}"))?;
694        Ok(json!({ "lease": current }))
695    }
696}
697
698/// Load the daemon's persistent device id, or mint + persist a fresh uuid.
699fn load_or_mint_device_id(root: &Path) -> Result<String, String> {
700    let path = root.join("device-id");
701    if path.exists() {
702        let id = std::fs::read_to_string(&path)
703            .map_err(|e| format!("sync: read device-id: {e}"))?
704            .trim()
705            .to_string();
706        if !id.is_empty() {
707            return Ok(id);
708        }
709    }
710    let id = format!("device-{}", uuid::Uuid::new_v4());
711    std::fs::write(&path, &id).map_err(|e| format!("sync: write device-id: {e}"))?;
712    Ok(id)
713}
714
715/// Parse an optional `scope` param: `{scope: "personal"}` (default) or
716/// `{scope: {org: "acme"}}` / `{org: "acme"}` → `Shared`.
717pub fn parse_scope(params: &Value) -> Scope {
718    if let Some(org) = params
719        .get("scope")
720        .and_then(|s| s.get("org"))
721        .or_else(|| params.get("org"))
722        .and_then(Value::as_str)
723    {
724        return Scope::Shared {
725            org: org.to_string(),
726        };
727    }
728    Scope::Personal
729}
730
731/// Parse a `surface` param string into a [`Surface`]. `registry:<kind>` maps to
732/// `Registry { kind }`. Unknown surfaces error (never silently defaulted).
733pub fn parse_surface(s: &str) -> Result<Surface, String> {
734    Ok(match s {
735        "routing" => Surface::Routing,
736        "declagent" => Surface::Declagent,
737        "conversation" => Surface::Conversation,
738        "knowledge" => Surface::Knowledge,
739        "skill" => Surface::Skill,
740        "trajectory" => Surface::Trajectory,
741        "run" => Surface::Run,
742        "intent" => Surface::Intent,
743        other => {
744            if let Some(kind) = other.strip_prefix("registry:") {
745                Surface::Registry {
746                    kind: kind.to_string(),
747                }
748            } else {
749                return Err(format!("unknown sync surface '{other}'"));
750            }
751        }
752    })
753}
754
755/// Parse an [`IntentStatus`] string.
756pub fn parse_intent_status(s: &str) -> Result<IntentStatus, String> {
757    Ok(match s {
758        "pending" => IntentStatus::Pending,
759        "committed" => IntentStatus::Committed,
760        "failed" => IntentStatus::Failed,
761        other => return Err(format!("unknown intent status '{other}'")),
762    })
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use std::sync::atomic::{AtomicU64, Ordering};
769    use std::sync::Arc;
770
771    fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
772        let t = Arc::new(AtomicU64::new(0));
773        let reader = t.clone();
774        (t, Arc::new(move || reader.load(Ordering::SeqCst)))
775    }
776
777    /// Two devices (distinct oplog dirs) syncing through one shared FsRelay dir
778    /// and one shared lease register — the realistic single-user case, driven
779    /// entirely through the `SyncSubsystem` methods the WS handlers call.
780    fn two_devices(relay_dir: &Path, a_dir: &Path, b_dir: &Path) -> (SyncSubsystem, SyncSubsystem) {
781        let coord = InMemoryLeaseCoordinator::new({
782            let (_t, w) = manual_clock();
783            w
784        });
785        let (_ta, wa) = manual_clock();
786        let (_tb, wb) = manual_clock();
787        let a = SyncSubsystem::open_with(
788            "mac-a".into(),
789            a_dir,
790            relay_dir,
791            Box::new(coord.clone()),
792            wa,
793        )
794        .unwrap();
795        let b = SyncSubsystem::open_with("mac-b".into(), b_dir, relay_dir, Box::new(coord), wb)
796            .unwrap();
797        (a, b)
798    }
799
800    #[test]
801    fn record_turn_rejects_empty_conversation_id_and_empty_turns() {
802        let tmp = tempfile::tempdir().unwrap();
803        let relay = tmp.path().join("relay");
804        let (mut a, _b) = two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
805
806        // Empty conversation_id → error (would otherwise pool into an anonymous thread).
807        assert!(a
808            .record_turn(Scope::Personal, "", "user", "hi", vec![], None, 1)
809            .unwrap_err()
810            .contains("conversation_id"));
811        assert!(a
812            .record_turn(Scope::Personal, "   ", "user", "hi", vec![], None, 1)
813            .is_err());
814
815        // Empty content with no tool_calls/tool_use_id → error (empty record).
816        assert!(a
817            .record_turn(Scope::Personal, "c1", "user", "", vec![], None, 1)
818            .unwrap_err()
819            .contains("content"));
820
821        // A real turn, and a tool turn with empty content but a tool_use_id, are accepted.
822        a.record_turn(Scope::Personal, "c1", "user", "hello", vec![], None, 1)
823            .unwrap();
824        a.record_turn(Scope::Personal, "c1", "tool", "", vec![], Some("call_1"), 2)
825            .unwrap();
826    }
827
828    #[test]
829    fn two_devices_converge_a_conversation_through_the_shared_relay() {
830        let tmp = tempfile::tempdir().unwrap();
831        let relay = tmp.path().join("relay");
832        let (mut a, mut b) = two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
833
834        // a records a user turn + a knowledge fact; b records an assistant turn.
835        a.record_turn(Scope::Personal, "c1", "user", "hello", vec![], None, 1)
836            .unwrap();
837        a.append(
838            Scope::Personal,
839            Surface::Knowledge,
840            json!({"id": "f1", "body": "sky is blue"}),
841        )
842        .unwrap();
843        a.pump().unwrap();
844        b.pump().unwrap();
845        b.record_turn(
846            Scope::Personal,
847            "c1",
848            "assistant",
849            "hi there",
850            vec![],
851            None,
852            2,
853        )
854        .unwrap();
855        b.pump().unwrap();
856        a.pump().unwrap();
857
858        // Divergence invariant: identical state hash after exchange.
859        let sa = a.status().unwrap();
860        let sb = b.status().unwrap();
861        assert_eq!(sa["state_hash"], sb["state_hash"], "two devices converge");
862
863        // Transcript resume is real on BOTH devices: the ordered user→assistant
864        // turns come back as provider-valid messages.
865        let resume_a = a.resume("c1").unwrap();
866        let resume_b = b.resume("c1").unwrap();
867        assert_eq!(resume_a, resume_b);
868        let msgs = resume_a.as_array().unwrap();
869        assert_eq!(msgs.len(), 2, "user + assistant");
870        assert_eq!(msgs[0]["role"], json!("user"));
871        assert_eq!(msgs[1]["role"], json!("assistant"));
872
873        // The knowledge fact folded on the other device too.
874        let transcript = a.transcript("c1");
875        assert_eq!(transcript.as_array().unwrap().len(), 2);
876    }
877
878    #[test]
879    fn two_devices_discover_each_others_a2a_endpoints_through_the_relay() {
880        // "Find CAR on my other machine through Parslee", at the data layer.
881        // Two daemons on one login announce their A2A endpoints; after a pump
882        // each can dial the other. The relay carries only ciphertext, so the
883        // endpoints converge without Parslee being able to read them — which is
884        // why this rides the oplog instead of an address field on the roster.
885        use car_sync::{DerivedKeyProvider, LoopbackTransport};
886        let tmp = tempfile::tempdir().unwrap();
887        let transport: Arc<dyn SyncTransport> = Arc::new(LoopbackTransport::new().unwrap());
888        let provider: Arc<dyn SyncKeyProvider> =
889            Arc::new(DerivedKeyProvider::new(b"parslee-login-master".to_vec()));
890
891        let mut mac = SyncSubsystem::open_remote(
892            &tmp.path().join("mac"),
893            transport.clone(),
894            "user:matt",
895            provider.clone(),
896        )
897        .unwrap();
898        let mut desktop = SyncSubsystem::open_remote(
899            &tmp.path().join("desktop"),
900            transport.clone(),
901            "user:matt",
902            provider.clone(),
903        )
904        .unwrap();
905
906        // Neither knows the other before anything is announced.
907        assert!(mac.host_endpoints().is_empty());
908        assert!(desktop.host_endpoints().is_empty());
909
910        mac.publish_host_endpoint("mac-studio", "http://192.168.1.10:8731", "MAC-PUBKEY")
911            .unwrap();
912        desktop
913            .publish_host_endpoint("desktop", "http://192.168.1.20:8731", "DESKTOP-PUBKEY")
914            .unwrap();
915        mac.pump().unwrap();
916        desktop.pump().unwrap();
917        mac.pump().unwrap();
918
919        let seen_by_mac = mac.host_endpoints();
920        assert_eq!(seen_by_mac.len(), 1, "mac should see exactly the desktop");
921        assert_eq!(seen_by_mac[0].name, "desktop");
922        assert_eq!(seen_by_mac[0].url, "http://192.168.1.20:8731");
923        // The key travels with the endpoint; without it the peer would be
924        // visible but unauthenticatable.
925        assert_eq!(seen_by_mac[0].pubkey, "DESKTOP-PUBKEY");
926
927        let seen_by_desktop = desktop.host_endpoints();
928        assert_eq!(seen_by_desktop.len(), 1);
929        assert_eq!(seen_by_desktop[0].name, "mac-studio");
930        assert_eq!(seen_by_desktop[0].url, "http://192.168.1.10:8731");
931        assert_eq!(seen_by_desktop[0].pubkey, "MAC-PUBKEY");
932
933        // A device that moves overwrites its own entry rather than adding one:
934        // the surface folds LWW per device id, so a stale address cannot linger
935        // and send peers somewhere nothing answers.
936        desktop
937            .publish_host_endpoint("desktop", "http://10.0.0.5:8731", "DESKTOP-PUBKEY")
938            .unwrap();
939        desktop.pump().unwrap();
940        mac.pump().unwrap();
941        let after_move = mac.host_endpoints();
942        assert_eq!(after_move.len(), 1, "re-announcing must not accumulate");
943        assert_eq!(after_move[0].url, "http://10.0.0.5:8731");
944    }
945
946    #[test]
947    fn a_malformed_or_scheme_less_endpoint_is_not_offered_as_a_peer() {
948        // An entry is dialed, so anything that is not an absolute http(s) URL is
949        // a request to connect somewhere unintended and must not reach a listing.
950        use car_sync::{DerivedKeyProvider, LoopbackTransport};
951        let tmp = tempfile::tempdir().unwrap();
952        let transport: Arc<dyn SyncTransport> = Arc::new(LoopbackTransport::new().unwrap());
953        let provider: Arc<dyn SyncKeyProvider> = Arc::new(DerivedKeyProvider::new(b"k".to_vec()));
954        let mut a = SyncSubsystem::open_remote(
955            &tmp.path().join("a"),
956            transport.clone(),
957            "user:matt",
958            provider.clone(),
959        )
960        .unwrap();
961        let mut b = SyncSubsystem::open_remote(
962            &tmp.path().join("b"),
963            transport.clone(),
964            "user:matt",
965            provider.clone(),
966        )
967        .unwrap();
968        b.publish_host_endpoint("hostile", "file:///etc/passwd", "K")
969            .unwrap();
970        b.pump().unwrap();
971        a.pump().unwrap();
972        assert!(
973            a.host_endpoints().is_empty(),
974            "a non-http(s) endpoint must never be offered as a dialable peer"
975        );
976    }
977
978    #[test]
979    fn two_remote_devices_converge_e2e_through_the_network_relay() {
980        // The capstone: two daemon subsystems built via `open_remote` — the
981        // Parslee-backed path — share ONE network service (a LoopbackTransport
982        // reference server) under the same login scope + login-derived key.
983        // They converge a conversation + knowledge fact end-to-end, with the
984        // relay carrying only ciphertext. This is "phone + Mac after one login".
985        use car_sync::{DerivedKeyProvider, LoopbackTransport};
986        let tmp = tempfile::tempdir().unwrap();
987        let transport: Arc<dyn SyncTransport> = Arc::new(LoopbackTransport::new().unwrap());
988        let provider: Arc<dyn SyncKeyProvider> =
989            Arc::new(DerivedKeyProvider::new(b"parslee-login-master".to_vec()));
990
991        let mut mac = SyncSubsystem::open_remote(
992            &tmp.path().join("mac"),
993            transport.clone(),
994            "user:matt",
995            provider.clone(),
996        )
997        .unwrap();
998        let mut phone = SyncSubsystem::open_remote(
999            &tmp.path().join("phone"),
1000            transport.clone(),
1001            "user:matt",
1002            provider.clone(),
1003        )
1004        .unwrap();
1005
1006        mac.record_turn(
1007            Scope::Personal,
1008            "c1",
1009            "user",
1010            "hello from mac",
1011            vec![],
1012            None,
1013            1,
1014        )
1015        .unwrap();
1016        mac.append(
1017            Scope::Personal,
1018            Surface::Knowledge,
1019            json!({"id": "f1", "body": "sensitive note"}),
1020        )
1021        .unwrap();
1022        mac.pump().unwrap();
1023        phone.pump().unwrap();
1024        phone
1025            .record_turn(
1026                Scope::Personal,
1027                "c1",
1028                "assistant",
1029                "hi from phone",
1030                vec![],
1031                None,
1032                2,
1033            )
1034            .unwrap();
1035        phone.pump().unwrap();
1036        mac.pump().unwrap();
1037
1038        // Converge: identical state hash across the two devices.
1039        assert_eq!(
1040            mac.status().unwrap()["state_hash"],
1041            phone.status().unwrap()["state_hash"],
1042            "remote-backed devices converge through the network relay"
1043        );
1044
1045        // The E2E round-trip works on both: the ordered transcript resumes as
1046        // provider-valid messages (which is only possible if the fold decrypted).
1047        let resume = phone.resume("c1").unwrap();
1048        let msgs = resume.as_array().unwrap();
1049        assert_eq!(msgs.len(), 2, "user + assistant, decrypted on the phone");
1050        assert_eq!(msgs[0]["role"], json!("user"));
1051        assert_eq!(msgs[1]["role"], json!("assistant"));
1052
1053        // A different login (wrong master) shares the relay but cannot read it —
1054        // it folds the same ciphertext to a different (undecrypted) state.
1055        let intruder: Arc<dyn SyncKeyProvider> =
1056            Arc::new(DerivedKeyProvider::new(b"someone-elses-login".to_vec()));
1057        let mut evil = SyncSubsystem::open_remote(
1058            &tmp.path().join("evil"),
1059            transport.clone(),
1060            "user:matt",
1061            intruder,
1062        )
1063        .unwrap();
1064        evil.pump().unwrap();
1065        assert_ne!(
1066            evil.status().unwrap()["state_hash"],
1067            mac.status().unwrap()["state_hash"],
1068            "a wrong-key reader cannot reconstruct the plaintext state"
1069        );
1070    }
1071
1072    #[test]
1073    fn config_tees_across_devices_and_refuses_device_local_domains() {
1074        // A setting changed on the Mac reaches the phone (LWW per domain), and a
1075        // device-local domain (a secret / OS grant) is refused — the partition
1076        // guard that keeps secrets off the relay.
1077        let tmp = tempfile::tempdir().unwrap();
1078        let relay = tmp.path().join("relay");
1079        let (mut mac, mut phone) =
1080            two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
1081
1082        mac.tee_config(
1083            "agent_permissions",
1084            json!({"milo": {"full_access": "require_approval"}}),
1085        )
1086        .unwrap();
1087        mac.pump().unwrap();
1088        phone.pump().unwrap();
1089
1090        assert_eq!(
1091            phone.config_get("agent_permissions"),
1092            json!({"milo": {"full_access": "require_approval"}}),
1093            "the phone converges the Mac's config change"
1094        );
1095
1096        // Last-writer-wins: the phone updates the same domain, the Mac converges.
1097        phone
1098            .tee_config(
1099                "agent_permissions",
1100                json!({"milo": {"full_access": "deny"}}),
1101            )
1102            .unwrap();
1103        phone.pump().unwrap();
1104        mac.pump().unwrap();
1105        assert_eq!(
1106            mac.config_get("agent_permissions")["milo"]["full_access"],
1107            json!("deny")
1108        );
1109
1110        // A device-local domain (secret / OS grant) must be refused.
1111        assert!(mac
1112            .tee_config("keychain_secrets", json!({"slack": "xoxb-…"}))
1113            .is_err());
1114        assert!(mac.tee_config("parslee_tokens", json!("tok")).is_err());
1115        assert_eq!(mac.config_get("keychain_secrets"), json!(null));
1116    }
1117
1118    #[test]
1119    fn dispatch_fence_refuses_stale_epoch_and_already_committed() {
1120        let tmp = tempfile::tempdir().unwrap();
1121        let relay = tmp.path().join("relay");
1122        // Shared coordinator (frozen clock → a's lease never expires here).
1123        let (_tc, wc) = manual_clock();
1124        let coord = InMemoryLeaseCoordinator::new(wc);
1125        let (_ta, wa) = manual_clock();
1126        let (_tb, wb) = manual_clock();
1127        let mut a = SyncSubsystem::open_with(
1128            "mac-a".into(),
1129            &tmp.path().join("a"),
1130            &relay,
1131            Box::new(coord.clone()),
1132            wa,
1133        )
1134        .unwrap();
1135        let mut b = SyncSubsystem::open_with(
1136            "mac-b".into(),
1137            &tmp.path().join("b"),
1138            &relay,
1139            Box::new(coord),
1140            wb,
1141        )
1142        .unwrap();
1143
1144        // a acquires epoch 1 and may dispatch.
1145        let lease = a.lease_acquire("milo", 100).unwrap();
1146        assert_eq!(lease["epoch"], json!(1));
1147        let f = a.fence_check("milo", "run-1", 1).unwrap();
1148        assert_eq!(f["may_dispatch"], json!(true));
1149
1150        // a records the run committed and syncs it to b.
1151        a.record_intent(
1152            Scope::Personal,
1153            &Intent::new("milo", "run-1", 1, IntentStatus::Committed),
1154        )
1155        .unwrap();
1156        a.pump().unwrap();
1157        b.pump().unwrap();
1158
1159        // The oracle now refuses a re-dispatch of run-1 on BOTH devices — even
1160        // a, the legitimate current holder (idempotency beats liveness).
1161        let f = a.fence_check("milo", "run-1", 1).unwrap();
1162        assert_eq!(f["decision"]["decision"], json!("already_committed"));
1163        assert_eq!(f["may_dispatch"], json!(false));
1164        let fb = b.fence_check("milo", "run-1", 1).unwrap();
1165        assert_eq!(fb["decision"]["decision"], json!("already_committed"));
1166
1167        // b is not the holder at epoch 1 → a fresh (uncommitted) run on b is
1168        // fenced as not-held; after b steals it becomes the holder.
1169        let stale = b.fence_check("milo", "run-2", 1).unwrap();
1170        assert_eq!(stale["decision"]["decision"], json!("stale_epoch"));
1171        assert_eq!(stale["may_dispatch"], json!(false));
1172    }
1173
1174    #[test]
1175    fn lease_is_visible_across_two_devices_sharing_the_register() {
1176        let tmp = tempfile::tempdir().unwrap();
1177        let relay = tmp.path().join("relay");
1178        let (mut a, mut b) = two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
1179
1180        // a acquires; b sees it held by mac-a (shared linearizable register).
1181        a.lease_acquire("milo", 1_000_000).unwrap();
1182        let status = b.lease_status("milo").unwrap();
1183        assert_eq!(status["lease"]["holder"], json!("mac-a"));
1184
1185        // b cannot acquire an unexpired lease.
1186        assert!(b.lease_acquire("milo", 100).is_err());
1187
1188        // a releases; b now acquires epoch 2 (monotone, never reused).
1189        a.lease_release("milo", 1).unwrap();
1190        let lease = b.lease_acquire("milo", 100).unwrap();
1191        assert_eq!(
1192            (lease["epoch"].as_u64(), lease["holder"].as_str()),
1193            (Some(2), Some("mac-b"))
1194        );
1195    }
1196
1197    #[test]
1198    fn assistant_checkpoint_and_action_survive_process_reopen_exactly() {
1199        use crate::assistant::governance::{
1200            ActionScope, AssistantCheckpoint, CompletionMatrix, CredentialCapability,
1201            ResumeDirective, SupervisedActionRecord,
1202        };
1203        use car_inference::tasks::generate::{
1204            ContentBlock, Message, Provenance, ThinkingBlock, ToolCall,
1205        };
1206
1207        let tmp = tempfile::tempdir().unwrap();
1208        let device = tmp.path().join("device");
1209        let relay = tmp.path().join("relay");
1210        let repo = tmp.path().join("repo");
1211        std::fs::create_dir_all(repo.join(".git")).unwrap();
1212        let (_t, wall) = manual_clock();
1213        let coordinator = InMemoryLeaseCoordinator::new(wall.clone());
1214        let checkpoint = AssistantCheckpoint {
1215            id: "task-1".into(),
1216            session_id: "task-1".into(),
1217            revision: 1,
1218            repository_root: repo.clone(),
1219            messages: vec![
1220                Message::System {
1221                    content: "system exact".into(),
1222                },
1223                Message::User {
1224                    content: "diagnose it".into(),
1225                },
1226                Message::UserMultimodal {
1227                    content: vec![ContentBlock::Text {
1228                        text: "screenshot attached".into(),
1229                    }],
1230                },
1231                Message::Assistant {
1232                    content: "checking".into(),
1233                    tool_calls: vec![serde_json::from_value::<ToolCall>(json!({
1234                        "id": "call-1",
1235                        "name": "read_file",
1236                        "arguments": {"path": "src/app.rs"}
1237                    }))
1238                    .unwrap()],
1239                    thinking: vec![ThinkingBlock {
1240                        text: "opaque reasoning".into(),
1241                        signature: Some("provider-signature".into()),
1242                        redacted_data: None,
1243                    }],
1244                    model_id: None,
1245                    local_last_resort: false,
1246                },
1247                Message::ToolResult {
1248                    tool_use_id: "call-1".into(),
1249                    content: "source".into(),
1250                    provenance: Provenance::Internal,
1251                },
1252                Message::ProviderOutputItems {
1253                    protocol: "openai-responses".into(),
1254                    items: vec![json!({"type": "reasoning", "encrypted_content": "opaque"})],
1255                },
1256            ],
1257            goal: Some(json!({"check": "./verify"})),
1258            compaction: Some(json!({"generation": 2, "supersedes": 1})),
1259            completion: CompletionMatrix::default(),
1260        };
1261        let scope = ActionScope {
1262            tool: "shell".into(),
1263            parameters: json!({"command": "git push origin HEAD:main"}),
1264            repository_root: repo,
1265            target: "origin/main".into(),
1266            environment: "fixture".into(),
1267            credential_capabilities: vec![CredentialCapability("git:origin".into())],
1268        };
1269        let mut action = SupervisedActionRecord::propose("task-1", "call-7", scope);
1270
1271        {
1272            let mut first = SyncSubsystem::open_with(
1273                "mac-a".into(),
1274                &device,
1275                &relay,
1276                Box::new(coordinator.clone()),
1277                wall.clone(),
1278            )
1279            .unwrap();
1280            first.assistant_checkpoint_put(checkpoint.clone()).unwrap();
1281            first.assistant_action_put(action.clone()).unwrap();
1282            action.transition(ActionState::Approved, None).unwrap();
1283            first.assistant_action_put(action.clone()).unwrap();
1284            action.transition(ActionState::Dispatched, None).unwrap();
1285            first.assistant_action_put(action.clone()).unwrap();
1286        }
1287
1288        let reopened =
1289            SyncSubsystem::open_with("mac-a".into(), &device, &relay, Box::new(coordinator), wall)
1290                .unwrap();
1291        assert_eq!(
1292            reopened.assistant_checkpoint_get("task-1").unwrap(),
1293            Some(checkpoint)
1294        );
1295        let recovered = reopened
1296            .assistant_action_get(&action.id)
1297            .unwrap()
1298            .expect("durable action");
1299        assert_eq!(recovered, action);
1300        assert_eq!(
1301            recovered.resume_directive(),
1302            ResumeDirective::MarkIndeterminate,
1303            "a crash after dispatch must not replay the external effect"
1304        );
1305    }
1306}