Skip to main content

car_sync/
net_relay.rs

1//! Network sync backend — CAR's client for a **remote** relay + lease register
2//! (the Parslee sync service), behind the same [`Relay`] / [`LeaseCoordinator`]
3//! traits the local [`FsRelay`] / [`InMemoryLeaseCoordinator`] already satisfy.
4//!
5//! # Why
6//!
7//! `FsRelay` converges two devices only if they share a filesystem directory —
8//! fine for one machine, useless for "my phone and my Mac". This module makes
9//! the relay a *service*: [`NetworkRelay`] and [`NetworkLeaseCoordinator`] speak
10//! to it over a pluggable [`SyncTransport`], scoped to the caller's Parslee
11//! identity (`scope`, e.g. `user:<id>` or `org:<id>`). The Parslee backend holds
12//! only what `FsRelay` holds — op metadata (`op_id`/`hlc`/`seq`) in the clear and
13//! op *payloads* as E2E ciphertext (see [`crate::crypto`]) — so it can route and
14//! GC without reading your config.
15//!
16//! # The transport seam
17//!
18//! [`SyncTransport`] is the wire contract (blocking, because [`Relay`] is sync;
19//! an HTTP impl blocks inside these calls, which the daemon already runs off a
20//! task). Two impls:
21//!
22//! - [`LoopbackTransport`] — an **in-process reference server**. Each scope is
23//!   backed by a real [`FsRelay`] + [`InMemoryLeaseCoordinator`] over a temp
24//!   dir, so `NetworkRelay`-over-loopback is *semantically identical* to
25//!   `FsRelay` by construction (no re-implementation of the GC-floor / dedup /
26//!   monotone-ack / dominance logic). It is the test double AND a usable
27//!   single-host multi-process relay. `Clone` shares one server across N
28//!   devices.
29//! - `HttpTransport` (a later slice) — reqwest against the real Parslee endpoint.
30//!   It must implement the exact same contract; the loopback is the executable
31//!   spec.
32
33use std::collections::BTreeMap;
34use std::sync::{Arc, Mutex};
35
36use serde::{Deserialize, Serialize};
37
38use crate::checkpoint::Checkpoint;
39use crate::crypto::WrappedOrgKey;
40use crate::lease::{InMemoryLeaseCoordinator, Lease, LeaseCoordinator, LeaseError};
41use crate::oplog::{Hlc, OpRecord, WallClock};
42use crate::org_key_directory::{
43    FsOrgKeyDirectory, MemberPublicKey, OrgKeyDirectory, OrgKeyDirectoryError,
44};
45use crate::relay::{
46    AckOutcome, Frontier, GcReport, PullResult, PushOutcome, Relay, RelayConfig, RelayError,
47    RosterEntry,
48};
49
50/// A transport-level failure — the *service* was unreachable or misbehaved.
51/// Distinct from an in-band verdict (a deduped push, a `Held` lease): those are
52/// success returns, not errors.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum TransportError {
55    /// The service could not be reached / timed out / returned a transport
56    /// error (HTTP 5xx, connection reset, …).
57    Unavailable(String),
58    /// The caller is not authorized for `scope` (bad/expired Parslee token).
59    Unauthorized(String),
60    /// A reply could not be parsed into the expected shape (protocol drift).
61    Protocol(String),
62    /// A pull requested a frontier below the relay's GC floor for `device_id`
63    /// (retained ops start at `dropped_below`). NOT a failure — the service's
64    /// signal that the caller must **cold-bootstrap** from the latest
65    /// checkpoint. Maps to [`RelayError::FrontierTruncated`], which
66    /// [`crate::session::SyncSession::pump`] already handles by rebasing.
67    FrontierTruncated {
68        device_id: String,
69        dropped_below: u64,
70    },
71}
72
73impl std::fmt::Display for TransportError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            TransportError::Unavailable(m) => write!(f, "sync transport unavailable: {m}"),
77            TransportError::Unauthorized(m) => write!(f, "sync transport unauthorized: {m}"),
78            TransportError::Protocol(m) => write!(f, "sync transport protocol error: {m}"),
79            TransportError::FrontierTruncated {
80                device_id,
81                dropped_below,
82            } => write!(
83                f,
84                "sync transport frontier truncated: device {device_id} ops below seq \
85                 {dropped_below} were GC'd (cold-bootstrap from checkpoint)"
86            ),
87        }
88    }
89}
90impl std::error::Error for TransportError {}
91
92impl From<TransportError> for RelayError {
93    fn from(e: TransportError) -> Self {
94        match e {
95            // The service's cold-bootstrap signal maps to the relay's own
96            // FrontierTruncated, which the pump handles by rebasing.
97            TransportError::FrontierTruncated {
98                device_id,
99                dropped_below,
100            } => RelayError::FrontierTruncated {
101                device_id,
102                dropped_below,
103            },
104            // Everything else has no relay variant; surface it as an IO-ish
105            // failure so the pump's best-effort loop treats it as "try later".
106            other => RelayError::Io(std::io::Error::other(other.to_string())),
107        }
108    }
109}
110
111/// The serialized verdict of a lease-register call. Mirrors the coordinator's
112/// `Result<_, LeaseError>` in a wire-portable shape; the client reconstructs the
113/// real [`LeaseError`] from it (the `agent_id` is known client-side).
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub enum LeaseWire {
116    /// `acquire`/`renew` granted this lease.
117    Granted(Lease),
118    /// `acquire` CAS-failed: a still-valid lease is held.
119    Held {
120        holder: String,
121        epoch: u64,
122        expires_at_ms: u64,
123    },
124    /// `renew`/`release` found the caller is no longer the holder at its epoch.
125    Lost {
126        claimed_epoch: u64,
127        current_epoch: u64,
128    },
129    /// `release` succeeded.
130    Released,
131    /// `current` read (holder or unheld).
132    Current(Option<Lease>),
133}
134
135/// The wire contract for a remote sync service, scoped per Parslee identity.
136///
137/// Every method takes a `scope` (`user:<id>` / `org:<id>`) so one service backs
138/// many tenants. Blocking by design. Impls MUST be faithful to [`FsRelay`] /
139/// [`InMemoryLeaseCoordinator`] semantics — [`LoopbackTransport`] is the spec.
140pub trait SyncTransport: Send + Sync {
141    fn enroll(&self, scope: &str, device_id: &str) -> Result<RosterEntry, TransportError>;
142    fn push(
143        &self,
144        scope: &str,
145        device_id: &str,
146        ops: &[OpRecord],
147    ) -> Result<PushOutcome, TransportError>;
148    fn pull(
149        &self,
150        scope: &str,
151        device_id: &str,
152        since: &Frontier,
153    ) -> Result<PullResult, TransportError>;
154    fn ack(
155        &self,
156        scope: &str,
157        device_id: &str,
158        frontier: Hlc,
159    ) -> Result<AckOutcome, TransportError>;
160    fn checkpoint_put(
161        &self,
162        scope: &str,
163        device_id: &str,
164        checkpoint: &Checkpoint,
165    ) -> Result<bool, TransportError>;
166    fn checkpoint_get(&self, scope: &str) -> Result<Option<Checkpoint>, TransportError>;
167    fn roster(&self, scope: &str) -> Result<Vec<RosterEntry>, TransportError>;
168    fn stable_frontier(&self, scope: &str) -> Result<Option<Hlc>, TransportError>;
169    fn gc(&self, scope: &str) -> Result<GcReport, TransportError>;
170
171    // --- execution lease register (B5, distributed) ---
172    fn lease_acquire(
173        &self,
174        scope: &str,
175        agent_id: &str,
176        device_id: &str,
177        ttl_ms: u64,
178    ) -> Result<LeaseWire, TransportError>;
179    fn lease_renew(
180        &self,
181        scope: &str,
182        agent_id: &str,
183        device_id: &str,
184        epoch: u64,
185        ttl_ms: u64,
186    ) -> Result<LeaseWire, TransportError>;
187    fn lease_release(
188        &self,
189        scope: &str,
190        agent_id: &str,
191        device_id: &str,
192        epoch: u64,
193    ) -> Result<LeaseWire, TransportError>;
194    fn lease_current(&self, scope: &str, agent_id: &str) -> Result<LeaseWire, TransportError>;
195}
196
197/// The scope-keyed wire form of [`crate::org_key_directory::OrgKeyDirectory`] —
198/// the org-key analogue of [`SyncTransport`]. Kept SEPARATE from `SyncTransport`
199/// for the same reason the local directory is separate from `Relay`: org-key
200/// blobs are a key-value directory, not oplog ops, and routing them around the
201/// op stream keeps the frontier/GC invariants clean. One backend (e.g.
202/// [`LoopbackTransport`], and later `ParsleeSyncTransport`) implements BOTH
203/// traits over the same `scope: "org:<id>"` tenant key.
204///
205/// Every method takes a leading `scope`; a [`NetworkOrgKeyDirectory`] binds one
206/// scope and adapts this into the local `OrgKeyDirectory` trait (the
207/// [`NetworkRelay`] analogue).
208pub trait OrgKeyTransport: Send + Sync {
209    fn publish_wrapped(&self, scope: &str, wrapped: &WrappedOrgKey) -> Result<(), TransportError>;
210    fn fetch_wrapped(
211        &self,
212        scope: &str,
213        epoch: u64,
214        recipient_user_id: &str,
215    ) -> Result<Option<WrappedOrgKey>, TransportError>;
216    fn fetch_wrapped_for(
217        &self,
218        scope: &str,
219        recipient_user_id: &str,
220    ) -> Result<Vec<WrappedOrgKey>, TransportError>;
221    fn publish_pubkey(
222        &self,
223        scope: &str,
224        account_id: &str,
225        public_hex: &str,
226    ) -> Result<(), TransportError>;
227    fn fetch_pubkeys(&self, scope: &str) -> Result<Vec<MemberPublicKey>, TransportError>;
228}
229
230/// A [`Relay`] backed by a remote [`SyncTransport`], scoped to one Parslee
231/// identity. One per device; share the transport (`Arc`) across devices.
232pub struct NetworkRelay {
233    transport: Arc<dyn SyncTransport>,
234    scope: String,
235}
236
237impl NetworkRelay {
238    pub fn new(transport: Arc<dyn SyncTransport>, scope: impl Into<String>) -> Self {
239        Self {
240            transport,
241            scope: scope.into(),
242        }
243    }
244}
245
246impl Relay for NetworkRelay {
247    fn register(&mut self, device_id: &str) -> Result<RosterEntry, RelayError> {
248        Ok(self.transport.enroll(&self.scope, device_id)?)
249    }
250    fn push(&mut self, device_id: &str, ops: &[OpRecord]) -> Result<PushOutcome, RelayError> {
251        Ok(self.transport.push(&self.scope, device_id, ops)?)
252    }
253    fn pull(&mut self, device_id: &str, since: &Frontier) -> Result<PullResult, RelayError> {
254        Ok(self.transport.pull(&self.scope, device_id, since)?)
255    }
256    fn ack(&mut self, device_id: &str, frontier: Hlc) -> Result<AckOutcome, RelayError> {
257        Ok(self.transport.ack(&self.scope, device_id, frontier)?)
258    }
259    fn checkpoint_put(
260        &mut self,
261        device_id: &str,
262        checkpoint: &Checkpoint,
263    ) -> Result<bool, RelayError> {
264        Ok(self
265            .transport
266            .checkpoint_put(&self.scope, device_id, checkpoint)?)
267    }
268    fn checkpoint_get(&mut self) -> Result<Option<Checkpoint>, RelayError> {
269        Ok(self.transport.checkpoint_get(&self.scope)?)
270    }
271    fn roster(&mut self) -> Result<Vec<RosterEntry>, RelayError> {
272        Ok(self.transport.roster(&self.scope)?)
273    }
274    fn stable_frontier(&mut self) -> Result<Option<Hlc>, RelayError> {
275        Ok(self.transport.stable_frontier(&self.scope)?)
276    }
277    fn gc(&mut self) -> Result<GcReport, RelayError> {
278        Ok(self.transport.gc(&self.scope)?)
279    }
280}
281
282/// A [`LeaseCoordinator`] backed by a remote [`SyncTransport`] — the
283/// **distributed** execution lease that makes "phone and Mac" mutually
284/// exclusive on a given agent. Reconstructs the real [`LeaseError`] from the
285/// transport's [`LeaseWire`] verdict.
286pub struct NetworkLeaseCoordinator {
287    transport: Arc<dyn SyncTransport>,
288    scope: String,
289}
290
291impl NetworkLeaseCoordinator {
292    pub fn new(transport: Arc<dyn SyncTransport>, scope: impl Into<String>) -> Self {
293        Self {
294            transport,
295            scope: scope.into(),
296        }
297    }
298}
299
300fn wire_to_lease(agent_id: &str, wire: LeaseWire, ctx: &'static str) -> Result<Lease, LeaseError> {
301    match wire {
302        LeaseWire::Granted(l) => Ok(l),
303        LeaseWire::Held {
304            holder,
305            epoch,
306            expires_at_ms,
307        } => Err(LeaseError::Held {
308            agent_id: agent_id.to_string(),
309            holder,
310            epoch,
311            expires_at_ms,
312        }),
313        LeaseWire::Lost {
314            claimed_epoch,
315            current_epoch,
316        } => Err(LeaseError::Lost {
317            agent_id: agent_id.to_string(),
318            claimed_epoch,
319            current_epoch,
320        }),
321        other => Err(LeaseError::Backend(format!(
322            "{ctx}: unexpected lease verdict {other:?}"
323        ))),
324    }
325}
326
327impl LeaseCoordinator for NetworkLeaseCoordinator {
328    fn acquire(
329        &mut self,
330        agent_id: &str,
331        device_id: &str,
332        ttl_ms: u64,
333    ) -> Result<Lease, LeaseError> {
334        let wire = self
335            .transport
336            .lease_acquire(&self.scope, agent_id, device_id, ttl_ms)
337            .map_err(|e| LeaseError::Backend(e.to_string()))?;
338        wire_to_lease(agent_id, wire, "acquire")
339    }
340
341    fn renew(
342        &mut self,
343        agent_id: &str,
344        device_id: &str,
345        epoch: u64,
346        ttl_ms: u64,
347    ) -> Result<Lease, LeaseError> {
348        let wire = self
349            .transport
350            .lease_renew(&self.scope, agent_id, device_id, epoch, ttl_ms)
351            .map_err(|e| LeaseError::Backend(e.to_string()))?;
352        wire_to_lease(agent_id, wire, "renew")
353    }
354
355    fn release(&mut self, agent_id: &str, device_id: &str, epoch: u64) -> Result<(), LeaseError> {
356        let wire = self
357            .transport
358            .lease_release(&self.scope, agent_id, device_id, epoch)
359            .map_err(|e| LeaseError::Backend(e.to_string()))?;
360        match wire {
361            LeaseWire::Released => Ok(()),
362            LeaseWire::Lost {
363                claimed_epoch,
364                current_epoch,
365            } => Err(LeaseError::Lost {
366                agent_id: agent_id.to_string(),
367                claimed_epoch,
368                current_epoch,
369            }),
370            other => Err(LeaseError::Backend(format!(
371                "release: unexpected lease verdict {other:?}"
372            ))),
373        }
374    }
375
376    fn current(&mut self, agent_id: &str) -> Result<Option<Lease>, LeaseError> {
377        let wire = self
378            .transport
379            .lease_current(&self.scope, agent_id)
380            .map_err(|e| LeaseError::Backend(e.to_string()))?;
381        match wire {
382            LeaseWire::Current(l) => Ok(l),
383            other => Err(LeaseError::Backend(format!(
384                "current: unexpected lease verdict {other:?}"
385            ))),
386        }
387    }
388}
389
390// ---------------------------------------------------------------------------
391// LoopbackTransport — the in-process reference server.
392// ---------------------------------------------------------------------------
393
394struct ScopeBackend {
395    relay: crate::relay::FsRelay,
396    lease: InMemoryLeaseCoordinator,
397    /// The scope's org-key directory — a sibling of the `FsRelay` in the same
398    /// per-scope dir (distinct file names: `org-keys.json`/`.lock` vs.
399    /// `relay-state.json`/`relay.lock`), so one server keeps org-key blobs
400    /// tenant-isolated exactly as it does the oplog.
401    org_keys: FsOrgKeyDirectory,
402}
403
404struct LoopbackInner {
405    dir: tempfile::TempDir,
406    wall: WallClock,
407    config: RelayConfig,
408    scopes: BTreeMap<String, ScopeBackend>,
409}
410
411/// An in-process [`SyncTransport`]: each scope is a real [`FsRelay`] +
412/// [`InMemoryLeaseCoordinator`] over a temp dir, so it reproduces the canonical
413/// relay/lease semantics exactly. `Clone` shares one server across devices.
414#[derive(Clone)]
415pub struct LoopbackTransport {
416    inner: Arc<Mutex<LoopbackInner>>,
417}
418
419impl LoopbackTransport {
420    /// A fresh reference server (system wall clock, no eviction horizon).
421    pub fn new() -> std::io::Result<Self> {
422        Self::with_config(RelayConfig::default(), crate::oplog::system_clock())
423    }
424
425    pub fn with_config(config: RelayConfig, wall: WallClock) -> std::io::Result<Self> {
426        Ok(Self {
427            inner: Arc::new(Mutex::new(LoopbackInner {
428                dir: tempfile::TempDir::new()?,
429                wall,
430                config,
431                scopes: BTreeMap::new(),
432            })),
433        })
434    }
435
436    /// Run `f` against the (lazily-created) backend for `scope`.
437    fn with_scope<R>(
438        &self,
439        scope: &str,
440        f: impl FnOnce(&mut ScopeBackend) -> Result<R, TransportError>,
441    ) -> Result<R, TransportError> {
442        let mut inner = self
443            .inner
444            .lock()
445            .map_err(|_| TransportError::Unavailable("loopback lock poisoned".into()))?;
446        if !inner.scopes.contains_key(scope) {
447            // FsRelay dirs are per-scope so one server keeps tenants isolated.
448            let dir = inner.dir.path().join(sanitize_scope(scope));
449            std::fs::create_dir_all(&dir)
450                .map_err(|e| TransportError::Unavailable(format!("loopback mkdir: {e}")))?;
451            let relay = crate::relay::FsRelay::open(&dir, inner.config.clone(), inner.wall.clone())
452                .map_err(|e| TransportError::Unavailable(format!("loopback FsRelay: {e}")))?;
453            let org_keys = FsOrgKeyDirectory::open(&dir)
454                .map_err(|e| TransportError::Unavailable(format!("loopback org-keys: {e}")))?;
455            let lease = InMemoryLeaseCoordinator::new(inner.wall.clone());
456            inner.scopes.insert(
457                scope.to_string(),
458                ScopeBackend {
459                    relay,
460                    lease,
461                    org_keys,
462                },
463            );
464        }
465        let backend = inner.scopes.get_mut(scope).expect("just inserted");
466        f(backend)
467    }
468}
469
470/// Map a scope string to a filesystem-safe dir name (loopback only).
471///
472/// NOTE: lossy — `org:acme` and `org_acme` both collapse to `org_acme`, so two
473/// *distinct* scopes could share a dir here. Harmless in the reference server
474/// (test scopes are well-separated) and absent from the real transport (which
475/// keys by scope directly), but do not lean on this for isolation.
476fn sanitize_scope(scope: &str) -> String {
477    scope
478        .chars()
479        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
480        .collect()
481}
482
483fn relay_err(e: RelayError) -> TransportError {
484    TransportError::Unavailable(e.to_string())
485}
486
487impl SyncTransport for LoopbackTransport {
488    fn enroll(&self, scope: &str, device_id: &str) -> Result<RosterEntry, TransportError> {
489        self.with_scope(scope, |b| b.relay.register(device_id).map_err(relay_err))
490    }
491    fn push(
492        &self,
493        scope: &str,
494        device_id: &str,
495        ops: &[OpRecord],
496    ) -> Result<PushOutcome, TransportError> {
497        self.with_scope(scope, |b| b.relay.push(device_id, ops).map_err(relay_err))
498    }
499    fn pull(
500        &self,
501        scope: &str,
502        device_id: &str,
503        since: &Frontier,
504    ) -> Result<PullResult, TransportError> {
505        self.with_scope(scope, |b| b.relay.pull(device_id, since).map_err(relay_err))
506    }
507    fn ack(
508        &self,
509        scope: &str,
510        device_id: &str,
511        frontier: Hlc,
512    ) -> Result<AckOutcome, TransportError> {
513        self.with_scope(scope, |b| {
514            b.relay.ack(device_id, frontier).map_err(relay_err)
515        })
516    }
517    fn checkpoint_put(
518        &self,
519        scope: &str,
520        device_id: &str,
521        checkpoint: &Checkpoint,
522    ) -> Result<bool, TransportError> {
523        self.with_scope(scope, |b| {
524            b.relay
525                .checkpoint_put(device_id, checkpoint)
526                .map_err(relay_err)
527        })
528    }
529    fn checkpoint_get(&self, scope: &str) -> Result<Option<Checkpoint>, TransportError> {
530        self.with_scope(scope, |b| b.relay.checkpoint_get().map_err(relay_err))
531    }
532    fn roster(&self, scope: &str) -> Result<Vec<RosterEntry>, TransportError> {
533        self.with_scope(scope, |b| b.relay.roster().map_err(relay_err))
534    }
535    fn stable_frontier(&self, scope: &str) -> Result<Option<Hlc>, TransportError> {
536        self.with_scope(scope, |b| b.relay.stable_frontier().map_err(relay_err))
537    }
538    fn gc(&self, scope: &str) -> Result<GcReport, TransportError> {
539        self.with_scope(scope, |b| b.relay.gc().map_err(relay_err))
540    }
541
542    fn lease_acquire(
543        &self,
544        scope: &str,
545        agent_id: &str,
546        device_id: &str,
547        ttl_ms: u64,
548    ) -> Result<LeaseWire, TransportError> {
549        self.with_scope(scope, |b| {
550            Ok(match b.lease.acquire(agent_id, device_id, ttl_ms) {
551                Ok(l) => LeaseWire::Granted(l),
552                Err(LeaseError::Held {
553                    holder,
554                    epoch,
555                    expires_at_ms,
556                    ..
557                }) => LeaseWire::Held {
558                    holder,
559                    epoch,
560                    expires_at_ms,
561                },
562                Err(e) => return Err(TransportError::Unavailable(e.to_string())),
563            })
564        })
565    }
566    fn lease_renew(
567        &self,
568        scope: &str,
569        agent_id: &str,
570        device_id: &str,
571        epoch: u64,
572        ttl_ms: u64,
573    ) -> Result<LeaseWire, TransportError> {
574        self.with_scope(scope, |b| {
575            Ok(match b.lease.renew(agent_id, device_id, epoch, ttl_ms) {
576                Ok(l) => LeaseWire::Granted(l),
577                Err(LeaseError::Lost {
578                    claimed_epoch,
579                    current_epoch,
580                    ..
581                }) => LeaseWire::Lost {
582                    claimed_epoch,
583                    current_epoch,
584                },
585                Err(e) => return Err(TransportError::Unavailable(e.to_string())),
586            })
587        })
588    }
589    fn lease_release(
590        &self,
591        scope: &str,
592        agent_id: &str,
593        device_id: &str,
594        epoch: u64,
595    ) -> Result<LeaseWire, TransportError> {
596        self.with_scope(scope, |b| {
597            Ok(match b.lease.release(agent_id, device_id, epoch) {
598                Ok(()) => LeaseWire::Released,
599                Err(LeaseError::Lost {
600                    claimed_epoch,
601                    current_epoch,
602                    ..
603                }) => LeaseWire::Lost {
604                    claimed_epoch,
605                    current_epoch,
606                },
607                Err(e) => return Err(TransportError::Unavailable(e.to_string())),
608            })
609        })
610    }
611    fn lease_current(&self, scope: &str, agent_id: &str) -> Result<LeaseWire, TransportError> {
612        self.with_scope(scope, |b| {
613            b.lease
614                .current(agent_id)
615                .map(LeaseWire::Current)
616                .map_err(|e| TransportError::Unavailable(e.to_string()))
617        })
618    }
619}
620
621fn org_key_err(e: OrgKeyDirectoryError) -> TransportError {
622    TransportError::Unavailable(e.to_string())
623}
624
625impl OrgKeyTransport for LoopbackTransport {
626    fn publish_wrapped(&self, scope: &str, wrapped: &WrappedOrgKey) -> Result<(), TransportError> {
627        self.with_scope(scope, |b| {
628            b.org_keys.publish_wrapped(wrapped).map_err(org_key_err)
629        })
630    }
631    fn fetch_wrapped(
632        &self,
633        scope: &str,
634        epoch: u64,
635        recipient_user_id: &str,
636    ) -> Result<Option<WrappedOrgKey>, TransportError> {
637        self.with_scope(scope, |b| {
638            b.org_keys
639                .fetch_wrapped(epoch, recipient_user_id)
640                .map_err(org_key_err)
641        })
642    }
643    fn fetch_wrapped_for(
644        &self,
645        scope: &str,
646        recipient_user_id: &str,
647    ) -> Result<Vec<WrappedOrgKey>, TransportError> {
648        self.with_scope(scope, |b| {
649            b.org_keys
650                .fetch_wrapped_for(recipient_user_id)
651                .map_err(org_key_err)
652        })
653    }
654    fn publish_pubkey(
655        &self,
656        scope: &str,
657        account_id: &str,
658        public_hex: &str,
659    ) -> Result<(), TransportError> {
660        self.with_scope(scope, |b| {
661            b.org_keys
662                .publish_pubkey(account_id, public_hex)
663                .map_err(org_key_err)
664        })
665    }
666    fn fetch_pubkeys(&self, scope: &str) -> Result<Vec<MemberPublicKey>, TransportError> {
667        self.with_scope(scope, |b| b.org_keys.fetch_pubkeys().map_err(org_key_err))
668    }
669}
670
671/// An [`OrgKeyDirectory`] backed by a remote [`OrgKeyTransport`], bound to one
672/// `scope` (e.g. `"org:<id>"`). The org-key analogue of [`NetworkRelay`]: it
673/// adapts the scope-keyed wire trait into the single-directory local trait so a
674/// client speaks the same `OrgKeyDirectory` API whether it is fs-backed or
675/// transport-backed. `TransportError` maps via [`transport_err`]: an authz
676/// refusal is preserved as [`OrgKeyDirectoryError::Unauthorized`] (a security
677/// "no", not a retry), everything else funnels into `Io` — the org-key analogue
678/// of how `NetworkRelay` preserves `FrontierTruncated` and funnels the rest.
679pub struct NetworkOrgKeyDirectory {
680    transport: Arc<dyn OrgKeyTransport>,
681    scope: String,
682}
683
684impl NetworkOrgKeyDirectory {
685    pub fn new(transport: Arc<dyn OrgKeyTransport>, scope: impl Into<String>) -> Self {
686        Self {
687            transport,
688            scope: scope.into(),
689        }
690    }
691}
692
693fn transport_err(e: TransportError) -> OrgKeyDirectoryError {
694    match e {
695        // A backend authz refusal is a security "no", not a retry — preserve it
696        // as the load-bearing distinct variant (see OrgKeyDirectoryError docs).
697        TransportError::Unauthorized(m) => OrgKeyDirectoryError::Unauthorized(m),
698        // Everything else is a transport blip; the directory has no dedicated
699        // variant, so funnel it into Io (a "try later" for callers).
700        other => OrgKeyDirectoryError::Io(std::io::Error::other(other.to_string())),
701    }
702}
703
704impl OrgKeyDirectory for NetworkOrgKeyDirectory {
705    fn publish_wrapped(&mut self, wrapped: &WrappedOrgKey) -> Result<(), OrgKeyDirectoryError> {
706        self.transport
707            .publish_wrapped(&self.scope, wrapped)
708            .map_err(transport_err)
709    }
710    fn fetch_wrapped(
711        &self,
712        epoch: u64,
713        recipient_user_id: &str,
714    ) -> Result<Option<WrappedOrgKey>, OrgKeyDirectoryError> {
715        self.transport
716            .fetch_wrapped(&self.scope, epoch, recipient_user_id)
717            .map_err(transport_err)
718    }
719    fn fetch_wrapped_for(
720        &self,
721        recipient_user_id: &str,
722    ) -> Result<Vec<WrappedOrgKey>, OrgKeyDirectoryError> {
723        self.transport
724            .fetch_wrapped_for(&self.scope, recipient_user_id)
725            .map_err(transport_err)
726    }
727    fn publish_pubkey(
728        &mut self,
729        account_id: &str,
730        public_hex: &str,
731    ) -> Result<(), OrgKeyDirectoryError> {
732        self.transport
733            .publish_pubkey(&self.scope, account_id, public_hex)
734            .map_err(transport_err)
735    }
736    fn fetch_pubkeys(&self) -> Result<Vec<MemberPublicKey>, OrgKeyDirectoryError> {
737        self.transport
738            .fetch_pubkeys(&self.scope)
739            .map_err(transport_err)
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::org_key_directory::conformance;
747    use crate::relay::Relay;
748
749    fn loopback() -> Arc<LoopbackTransport> {
750        let clock: WallClock = Arc::new(|| 1000);
751        Arc::new(LoopbackTransport::with_config(RelayConfig::default(), clock).unwrap())
752    }
753
754    fn transport() -> Arc<dyn SyncTransport> {
755        loopback()
756    }
757
758    #[test]
759    fn loopback_org_key_directory_satisfies_the_shared_contract() {
760        // The SAME conformance sequence the fs/in-memory references pass in
761        // org_key_directory.rs, now driven through the scope-keyed transport +
762        // NetworkOrgKeyDirectory adapter — proving the wire form is behaviourally
763        // identical before a byte crosses into car-parslee.
764        let mut dir = NetworkOrgKeyDirectory::new(loopback(), "org:acme");
765        conformance::round_trip_suite(&mut dir);
766    }
767
768    #[test]
769    fn org_key_scopes_are_tenant_isolated() {
770        // One server, two org scopes: a wrap/pubkey published under org:acme must
771        // never be visible under org:globex (per-scope FsOrgKeyDirectory dirs).
772        let svc = loopback();
773        let mut acme = NetworkOrgKeyDirectory::new(svc.clone(), "org:acme");
774        let globex = NetworkOrgKeyDirectory::new(svc.clone(), "org:globex");
775
776        let w = conformance::make_wrap("acme", 1, "alice");
777        acme.publish_wrapped(&w).unwrap();
778        acme.publish_pubkey("alice", "cafe").unwrap();
779
780        assert_eq!(acme.fetch_wrapped(1, "alice").unwrap().as_ref(), Some(&w));
781        assert!(globex.fetch_wrapped(1, "alice").unwrap().is_none());
782        assert!(globex.fetch_wrapped_for("alice").unwrap().is_empty());
783        assert!(globex.fetch_pubkeys().unwrap().is_empty());
784    }
785
786    #[test]
787    fn org_keys_persist_across_transport_handles_at_same_scope() {
788        // Two NetworkOrgKeyDirectory handles over the SAME shared transport +
789        // scope see each other's writes (the "phone + Mac share one org key"
790        // path — and the load-bearing half of the isolation claim above), and
791        // reads are &self.
792        let svc = loopback();
793        let mut writer = NetworkOrgKeyDirectory::new(svc.clone(), "org:acme");
794        let reader = NetworkOrgKeyDirectory::new(svc.clone(), "org:acme");
795        let w = conformance::make_wrap("acme", 7, "bob");
796        writer.publish_wrapped(&w).unwrap();
797        assert_eq!(reader.fetch_wrapped(7, "bob").unwrap().as_ref(), Some(&w));
798    }
799
800    #[test]
801    fn transport_err_preserves_unauthorized_and_funnels_the_rest() {
802        // The security-critical mapping: a backend authz refusal must NOT be
803        // seen as a retryable Io blip (slice 3 returns Unauthorized on rejected
804        // publishes).
805        assert!(matches!(
806            transport_err(TransportError::Unauthorized("nope".into())),
807            OrgKeyDirectoryError::Unauthorized(_)
808        ));
809        assert!(matches!(
810            transport_err(TransportError::Unavailable("down".into())),
811            OrgKeyDirectoryError::Io(_)
812        ));
813        assert!(matches!(
814            transport_err(TransportError::Protocol("drift".into())),
815            OrgKeyDirectoryError::Io(_)
816        ));
817    }
818
819    #[test]
820    fn two_devices_converge_through_a_shared_service() {
821        // Mac and phone push through ONE remote service (shared Arc). Each pulls
822        // the other's ops — the "phone + Mac" convergence, but over the network
823        // relay rather than a shared folder.
824        let svc = transport();
825        let scope = "user:matt";
826        let mut mac = NetworkRelay::new(svc.clone(), scope);
827        let mut phone = NetworkRelay::new(svc.clone(), scope);
828
829        mac.register("mac").unwrap();
830        phone.register("phone").unwrap();
831
832        // Build a real, content-addressed op (the relay verifies op_id == the
833        // content hash, so it must go through OpRecord::new).
834        let op = crate::oplog::OpRecord::new(
835            Hlc {
836                wall_ms: 1000,
837                counter: 0,
838                device_id: "mac".into(),
839            },
840            0,
841            None,
842            crate::oplog::Scope::Personal,
843            crate::oplog::Surface::Routing,
844            serde_json::json!({"hello": "phone"}),
845        );
846        let out = mac.push("mac", std::slice::from_ref(&op)).unwrap();
847        assert_eq!(out.accepted, 1);
848
849        // The phone pulls from empty and sees the Mac's op.
850        let pulled = phone.pull("phone", &Frontier::new()).unwrap();
851        assert_eq!(pulled.ops.len(), 1);
852        assert_eq!(pulled.ops[0].op_id, op.op_id);
853
854        // Tenant isolation: a different scope is a different, empty relay.
855        let mut other = NetworkRelay::new(svc.clone(), "user:someone_else");
856        assert!(other.pull("d", &Frontier::new()).unwrap().ops.is_empty());
857    }
858
859    #[test]
860    fn frontier_truncation_maps_to_the_relay_cold_bootstrap_signal() {
861        // The service's distinct truncation signal must become
862        // RelayError::FrontierTruncated so the pump rebases (cold bootstrap),
863        // not a generic IO error that would just retry forever.
864        let te = TransportError::FrontierTruncated {
865            device_id: "mac".into(),
866            dropped_below: 7,
867        };
868        match RelayError::from(te) {
869            RelayError::FrontierTruncated {
870                device_id,
871                dropped_below,
872            } => {
873                assert_eq!(device_id, "mac");
874                assert_eq!(dropped_below, 7);
875            }
876            other => panic!("expected FrontierTruncated, got {other:?}"),
877        }
878    }
879
880    #[test]
881    fn distributed_lease_is_mutually_exclusive_across_devices() {
882        // Mac and phone contend for the same agent's execution lease through the
883        // one service. Only one holds it; the loser sees `Held`; on release the
884        // other acquires with the next (monotone) epoch — the fencing token that
885        // stops both devices double-running the agent.
886        let svc = transport();
887        let scope = "user:matt";
888        let mut mac = NetworkLeaseCoordinator::new(svc.clone(), scope);
889        let mut phone = NetworkLeaseCoordinator::new(svc.clone(), scope);
890
891        let l1 = mac.acquire("milo", "mac", 10_000).unwrap();
892        assert_eq!(l1.epoch, 1);
893        assert_eq!(l1.holder, "mac");
894
895        // Phone loses the CAS while the Mac's lease is valid.
896        match phone.acquire("milo", "phone", 10_000) {
897            Err(LeaseError::Held { holder, epoch, .. }) => {
898                assert_eq!(holder, "mac");
899                assert_eq!(epoch, 1);
900            }
901            other => panic!("expected Held, got {other:?}"),
902        }
903
904        // Both devices read the same holder.
905        assert_eq!(phone.current("milo").unwrap().unwrap().holder, "mac");
906
907        // Mac releases; phone now acquires with epoch 2 (never reused).
908        mac.release("milo", "mac", 1).unwrap();
909        let l2 = phone.acquire("milo", "phone", 10_000).unwrap();
910        assert_eq!(l2.epoch, 2);
911        assert_eq!(l2.holder, "phone");
912
913        // The Mac (a zombie holding the stale epoch) learns it lost.
914        match mac.renew("milo", "mac", 1, 10_000) {
915            Err(LeaseError::Lost { current_epoch, .. }) => assert_eq!(current_epoch, 2),
916            other => panic!("expected Lost, got {other:?}"),
917        }
918    }
919}