Skip to main content

borg_mesh/
sync.rs

1//! Two members of one collective reconcile its logs over iroh (QUIC, TLS with raw ed25519 keys).
2//!
3//! iroh authenticates the peer's key during the handshake; a node then checks that key against
4//! its verified membership before it sends a single byte of the collective. One session is a
5//! sequence of swaps on one stream (the dialler speaks first, the listener answers after it has
6//! checked what it heard). The order is the one PLAN.md "Reconnect" fixes, trust first:
7//!
8//! 1. `Hello`: which collective (the listener refuses a name that is not its own), and the port the
9//!    dialler listens on. The listener remembers it in peers.json at the IP QUIC saw the dialler
10//!    come from, so a node bound to 0.0.0.0 is still dialled back at an address that works.
11//! 2. `Membership`: both nodes' whole history of signed versions; each resolves it from its own
12//!    root (membership.rs: every step signed by an admin of the previous one, forks resolved the
13//!    same way everywhere). The peer must still be a member under the result, and revocations are
14//!    applied (quarantine) before any data moves, also when the held version did not change but
15//!    the logs were never checked against it (`origin::apply_if_new`).
16//! 3. `lessons.jsonl`, `stats.jsonl`, `outcomes-team.jsonl`, `playbooks.jsonl`, `constraints.jsonl`, `messages.jsonl`
17//!    (lines.rs `SYNCED` and the removed playbooks' step, `PLAYBOOKS`; a 0.3.0 peer's `borg/mesh/12`
18//!    session stops before the messages), each in turn:
19//!    range-based set reconciliation (recon.rs), cost ∝ difference: one fingerprint when nothing
20//!    changed. Then `Lines`: what the other side lacks, each line with its
21//!    signed origin (origin.rs). A line is stored only if the peer announced it, its origin verifies,
22//!    the membership admits its origin key at its stamp time, and it may travel under that key
23//!    (lines.rs `travels`: a message names its origin key as its node, and is fresh). Messages also
24//!    travel only within a `window` (one per sender and recipient per rate slot, a share of `KEEP` per
25//!    node, the `KEEP` newest), on offer and on receipt, and what the window cuts is pruned (`prune`).
26//!    A pause, resume or veto from another node counts only once admitted under its node's role
27//!    (steer.rs `admit`), when it is stored.
28//!
29//!    The playbooks are removed, but their step is still spoken so a 0.3.0 or 0.4.0 peer keeps its
30//!    order: this node offers nothing there, follows the peer's reconciliation and stores nothing
31//!    (`skip`), as for a hive goal below.
32//!
33//!    3b. `Goals`: the removed hive's step, still spoken so a 0.3.0 peer keeps syncing on
34//!    `borg/mesh/12`. This node names no goal; for each valid goal id the peer names it follows the
35//!    peer's reconciliation of that goal's `LEGACY_GOAL_LOGS` logs and stores nothing (`skip`). What
36//!    a peer gave is remembered in memory (`Node::legacy`), so it is not sent again every session.
37//!
38//! One `Lines` frame carries at most `MAX_GIVE` bytes of lines; the rest go at the next session (the
39//! sets still differ, so its reconciliation finds them again).
40//! 4. `reasoning.jsonl` (git-note excerpts, notes in the borg crate): not reconciled with everyone.
41//!    Each author offers its own excerpts to the k = 2 members that rendezvous hashing assigns to
42//!    each excerpt (`replicas`), so the reasoning survives the author being offline without every
43//!    member holding all of it.
44//! 5. `Ask`: the commits whose reasoning a member wants (`borg notes fetch`); any node that holds
45//!    an admitted excerpt for one (or a link record with it on either side) answers with it, author
46//!    or replica, so the "why" can be read back while its author is offline.
47//! 6. `Live`: ephemeral, never reconciled into a log: presence (live.rs: which drones run where,
48//!    latest per member and session, a day at most). A 0.3.0 peer's hive beats and signed steering
49//!    directives in it are ignored: steering travels as messages, admitted when stored (steer.rs).
50//!
51//! A completed session, dialled or served, is recorded on each side by when it started, per member
52//! (`mesh/synced.json`, borg presence.rs `synced_path`).
53//!
54//! Addressing is by key (addr.rs): a peer is dialled at its node id, with direct addresses and
55//! relay URLs as optional hints. How the endpoint reaches it across NAT is the collective's relay
56//! setting: n0's relays and address lookup (default), a self-hosted relay, or direct only.
57//!
58//! **Joining** (invite.rs) is a second ALPN: a node that is not a member yet presents a signed
59//! invite to the admin that issued it, and gets the membership history back (`join`, `admit`).
60
61use std::collections::{BTreeMap, BTreeSet, HashMap};
62use std::net::SocketAddr;
63use std::path::{Path, PathBuf};
64use std::sync::Mutex;
65use std::time::Duration;
66
67use borg::collective::{self, Config, Route};
68use iroh::Endpoint;
69use iroh::endpoint::{Builder, ConnectOptions, Connection, Incoming, IncomingAddr, PortmapperConfig, RecvStream, SendStream, presets};
70use iroh::tls::CaTlsConfig;
71use serde::{Deserialize, Serialize};
72
73use crate::addr::{self, Hint, Relay, Target};
74use crate::identity::Identity;
75use crate::invite::{self, Invite, Ticket};
76use crate::lines::{self, Hash, REASONING, SYNCED, logs};
77use crate::membership::Membership;
78use crate::origin::{self, Origin};
79use crate::recon::{self, Recon};
80use crate::{from_hex, peers, to_hex};
81use crate::{live, steer};
82
83mod vectors;
84
85/// The session protocol. 13: `messages.jsonl` joined the synced logs (lines.rs `SYNCED`). The logs
86/// reconcile in a fixed list, so a peer on another list would reconcile another file at the same step;
87/// 11 added `constraints.jsonl` and refused 10 at the handshake for that reason.
88pub const ALPN: &[u8] = b"borg/mesh/13";
89/// 0.3.0's protocol, still spoken whole: the session of `ALPN` without the messages (`synced`). 12
90/// already outlived the hive: its steps (`Goals`, the beats of `Live`) are spoken empty.
91pub const LEGACY_ALPN: &[u8] = b"borg/mesh/12";
92/// The protocols of older nodes (`borg/mesh/11` down to `/1`), offered and accepted beside `ALPN` only to
93/// tell a mismatch: without them the handshake fails and nothing says why. A session on one is closed at
94/// once and the peer recorded (`mismatch`). A newer node does the same with this one's `ALPN`.
95fn older() -> Vec<Vec<u8>> {
96    let below = version(LEGACY_ALPN).unwrap_or(0);
97    (1..below).rev().map(|v| format!("borg/mesh/{v}").into_bytes()).collect()
98}
99
100/// The other side of a session, as the handshake left it: its member key and the protocol it speaks.
101#[derive(Clone, Copy)]
102struct Peer<'a> {
103    key: &'a str,
104    alpn: &'a [u8],
105}
106
107/// Whether a session on `alpn` syncs with this node: `ALPN`, or a 0.3.0 peer's `LEGACY_ALPN`.
108fn spoken(alpn: &[u8]) -> bool {
109    alpn == ALPN || alpn == LEGACY_ALPN
110}
111
112/// The removed playbooks' log. Its step (after the outcomes) stays in the session, so `ALPN` and
113/// `LEGACY_ALPN` keep their order; a peer that still reconciles it is followed and nothing is kept
114/// (`skip`). A leftover file in a store is neither offered nor stamped.
115const PLAYBOOKS: &str = "playbooks.jsonl";
116
117/// The logs a session on `ALPN` steps through, in order: `SYNCED` with the `PLAYBOOKS` step.
118const STEPS: [&str; 6] = [SYNCED[0], SYNCED[1], SYNCED[2], PLAYBOOKS, SYNCED[3], SYNCED[4]];
119
120/// The logs a session on `alpn` steps through, in order: all of `STEPS`, or on `LEGACY_ALPN` the five a
121/// 0.3.0 peer knows.
122fn synced(alpn: &[u8]) -> impl Iterator<Item = &'static str> {
123    let legacy = alpn == LEGACY_ALPN;
124    STEPS.into_iter().filter(move |f| !legacy || *f != borg::msg::FILE)
125}
126
127/// The number of a session protocol (`borg/mesh/<n>`).
128fn version(alpn: &[u8]) -> Option<u32> {
129    std::str::from_utf8(alpn).ok()?.strip_prefix("borg/mesh/")?.parse().ok()
130}
131/// The join handshake: an invite for the membership history (`Node::join`, `Node::admit`).
132pub const JOIN_ALPN: &[u8] = b"borg/join/1";
133/// Largest frame accepted; a lessons log is far smaller, a bigger frame is a broken or hostile peer.
134const MAX_FRAME: usize = 256 << 20;
135/// Most bytes of lines one `Lines` frame gives (well within `MAX_FRAME`): a big difference goes over
136/// several sessions, never in a frame the peer refuses.
137const MAX_GIVE: usize = 64 << 20;
138/// How many members hold a replica of each reasoning excerpt besides its author.
139pub const REPLICAS: usize = 2;
140/// Most commits one `Ask` names, and most excerpts one answer carries.
141const MAX_ASK: usize = 1000;
142/// How many logs per goal a 0.3.0 peer reconciles in step 3b (its hive's synced logs: goal, tasks,
143/// contracts, messages, flags, bundles, spend).
144const LEGACY_GOAL_LOGS: usize = 7;
145
146/// Where a node finds its collective. Kept instead of a fixed `Route` so every session re-reads the
147/// config: setting the collective to `share: local`, adding the repo to `off` or breaking the file
148/// stops a running `serve` at its next session, not at its next restart.
149#[derive(Debug, Clone)]
150pub struct Place {
151    /// The collectives config (`BORG_CONFIG`).
152    pub config: PathBuf,
153    /// The root store (`BORG_HOME`).
154    pub base: PathBuf,
155    /// The directory whose collective this is (`--dir`).
156    pub dir: String,
157    /// The collective named directly (`--collective`, and `join` before any repo routes to it):
158    /// it must be listed in the config, and its `share` still decides.
159    pub collective: Option<String>,
160}
161
162impl Place {
163    pub fn from_env(dir: &str) -> Place {
164        Place { config: collective::config_path(), base: borg::learn::home(), dir: dir.into(), collective: None }
165    }
166
167    /// The same machine, the collective named `name` instead of the one a directory routes to.
168    pub fn named(&self, name: &str) -> Place {
169        Place { collective: Some(name.into()), ..self.clone() }
170    }
171
172    /// The route as of now; a broken config never leaks a repo into a collective, it routes off.
173    pub fn route(&self) -> Route {
174        self.resolve().1
175    }
176
177    fn resolve(&self) -> (Option<Config>, Route) {
178        match collective::load_from(&self.config) {
179            Ok(cfg) => {
180                let r = match &self.collective {
181                    None => collective::route(cfg.as_ref(), &self.dir, &self.base),
182                    Some(name) => match cfg.as_ref() {
183                        None if name == "default" => collective::route(None, &self.dir, &self.base),
184                        None => Route::Off,
185                        Some(c) => c.collectives.iter().find(|e| e.name == *name).map_or(Route::Off, |e| Route::Collective {
186                            name: e.name.clone(),
187                            home: collective::store(&self.base, &e.name),
188                            share: e.share,
189                        }),
190                    },
191                };
192                (cfg, r)
193            }
194            Err(_) => (None, Route::Off),
195        }
196    }
197
198    /// How this collective reaches its members (addr.rs): `BORG_RELAY`, else its `relay` setting.
199    pub fn relay(&self) -> Result<Relay, String> {
200        let (cfg, route) = self.resolve();
201        let configured = match (&cfg, &route) {
202            (Some(c), Route::Collective { name, .. }) => c.collectives.iter().find(|e| e.name == *name).and_then(|e| e.relay.clone()),
203            _ => None,
204        };
205        Relay::choose(configured.as_deref())
206    }
207
208    /// The gate on the config as it is now: the collective's name and store, or why nothing may sync.
209    pub fn gate(&self) -> Result<(String, PathBuf), String> {
210        let (cfg, route) = self.resolve();
211        if let (Some(name), Route::Off) = (&self.collective, &route) {
212            return Err(format!("collective {name:?} is not in {}", self.config.display()));
213        }
214        let (name, home) = gate(&route)?;
215        if let Some(local) = collective::local_owner_of(cfg.as_ref(), home, &self.base) {
216            return Err(format!("store {} also belongs to local collective {local:?}: refusing to sync", home.display()));
217        }
218        Ok((name.to_string(), home.to_path_buf()))
219    }
220}
221
222/// The gate. A collective's data may leave this machine only if `collective::may_sync` says so.
223pub fn gate(route: &Route) -> Result<(&str, &Path), String> {
224    match route {
225        Route::Collective { name, home, .. } if collective::may_sync(route) => Ok((name, home)),
226        Route::Collective { name, .. } => Err(format!("collective {name:?} is local: it never leaves this machine, refusing to sync")),
227        Route::Off => Err("this repo belongs to no collective (off): nothing may sync".into()),
228    }
229}
230
231/// What one session moved.
232#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
233pub struct Report {
234    /// Lines this node sent / newly stored (lessons and reasoning).
235    pub sent: usize,
236    pub received: usize,
237    /// Lines the peer sent that were refused (bad origin, or an origin the membership no longer admits).
238    pub rejected: usize,
239    /// Lines moved to quarantine because a newer membership revoked their origin.
240    pub quarantined: usize,
241    /// Reconciliation of lessons: rounds, fingerprints and hashes this node sent.
242    pub rounds: usize,
243    pub fps: usize,
244    pub hashes: usize,
245    /// Reasoning excerpts this node handed to the peer as one of their replicas.
246    pub replicated: usize,
247    /// Reasoning excerpts this node answered an `Ask` with.
248    pub answered: usize,
249    /// Remote presence records that were new or newer (live.rs).
250    pub presence: usize,
251    /// Another node's pauses, resumes and vetoes newly admitted / refused (steer.rs).
252    pub steered: usize,
253    pub dropped: usize,
254    /// Members this node admitted with an invite (`admit`).
255    pub joined: usize,
256}
257
258/// A line and where it came from.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Signed {
261    pub line: String,
262    pub origin: Origin,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
266#[serde(tag = "t", rename_all = "snake_case")]
267enum Msg {
268    /// `relay`: the dialler's home relay, so the listener can dial it back across NAT (absent from
269    /// older nodes).
270    Hello {
271        collective: String,
272        listen: Option<String>,
273        #[serde(default)]
274        relay: Option<String>,
275    },
276    Refused {
277        reason: String,
278    },
279    Join {
280        invite: Invite,
281        listen: Option<String>,
282        relay: Option<String>,
283    },
284    Welcome {
285        docs: Vec<Membership>,
286    },
287    Membership {
288        docs: Vec<Membership>,
289    },
290    Round {
291        round: recon::Round,
292    },
293    Lines {
294        lines: Vec<Signed>,
295    },
296    /// The hive goals a side holds (3b): none from this node, a 0.3.0 peer's are skipped.
297    Goals {
298        ids: Vec<String>,
299    },
300    Offer {
301        hashes: Vec<String>,
302    },
303    Want {
304        hashes: Vec<String>,
305    },
306    Ask {
307        commits: Vec<String>,
308    },
309    Live {
310        presence: Vec<borg::presence::Remote>,
311        #[serde(default)]
312        directives: Directives,
313    },
314}
315
316/// 0.3.0's signed steering directives in `Live`: sent empty, as a 0.3.0 peer requires the field, and
317/// a 0.3.0 peer's ignored unread. Steering travels as messages now (steer.rs).
318#[derive(Debug, Default)]
319struct Directives;
320
321impl Serialize for Directives {
322    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
323        s.collect_seq(std::iter::empty::<()>())
324    }
325}
326
327impl<'de> Deserialize<'de> for Directives {
328    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
329        serde::de::IgnoredAny::deserialize(d).map(|_| Directives)
330    }
331}
332
333/// Which CAs vouch for a relay's, address lookup's or DNS-over-HTTPS server's certificate: the
334/// system store, so a company proxy that re-signs TLS with its own CA (installed on the machine)
335/// does not cut the node off its relay. A machine with no readable store falls back to Mozilla's.
336/// Peer connections never depend on this: iroh checks those by key.
337fn ca_trust() -> CaTlsConfig {
338    let system = CaTlsConfig::system();
339    match system.server_cert_verifier(iroh::tls::default_provider()) {
340        Ok(_) => system,
341        Err(_) => CaTlsConfig::embedded(),
342    }
343}
344
345/// One node of one collective: its key, its store and an iroh endpoint.
346pub struct Node {
347    place: Place,
348    /// The store the node was bound to; a session whose route now names another store is refused.
349    home: PathBuf,
350    me: Identity,
351    endpoint: Endpoint,
352    /// Serialises this node's writes to its store (sessions run concurrently under `serve`).
353    store: Mutex<()>,
354    /// Whether peers are told where this node listens (false for a one-shot dial).
355    announce: bool,
356    /// How peers are reached (addr.rs), fixed when the endpoint was built.
357    relay: Relay,
358    /// The hashes each peer gave per log this node follows and keeps nowhere (`skip`: the playbooks, or
359    /// a 0.3.0 peer's hive goal log, 3b), by (peer, goal or file, log): never stored, so the next dial
360    /// reconciles against them and the peer gives only what is new.
361    legacy: Mutex<HashMap<(String, String, usize), BTreeSet<Hash>>>,
362}
363
364impl Node {
365    /// Opens the endpoint for `place` on `addr` — only after the gate, so a local collective or an
366    /// off repo never even gets a socket.
367    pub async fn bind(place: &Place, addr: SocketAddr) -> Result<Node, String> {
368        Node::open(place, Some(addr), true, |b| b).await
369    }
370
371    /// A node of a collective whose membership this machine does not hold yet: it can only `join`.
372    pub async fn joining(place: &Place, addr: SocketAddr) -> Result<Node, String> {
373        Node::open(place, Some(addr), false, |b| b).await
374    }
375
376    /// The endpoint for the collective's relay setting (addr.rs). `addr` is the UDP socket for
377    /// direct paths (`None`: relay only, for tests); `member`: refuse without a membership;
378    /// `tweak` adjusts the builder (tests trust a local relay's self-signed certificate).
379    pub async fn open(place: &Place, addr: Option<SocketAddr>, member: bool, tweak: impl FnOnce(Builder) -> Builder) -> Result<Node, String> {
380        let (_, home) = place.gate()?;
381        let relay = place.relay()?;
382        let me = Identity::load_or_create(&home)?;
383        if member {
384            members(&home)?;
385        }
386        let builder = match &relay {
387            // n0's relays and DNS address lookup (pkarr): found by key alone, hole-punched across NAT.
388            Relay::Default => Endpoint::builder(presets::N0),
389            // A self-hosted relay and no public lookup: nothing about this node reaches n0.
390            Relay::Custom(_) => Endpoint::builder(presets::Minimal).relay_mode(relay.mode()),
391            // Direct addresses only: no relay, no lookup, no probing of the LAN's router.
392            Relay::Disabled => Endpoint::builder(presets::Minimal).relay_mode(relay.mode()).portmapper_config(PortmapperConfig::Disabled),
393        };
394        let mut builder = builder
395            .secret_key(me.iroh_secret())
396            .alpns([vec![ALPN.to_vec(), JOIN_ALPN.to_vec(), LEGACY_ALPN.to_vec()], older()].concat())
397            .clear_ip_transports()
398            .ca_tls_config(ca_trust());
399        if let Some(a) = addr {
400            builder = builder.bind_addr(a).map_err(|e| e.to_string())?;
401        }
402        let endpoint = tweak(builder).bind().await.map_err(|e| format!("bind {}: {e}", addr.map(|a| a.to_string()).unwrap_or("relay".into())))?;
403        Ok(Node { place: place.clone(), home, me, endpoint, store: Mutex::new(()), announce: true, relay, legacy: Mutex::default() })
404    }
405
406    /// Waits until the endpoint holds a connection to its home relay (at most `within`); true if
407    /// it does. Nothing to wait for with relays disabled.
408    pub async fn online(&self, within: Duration) -> bool {
409        self.relay == Relay::Disabled || tokio::time::timeout(within, self.endpoint.online()).await.is_ok()
410    }
411
412    /// The relay this node is reachable through, once it is online.
413    pub fn home_relay(&self) -> Option<String> {
414        self.endpoint.addr().relay_urls().next().map(|u| u.to_string())
415    }
416
417    /// Where another machine could reach this node: its direct addresses (`tickets`) and its home
418    /// relay. What `serve` writes to `mesh/addr.json` for `invite` to put in a ticket.
419    pub fn hints(&self) -> Vec<Hint> {
420        let mut h: Vec<Hint> = self.tickets().iter().filter_map(|t| Target::parse(t).ok()).flat_map(|t| t.hints).collect();
421        h.extend(self.home_relay().and_then(|u| addr::hints(&u).ok()).into_iter().flatten());
422        h
423    }
424
425    pub fn relay(&self) -> &Relay {
426        &self.relay
427    }
428
429    /// A node that only dials and exits (`sync`, `fetch`): it tells no peer where it listens,
430    /// because its ephemeral port is gone a moment later.
431    pub fn quiet(mut self) -> Node {
432        self.announce = false;
433        self
434    }
435
436    /// The gate again, on the config as it is now, and the store must still be the bound one.
437    pub fn gate(&self) -> Result<(String, PathBuf), String> {
438        let (name, home) = self.place.gate()?;
439        if home != self.home {
440            return Err(format!("{} now routes to store {}, this node serves {}: restart it", self.place.dir, home.display(), self.home.display()));
441        }
442        Ok((name, home))
443    }
444
445    pub fn home(&self) -> &Path {
446        &self.home
447    }
448
449    pub fn public(&self) -> String {
450        self.me.public()
451    }
452
453    fn addr(&self) -> Option<SocketAddr> {
454        self.endpoint.bound_sockets().first().copied()
455    }
456
457    /// `<key>@<ip:port>`: what a peer passes to `sync`. The first of `tickets`.
458    pub fn ticket(&self) -> String {
459        self.tickets().into_iter().next().unwrap_or_else(|| format!("{}@{}", self.me.public(), self.addr().map(|a| a.to_string()).unwrap_or_default()))
460    }
461
462    /// One ticket per address another machine could dial. Bound to a specific IP that is the one;
463    /// bound to an unspecified IP (0.0.0.0, the default) it is each interface address iroh found,
464    /// never 0.0.0.0 itself (empty if there is none: then `--bind IP:PORT`).
465    pub fn tickets(&self) -> Vec<String> {
466        let Some(bound) = self.addr() else { return Vec::new() };
467        let addrs: Vec<SocketAddr> = if bound.ip().is_unspecified() {
468            let mut v: Vec<SocketAddr> = self
469                .endpoint
470                .addr()
471                .ip_addrs()
472                .filter(|a| !a.ip().is_unspecified() && a.is_ipv4() == bound.is_ipv4())
473                .map(|a| SocketAddr::new(a.ip(), bound.port()))
474                .collect();
475            v.sort_by_key(|a| (a.ip().is_loopback(), *a));
476            v.dedup();
477            v
478        } else {
479            vec![bound]
480        };
481        addrs.into_iter().map(|a| format!("{}@{a}", self.me.public())).collect()
482    }
483
484    /// What this node announces in its hello: the address it listens on (the port is what counts).
485    fn listen(&self) -> Option<String> {
486        self.announce.then(|| self.addr()).flatten().map(|a| a.to_string())
487    }
488
489    /// Applies the held membership to the logs unless they were already checked against it (a
490    /// `join` or `revoke` by another process, or a sync, changed it). Returns how many lines moved.
491    pub fn enforce(&self) -> Result<usize, String> {
492        let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
493        let m = members(&self.home)?;
494        steer::enforce(&self.home, &m, borg::learn::now_ms());
495        origin::apply_if_new(&self.home, &m)
496    }
497
498    /// Stamps the lines this node's hooks wrote since the last call as its own (origin.rs).
499    pub fn stamp(&self) -> Result<usize, String> {
500        let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
501        // Fresh outcomes first, so they are stamped in the same step.
502        borg::outcome::publish(&self.home);
503        origin::stamp(&self.home, &self.me, &logs(&self.home), borg::learn::now_ms())
504    }
505
506    /// Records a completed session with `remote` that started at `started` (borg presence.rs
507    /// `synced_path`): a claim written before it reached the peer, and the peer's earlier claims came back.
508    fn synced(&self, remote: &str, started: i64) {
509        let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
510        let _ = borg::presence::note_synced(&self.home, remote, started);
511        let _ = borg::presence::forget_mismatch(&self.home, remote);
512    }
513
514    /// A session with member `remote` that came up on another protocol (`older`): recorded for `borg
515    /// doctor` (borg presence.rs `protocols_path`), closed, and the error says who has to upgrade; the
516    /// close reason says the same from the peer's side.
517    fn mismatch(&self, conn: &Connection, remote: &str) -> String {
518        let m = borg::presence::Mismatch {
519            theirs: String::from_utf8_lossy(conn.alpn()).into_owned(),
520            ours: String::from_utf8_lossy(ALPN).into_owned(),
521            ms: borg::learn::now_ms(),
522        };
523        let (line, told) = (m.line(remote), m.told(&self.me.public()));
524        {
525            let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
526            let _ = borg::presence::note_mismatch(&self.home, remote, m);
527        }
528        conn.close(2u32.into(), told.as_bytes());
529        line
530    }
531
532    /// Forgets `remote`'s last completed session (presence.rs `forget_synced`): a dial to it failed.
533    pub(crate) fn unreachable(&self, remote: &str) {
534        let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
535        let _ = borg::presence::forget_synced(&self.home, remote);
536    }
537
538    /// The home relay this node tells peers about (not for a one-shot dial, nor with relays off).
539    fn announced(&self) -> Option<String> {
540        self.announce.then(|| self.home_relay()).flatten()
541    }
542
543    /// Dials the member named by `target` (`<key>[@<hints>]`, addr.rs) and reconciles.
544    pub async fn sync(&self, target: &str) -> Result<Report, String> {
545        self.fetch(target, &[]).await
546    }
547
548    /// `sync`, also asking the peer for the reasoning excerpts of `commits` (step 5): what it
549    /// holds lands in this node's reasoning.jsonl.
550    pub async fn fetch(&self, target: &str, commits: &[String]) -> Result<Report, String> {
551        let (name, home) = self.gate()?;
552        let (name, home) = (name.as_str(), home.as_path());
553        let membership = members(home)?;
554        let t = Target::parse(target)?;
555        if membership.role_of(&t.key).is_none() {
556            return Err(format!("{} is not a member of {name}: refusing to connect", t.key));
557        }
558        let started = borg::learn::now_ms();
559        let alpns = [vec![LEGACY_ALPN.to_vec()], older()].concat();
560        let connecting = self.endpoint.connect_with_opts(t.endpoint_addr(&self.relay)?, ALPN, ConnectOptions::new().with_additional_alpns(alpns));
561        let conn = connecting.await.map_err(|e| format!("connect {target}: {e}"))?.await.map_err(|e| format!("connect {target}: {e}"))?;
562        if !spoken(conn.alpn()) {
563            return Err(self.mismatch(&conn, &t.key));
564        }
565        let result = async {
566            let remote = authorise(&conn, &membership, name)?;
567            let (mut tx, mut rx) = conn.open_bi().await.map_err(|e| e.to_string())?;
568            let hello = Msg::Hello { collective: name.into(), listen: self.listen(), relay: self.announced() };
569            let Msg::Hello { collective, .. } = swap(true, &mut tx, &mut rx, hello).await? else {
570                return Err("protocol: expected hello".into());
571            };
572            if collective != name {
573                return Err(format!("peer answered for collective {collective:?}, not {name:?}"));
574            }
575            let report = self.exchange(Peer { key: &remote, alpn: conn.alpn() }, true, &mut tx, &mut rx, membership, commits).await?;
576            let _ = tx.finish();
577            self.synced(&remote, started);
578            Ok(report)
579        }
580        .await;
581        conn.close(0u32.into(), if result.is_ok() { b"done" } else { b"error" });
582        vectors::reindexed(&self.home, result).await
583    }
584
585    /// Joins with an invite ticket (invite.rs): dials the admin that issued it, presents the signed
586    /// invite, installs the membership history it answers with (trusted because iroh authenticated
587    /// the ticket's admin key), remembers the admin as a peer and syncs with it at once.
588    pub async fn join(&self, ticket: &Ticket) -> Result<(Membership, Report), String> {
589        let (name, home) = self.gate()?;
590        let (name, home) = (name.as_str(), home.as_path());
591        if ticket.invite.collective != name {
592            return Err(format!("the ticket is for collective {:?}, this node is {name:?}", ticket.invite.collective));
593        }
594        let admin = ticket.target();
595        let conn = self.endpoint.connect(admin.endpoint_addr(&self.relay)?, JOIN_ALPN).await.map_err(|e| format!("connect {}: {e}", admin.key))?;
596        let me = self.public();
597        let result = async {
598            let (mut tx, mut rx) = conn.open_bi().await.map_err(|e| e.to_string())?;
599            let ask = Msg::Join { invite: ticket.invite.clone(), listen: self.listen(), relay: self.announced() };
600            let Msg::Welcome { docs } = swap(true, &mut tx, &mut rx, ask).await? else { return Err("protocol: expected welcome".into()) };
601            let _ = tx.finish();
602            let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
603            let installed = match origin::join(home, name, &docs) {
604                Ok((m, _)) => m,
605                // Joined before (a lost answer, a second ticket): the held version already lists us.
606                Err(e) => Membership::load(home)?.filter(|m| m.role_of(&me).is_some()).ok_or(e)?,
607            };
608            if installed.role_of(&me).is_none() {
609                return Err(format!("the admin's membership of {name} does not list this node ({me})"));
610            }
611            steer::enforce(home, &installed, borg::learn::now_ms());
612            Ok(installed)
613        }
614        .await;
615        conn.close(0u32.into(), if result.is_ok() { b"done" } else { b"error" });
616        let installed = result?;
617        peers::remember(home, &admin.key, &addr::join_hints(&admin.hints))?;
618        let report = self.sync(&admin.to_string()).await.map_err(|e| format!("joined {name}, but the first sync failed: {e}"))?;
619        Ok((installed, report))
620    }
621
622    /// Answers a node presenting an invite (`join`): checks it (invite.rs `redeem`: issued by this
623    /// node, signed, not expired, not spent, not withdrawn), adds the key with the invite's role,
624    /// and hands over the membership history.
625    async fn admit(&self, conn: &Connection, observed: Option<SocketAddr>) -> Result<Report, String> {
626        let (name, home) = self.gate()?;
627        let (name, home) = (name.as_str(), home.as_path());
628        let joiner = to_hex(conn.remote_id().as_bytes());
629        let (mut tx, mut rx) = conn.accept_bi().await.map_err(|e| e.to_string())?;
630        let Msg::Join { invite, listen, relay } = recv(&mut rx).await? else { return Err("protocol: expected join".into()) };
631        let verdict = {
632            let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
633            invite::redeem(home, &self.me, name, &invite, &joiner, borg::learn::now_ms())
634        };
635        let m = match verdict {
636            Ok(m) => m,
637            Err(reason) => {
638                send(&mut tx, &Msg::Refused { reason: reason.clone() }).await?;
639                let _ = tx.finish();
640                // Let the joiner read why before the connection goes (it closes once it has).
641                let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
642                return Err(format!("join by {joiner}: {reason}"));
643            }
644        };
645        steer::enforce(home, &m, borg::learn::now_ms());
646        heard(home, &joiner, listen, relay, observed);
647        send(&mut tx, &Msg::Welcome { docs: Membership::history(home) }).await?;
648        let _ = tx.finish();
649        conn.closed().await; // the joiner closes once it has read everything
650        Ok(Report { joined: 1, ..Report::default() })
651    }
652
653    /// The next peer dialling in, `None` once the endpoint is closed.
654    pub async fn incoming(&self) -> Option<Incoming> {
655        self.endpoint.accept().await
656    }
657
658    /// Serves the next peer that dials in; returns what it moved, or why it was turned away.
659    pub async fn accept(&self) -> Result<Report, String> {
660        self.handle(self.incoming().await.ok_or("endpoint closed")?).await
661    }
662
663    /// Serves one peer that dialled in.
664    pub async fn handle(&self, incoming: Incoming) -> Result<Report, String> {
665        // The IP the dialler's packets came from: where it can be reached, whatever it bound to.
666        let observed = match incoming.remote_addr() {
667            IncomingAddr::Ip(a) => Some(a),
668            _ => None,
669        };
670        let started = borg::learn::now_ms();
671        let conn = incoming.await.map_err(|e| format!("handshake: {e}"))?;
672        if !spoken(conn.alpn()) && conn.alpn() != JOIN_ALPN {
673            let (name, home) = self.gate()?;
674            let remote = authorise(&conn, &members(&home)?, &name)?;
675            return Err(self.mismatch(&conn, &remote));
676        }
677        let session = async {
678            let (name, home) = self.gate()?;
679            let (name, home) = (name.as_str(), home.as_path());
680            let membership = members(home)?;
681            let remote = authorise(&conn, &membership, name)?;
682            let (mut tx, mut rx) = conn.accept_bi().await.map_err(|e| e.to_string())?;
683            let Msg::Hello { collective, listen, relay } = recv(&mut rx).await? else { return Err("protocol: expected hello".into()) };
684            if collective != name {
685                let reason = format!("this node serves collective {name:?}, not {collective:?}");
686                send(&mut tx, &Msg::Refused { reason: reason.clone() }).await?;
687                return Err(reason);
688            }
689            heard(home, &remote, listen, relay, observed);
690            send(&mut tx, &Msg::Hello { collective: name.into(), listen: self.listen(), relay: self.announced() }).await?;
691            let report = self.exchange(Peer { key: &remote, alpn: conn.alpn() }, false, &mut tx, &mut rx, membership, &[]).await?;
692            let _ = tx.finish();
693            conn.closed().await; // the dialler closes once it has read everything
694            self.synced(&remote, started);
695            Ok(report)
696        };
697        let result = if conn.alpn() == JOIN_ALPN { self.admit(&conn, observed).await } else { session.await };
698        if let Err(e) = &result {
699            conn.close(1u32.into(), e.as_bytes());
700        }
701        vectors::reindexed(&self.home, result).await
702    }
703
704    pub async fn close(&self) {
705        self.endpoint.close().await;
706    }
707
708    /// The session with `peer` after both sides know who they talk to (module doc, steps 2–6).
709    async fn exchange(
710        &self,
711        peer: Peer<'_>,
712        dialler: bool,
713        tx: &mut SendStream,
714        rx: &mut RecvStream,
715        mine: Membership,
716        ask: &[String],
717    ) -> Result<Report, String> {
718        let Peer { key: remote, alpn } = peer;
719        let home = self.home.as_path();
720        let mut report = Report::default();
721        let Msg::Membership { docs } = swap(dialler, tx, rx, Msg::Membership { docs: Membership::history(home) }).await? else {
722            return Err("protocol: expected membership".into());
723        };
724        let mine = {
725            let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
726            let (held, _) = Membership::adopt(home, &docs).unwrap_or((mine, false));
727            report.quarantined += origin::apply_if_new(home, &held)?;
728            steer::enforce(home, &held, borg::learn::now_ms()); // a node that lost the right to steer, or an expired steer, stops here
729            held
730        };
731        if mine.role_of(remote).is_none() {
732            return Err(format!("{remote} is no longer a member of {}", mine.collective));
733        }
734        self.stamp()?;
735        let origins = origin::load(home);
736
737        // Lessons, and the other synced logs: reconcile with everyone. The playbooks: followed, nothing kept.
738        for file in synced(alpn) {
739            if file == PLAYBOOKS {
740                self.skip(dialler, tx, rx, (remote.to_string(), file.to_string(), 0)).await?;
741            } else {
742                self.reconcile(dialler, tx, rx, &home.join(file), &origins, &mine, &mut report).await?;
743            }
744        }
745
746        // A 0.3.0 peer's hive goals (3b): followed, nothing kept.
747        let Msg::Goals { ids } = swap(dialler, tx, rx, Msg::Goals { ids: Vec::new() }).await? else {
748            return Err("protocol: expected goals".into());
749        };
750        for goal in legacy_goals(ids) {
751            for log in 0..LEGACY_GOAL_LOGS {
752                self.skip(dialler, tx, rx, (remote.to_string(), goal.clone(), log)).await?;
753            }
754        }
755
756        // Reasoning: each author hands its excerpts to their replicas.
757        let path = home.join(REASONING);
758        let held = lines::read(&path);
759        let set = origin::admitted(held.clone(), &origins, &mine);
760        self.gate()?;
761        let me = self.me.public();
762        let offer: BTreeSet<Hash> =
763            set.keys().filter(|h| origins.get(*h).is_some_and(|o| o.key == me) && replicas(h, &mine, &me).iter().any(|k| k == remote)).copied().collect();
764        let Msg::Offer { hashes } = swap(dialler, tx, rx, Msg::Offer { hashes: offer.iter().map(|h| to_hex(h)).collect() }).await? else {
765            return Err("protocol: expected offer".into());
766        };
767        let offered: BTreeSet<Hash> = hashes.iter().map(|h| from_hex::<32>(h)).collect::<Result<_, _>>()?;
768        let want: BTreeSet<Hash> = offered.into_iter().filter(|h| !held.contains_key(h)).collect();
769        let Msg::Want { hashes } = swap(dialler, tx, rx, Msg::Want { hashes: want.iter().map(|h| to_hex(h)).collect() }).await? else {
770            return Err("protocol: expected want".into());
771        };
772        let wanted: Vec<Hash> = hashes.iter().map(|h| from_hex::<32>(h)).collect::<Result<Vec<_>, _>>()?.into_iter().filter(|h| offer.contains(h)).collect();
773        let give = signed(&set, &origins, wanted.iter());
774        report.replicated += give.len();
775        self.gate()?; // again just before lines leave
776        let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else {
777            return Err("protocol: expected lines".into());
778        };
779        self.store_lines(&path, got, &want, &mine, Some(remote), &mut report)?;
780
781        // Excerpts by commit: any holder answers, whoever wrote them.
782        let ask: Vec<String> = ask.iter().take(MAX_ASK).cloned().collect();
783        let Msg::Ask { commits: theirs } = swap(dialler, tx, rx, Msg::Ask { commits: ask.clone() }).await? else {
784            return Err("protocol: expected ask".into());
785        };
786        let held = lines::read(&path);
787        let set = origin::admitted(held.clone(), &origin::load(home), &mine);
788        let answer: Vec<&Hash> = set.iter().filter(|(_, l)| about(&theirs, l)).map(|(h, _)| h).take(MAX_ASK).collect();
789        let give = signed(&set, &origin::load(home), answer.into_iter());
790        report.answered += give.len();
791        self.gate()?; // again just before lines leave
792        let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else {
793            return Err("protocol: expected lines".into());
794        };
795        let want: BTreeSet<Hash> = got.iter().filter(|s| about(&ask, &s.line)).map(|s| lines::hash(&s.line)).filter(|h| !held.contains_key(h)).collect();
796        self.store_lines(&path, got, &want, &mine, None, &mut report)?;
797
798        // Live: presence, ephemeral (live.rs).
799        let now = borg::learn::now_ms();
800        let live = Msg::Live { presence: live::outgoing(home, &self.me, now), directives: Directives };
801        self.gate()?; // again just before anything leaves
802        let Msg::Live { presence, .. } = swap(dialler, tx, rx, live).await? else {
803            return Err("protocol: expected live".into());
804        };
805        let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
806        report.presence += live::receive(home, &mine, &me, presence, now)?;
807        Ok(report)
808    }
809
810    /// One log reconciled with the peer (step 3): the admitted set, recon rounds, then the lines each
811    /// side lacks, signed, stored when their origin verifies and is admitted.
812    #[allow(clippy::too_many_arguments)]
813    async fn reconcile(
814        &self,
815        dialler: bool,
816        tx: &mut SendStream,
817        rx: &mut RecvStream,
818        path: &Path,
819        origins: &HashMap<Hash, Origin>,
820        mine: &Membership,
821        report: &mut Report,
822    ) -> Result<(), String> {
823        let set = offered(path, origins, mine, borg::learn::now_ms());
824        self.gate()?; // read first, then gate: a line written after a switch to local is never sent
825        let mut r = Recon::new(&set);
826        loop {
827            let m = r.message();
828            let Msg::Round { round } = swap(dialler, tx, rx, Msg::Round { round: m.clone() }).await? else {
829                return Err("protocol: expected a reconciliation round".into());
830            };
831            if r.absorb(&m, round)? {
832                break;
833            }
834        }
835        (report.rounds, report.fps, report.hashes) = (report.rounds + r.cost.rounds, report.fps + r.cost.fps, report.hashes + r.cost.hashes);
836        let give = within_budget(signed(&set, origins, r.give.iter()));
837        report.sent += give.len();
838        self.gate()?; // again just before lines leave
839        let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else {
840            return Err("protocol: expected lines".into());
841        };
842        self.store_lines(path, got, &r.want, mine, None, report)
843    }
844
845    /// One log a peer reconciles and this node no longer keeps (the `PLAYBOOKS` step, or a log of a
846    /// 0.3.0 peer's hive goal, 3b), followed in step with it and kept nowhere. `log` names it: the peer,
847    /// then the goal or the file (no goal id holds a dot), then the goal's log. The listener
848    /// answers the peer's first round with that round itself, which closes it with nothing to give
849    /// either way. The dialler speaks first, so it reconciles the hashes this peer gave before (`legacy`)
850    /// and remembers the ones it gives now: the peer sends each line once per process, not per session.
851    async fn skip(&self, dialler: bool, tx: &mut SendStream, rx: &mut RecvStream, log: (String, String, usize)) -> Result<(), String> {
852        let mut want = BTreeSet::new();
853        if dialler {
854            let given = self.legacy.lock().unwrap_or_else(|e| e.into_inner()).get(&log).cloned().unwrap_or_default();
855            let given: BTreeMap<Hash, ()> = given.into_iter().map(|h| (h, ())).collect();
856            let mut r = Recon::new(&given);
857            loop {
858                let m = r.message();
859                let Msg::Round { round } = swap(true, tx, rx, Msg::Round { round: m.clone() }).await? else {
860                    return Err("protocol: expected a reconciliation round".into());
861                };
862                if r.absorb(&m, round)? {
863                    break;
864                }
865            }
866            want = r.want;
867        } else {
868            let Msg::Round { round } = recv(rx).await? else {
869                return Err("protocol: expected a reconciliation round".into());
870            };
871            send(tx, &Msg::Round { round }).await?;
872        }
873        let Msg::Lines { lines: got } = swap(dialler, tx, rx, Msg::Lines { lines: Vec::new() }).await? else {
874            return Err("protocol: expected lines".into());
875        };
876        let got: Vec<Hash> = got.iter().map(|s| lines::hash(&s.line)).filter(|h| want.contains(h)).collect();
877        if !got.is_empty() {
878            self.legacy.lock().unwrap_or_else(|e| e.into_inner()).entry(log).or_default().extend(got);
879        }
880        Ok(())
881    }
882
883    /// Stores the lines that were asked for, whose origin verifies and is admitted (and, for
884    /// replicas, is the peer itself), and that may travel under it (lines.rs `travels`): origin
885    /// records first, so a line is never re-stamped as ours.
886    fn store_lines(
887        &self,
888        path: &Path,
889        got: Vec<Signed>,
890        want: &BTreeSet<Hash>,
891        m: &Membership,
892        author: Option<&str>,
893        report: &mut Report,
894    ) -> Result<(), String> {
895        let (file, now) = (log_name(path), borg::learn::now_ms());
896        let mut keep: BTreeMap<Hash, Signed> = BTreeMap::new();
897        for s in got {
898            let h = lines::hash(&s.line);
899            let ok = want.contains(&h)
900                && s.origin.verify(&s.line).is_ok()
901                && m.admits(&s.origin.key, s.origin.ms)
902                && author.is_none_or(|a| a == s.origin.key)
903                && lines::travels(file, &s.line, &s.origin.key, now);
904            if ok {
905                keep.insert(h, s);
906            } else {
907                report.rejected += 1;
908            }
909        }
910        let _w = self.store.lock().unwrap_or_else(|e| e.into_inner());
911        let known = origin::load(&self.home);
912        if file != borg::msg::FILE {
913            origin::append(&self.home, &keep.iter().filter(|(h, _)| !known.contains_key(*h)).map(|(_, s)| s.origin.clone()).collect::<Vec<_>>())?;
914            report.received += lines::merge(path, &keep.into_values().map(|s| s.line).collect::<Vec<_>>())?;
915            return Ok(());
916        }
917        // Messages: under the log's lock (borg msg.rs `send`), only what the window of held and new takes.
918        let a = borg::live::locked(path, || {
919            let held = travelling(path, &known, m, now);
920            let mut all = held.clone();
921            all.extend(keep.iter().map(|(h, s)| (*h, s.line.clone())));
922            let w = window(&all, per_node(m));
923            let before = keep.len();
924            keep.retain(|h, _| w.contains(h));
925            report.rejected += before - keep.len();
926            let new: Vec<borg::msg::Message> =
927                keep.iter().filter(|(h, _)| !known.contains_key(*h)).filter_map(|(_, s)| serde_json::from_str(&s.line).ok()).collect();
928            origin::append(&self.home, &keep.iter().filter(|(h, _)| !known.contains_key(*h)).map(|(_, s)| s.origin.clone()).collect::<Vec<_>>())?;
929            report.received += lines::merge(path, &keep.into_values().map(|s| s.line).collect::<Vec<_>>())?;
930            prune(path, &held, &w, now)?;
931            // After the merge: a hook that sees the line before its admission shows it at its next call.
932            steer::admit(&self.home, m, &self.me.public(), &new, now).map_err(|e| format!("admissions: {e}"))
933        })?;
934        (report.steered, report.dropped) = (report.steered + a.new, report.dropped + a.dropped);
935        drop(_w);
936        // Outside both locks: it rereads the log and the outcomes, and a `send` must not wait on it.
937        if a.new > 0 {
938            borg::veto::apply_messages(&self.home, now);
939        }
940        Ok(())
941    }
942}
943
944/// The lines of the log at `path` this node offers at `now`: `travelling`, and for the messages
945/// within their `window`.
946fn offered(path: &Path, origins: &HashMap<Hash, Origin>, m: &Membership, now: i64) -> BTreeMap<Hash, String> {
947    let mut set = travelling(path, origins, m, now);
948    if log_name(path) == borg::msg::FILE {
949        let w = window(&set, per_node(m));
950        set.retain(|h, _| w.contains(h));
951    }
952    set
953}
954
955/// The lines of the log at `path` that are admitted and may travel under their origin at `now`
956/// (lines.rs `travels`).
957fn travelling(path: &Path, origins: &HashMap<Hash, Origin>, m: &Membership, now: i64) -> BTreeMap<Hash, String> {
958    let file = log_name(path);
959    let mut set = origin::admitted(lines::read(path), origins, m);
960    set.retain(|h, l| origins.get(h).is_some_and(|o| lines::travels(file, l, &o.key, now)));
961    set
962}
963
964/// How many messages of one node travel: an equal share of `msg::KEEP` per member, so one member
965/// cannot crowd out the others.
966fn per_node(m: &Membership) -> usize {
967    (borg::msg::KEEP / m.members.len().max(1)).max(1)
968}
969
970/// The messages of `set` that travel: per node, sender and recipient one tell or ask per `msg::RATE_MS`
971/// (the earliest wins, as `send` refuses the later; a steer is not paced, as `send` does not pace it:
972/// a resume follows its pause at once), then for conversation and for steers apart (as msg.rs
973/// `bound` keeps them, so no tell pushes a pause out) the `per_node` newest per node and the
974/// `msg::KEEP` newest of those. Ordered by (ms, hash), so nodes holding the same lines pick the same
975/// ones, and a line cut here is neither offered nor taken back from a peer that still holds it.
976fn window(set: &BTreeMap<Hash, String>, per_node: usize) -> BTreeSet<Hash> {
977    let mut all: Vec<(i64, Hash, borg::msg::Message)> = set
978        .iter()
979        .filter_map(|(h, l)| serde_json::from_str::<borg::msg::Message>(l).ok().filter(borg::msg::Message::well_formed).map(|m| (m.ms, *h, m)))
980        .collect();
981    all.sort_by_key(|x| (x.0, x.1));
982    let mut last: HashMap<(String, String, String), i64> = HashMap::new();
983    let mut paced: Vec<(Hash, String, bool)> = Vec::new();
984    for (ms, h, m) in all {
985        if m.kind.privileged() {
986            paced.push((h, m.node, true));
987            continue;
988        }
989        let slot = last.entry((m.node.clone(), m.by, m.to)).or_insert(i64::MIN);
990        if ms.saturating_sub(*slot) >= borg::msg::RATE_MS {
991            *slot = ms;
992            paced.push((h, m.node, false));
993        }
994    }
995    let mut per: HashMap<(String, bool), usize> = HashMap::new();
996    let mut total = [0usize; 2];
997    paced
998        .into_iter()
999        .rev()
1000        .filter(|(_, node, steers)| {
1001            let n = per.entry((node.clone(), *steers)).or_default();
1002            *n += 1;
1003            *n <= per_node
1004        })
1005        .filter(|(_, _, steers)| {
1006            let t = &mut total[usize::from(*steers)];
1007            *t += 1;
1008            *t <= borg::msg::KEEP
1009        })
1010        .map(|(h, _, _)| h)
1011        .collect()
1012}
1013
1014/// Rewrites the messages log at `path` without the `held` lines outside the window `w` and the lines
1015/// past `msg::TTL_MS` (with steer.rs `SLACK_MS`), as borg msg.rs `send` bounds it; untouched when none
1016/// goes. A line this node holds but has not stamped yet is not `held`, so it stays.
1017fn prune(path: &Path, held: &BTreeMap<Hash, String>, w: &BTreeSet<Hash>, now: i64) -> Result<(), String> {
1018    let text = std::fs::read_to_string(path).unwrap_or_default();
1019    let expired = |l: &str| serde_json::from_str::<borg::msg::Message>(l).is_ok_and(|m| now - m.ms > borg::msg::TTL_MS + borg::steer::SLACK_MS);
1020    let goes = |l: &str| {
1021        let h = lines::hash(l);
1022        (held.contains_key(&h) && !w.contains(&h)) || expired(l)
1023    };
1024    if !text.lines().any(goes) {
1025        return Ok(());
1026    }
1027    let body: String = text.lines().filter(|l| !goes(l)).map(|l| format!("{l}\n")).collect();
1028    borg::steer::rewrite(path, &body).map_err(|e| format!("{}: {e}", path.display()))
1029}
1030
1031/// A log's file name, as lines.rs `SYNCED` lists it.
1032fn log_name(path: &Path) -> &str {
1033    path.file_name().and_then(|f| f.to_str()).unwrap_or_default()
1034}
1035
1036/// The goals a 0.3.0 peer reconciles after naming `ids` while this node names none, in its order: the
1037/// distinct valid goal ids (its `goal::valid_id`: `[a-z0-9-]{1,40}`), sorted.
1038fn legacy_goals(ids: Vec<String>) -> BTreeSet<String> {
1039    let valid = |id: &String| (1..=40).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
1040    ids.into_iter().filter(valid).collect()
1041}
1042
1043/// Where a dialler that announced `listen` can be reached: the IP its packets came from (what
1044/// QUIC observed) with the port it listens on. Nothing for an address nobody could dial.
1045fn reachable(listen: &str, observed: Option<SocketAddr>) -> Option<SocketAddr> {
1046    let listen: SocketAddr = listen.parse().ok()?;
1047    let ip = match observed {
1048        Some(o) => o.ip().to_canonical(),
1049        None => listen.ip(),
1050    };
1051    let at = SocketAddr::new(ip, listen.port());
1052    (!at.ip().is_unspecified() && at.port() != 0).then_some(at)
1053}
1054
1055/// Remembers a member that dialled in where it can be dialled back: the IP its packets came from
1056/// with the port it listens on, and the home relay it announced. Known by key at least.
1057fn heard(home: &Path, key: &str, listen: Option<String>, relay: Option<String>, observed: Option<SocketAddr>) {
1058    let direct = listen.and_then(|l| reachable(&l, observed)).map(|a| a.to_string());
1059    let hints: Vec<String> = direct.into_iter().chain(relay.filter(|r| addr::hints(r).is_ok())).collect();
1060    let _ = peers::remember(home, key, &hints.join(","));
1061}
1062
1063/// Whether a line of reasoning.jsonl is about one of `commits`: an excerpt by its `commit`, a
1064/// decision → fix link record (borg notes/links.rs) by either side, so fetching the fix brings it too.
1065fn about(commits: &[String], line: &str) -> bool {
1066    let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { return false };
1067    ["commit", "fix"].iter().filter_map(|k| v.get(k)?.as_str()).any(|c| asked(commits, c))
1068}
1069
1070/// Whether `commit` is one of `asked` (full ids, or abbreviations of at least 7 characters).
1071fn asked(asked: &[String], commit: &str) -> bool {
1072    asked.iter().any(|a| a == commit || (a.len() >= 7 && commit.starts_with(a.as_str())))
1073}
1074
1075/// The first of `lines` whose sizes add up to at most `MAX_GIVE` (at least one).
1076fn within_budget(lines: Vec<Signed>) -> Vec<Signed> {
1077    let mut used = 0;
1078    lines
1079        .into_iter()
1080        .enumerate()
1081        .take_while(|(i, s)| {
1082            used += s.line.len();
1083            *i == 0 || used <= MAX_GIVE
1084        })
1085        .map(|(_, s)| s)
1086        .collect()
1087}
1088
1089fn signed<'a>(set: &BTreeMap<Hash, String>, origins: &HashMap<Hash, Origin>, hashes: impl Iterator<Item = &'a Hash>) -> Vec<Signed> {
1090    hashes.filter_map(|h| Some(Signed { line: set.get(h)?.clone(), origin: origins.get(h)?.clone() })).collect()
1091}
1092
1093/// The members (other than `author`) that hold a replica of excerpt `h`: the `REPLICAS` highest
1094/// `blake3(h, key)` (rendezvous hashing: every node computes the same choice, and a membership
1095/// change moves only the excerpts of the members it touches).
1096pub fn replicas(h: &Hash, m: &Membership, author: &str) -> Vec<String> {
1097    let mut keys: Vec<(Hash, &str)> = m
1098        .members
1099        .iter()
1100        .filter(|x| x.key != author)
1101        .map(|x| {
1102            let mut s = blake3::Hasher::new();
1103            s.update(h);
1104            s.update(x.key.as_bytes());
1105            (*s.finalize().as_bytes(), x.key.as_str())
1106        })
1107        .collect();
1108    keys.sort_by(|a, b| b.cmp(a));
1109    keys.into_iter().take(REPLICAS).map(|(_, k)| k.to_string()).collect()
1110}
1111
1112fn members(home: &Path) -> Result<Membership, String> {
1113    Membership::load(home)?
1114        .ok_or_else(|| format!("no membership in {}: `borg mesh init` (admin) or `borg mesh join <file>`", crate::membership::path(home).display()))
1115}
1116
1117/// The key iroh authenticated, if the membership lists it.
1118fn authorise(conn: &Connection, membership: &Membership, name: &str) -> Result<String, String> {
1119    let remote = to_hex(conn.remote_id().as_bytes());
1120    match membership.role_of(&remote) {
1121        Some(_) => Ok(remote),
1122        None => Err(format!("rejected {remote}: not a member of {name}")),
1123    }
1124}
1125
1126/// The dialler sends then reads; the listener reads (and could refuse) then sends.
1127async fn swap(dialler: bool, tx: &mut SendStream, rx: &mut RecvStream, m: Msg) -> Result<Msg, String> {
1128    if dialler {
1129        send(tx, &m).await?;
1130        recv(rx).await
1131    } else {
1132        let theirs = recv(rx).await?;
1133        send(tx, &m).await?;
1134        Ok(theirs)
1135    }
1136}
1137
1138async fn send(tx: &mut SendStream, m: &Msg) -> Result<(), String> {
1139    let body = serde_json::to_vec(m).map_err(|e| e.to_string())?;
1140    tx.write_all(&(body.len() as u32).to_be_bytes()).await.map_err(|e| format!("send: {e}"))?;
1141    tx.write_all(&body).await.map_err(|e| format!("send: {e}"))
1142}
1143
1144async fn recv(rx: &mut RecvStream) -> Result<Msg, String> {
1145    let mut len = [0u8; 4];
1146    rx.read_exact(&mut len).await.map_err(|e| format!("receive: {e}"))?;
1147    let len = u32::from_be_bytes(len) as usize;
1148    if len > MAX_FRAME {
1149        return Err(format!("frame of {len} bytes exceeds {MAX_FRAME}"));
1150    }
1151    let mut body = vec![0u8; len];
1152    rx.read_exact(&mut body).await.map_err(|e| format!("receive: {e}"))?;
1153    match serde_json::from_slice(&body).map_err(|e| format!("protocol: {e}"))? {
1154        Msg::Refused { reason } => Err(format!("refused by peer: {reason}")),
1155        m => Ok(m),
1156    }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162    use serde_json::json;
1163
1164    /// An excerpt is asked for by its commit; a link record by its decision or by its fix.
1165    #[test]
1166    fn lines_about_the_asked_commits() {
1167        let ask = vec!["d0d0d0d".to_string(), "f1f1f1f1f1".to_string()];
1168        let excerpt = json!({"kind": "reasoning", "commit": "d0d0d0d0d0"}).to_string();
1169        let link = |c: &str, f: &str| json!({"kind": "regress", "commit": c, "fix": f}).to_string();
1170        assert!(about(&ask, &excerpt) && about(&ask, &link("aaaaaaaaaa", "f1f1f1f1f1")) && about(&ask, &link("d0d0d0d0d0", "bbbbbbbbbb")));
1171        assert!(!about(&ask, &link("aaaaaaaaaa", "bbbbbbbbbb")) && !about(&ask, "not json"));
1172    }
1173
1174    /// A frame of lines stays within `MAX_GIVE`: the rest wait for the next session; one line always goes.
1175    #[test]
1176    fn a_frame_gives_lines_up_to_its_budget() {
1177        let me = Identity::load_or_create(&std::env::temp_dir().join(format!("borg-mesh-budget-{}", std::process::id()))).unwrap();
1178        let line = |n: usize| {
1179            let l = "x".repeat(n);
1180            Signed { origin: Origin::new(&me, &lines::hash(&l), 1), line: l }
1181        };
1182        let give = within_budget(vec![line(MAX_GIVE / 2), line(MAX_GIVE / 2), line(1)]);
1183        assert_eq!(give.len(), 2);
1184        assert_eq!(within_budget(vec![line(MAX_GIVE + 1), line(1)]).len(), 1);
1185        assert!(within_budget(Vec::new()).is_empty());
1186    }
1187
1188    /// One node's machine: a root store (`BORG_HOME`), a config (`BORG_CONFIG`) and a repo that
1189    /// routes to collective "work" with `share`.
1190    fn node(tag: &str, share: &str) -> Place {
1191        let base = std::env::temp_dir().join(format!("borg-mesh-sync-{tag}-{}", std::process::id()));
1192        let _ = std::fs::remove_dir_all(&base);
1193        std::fs::create_dir_all(home_of(&base)).unwrap();
1194        let p = Place { config: base.join("collectives.json"), base: base.clone(), dir: base.join("repo").display().to_string(), collective: None };
1195        configure(&p, share);
1196        p
1197    }
1198
1199    fn configure(p: &Place, share: &str) {
1200        let cfg = json!({"default": "off", "collectives": [{"name": "work", "paths": [p.dir], "share": share, "relay": "disabled"}]});
1201        std::fs::write(&p.config, cfg.to_string()).unwrap();
1202    }
1203
1204    fn home_of(base: &Path) -> PathBuf {
1205        base.join("collectives").join("work")
1206    }
1207
1208    fn home(p: &Place) -> PathBuf {
1209        home_of(&p.base)
1210    }
1211
1212    fn lesson(id: &str, version: u32, q: &str) -> String {
1213        json!({"id": id, "origin_ms": 1, "question": q, "version": version}).to_string()
1214    }
1215
1216    fn write(home: &Path, file: &str, lines: &[String]) {
1217        std::fs::write(home.join(file), lines.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
1218    }
1219
1220    const LOCAL: &str = "127.0.0.1:0";
1221
1222    /// Alice founds the collective and invites Bob, who installs the document she hands him.
1223    fn team(tag: &str) -> (Place, Place) {
1224        let (a, b) = (node(&format!("{tag}-alice"), "team"), node(&format!("{tag}-bob"), "team"));
1225        let alice = Identity::load_or_create(&home(&a)).unwrap();
1226        let bob = Identity::load_or_create(&home(&b)).unwrap();
1227        let mut m = Membership::found("work", &alice);
1228        m.invite(&alice, &bob.public(), "member").unwrap();
1229        m.save(&home(&a)).unwrap();
1230        m.save(&home(&b)).unwrap();
1231        (a, b)
1232    }
1233
1234    fn rollup(node: &str, version: u64, turns: u64) -> String {
1235        json!({"id": format!("stat:{node}:dev@x:2026-10-12"), "version": version, "day": "2026-10-12", "node": node,
1236            "engineer": "dev@x", "counts": {"turn_end": turns}, "usd": 0.0, "dur_ms": 0})
1237        .to_string()
1238    }
1239
1240    #[tokio::test(flavor = "multi_thread")]
1241    async fn usage_rollups_sync_and_the_latest_version_wins() {
1242        let (pa, pb) = team("stats");
1243        let (a, b) = (home(&pa), home(&pb));
1244        write(&a, "stats.jsonl", &[rollup("na", 1, 3), rollup("na", 2, 5)]);
1245        write(&b, "stats.jsonl", &[rollup("nb", 1, 7)]);
1246        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1247        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1248        let ticket = alice.ticket();
1249        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1250        let (served, synced) = (served.unwrap(), synced.unwrap());
1251        assert_eq!((synced.received, served.received), (2, 1), "both of alice's lines reach bob, bob's one reaches alice");
1252        for h in [&a, &b] {
1253            let r = borg::stats::rollups(h);
1254            assert_eq!(r.len(), 2, "one bucket per node");
1255            assert_eq!(r["stat:na:dev@x:2026-10-12"].counts["turn_end"], 5, "the newest version of alice's bucket");
1256            assert_eq!(r["stat:nb:dev@x:2026-10-12"].counts["turn_end"], 7);
1257        }
1258    }
1259
1260    /// A store upgraded from 0.4.0 still holds playbooks.jsonl, stamped: it is neither offered nor
1261    /// taken, and the session still steps through it, so the two nodes stay in order.
1262    #[tokio::test(flavor = "multi_thread")]
1263    async fn a_leftover_playbooks_log_is_not_synced() {
1264        let (pa, pb) = team("playbooks");
1265        let (a, b) = (home(&pa), home(&pb));
1266        let edit = json!({"pb": "p1", "edit": "e1", "step": "s1", "text": "run the parser tests", "ms": 1}).to_string();
1267        write(&a, PLAYBOOKS, std::slice::from_ref(&edit));
1268        let h = lines::hash(&edit);
1269        origin::append(&a, &[Origin::new(&Identity::load_or_create(&a).unwrap(), &h, 1)]).unwrap();
1270        let c = json!({"id": "cabc", "text": "locks are taken in id order", "lesson": "L1", "by": "lead@x", "ms": 5, "source": "veto"}).to_string();
1271        write(&a, borg::veto::FILE, std::slice::from_ref(&c));
1272        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1273        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1274        let ticket = alice.ticket();
1275        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1276        let (served, synced) = (served.unwrap(), synced.unwrap());
1277        assert_eq!((synced.received, served.sent), (1, 1), "only the constraint, the step after the playbooks', moves");
1278        assert!(holds(&b, borg::veto::FILE, &c));
1279        assert!(!b.join(PLAYBOOKS).exists(), "no playbook reaches bob");
1280        assert!(holds(&a, PLAYBOOKS, &edit), "alice's leftover stays untouched");
1281        alice.close().await;
1282        bob.close().await;
1283    }
1284
1285    #[tokio::test(flavor = "multi_thread")]
1286    async fn a_ratified_constraint_reaches_the_team_signed() {
1287        let (pa, pb) = team("constraints");
1288        let (a, b) = (home(&pa), home(&pb));
1289        write(&a, "lessons.jsonl", &[lesson("L1", 1, "lock order?")]);
1290        let c = json!({"id": "cabc", "text": "locks are taken in id order", "lesson": "L1", "by": "lead@x", "ms": 5, "source": "veto"}).to_string();
1291        write(&a, borg::veto::FILE, std::slice::from_ref(&c));
1292        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1293        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1294        let ticket = alice.ticket();
1295        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1296        served.unwrap();
1297        assert!(synced.unwrap().received >= 2, "the lesson and the constraint");
1298        assert_eq!(lines::read(&b.join(borg::veto::FILE)).into_values().collect::<Vec<_>>(), std::slice::from_ref(&c));
1299        assert!(origin::load(&b).contains_key(&lines::hash(&c)), "it arrived with its signed origin");
1300        let standing: Vec<String> = borg::conscience::load(&b.join("lessons.jsonl"), None).into_iter().filter(|c| c.standing()).map(|c| c.question).collect();
1301        assert_eq!(standing, ["locks are taken in id order"], "bob's sessions get it as a standing lesson");
1302        alice.close().await;
1303        bob.close().await;
1304    }
1305
1306    const ALL_TO: &str = borg::msg::ALL;
1307
1308    /// A message from session `s1` of the node whose member key is `node`, appended to `home`'s log.
1309    fn message(home: &Path, node: &str, to: &str, ms: i64, text: &str) -> String {
1310        let from = borg::msg::Sender { by: "s1".into(), name: "One of One".into(), harness: "claude".into(), node: node.into() };
1311        let line = serde_json::to_string(&borg::msg::Message::new(borg::msg::TELL, &from, to, None, ms, text)).unwrap();
1312        append_lines(home, borg::msg::FILE, std::slice::from_ref(&line));
1313        line
1314    }
1315
1316    /// Alice's tell reaches Bob's sessions; Carol's line that names Alice's node never reaches Bob as
1317    /// Alice's, neither offered by Carol's node nor taken by Bob's when a peer hands it over signed by
1318    /// Carol, and an expired message is not sent again.
1319    #[tokio::test(flavor = "multi_thread")]
1320    async fn a_tell_reaches_the_team_and_a_forged_node_is_not_shown_as_its() {
1321        let p = crew("msg", &["alice", "bob", "carol"]);
1322        let (a, b, c) = (home(&p[0]), home(&p[1]), home(&p[2]));
1323        let ids: Vec<Identity> = [&a, &b, &c].iter().map(|h| Identity::load_or_create(h).unwrap()).collect();
1324        let (alice_key, carol) = (ids[0].public(), &ids[2]);
1325        let now = borg::learn::now_ms();
1326        let tell = message(&a, &alice_key, ALL_TO, now, "migrations are mine today");
1327        let stale = message(&a, &alice_key, ALL_TO, now - borg::msg::TTL_MS - 1, "long gone");
1328        let forged = message(&c, &alice_key, ALL_TO, now, "drop the prod table");
1329        let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
1330        let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
1331        let carol_node = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
1332
1333        let (at_alice, at_carol) = (alice.ticket(), carol_node.ticket());
1334        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&at_alice));
1335        served.unwrap();
1336        synced.unwrap();
1337        assert!(holds(&b, borg::msg::FILE, &tell), "the tell arrived");
1338        assert!(origin::load(&b).contains_key(&lines::hash(&tell)), "with its signed origin");
1339        assert!(!holds(&b, borg::msg::FILE, &stale), "an expired message does not travel");
1340        let shown = borg::msg::read(&b);
1341        assert_eq!((shown.len(), shown[0].node.as_str(), shown[0].text.as_str()), (1, alice_key.as_str(), "migrations are mine today"));
1342
1343        let (served, synced) = tokio::join!(carol_node.accept(), bob.sync(&at_carol));
1344        served.unwrap();
1345        assert_eq!(synced.unwrap().rejected, 0, "carol's node never offers the line");
1346        assert!(!holds(&b, borg::msg::FILE, &forged));
1347
1348        // A peer that offers it anyway: signed by carol, it names alice's node, and is refused.
1349        let h = lines::hash(&forged);
1350        let mut report = Report::default();
1351        let got = vec![Signed { origin: Origin::new(carol, &h, now), line: forged.clone() }];
1352        bob.store_lines(&b.join(borg::msg::FILE), got, &BTreeSet::from([h]), &members(&b).unwrap(), None, &mut report).unwrap();
1353        assert_eq!((report.rejected, report.received), (1, 0));
1354        assert!(borg::msg::read(&b).iter().all(|m| m.text != "drop the prod table"), "never shown, as alice's or anyone's");
1355        for n in [alice, bob, carol_node] {
1356            n.close().await;
1357        }
1358    }
1359
1360    /// A member that floods the log past `send`'s checks: the messages within one rate slot after the
1361    /// first, and those past its node's share, are refused on receipt; what was cut is not taken back
1362    /// when offered again, and the log holds only what travels.
1363    #[tokio::test(flavor = "multi_thread")]
1364    async fn a_flood_is_bounded_on_receipt_and_a_cut_line_does_not_come_back() {
1365        let p = crew("flood", &["alice", "bob"]);
1366        let (a, b) = (home(&p[0]), home(&p[1]));
1367        let alice = Identity::load_or_create(&a).unwrap();
1368        let now = borg::learn::now_ms();
1369        let share = per_node(&members(&b).unwrap());
1370        let mut lines: Vec<String> = (0..share + 10).map(|i| message(&a, &alice.public(), &format!("s-{i}"), now - i as i64 * 1000, "hi")).collect();
1371        lines.push(message(&a, &alice.public(), "s-1", now - 500, "again"));
1372        let got: Vec<Signed> = lines.iter().map(|l| Signed { origin: Origin::new(&alice, &lines::hash(l), now), line: l.clone() }).collect();
1373        let want: BTreeSet<Hash> = got.iter().map(|s| lines::hash(&s.line)).collect();
1374        let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
1375        let path = b.join(borg::msg::FILE);
1376        let mut report = Report::default();
1377        bob.store_lines(&path, got.clone(), &want, &members(&b).unwrap(), None, &mut report).unwrap();
1378        assert_eq!((report.received, report.rejected), (share, 11), "the share's newest, a second to s-1 within the slot refused");
1379        assert!(!holds(&b, borg::msg::FILE, &lines[share + 10]), "a second message within the rate slot");
1380        assert!(!holds(&b, borg::msg::FILE, &lines[share + 9]), "the oldest past the share");
1381
1382        let mut again = Report::default();
1383        bob.store_lines(&path, got, &want, &members(&b).unwrap(), None, &mut again).unwrap();
1384        assert_eq!((again.received, again.rejected), (0, 11), "nothing cut comes back");
1385        assert_eq!(lines::read(&path).len(), share);
1386        assert_eq!(offered(&path, &origin::load(&b), &members(&b).unwrap(), now).len(), share);
1387        bob.close().await;
1388    }
1389
1390    #[tokio::test(flavor = "multi_thread")]
1391    async fn team_outcomes_sync_and_count_once() {
1392        let (pa, pb) = team("outcomes");
1393        let (a, b) = (home(&pa), home(&pb));
1394        write(&a, "lessons.jsonl", &[lesson("L1", 1, "lock order?")]);
1395        let o = |kind: &str, ms| json!({"kind": kind, "session": "s1", "lessons": ["L1"], "ms": ms, "repo": "app", "groups": [["L1", "L2"]]}).to_string();
1396        write(&a, "outcomes.jsonl", &[o("asked", 1), o("asked_then_changed", 2)]);
1397        assert_eq!(borg::outcome::publish(&a), 2);
1398        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1399        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1400        let ticket = alice.ticket();
1401        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1402        served.unwrap();
1403        synced.unwrap();
1404        let team = borg::outcome::TEAM_FILE;
1405        assert_eq!(lines::read(&b.join(team)), lines::read(&a.join(team)), "bob holds alice's outcomes");
1406        let text = std::fs::read_to_string(b.join(team)).unwrap();
1407        assert!(!text.contains("\"s1\"") && !text.contains("app") && !text.contains("groups"), "redacted: {text}");
1408        assert!(!b.join("outcomes.jsonl").exists(), "the raw log stays with alice");
1409        let l1 = [borg::conscience::Challenge { id: "L1".into(), ..Default::default() }];
1410        let changed = |h: &Path| borg::memory::derive(&borg::outcome::read_team(&h.join("lessons.jsonl")), &l1, None)["L1"].changed;
1411        assert_eq!(changed(&b), 1, "alice's session counts on bob");
1412        assert_eq!(changed(&a), 1, "alice's own published copy does not count twice");
1413        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1414        let quiet = Report { rounds: SYNCED.len(), fps: SYNCED.len(), ..Report::default() };
1415        assert_eq!((synced.unwrap(), served.unwrap()), (quiet, quiet), "resync is quiet");
1416        alice.close().await;
1417        bob.close().await;
1418    }
1419
1420    #[tokio::test(flavor = "multi_thread")]
1421    async fn two_members_end_with_the_union_and_resync_moves_only_digests() {
1422        let (pa, pb) = team("union");
1423        let (a, b) = (home(&pa), home(&pb));
1424        write(&a, "lessons.jsonl", &[lesson("L1", 1, "lock order?"), lesson("L2", 1, "retry budget?")]);
1425        write(&b, "lessons.jsonl", &[lesson("L1", 1, "lock order?"), lesson("L1", 2, "lock order, also in tests?"), lesson("L3", 1, "idempotent?")]);
1426
1427        // Since bob joined, alice invited carol: bob learns of her through the sync (trust first).
1428        let carol = Identity::load_or_create(&home(&node("union-carol", "team"))).unwrap().public();
1429        let mut m = Membership::load(&a).unwrap().unwrap();
1430        m.invite(&Identity::load_or_create(&a).unwrap(), &carol, "member").unwrap();
1431        m.save(&a).unwrap();
1432
1433        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1434        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1435        let ticket = alice.ticket();
1436        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1437        let (served, synced) = (served.unwrap(), synced.unwrap());
1438        assert_eq!((synced.received, synced.sent), (1, 2), "bob gets L2, gives L1v2 and L3");
1439        assert_eq!((served.received, served.sent), (2, 1));
1440        assert_eq!(Membership::load(&b).unwrap().unwrap().role_of(&carol), Some("member"), "bob adopted the newer membership");
1441        for file in SYNCED {
1442            assert_eq!(lines::read(&a.join(file)), lines::read(&b.join(file)), "{file}: both hold the union");
1443        }
1444        let asked = |h: &Path| {
1445            let mut v: Vec<(String, String)> = borg::conscience::load(&h.join("lessons.jsonl"), None).into_iter().map(|c| (c.id, c.question)).collect();
1446            v.sort();
1447            v
1448        };
1449        assert_eq!(asked(&a), asked(&b));
1450        assert!(asked(&a).contains(&("L1".into(), "lock order, also in tests?".into())), "the newer version wins on alice too");
1451
1452        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1453        // One round and one fingerprint per synced log (lessons, stats, outcomes), nothing else.
1454        let quiet = Report { rounds: SYNCED.len(), fps: SYNCED.len(), ..Report::default() };
1455        assert_eq!(synced.unwrap(), quiet, "re-sync: one fingerprint per log, nothing new");
1456        assert_eq!(served.unwrap(), quiet);
1457        alice.close().await;
1458        bob.close().await;
1459    }
1460
1461    /// Each episode is learned once, by the node that captured it: before alice's lesson arrives
1462    /// bob has nothing to learn, afterwards he holds her lesson and still asks no model.
1463    #[tokio::test(flavor = "multi_thread")]
1464    async fn an_episode_is_learned_only_where_it_was_captured() {
1465        let (pa, pb) = team("episodes");
1466        let (a, b) = (home(&pa), home(&pb));
1467        let e1 = json!({"id": "e1", "ms": 1, "repo": "r", "session": "s", "task": "t", "agent": "a",
1468            "rejected_tool": null, "correction": "check TOCTOU on the flag", "engineer": "ann@x"});
1469        write(&a, "episodes.jsonl", &[e1.to_string()]);
1470        let calls = std::cell::Cell::new(0);
1471        let ask = |_: &str| {
1472            calls.set(calls.get() + 1);
1473            Some(json!({"is_lesson": true, "question": "Is the check-then-act atomic?", "situations": [], "consequence": "race", "same_as": ""}))
1474        };
1475
1476        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1477        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1478        let ticket = alice.ticket();
1479        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1480        served.unwrap();
1481        synced.unwrap();
1482        assert!(!b.join("episodes.jsonl").exists(), "alice's raw episode stays with alice");
1483        assert_eq!(borg::learn::learn_in(&b, None, &ask, false, 2), (0, 0, 0));
1484        assert_eq!(calls.get(), 0, "bob asks no model for alice's episode");
1485
1486        assert_eq!(borg::learn::learn_in(&a, None, &ask, false, 2), (1, 0, 0));
1487        assert_eq!(calls.get(), 1, "alice learns e1 once");
1488        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1489        served.unwrap();
1490        assert_eq!(synced.unwrap().received, 1, "bob receives the lesson");
1491        let got = borg::learn::lessons(&b.join("lessons.jsonl"));
1492        assert_eq!(
1493            (got.len(), got[0].id.as_str(), got[0].episodes.clone(), got[0].engineers.clone()),
1494            (1, "Le1", vec!["e1".to_string()], vec!["ann@x".to_string()])
1495        );
1496        assert_eq!(borg::learn::learn_in(&b, None, &ask, false, 3), (0, 0, 0));
1497        assert_eq!(calls.get(), 1, "and still learns nothing again");
1498        alice.close().await;
1499        bob.close().await;
1500    }
1501
1502    /// A member on an older protocol (a node that only speaks `borg/mesh/5`) is told apart from a broken
1503    /// network both ways: the session ends at once, the error and the record (for `borg doctor`) name
1504    /// the peer and who has to upgrade, and the first session that completes drops the record.
1505    #[tokio::test(flavor = "multi_thread")]
1506    async fn a_member_on_an_older_protocol_is_named_both_ways() {
1507        const OLD: &[u8] = b"borg/mesh/5";
1508        let (pa, pb) = team("protocol");
1509        let (a, b) = (home(&pa), home(&pb));
1510        let bob_key = Identity::load_or_create(&b).unwrap().public();
1511        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1512        let old_bob = Node::open(&pb, Some(LOCAL.parse().unwrap()), true, |x| x.alpns(vec![OLD.to_vec()])).await.unwrap();
1513
1514        // Alice dials the old node.
1515        let serve_old = async {
1516            let conn = old_bob.incoming().await?.await.ok()?;
1517            Some((conn.alpn().to_vec(), conn.closed().await.to_string()))
1518        };
1519        let old_ticket = old_bob.ticket();
1520        let (seen, synced) = tokio::join!(serve_old, alice.sync(&old_ticket));
1521        let err = synced.unwrap_err();
1522        let ours = String::from_utf8_lossy(ALPN);
1523        let line = format!("peer {} speaks borg/mesh/5, this node {ours}: nothing syncs with it; upgrade it", &bob_key[..12]);
1524        assert_eq!(err, line);
1525        let (alpn, closed) = seen.expect("the old node completed the handshake");
1526        assert_eq!(alpn, OLD);
1527        let alice_key = Identity::load_or_create(&a).unwrap().public();
1528        let told = format!("peer {} speaks {ours}, this node borg/mesh/5: nothing syncs with it; upgrade this node", &alice_key[..12]);
1529        assert!(closed.contains(&told), "the old node is told why, as it reads it: {closed}");
1530        assert_eq!(borg::presence::mismatches(&a)[&bob_key].theirs, "borg/mesh/5");
1531
1532        // The old node dials alice.
1533        let _ = std::fs::remove_file(borg::presence::protocols_path(&a));
1534        let addr = Target::parse(&alice.ticket()).unwrap().endpoint_addr(&old_bob.relay).unwrap();
1535        let dial_old = async {
1536            let conn = old_bob.endpoint.connect(addr, OLD).await.map_err(|e| e.to_string())?;
1537            Ok::<String, String>(conn.closed().await.to_string())
1538        };
1539        let (served, dialled) = tokio::join!(alice.accept(), dial_old);
1540        assert_eq!(served.unwrap_err(), line);
1541        assert!(dialled.unwrap().contains(&told));
1542        assert_eq!(borg::presence::mismatches(&a)[&bob_key].ours, ours);
1543        old_bob.close().await;
1544
1545        // Bob upgraded: the first session that completes drops the record.
1546        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1547        let ticket = alice.ticket();
1548        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1549        served.unwrap();
1550        synced.unwrap();
1551        assert!(borg::presence::mismatches(&a).is_empty());
1552        alice.close().await;
1553        bob.close().await;
1554    }
1555
1556    #[test]
1557    fn older_protocols_are_every_version_below_the_spoken_ones() {
1558        let n = version(LEGACY_ALPN).expect("borg/mesh/<n>");
1559        assert_eq!(version(ALPN), Some(n + 1), "this protocol and 0.3.0's are the two spoken");
1560        let o = older();
1561        let below = format!("borg/mesh/{}", n - 1);
1562        assert_eq!((o.len(), o[0].as_slice(), o[o.len() - 1].as_slice()), (n as usize - 1, below.as_bytes(), b"borg/mesh/1".as_slice()));
1563        assert!(o.iter().all(|a| !spoken(a)));
1564        assert_eq!(synced(ALPN).filter(|f| *f != PLAYBOOKS).collect::<Vec<_>>(), SYNCED, "every synced log, in order");
1565        assert_eq!(synced(ALPN).collect::<Vec<_>>(), [&OLD_SYNCED[..], &[borg::msg::FILE]].concat(), "0.4.0's list, in its order");
1566        assert_eq!(synced(LEGACY_ALPN).collect::<Vec<_>>(), OLD_SYNCED, "0.3.0's list, in its order");
1567    }
1568
1569    #[tokio::test(flavor = "multi_thread")]
1570    async fn an_unlisted_key_is_rejected_both_ways() {
1571        let (pa, pb) = team("unlisted");
1572        let a = home(&pa);
1573        write(&a, "lessons.jsonl", &[lesson("L1", 1, "secret sauce?")]);
1574        // Eve forges her own "work" membership that lists alice, but alice's does not list eve.
1575        let pe = node("unlisted-eve", "team");
1576        let e = home(&pe);
1577        let eve = Identity::load_or_create(&e).unwrap();
1578        let mut forged = Membership::found("work", &eve);
1579        forged.invite(&eve, &Identity::load_or_create(&a).unwrap().public(), "member").unwrap();
1580        forged.save(&e).unwrap();
1581
1582        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1583        let eve_node = Node::bind(&pe, LOCAL.parse().unwrap()).await.unwrap();
1584        let ticket = alice.ticket();
1585        let (served, synced) = tokio::join!(alice.accept(), eve_node.sync(&ticket));
1586        assert!(served.unwrap_err().contains("not a member"), "alice turns eve away");
1587        assert!(synced.is_err());
1588        assert!(!e.join("lessons.jsonl").exists(), "eve received nothing");
1589
1590        // And a member never dials a key its membership does not list.
1591        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1592        let err = bob.sync(&eve_node.ticket()).await.unwrap_err();
1593        assert!(err.contains("not a member") && err.contains("refusing to connect"), "{err}");
1594        for n in [alice, eve_node, bob] {
1595            n.close().await;
1596        }
1597    }
1598
1599    #[tokio::test(flavor = "multi_thread")]
1600    async fn a_local_collective_or_off_repo_never_syncs() {
1601        let (pa, _) = team("local");
1602        write(&home(&pa), "lessons.jsonl", &[lesson("L1", 1, "private thought?")]);
1603        configure(&pa, "local");
1604        assert!(!collective::may_sync(&pa.route()));
1605        let err = Node::bind(&pa, LOCAL.parse().unwrap()).await.err().expect("local must be refused");
1606        assert!(err.contains("is local") && err.contains("refusing to sync"), "{err}");
1607        let off = Place { dir: "/somewhere/else".into(), ..pa.clone() };
1608        let err = Node::bind(&off, LOCAL.parse().unwrap()).await.err().expect("off must be refused");
1609        assert!(err.contains("off"), "{err}");
1610        std::fs::write(&pa.config, "{ broken").unwrap();
1611        assert_eq!(pa.route(), Route::Off, "a broken config routes off");
1612        assert!(Node::bind(&pa, LOCAL.parse().unwrap()).await.is_err());
1613        let team = Route::Collective { name: "work".into(), home: home(&pa), share: collective::Share::Team };
1614        assert!(gate(&team).is_ok() && gate(&Route::Off).is_err());
1615    }
1616
1617    /// A running `serve` re-reads the config at each session: once the collective is set to
1618    /// `share: local`, the next peer is refused before a single frame of it is sent.
1619    #[tokio::test(flavor = "multi_thread")]
1620    async fn serve_stops_syncing_when_the_collective_turns_local() {
1621        let (pa, pb) = team("turns-local");
1622        write(&home(&pa), "lessons.jsonl", &[lesson("L1", 1, "now private?")]);
1623        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
1624        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
1625        let ticket = alice.ticket();
1626        configure(&pa, "local");
1627        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
1628        let err = served.unwrap_err();
1629        assert!(err.contains("is local") && err.contains("refusing to sync"), "{err}");
1630        assert!(synced.is_err());
1631        assert!(!home(&pb).join("lessons.jsonl").exists(), "bob received nothing");
1632        // A dialling node is gated the same way.
1633        configure(&pb, "local");
1634        assert!(bob.sync(&ticket).await.unwrap_err().contains("is local"));
1635        alice.close().await;
1636        bob.close().await;
1637    }
1638
1639    /// Second guard: a team route whose store a local collective also maps to never syncs, even if
1640    /// such a config got past `Config::check`.
1641    #[test]
1642    fn a_store_shared_with_a_local_collective_is_refused() {
1643        let p = node("shared-store", "team");
1644        let cfg: Config = serde_json::from_value(json!({"collectives": [
1645            {"name": "work", "paths": [p.dir], "share": "team"},
1646            {"name": "work", "paths": ["/elsewhere"], "share": "local"}]}))
1647        .unwrap();
1648        let route = collective::route(Some(&cfg), &p.dir, &p.base);
1649        assert!(gate(&route).is_ok(), "the route alone looks shareable");
1650        assert_eq!(collective::local_owner_of(Some(&cfg), &home(&p), &p.base), Some("work"));
1651        // Written to disk, load rejects the config outright, which routes off.
1652        std::fs::write(
1653            &p.config,
1654            serde_json::to_string(&json!({"collectives": [
1655            {"name": "work", "paths": [p.dir], "share": "team"},
1656            {"name": "work", "paths": ["/elsewhere"], "share": "local"}]}))
1657            .unwrap(),
1658        )
1659        .unwrap();
1660        assert!(p.gate().is_err());
1661    }
1662
1663    /// Alice founds the collective and invites everyone else; all install the same document.
1664    fn crew(tag: &str, names: &[&str]) -> Vec<Place> {
1665        let places: Vec<Place> = names.iter().map(|n| node(&format!("{tag}-{n}"), "team")).collect();
1666        let ids: Vec<Identity> = places.iter().map(|p| Identity::load_or_create(&home(p)).unwrap()).collect();
1667        let mut m = Membership::found("work", &ids[0]);
1668        for id in &ids[1..] {
1669            m.invite(&ids[0], &id.public(), "member").unwrap();
1670        }
1671        for p in &places {
1672            m.save(&home(p)).unwrap();
1673        }
1674        places
1675    }
1676
1677    fn append_lines(home: &Path, file: &str, lines: &[String]) {
1678        use std::io::Write;
1679        let mut f = std::fs::OpenOptions::new().create(true).append(true).open(home.join(file)).unwrap();
1680        for l in lines {
1681            writeln!(f, "{l}").unwrap();
1682        }
1683    }
1684
1685    fn holds(home: &Path, file: &str, line: &str) -> bool {
1686        lines::read(&home.join(file)).contains_key(&lines::hash(line))
1687    }
1688
1689    async fn eventually(what: &str, limit: std::time::Duration, f: impl Fn() -> bool) {
1690        let start = std::time::Instant::now();
1691        while !f() {
1692            assert!(start.elapsed() < limit, "timed out waiting for: {what}");
1693            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1694        }
1695    }
1696
1697    fn addr_of(n: &Node) -> String {
1698        n.ticket().split_once('@').unwrap().1.to_string()
1699    }
1700
1701    /// Two `serve` loops: a lesson either side writes reaches the other with no manual sync, and
1702    /// once alice's collective turns local, her next lesson stays home. Both stop cleanly.
1703    #[tokio::test(flavor = "multi_thread")]
1704    async fn serve_spreads_new_lessons_without_a_manual_sync() {
1705        use crate::daemon::{self, Event, Options};
1706        use std::sync::Arc;
1707        use std::time::Duration;
1708        let p = crew("daemon", &["alice", "bob"]);
1709        let (a, b) = (home(&p[0]), home(&p[1]));
1710        let alice = Arc::new(Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap());
1711        let bob = Arc::new(Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap());
1712        // Bob knows where alice is; alice learns bob's address when he first dials in.
1713        assert!(peers::remember(&b, &alice.public(), &addr_of(&alice)).unwrap());
1714        let opts = Options {
1715            interval: Duration::from_millis(300),
1716            poll: Duration::from_millis(50),
1717            backoff_max: Duration::from_millis(600),
1718            timeout: Duration::from_secs(5),
1719            grace: Duration::from_secs(1),
1720        };
1721        let synced = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1722        let mut stops = Vec::new();
1723        let mut loops = Vec::new();
1724        for n in [alice.clone(), bob.clone()] {
1725            let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1726            stops.push(tx);
1727            let count = synced.clone();
1728            loops.push(tokio::spawn(daemon::serve(
1729                n,
1730                opts,
1731                async move {
1732                    let _ = rx.await;
1733                },
1734                move |e| {
1735                    if matches!(e, Event::Synced { result: Ok(_), .. }) {
1736                        count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1737                    }
1738                },
1739            )));
1740        }
1741        let l1 = lesson("L1", 1, "who owns the lock?");
1742        append_lines(&a, "lessons.jsonl", std::slice::from_ref(&l1));
1743        eventually("alice's lesson at bob", Duration::from_secs(10), || holds(&b, "lessons.jsonl", &l1)).await;
1744        let l2 = lesson("L2", 1, "is the retry idempotent?");
1745        append_lines(&b, "lessons.jsonl", std::slice::from_ref(&l2));
1746        eventually("bob's lesson at alice", Duration::from_secs(10), || holds(&a, "lessons.jsonl", &l2)).await;
1747        assert_eq!(peers::load(&a).get(&bob.public()), Some(&addr_of(&bob)), "alice learned bob's address from his hello");
1748        // The receiver holds a lesson once its served session wrote it; the dialler's round reports
1749        // `Synced` only after it closes, a moment later.
1750        eventually("a successful sync round", Duration::from_secs(10), || synced.load(std::sync::atomic::Ordering::Relaxed) > 0).await;
1751
1752        // Alice's collective turns local: her loops stop dialling, bob is turned away.
1753        configure(&p[0], "local");
1754        let l3 = lesson("L3", 1, "private now?");
1755        append_lines(&a, "lessons.jsonl", std::slice::from_ref(&l3));
1756        tokio::time::sleep(Duration::from_millis(1500)).await;
1757        assert!(!holds(&b, "lessons.jsonl", &l3), "a local collective's lesson never leaves");
1758
1759        for s in stops {
1760            let _ = s.send(());
1761        }
1762        for l in loops {
1763            tokio::time::timeout(Duration::from_secs(5), l).await.expect("serve stops within its grace").unwrap();
1764        }
1765    }
1766
1767    /// `serve` loops for `nodes`, each stopped by its sender; the failures they report, with who and when.
1768    struct Serving {
1769        stops: Vec<tokio::sync::oneshot::Sender<()>>,
1770        loops: Vec<tokio::task::JoinHandle<()>>,
1771        failed: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1772    }
1773
1774    fn serving(nodes: Vec<(&'static str, std::sync::Arc<Node>)>, opts: crate::daemon::Options) -> Serving {
1775        let s = Serving { stops: Vec::new(), loops: Vec::new(), failed: Default::default() };
1776        nodes.into_iter().fold(s, |mut s, (who, n)| {
1777            s.add(who, n, opts);
1778            s
1779        })
1780    }
1781
1782    impl Serving {
1783        fn add(&mut self, who: &'static str, n: std::sync::Arc<Node>, opts: crate::daemon::Options) {
1784            use crate::daemon::{self, Event};
1785            let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1786            self.stops.push(tx);
1787            let failed = self.failed.clone();
1788            self.loops.push(tokio::spawn(daemon::serve(
1789                n,
1790                opts,
1791                async move {
1792                    let _ = rx.await;
1793                },
1794                move |e| {
1795                    if matches!(e, Event::Served(Err(_)) | Event::Synced { result: Err(_), .. } | Event::Gate(Err(_))) {
1796                        failed.lock().unwrap().push(format!("{who} at {}: {e:?}", borg::learn::now_ms()));
1797                    }
1798                },
1799            )));
1800        }
1801
1802        async fn stop(self) {
1803            for s in self.stops {
1804                let _ = s.send(());
1805            }
1806            for l in self.loops {
1807                tokio::time::timeout(std::time::Duration::from_secs(10), l).await.expect("serve stops within its grace").unwrap();
1808            }
1809        }
1810    }
1811
1812    /// Carol is a member alice dials, and she takes the dial and never answers: she reads alice's hello
1813    /// and holds the connection until the test lets her go. While alice's dial to carol is still open,
1814    /// a line alice writes reaches bob, whom only alice's dials reach. Once carol closes, alice's dial
1815    /// fails and carol's session record from before goes with it. Ordered by events, not by time:
1816    /// the dial timeout is far past the test.
1817    #[tokio::test(flavor = "multi_thread")]
1818    async fn a_peer_that_does_not_answer_holds_no_line_back() {
1819        use std::sync::Arc;
1820        use std::time::Duration;
1821        use tokio::sync::oneshot;
1822        let p = crew("deaf", &["alice", "bob", "carol"]);
1823        let (a, b) = (home(&p[0]), home(&p[1]));
1824        let alice = Arc::new(Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap());
1825        let bob = Arc::new(Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap());
1826        let carol = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
1827        let key = carol.public();
1828        assert!(peers::remember(&a, &key, &addr_of(&carol)).unwrap());
1829        assert!(peers::remember(&a, &bob.public(), &addr_of(&bob)).unwrap());
1830        borg::presence::note_synced(&a, &key, borg::learn::now_ms()).unwrap();
1831        let (held_tx, held) = oneshot::channel::<()>();
1832        let (release, release_rx) = oneshot::channel::<()>();
1833        let deaf = tokio::spawn(async move {
1834            let conn = carol.incoming().await.expect("alice dials carol").await.expect("handshake");
1835            let (_tx, mut rx) = conn.accept_bi().await.expect("alice's stream");
1836            assert!(matches!(recv(&mut rx).await, Ok(Msg::Hello { .. })), "alice's hello");
1837            let _ = held_tx.send(());
1838            let _ = release_rx.await;
1839            let open = conn.close_reason().is_none();
1840            conn.close(1u32.into(), b"deaf");
1841            carol.close().await;
1842            open
1843        });
1844        let opts =
1845            crate::daemon::Options { poll: Duration::from_millis(100), timeout: Duration::from_secs(600), grace: Duration::from_secs(1), ..Default::default() };
1846        let serve = serving(vec![("alice", alice.clone()), ("bob", bob.clone())], opts);
1847        let limit = Duration::from_secs(60); // liveness only: every wait below ends on an event
1848        tokio::time::timeout(limit, held).await.expect("carol holds alice's dial").unwrap();
1849
1850        let l = rollup("n1", 1, 1);
1851        append_lines(&a, "stats.jsonl", std::slice::from_ref(&l));
1852        eventually("alice's line at bob", limit, || holds(&b, "stats.jsonl", &l)).await;
1853        assert!(serve.failed.lock().unwrap().iter().all(|f| !f.contains(&key)), "carol's dial has not ended");
1854        release.send(()).unwrap();
1855        assert!(deaf.await.unwrap(), "alice's dial to carol was still open when bob got the line");
1856
1857        eventually("alice's dial to carol failed", limit, || serve.failed.lock().unwrap().iter().any(|f| f.contains(&key))).await;
1858        eventually("carol's record dropped", limit, || !borg::presence::synced(&a).contains_key(&key)).await;
1859        serve.stop().await;
1860    }
1861
1862    /// Alice's first dial to bob fails (his node runs no `serve` yet); the lesson it was to carry reaches
1863    /// him a `poll` later, with no new change and bob never dialling in: not after a 2-minute backoff.
1864    #[tokio::test(flavor = "multi_thread")]
1865    async fn a_failed_dial_is_retried_soon_with_what_it_missed() {
1866        use std::sync::Arc;
1867        use std::time::Duration;
1868        let p = crew("retry", &["alice", "bob"]);
1869        let (a, b) = (home(&p[0]), home(&p[1]));
1870        let alice = Arc::new(Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap());
1871        let bob = Arc::new(Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap());
1872        assert!(peers::remember(&a, &bob.public(), &addr_of(&bob)).unwrap());
1873        let l1 = lesson("L1", 1, "who owns the lock?");
1874        append_lines(&a, "lessons.jsonl", std::slice::from_ref(&l1));
1875        let opts = crate::daemon::Options { timeout: Duration::from_secs(1), grace: Duration::from_secs(1), ..Default::default() };
1876        let mut serve = serving(vec![("alice", alice.clone())], opts);
1877        eventually("alice's dial to bob failed", Duration::from_secs(10), || !serve.failed.lock().unwrap().is_empty()).await;
1878        serve.add("bob", bob.clone(), opts);
1879        eventually("alice's lesson at bob", Duration::from_secs(10), || holds(&b, "lessons.jsonl", &l1)).await;
1880        serve.stop().await;
1881    }
1882
1883    /// A 0.3.0 peer's goal ids (3b) count once each and only when valid, so both sides follow the
1884    /// same number of reconciliations: "../evil" or "A B" never names a goal.
1885    #[test]
1886    fn legacy_goals_count_the_valid_distinct_ids() {
1887        let ids = ["g1", "../evil", "A B", "g0", "g1", "/abs", ""].map(String::from).to_vec();
1888        assert_eq!(legacy_goals(ids).into_iter().collect::<Vec<_>>(), ["g0", "g1"], "sorted, as the peer walks them");
1889        assert!(legacy_goals(vec!["x".repeat(41)]).is_empty());
1890    }
1891
1892    /// The logs a 0.3.0 node reconciled, in its order (base 188c262 lines.rs `SYNCED`).
1893    const OLD_SYNCED: [&str; 5] = ["lessons.jsonl", "stats.jsonl", "outcomes-team.jsonl", "playbooks.jsonl", "constraints.jsonl"];
1894
1895    /// A 0.3.0 node's hive goal: its seven synced logs, held in memory, each line signed by the node.
1896    struct OldGoal {
1897        logs: Vec<BTreeMap<Hash, String>>,
1898        origins: HashMap<Hash, Origin>,
1899    }
1900
1901    impl OldGoal {
1902        fn new(me: &Identity) -> OldGoal {
1903            let mut g = OldGoal { logs: vec![BTreeMap::new(); LEGACY_GOAL_LOGS], origins: HashMap::new() };
1904            for log in 0..LEGACY_GOAL_LOGS {
1905                g.add(me, log, &json!({"log": log, "n": 0}).to_string());
1906            }
1907            g
1908        }
1909
1910        fn add(&mut self, me: &Identity, log: usize, line: &str) {
1911            let h = lines::hash(line);
1912            self.origins.insert(h, Origin::new(me, &h, 1));
1913            self.logs[log].insert(h, line.into());
1914        }
1915    }
1916
1917    async fn send_raw(tx: &mut SendStream, v: &serde_json::Value) -> Result<(), String> {
1918        let body = serde_json::to_vec(v).map_err(|e| e.to_string())?;
1919        tx.write_all(&(body.len() as u32).to_be_bytes()).await.map_err(|e| e.to_string())?;
1920        tx.write_all(&body).await.map_err(|e| e.to_string())
1921    }
1922
1923    /// What a 0.3.0 node said after the hello, step by step (base 188c262 `exchange`): its goal ids
1924    /// (a duplicate and an invalid one among them), a full reconciliation of each valid goal's logs,
1925    /// and `Live` with its hive beats and a signed directive. Returns the hive lines it gave and the
1926    /// presence it took in.
1927    async fn old_exchange(n: &Node, dialler: bool, tx: &mut SendStream, rx: &mut RecvStream, goal: &OldGoal) -> Result<(usize, usize), String> {
1928        let home = n.home.as_path();
1929        let mine = members(home)?;
1930        let Msg::Membership { .. } = swap(dialler, tx, rx, Msg::Membership { docs: Membership::history(home) }).await? else { return Err("membership".into()) };
1931        n.stamp()?;
1932        let (origins, mut report) = (origin::load(home), Report::default());
1933        for file in OLD_SYNCED {
1934            n.reconcile(dialler, tx, rx, &home.join(file), &origins, &mine, &mut report).await?;
1935        }
1936        let ids = ["g1", "g1", "Bad Id"].map(String::from).to_vec();
1937        let Msg::Goals { ids: theirs } = swap(dialler, tx, rx, Msg::Goals { ids }).await? else { return Err("goals".into()) };
1938        assert!(theirs.is_empty(), "a new node names no goal");
1939        let mut gave = 0;
1940        for set in &goal.logs {
1941            let mut r = Recon::new(set);
1942            loop {
1943                let m = r.message();
1944                let Msg::Round { round } = swap(dialler, tx, rx, Msg::Round { round: m.clone() }).await? else { return Err("round".into()) };
1945                if r.absorb(&m, round)? {
1946                    break;
1947                }
1948            }
1949            let give = within_budget(signed(set, &goal.origins, r.give.iter()));
1950            gave += give.len();
1951            let Msg::Lines { lines } = swap(dialler, tx, rx, Msg::Lines { lines: give }).await? else { return Err("lines".into()) };
1952            assert!(lines.is_empty(), "a new node gives no hive line");
1953        }
1954        for m in [
1955            Msg::Offer { hashes: vec![] },
1956            Msg::Want { hashes: vec![] },
1957            Msg::Lines { lines: vec![] },
1958            Msg::Ask { commits: vec![] },
1959            Msg::Lines { lines: vec![] },
1960        ] {
1961            swap(dialler, tx, rx, m).await?;
1962        }
1963        let now = borg::learn::now_ms();
1964        let directive = json!({"id": "d1", "collective": "work", "key": n.me.public(), "engineer": "old@x", "target": {"node": "", "session": ""},
1965            "kind": "pause", "text": "", "ms": now, "sig": "00"});
1966        let live = json!({"t": "live", "presence": live::outgoing(home, &n.me, now), "directives": [directive],
1967            "beats": [{"goal": "g1", "line": "{}", "key": n.me.public(), "sig": "00"}]});
1968        let theirs = if dialler {
1969            send_raw(tx, &live).await?;
1970            recv(rx).await?
1971        } else {
1972            let theirs = recv(rx).await?;
1973            send_raw(tx, &live).await?;
1974            theirs
1975        };
1976        let Msg::Live { presence, .. } = theirs else { return Err("live".into()) };
1977        Ok((gave, live::receive(home, &mine, &n.me.public(), presence, now)?))
1978    }
1979
1980    async fn old_dial(n: &Node, target: &str, goal: &OldGoal) -> Result<(usize, usize), String> {
1981        let t = Target::parse(target)?;
1982        let conn = n.endpoint.connect(t.endpoint_addr(&n.relay)?, LEGACY_ALPN).await.map_err(|e| e.to_string())?;
1983        let (mut tx, mut rx) = conn.open_bi().await.map_err(|e| e.to_string())?;
1984        swap(true, &mut tx, &mut rx, Msg::Hello { collective: "work".into(), listen: None, relay: None }).await?;
1985        let got = old_exchange(n, true, &mut tx, &mut rx, goal).await;
1986        let _ = tx.finish();
1987        conn.close(0u32.into(), b"done");
1988        got
1989    }
1990
1991    async fn old_serve(n: &Node, goal: &OldGoal) -> Result<(usize, usize), String> {
1992        let conn = n.incoming().await.ok_or("closed")?.await.map_err(|e| e.to_string())?;
1993        let (mut tx, mut rx) = conn.accept_bi().await.map_err(|e| e.to_string())?;
1994        let Msg::Hello { .. } = recv(&mut rx).await? else { return Err("hello".into()) };
1995        send(&mut tx, &Msg::Hello { collective: "work".into(), listen: None, relay: None }).await?;
1996        let got = old_exchange(n, false, &mut tx, &mut rx, goal).await;
1997        let _ = tx.finish();
1998        conn.closed().await;
1999        got
2000    }
2001
2002    /// A 0.3.0 peer (it names hive goals, reconciles their logs and the playbooks, and sends beats in
2003    /// `Live`) keeps syncing with this node in both roles: lessons and presence arrive both ways, its
2004    /// directives are ignored, no hive or playbooks file is written, and a hive line it gave once is not
2005    /// sent again when this node dials.
2006    #[tokio::test(flavor = "multi_thread")]
2007    async fn a_0_3_0_peer_syncs_in_both_roles_and_its_hive_lines_come_once() {
2008        let (pa, pb) = team("legacy");
2009        let (a, b) = (home(&pa), home(&pb));
2010        let (alice_id, bob_id) = (Identity::load_or_create(&a).unwrap(), Identity::load_or_create(&b).unwrap());
2011        let mut m = Membership::load(&a).unwrap().unwrap();
2012        m.invite(&alice_id, &bob_id.public(), "steer").unwrap();
2013        m.save(&a).unwrap();
2014        m.save(&b).unwrap();
2015        let (l1, l2) = (lesson("L1", 1, "lock order?"), lesson("L2", 1, "retry budget?"));
2016        write(&a, "lessons.jsonl", std::slice::from_ref(&l1));
2017        write(&b, "lessons.jsonl", std::slice::from_ref(&l2));
2018        let edit = json!({"pb": "p1", "edit": "e1", "step": "s1", "text": "run the parser tests", "ms": 1}).to_string();
2019        write(&a, PLAYBOOKS, std::slice::from_ref(&edit));
2020        origin::append(&a, &[Origin::new(&alice_id, &lines::hash(&edit), 1)]).unwrap();
2021        let now = borg::learn::now_ms();
2022        for (h, p, who) in [(&a, &pa, "alice"), (&b, &pb, "bob")] {
2023            let ev = json!({"session_id": format!("{who}-s1"), "cwd": p.dir, "prompt": format!("{who} works")});
2024            borg::presence::write(h, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| (format!("{who}@x"), "repo".into()))).unwrap();
2025        }
2026        let tell = message(&b, &bob_id.public(), ALL_TO, now, "the new node's note");
2027        let old = Node::open(&pa, Some(LOCAL.parse().unwrap()), true, |x| x.alpns(vec![LEGACY_ALPN.to_vec()])).await.unwrap();
2028        let new = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2029        let (old_at, new_at) = (old.ticket(), new.ticket());
2030        let mut goal = OldGoal::new(&alice_id);
2031
2032        // The new node dials: every hive line comes once and is dropped.
2033        let (old_got, new_got) = tokio::join!(old_serve(&old, &goal), new.sync(&old_at));
2034        let (new_got, (gave, presence)) = (new_got.unwrap(), old_got.unwrap());
2035        assert_eq!((gave, presence, new_got.presence), (LEGACY_GOAL_LOGS, 1, 1));
2036        assert!(holds(&a, "lessons.jsonl", &l2) && holds(&b, "lessons.jsonl", &l1), "lessons both ways");
2037
2038        // The old node dials: its directive (bob may steer) is ignored, and the listener's echo moves
2039        // no hive line.
2040        let (old_got, new_got) = tokio::join!(old_dial(&old, &new_at, &goal), new.accept());
2041        let (new_got, (gave, _)) = (new_got.unwrap(), old_got.unwrap());
2042        assert_eq!((gave, new_got.steered, new_got.dropped), (0, 0, 0));
2043        assert!(!borg::steer::active(&b), "a 0.3.0 directive counts nowhere");
2044
2045        // The new node dials again: only the line added since comes.
2046        goal.add(&alice_id, 5, "{\"bundle\":\"new\"}");
2047        let (old_got, new_got) = tokio::join!(old_serve(&old, &goal), new.sync(&old_at));
2048        new_got.unwrap();
2049        assert_eq!(old_got.unwrap().0, 1, "what was given before is not sent again");
2050        assert!(!b.join("hive").exists(), "no hive file on the new node");
2051        assert!(!b.join(PLAYBOOKS).exists(), "the old node's playbook edit is followed, not kept");
2052        assert!(holds(&b, borg::msg::FILE, &tell) && !a.join(borg::msg::FILE).exists(), "messages stay off a 0.3.0 session");
2053        old.close().await;
2054        new.close().await;
2055    }
2056
2057    /// A revokes C; B, which has not heard yet, still takes a lesson C wrote after its revocation.
2058    /// B's next sync with A brings the new membership first: the late lesson is quarantined before
2059    /// any data moves and never reaches A, C's earlier lesson stays, and C is refused by B.
2060    #[tokio::test(flavor = "multi_thread")]
2061    async fn a_revocation_spreads_and_the_revoked_node_is_refused() {
2062        let p = crew("revoke", &["alice", "bob", "carol"]);
2063        let (a, b, c) = (home(&p[0]), home(&p[1]), home(&p[2]));
2064        let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
2065        let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
2066        let carol = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
2067        let (ta, tb, tc) = (alice.ticket(), bob.ticket(), carol.ticket());
2068
2069        let early = lesson("Lc1", 1, "carol's useful lesson?");
2070        write(&c, "lessons.jsonl", std::slice::from_ref(&early));
2071        let (s, r) = tokio::join!(bob.accept(), carol.sync(&tb));
2072        assert_eq!((s.unwrap().received, r.unwrap().sent), (1, 1));
2073        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2074
2075        let mut m = Membership::load(&a).unwrap().unwrap();
2076        m.revoke(&Identity::load_or_create(&a).unwrap(), &carol.public(), borg::learn::now_ms()).unwrap();
2077        m.save(&a).unwrap();
2078        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2079
2080        let late = lesson("Lc2", 1, "carol's lesson after her revocation?");
2081        append_lines(&c, "lessons.jsonl", std::slice::from_ref(&late));
2082        let (s, r) = tokio::join!(bob.accept(), carol.sync(&tb));
2083        assert_eq!((s.unwrap().received, r.unwrap().sent), (1, 1), "bob has not heard of the revocation yet");
2084        assert!(holds(&b, "lessons.jsonl", &late));
2085
2086        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ta));
2087        let (served, synced) = (served.unwrap(), synced.unwrap());
2088        assert_eq!(Membership::load(&b).unwrap().unwrap().version, m.version, "bob learned the revocation from alice");
2089        assert_eq!(synced.quarantined, 1, "carol's late lesson is set aside before any data moves");
2090        assert!(!holds(&b, "lessons.jsonl", &late) && holds(&b.join("mesh"), "quarantine.jsonl", &late));
2091        assert_eq!(served.received, 1, "alice gets carol's earlier lesson only");
2092        assert!(holds(&a, "lessons.jsonl", &early) && !holds(&a, "lessons.jsonl", &late));
2093
2094        let (served, synced) = tokio::join!(bob.accept(), carol.sync(&tb));
2095        assert!(served.unwrap_err().contains("not a member"), "bob turns carol away");
2096        assert!(synced.is_err());
2097        let err = bob.sync(&tc).await.unwrap_err();
2098        assert!(err.contains("not a member") && err.contains("refusing to connect"), "{err}");
2099        for n in [alice, bob, carol] {
2100            n.close().await;
2101        }
2102    }
2103
2104    /// After days apart, two nodes holding 2000 common lessons and 5 new ones between them move
2105    /// exactly the 5 lines and a few dozen hashes, not the history.
2106    #[tokio::test(flavor = "multi_thread")]
2107    async fn catch_up_transfers_only_the_difference() {
2108        let (pa, pb) = team("catch-up");
2109        let (a, b) = (home(&pa), home(&pb));
2110        let common: Vec<String> = (0..2000).map(|i| lesson(&format!("L{i}"), 1, &format!("question {i}?"))).collect();
2111        write(&a, "lessons.jsonl", &common);
2112        write(&b, "lessons.jsonl", &common);
2113        append_lines(&a, "lessons.jsonl", &(0..3).map(|i| lesson(&format!("A{i}"), 1, "alice offline?")).collect::<Vec<_>>());
2114        append_lines(&b, "lessons.jsonl", &(0..2).map(|i| lesson(&format!("B{i}"), 1, "bob offline?")).collect::<Vec<_>>());
2115        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
2116        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2117        let ticket = alice.ticket();
2118        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2119        let (served, synced) = (served.unwrap(), synced.unwrap());
2120        assert_eq!((synced.received, synced.sent, served.received, served.sent), (3, 2, 2, 3));
2121        let hashes = synced.hashes + served.hashes;
2122        let fps = synced.fps + served.fps;
2123        eprintln!("catch-up: 5 of 2005 lines moved, {hashes} hashes and {fps} fingerprints in {} rounds", synced.rounds);
2124        assert!(hashes <= 2 * crate::recon::LEAF * 5, "hashes ∝ difference, not history: {hashes}");
2125        assert!(fps <= 2 * (1 + 16 * 5 * synced.rounds), "{fps}");
2126        assert_eq!(lines::read(&a.join("lessons.jsonl")), lines::read(&b.join("lessons.jsonl")));
2127        alice.close().await;
2128        bob.close().await;
2129    }
2130
2131    /// A reasoning excerpt goes from its author to exactly its 2 rendezvous replicas, and no
2132    /// member accepts another member's excerpt passed off as a replica.
2133    #[tokio::test(flavor = "multi_thread")]
2134    async fn reasoning_goes_to_two_replicas() {
2135        let p = crew("replicas", &["alice", "bob", "carol", "dave"]);
2136        let a = home(&p[0]);
2137        let excerpt = json!({"commit": "abc", "task": "fix the lock", "proposal": "take it in order"}).to_string();
2138        write(&a, REASONING, std::slice::from_ref(&excerpt));
2139        let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
2140        let m = Membership::load(&a).unwrap().unwrap();
2141        let chosen = replicas(&lines::hash(&excerpt), &m, &alice.public());
2142        assert_eq!(chosen.len(), 2);
2143        let mut holders = Vec::new();
2144        for q in &p[1..] {
2145            let n = Node::bind(q, LOCAL.parse().unwrap()).await.unwrap();
2146            let ticket = alice.ticket();
2147            let (served, synced) = tokio::join!(alice.accept(), n.sync(&ticket));
2148            served.unwrap();
2149            synced.unwrap();
2150            if holds(&home(q), REASONING, &excerpt) {
2151                holders.push(n.public());
2152            }
2153            n.close().await;
2154        }
2155        holders.sort();
2156        let mut want = chosen.clone();
2157        want.sort();
2158        assert_eq!(holders, want, "exactly the two replicas hold it");
2159        alice.close().await;
2160    }
2161
2162    /// With the default bind (0.0.0.0) no ticket says 0.0.0.0, and the listener remembers the
2163    /// dialler at the IP it saw it come from, with the port it announced: it can dial back.
2164    #[tokio::test(flavor = "multi_thread")]
2165    async fn a_node_bound_to_any_address_is_dialled_back_where_it_was_seen() {
2166        let (pa, pb) = team("any-addr");
2167        let alice = Node::bind(&pa, "0.0.0.0:0".parse().unwrap()).await.unwrap();
2168        let bob = Node::bind(&pb, "0.0.0.0:0".parse().unwrap()).await.unwrap();
2169        let (pa_port, pb_port) = (alice.addr().unwrap().port(), bob.addr().unwrap().port());
2170        for t in alice.tickets() {
2171            assert!(!t.contains("@0.0.0.0:") && t.ends_with(&format!(":{pa_port}")), "{t}");
2172        }
2173        let ticket = format!("{}@127.0.0.1:{pa_port}", alice.public());
2174        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2175        served.unwrap();
2176        synced.unwrap();
2177        assert_eq!(peers::load(&home(&pa)).get(&bob.public()), Some(&format!("127.0.0.1:{pb_port}")), "bob is remembered where alice saw him");
2178        // And alice can dial bob back there.
2179        let known = peers::load(&home(&pa));
2180        let back = format!("{}@{}", bob.public(), known[&bob.public()]);
2181        let (served, synced) = tokio::join!(bob.accept(), alice.sync(&back));
2182        served.unwrap();
2183        synced.unwrap();
2184        // A one-shot dialler announces nothing: its port is gone a moment later.
2185        let quiet = Node::bind(&pb, "0.0.0.0:0".parse().unwrap()).await.unwrap().quiet();
2186        assert_eq!(quiet.listen(), None);
2187        assert_eq!(reachable("0.0.0.0:7878", Some("10.0.0.5:51000".parse().unwrap())), Some("10.0.0.5:7878".parse().unwrap()));
2188        assert_eq!(reachable("0.0.0.0:7878", None), None);
2189        for n in [alice, bob, quiet] {
2190            n.close().await;
2191        }
2192    }
2193
2194    /// B already holds a lesson C wrote after its revocation (from C, before B knew). The admin
2195    /// then hands B the new membership with `join`: the lesson is quarantined right there, not at
2196    /// some later sync (which, B holding the newest version already, would find nothing newer).
2197    /// The same for a node whose membership file another process replaced: `enforce` catches it.
2198    #[tokio::test(flavor = "multi_thread")]
2199    async fn join_and_enforce_quarantine_what_the_new_membership_refuses() {
2200        let p = crew("join-q", &["alice", "bob", "carol", "dave"]);
2201        let (a, b, c, d) = (home(&p[0]), home(&p[1]), home(&p[2]), home(&p[3]));
2202        let bob = Node::bind(&p[1], LOCAL.parse().unwrap()).await.unwrap();
2203        let carol = Node::bind(&p[2], LOCAL.parse().unwrap()).await.unwrap();
2204        let dave = Node::bind(&p[3], LOCAL.parse().unwrap()).await.unwrap();
2205        let early = lesson("Lc1", 1, "carol's useful lesson?");
2206        write(&c, "lessons.jsonl", std::slice::from_ref(&early));
2207        carol.stamp().unwrap();
2208        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2209        let (m, _) = origin::revoke(&a, &Identity::load_or_create(&a).unwrap(), &carol.public(), borg::learn::now_ms()).unwrap();
2210        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2211        let late = lesson("Lc2", 1, "carol's lesson after her revocation?");
2212        append_lines(&c, "lessons.jsonl", std::slice::from_ref(&late));
2213        for (node, h) in [(&bob, &b), (&dave, &d)] {
2214            let t = node.ticket();
2215            let (s, r) = tokio::join!(node.accept(), carol.sync(&t));
2216            s.unwrap();
2217            r.unwrap();
2218            assert!(holds(h, "lessons.jsonl", &late) && holds(h, "lessons.jsonl", &early));
2219        }
2220
2221        let handed = std::fs::read_to_string(crate::membership::path(&a)).unwrap();
2222        let (got, moved) = origin::join(&b, "work", &crate::membership::parse_docs(&handed).unwrap()).unwrap();
2223        assert_eq!((got.version, moved), (m.version, 1));
2224        assert!(!holds(&b, "lessons.jsonl", &late) && holds(&b, "lessons.jsonl", &early), "quarantined at join");
2225        assert!(origin::join(&b, "work", &crate::membership::parse_docs(&handed).unwrap()).is_err(), "nothing newer the second time");
2226
2227        m.save(&d).unwrap(); // installed behind the daemon's back
2228        assert_eq!(dave.enforce().unwrap(), 1);
2229        assert_eq!(dave.enforce().unwrap(), 0, "once per membership");
2230        assert!(!holds(&d, "lessons.jsonl", &late));
2231        for n in [bob, carol, dave] {
2232            n.close().await;
2233        }
2234    }
2235
2236    fn git(repo: &Path, args: &[&str]) -> String {
2237        let o = std::process::Command::new("git")
2238            .arg("-C")
2239            .arg(repo)
2240            .args(args)
2241            .env("GIT_CONFIG_GLOBAL", "/dev/null")
2242            .env("GIT_CONFIG_NOSYSTEM", "1")
2243            .output()
2244            .unwrap();
2245        assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
2246        String::from_utf8_lossy(&o.stdout).trim().to_string()
2247    }
2248
2249    /// The author hands an excerpt to its two replicas and goes offline; a member that is no
2250    /// replica asks one of them by commit and gets it back as a git note.
2251    #[tokio::test(flavor = "multi_thread")]
2252    async fn reasoning_is_fetched_from_a_replica_while_its_author_is_offline() {
2253        let p = crew("fetch", &["alice", "bob", "carol", "dave"]);
2254        let a = home(&p[0]);
2255        let repo = p[3].base.join("work");
2256        std::fs::create_dir_all(&repo).unwrap();
2257        git(&repo, &["init", "-q"]);
2258        git(&repo, &["-c", "user.email=ann@example.com", "-c", "user.name=Ann", "commit", "-q", "--allow-empty", "-m", "take the lock first"]);
2259        let commit = git(&repo, &["rev-parse", "HEAD"]);
2260        let excerpt = json!({"kind": "reasoning", "commit": commit, "repo": "work", "session": "s-9", "task": "fix the race",
2261            "proposal": "take the lock before reading the flag", "episodes": [], "ms": 1})
2262        .to_string();
2263        write(&a, REASONING, std::slice::from_ref(&excerpt));
2264        let alice = Node::bind(&p[0], LOCAL.parse().unwrap()).await.unwrap();
2265        let chosen = replicas(&lines::hash(&excerpt), &Membership::load(&a).unwrap().unwrap(), &alice.public());
2266        let mut others = Vec::new();
2267        for q in &p[1..] {
2268            let n = Node::bind(q, LOCAL.parse().unwrap()).await.unwrap();
2269            let t = alice.ticket();
2270            let (served, synced) = tokio::join!(alice.accept(), n.sync(&t));
2271            served.unwrap();
2272            synced.unwrap();
2273            others.push((q.clone(), n));
2274        }
2275        alice.close().await; // the author is offline from here on
2276        let (holders, askers): (Vec<_>, Vec<_>) = others.into_iter().partition(|(_, n)| chosen.contains(&n.public()));
2277        let (asker_place, asker) = askers.into_iter().next().expect("one member is no replica");
2278        let holder = &holders[0].1;
2279        assert!(!holds(&home(&asker_place), REASONING, &excerpt));
2280
2281        let short = commit[..10].to_string();
2282        let t = holder.ticket();
2283        let (served, fetched) = tokio::join!(holder.accept(), asker.fetch(&t, std::slice::from_ref(&short)));
2284        assert_eq!(served.unwrap().answered, 1, "a replica answers for an excerpt it did not write");
2285        assert_eq!(fetched.unwrap().received, 1);
2286        let written = borg::notes::restore(&repo, &home(&asker_place), std::slice::from_ref(&commit)).unwrap();
2287        assert_eq!(written, vec![commit.clone()]);
2288        assert!(git(&repo, &["notes", "--ref=refs/notes/borg", "show", &commit]).contains("take the lock before reading the flag"));
2289
2290        // Nobody is handed an excerpt it did not ask for.
2291        let (served, synced) = tokio::join!(holder.accept(), asker.sync(&t));
2292        assert_eq!((served.unwrap().answered, synced.unwrap().received), (0, 0));
2293        asker.close().await;
2294        for (_, n) in holders {
2295            n.close().await;
2296        }
2297    }
2298
2299    /// A steer from the node whose member key is `node` (its engineer at a terminal) to `to`.
2300    fn steering(node: &str, kind: borg::msg::Kind, to: &str, ms: i64, text: &str) -> borg::msg::Message {
2301        let from = borg::msg::Sender { by: "engineer:bob@x".into(), name: "bob@x".into(), harness: "terminal".into(), node: node.into() };
2302        borg::msg::Message::new(kind, &from, to, None, ms, text)
2303    }
2304
2305    /// Presence and steering ride the session: bob (role steer) sees alice's drone and pauses it, a
2306    /// message line whose origin is bob's node; alice admits it under bob's role and her hook refuses
2307    /// tools until bob's resume, sent at once after. An observer's pause is stored but refused and
2308    /// logged; once bob is revoked, his pause no longer holds.
2309    #[tokio::test(flavor = "multi_thread")]
2310    async fn presence_and_steering_travel_between_members() {
2311        use borg::msg::Kind;
2312        let (pa, pb) = team("live");
2313        let (a, b) = (home(&pa), home(&pb));
2314        let (alice_id, bob_id) = (Identity::load_or_create(&a).unwrap(), Identity::load_or_create(&b).unwrap());
2315        let (alice_key, bob_key) = (alice_id.public(), bob_id.public());
2316        let mut m = Membership::load(&a).unwrap().unwrap();
2317        m.invite(&alice_id, &bob_key, "steer").unwrap();
2318        m.save(&a).unwrap(); // every version goes into the history, or the chain breaks
2319        let observer = Identity::load_or_create(&a.join("observer")).unwrap();
2320        m.invite(&alice_id, &observer.public(), "observe").unwrap();
2321        m.save(&a).unwrap(); // bob adopts both versions at the first sync
2322        let now = borg::learn::now_ms();
2323        let ev = json!({"session_id": "alice-s1", "cwd": pa.dir, "prompt": "refactor the recon loop"});
2324        borg::presence::write(&a, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| ("alice@x".into(), "repo".into()))).unwrap();
2325
2326        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
2327        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2328        let ticket = alice.ticket();
2329        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2330        served.unwrap();
2331        assert_eq!(synced.unwrap().presence, 1);
2332        let ds = borg::top::drones(&b, true, now);
2333        assert_eq!((ds.len(), ds[0].node.as_str(), ds[0].p.prompt.as_str()), (1, alice_key.as_str(), "refactor the recon loop"));
2334        let lessons = std::fs::read_to_string(b.join("lessons.jsonl")).unwrap_or_default();
2335        assert!(!lessons.contains("recon loop"), "never in lessons");
2336
2337        let to = format!("alice-s1@{alice_key}");
2338        let pre = json!({"session_id": "alice-s1", "cwd": pa.dir, "tool_name": "Bash"});
2339        let refused = |at: i64| borg::steer::around(&a, "pre_tool", &pre, Some(now), at, || None);
2340        let round = || async {
2341            let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2342            synced.unwrap();
2343            served.unwrap()
2344        };
2345        borg::msg::send(&b, steering(&bob_key, Kind::Pause, &to, now, ""), now).unwrap();
2346        let served = round().await;
2347        assert_eq!((served.steered, served.dropped), (1, 0));
2348        let deny = refused(now + 1).expect("paused");
2349        let why = deny["hookSpecificOutput"]["permissionDecisionReason"].as_str().unwrap();
2350        assert!(why.contains(&format!("paused by bob@x (node {}, role steer)", &bob_key[..12])), "{why}");
2351
2352        // An observer's pause, handed over signed by its node: stored, refused, logged.
2353        let by_observer = serde_json::to_string(&steering(&observer.public(), Kind::Pause, ALL_TO, now, "")).unwrap();
2354        let h = lines::hash(&by_observer);
2355        let mut report = Report::default();
2356        let got = vec![Signed { origin: Origin::new(&observer, &h, now), line: by_observer.clone() }];
2357        alice.store_lines(&a.join(borg::msg::FILE), got, &BTreeSet::from([h]), &members(&a).unwrap(), None, &mut report).unwrap();
2358        assert_eq!((report.received, report.steered, report.dropped), (1, 0, 1));
2359        let log = std::fs::read_to_string(borg::steer::dropped_path(&a)).unwrap();
2360        assert!(log.contains("role observe may not steer"), "{log}");
2361
2362        // Bob's resume, a moment after his pause: not paced away, and it lifts the pause.
2363        borg::msg::send(&b, steering(&bob_key, Kind::Resume, &to, now + 2, ""), now + 2).unwrap();
2364        assert_eq!(round().await.steered, 1);
2365        assert_eq!(refused(now + 3), None);
2366
2367        // Bob pauses again, then is revoked: his pause stops holding at alice.
2368        borg::msg::send(&b, steering(&bob_key, Kind::Pause, &to, now + 4, ""), now + 4).unwrap();
2369        assert_eq!(round().await.steered, 1);
2370        assert!(refused(now + 5).is_some(), "paused again");
2371        let mut m = Membership::load(&a).unwrap().unwrap();
2372        m.revoke(&alice_id, &bob_key, now + 5).unwrap();
2373        m.save(&a).unwrap();
2374        alice.enforce().unwrap();
2375        assert!(borg::steer::read_admitted(&a).iter().all(|x| x.node != bob_key), "the revoked node's admissions are gone");
2376        assert_eq!(refused(now + 6), None, "and the pause no longer holds");
2377        alice.close().await;
2378        bob.close().await;
2379    }
2380
2381    /// `borg no` on bob's node for alice's drone: a veto message, admitted and recorded once on alice's
2382    /// node (credit, episode, stat), shown to her drone once, and back on bob's node as a team outcome,
2383    /// so both nodes' beliefs count the veto once.
2384    #[tokio::test(flavor = "multi_thread")]
2385    async fn a_veto_on_one_node_reaches_the_drones_node_once() {
2386        let (pa, pb) = team("veto");
2387        let (a, b) = (home(&pa), home(&pb));
2388        let (alice_id, bob_id) = (Identity::load_or_create(&a).unwrap(), Identity::load_or_create(&b).unwrap());
2389        let mut m = Membership::load(&a).unwrap().unwrap();
2390        m.invite(&alice_id, &bob_id.public(), "steer").unwrap();
2391        m.save(&a).unwrap();
2392        let now = borg::learn::now_ms();
2393        let ev = json!({"session_id": "alice-s1", "cwd": pa.dir, "prompt": "look merges up"});
2394        borg::presence::write(&a, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| ("alice@x".into(), "repo".into()))).unwrap();
2395        write(&a, "lessons.jsonl", &[lesson("L1", 1, "how are merges found?")]);
2396        let asked = json!({"kind": "asked", "session": "alice-s1", "lessons": ["L1"], "ms": now - 10, "repo": "repo"}).to_string();
2397        write(&a, "outcomes.jsonl", &[asked]);
2398        let alice = Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap();
2399        let bob = Node::bind(&pb, LOCAL.parse().unwrap()).await.unwrap();
2400        let ticket = alice.ticket();
2401        let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2402        let _ = (served.unwrap(), synced.unwrap());
2403
2404        let to = format!("alice-s1@{}", alice_id.public());
2405        let veto = steering(&bob_id.public(), borg::msg::Kind::Veto, &to, now, "never look merges up in the commit log");
2406        borg::msg::send(&b, veto, now).unwrap();
2407        assert!(!b.join("episodes.jsonl").exists(), "the issuing node records no veto of its own");
2408        for _ in 0..2 {
2409            let (served, synced) = tokio::join!(alice.accept(), bob.sync(&ticket));
2410            let _ = (served.unwrap(), synced.unwrap());
2411        }
2412        let vetoed =
2413            |h: &Path| borg::outcome::read(&h.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == borg::outcome::VETOED && o.lessons == ["L1"]).count();
2414        assert_eq!(vetoed(&a), 1, "credited once on the drone's node, over two rounds");
2415        let eps = std::fs::read_to_string(a.join("episodes.jsonl")).unwrap();
2416        assert_eq!(eps.lines().count(), 1);
2417        assert!(eps.contains("\"kind\":\"veto\"") && eps.contains("bob@x") && eps.contains("never look merges up"), "{eps}");
2418        let stats: Vec<_> = borg::stats::events(&a).into_iter().filter(|e| e.kind == borg::stats::VETO).collect();
2419        assert_eq!((stats.len(), stats[0].why.as_str()), (1, "mesh"));
2420        // Alice's drone is told once, labelled with bob's node and role.
2421        let caps = borg::harness::by_name("claude").unwrap().caps();
2422        let prompt = json!({"session_id": "alice-s1", "cwd": pa.dir});
2423        let out = borg::msg::deliver(&a, caps, "prompt", &prompt, Some(now - 10), now + 1, None).expect("shown");
2424        let ctx = out["hookSpecificOutput"]["additionalContext"].as_str().unwrap();
2425        assert!(ctx.contains(&format!("[borg] Veto from bob@x (node {}, role steer)", &bob_id.public()[..12])), "{ctx}");
2426        assert_eq!(borg::msg::deliver(&a, caps, "prompt", &prompt, Some(now - 10), now + 2, None), None, "once");
2427        // Both nodes' beliefs count it once: alice's from her own record, bob's from the team outcome.
2428        let l1 = [borg::conscience::Challenge { id: "L1".into(), ..Default::default() }];
2429        let belief = |h: &Path| borg::memory::derive(&borg::outcome::read_team(&h.join("lessons.jsonl")), &l1, None)["L1"].vetoed;
2430        assert_eq!((belief(&a), belief(&b)), (1, 1));
2431        assert!(!b.join("outcomes.jsonl").exists() && !b.join("episodes.jsonl").exists(), "bob's node holds only the team copy");
2432        alice.close().await;
2433        bob.close().await;
2434    }
2435
2436    #[tokio::test(flavor = "multi_thread")]
2437    async fn a_local_collectives_presence_and_steering_never_leave() {
2438        let p = node("live-private", "local");
2439        let h = home(&p);
2440        let now = borg::learn::now_ms();
2441        let ev = json!({"session_id": "s1", "cwd": p.dir, "prompt": "private diary"});
2442        borg::presence::write(&h, &borg::presence::update(None, "work", "claude", "prompt", &ev, now, |_| ("me@x".into(), "r".into()))).unwrap();
2443        let me = Identity::load_or_create(&h).unwrap().public();
2444        borg::msg::send(&h, steering(&me, borg::msg::Kind::Pause, ALL_TO, now, "lunch"), now).unwrap();
2445        let held = borg::steer::read_admitted(&h);
2446        assert_eq!((held.len(), held[0].role), (1, borg::steer::Role::Local), "this machine's engineer steers it");
2447        assert!(Node::bind(&p, LOCAL.parse().unwrap()).await.is_err(), "no socket at all");
2448    }
2449
2450    /// A pause and its resume from one sender to one session within a rate slot both travel (send
2451    /// does not pace a steer); a second tell within the slot does not.
2452    #[test]
2453    fn the_window_paces_tells_and_not_steers() {
2454        use borg::msg::Kind;
2455        let node = "a".repeat(64);
2456        let to = format!("s1@{}", "b".repeat(64));
2457        let ms = [
2458            steering(&node, Kind::Pause, &to, 1000, ""),
2459            steering(&node, Kind::Resume, &to, 1001, ""),
2460            steering(&node, Kind::Tell, &to, 1002, "hi"),
2461            steering(&node, Kind::Tell, &to, 1003, "again"),
2462        ];
2463        let set: BTreeMap<Hash, String> = ms.iter().map(|m| serde_json::to_string(m).unwrap()).map(|l| (lines::hash(&l), l)).collect();
2464        let w = window(&set, 10);
2465        let kept: Vec<&str> = ms.iter().filter(|m| w.contains(&lines::hash(&serde_json::to_string(m).unwrap()))).map(|m| m.kind.as_str()).collect();
2466        assert_eq!(kept, ["pause", "resume", "tell"]);
2467    }
2468
2469    /// A pause and then `per_node + 1` newer tells from its node: the pause stays in the window (steers
2470    /// have their own cap), so `prune` keeps it and it holds; the oldest tell goes.
2471    #[test]
2472    fn newer_tells_never_push_a_pause_out_of_the_window() {
2473        use borg::msg::Kind;
2474        let (node, per) = ("a".repeat(64), 5);
2475        let to = |i: usize| format!("s{i}@{}", "b".repeat(64));
2476        let ms: Vec<borg::msg::Message> = std::iter::once(steering(&node, Kind::Pause, &to(0), 1000, ""))
2477            .chain((0..=per).map(|i| steering(&node, Kind::Tell, &to(i), 1001 + i as i64, "hi")))
2478            .collect();
2479        let set: BTreeMap<Hash, String> = ms.iter().map(|m| serde_json::to_string(m).unwrap()).map(|l| (lines::hash(&l), l)).collect();
2480        let w = window(&set, per);
2481        let kept = |m: &borg::msg::Message| w.contains(&lines::hash(&serde_json::to_string(m).unwrap()));
2482        assert!(kept(&ms[0]), "the pause travels");
2483        assert_eq!((kept(&ms[1]), ms[2..].iter().filter(|m| kept(m)).count()), (false, per), "the oldest tell went");
2484    }
2485
2486    /// `Live` still carries the `directives` a 0.3.0 peer requires, always empty, and takes whatever
2487    /// such a peer puts there without reading it.
2488    #[test]
2489    fn live_keeps_an_empty_directives_field_for_0_3_0() {
2490        let out = serde_json::to_value(Msg::Live { presence: Vec::new(), directives: Directives }).unwrap();
2491        assert_eq!(out, json!({"t": "live", "presence": [], "directives": []}));
2492        let theirs = json!({"t": "live", "presence": [], "directives": [{"kind": "pause", "sig": "00"}], "beats": []});
2493        assert!(matches!(serde_json::from_value::<Msg>(theirs).unwrap(), Msg::Live { .. }));
2494        assert!(matches!(serde_json::from_value::<Msg>(json!({"t": "live", "presence": []})).unwrap(), Msg::Live { .. }));
2495    }
2496
2497    /// The admin answers every dial-in (joins and syncs) until the test ends.
2498    fn answer(n: std::sync::Arc<Node>) {
2499        tokio::spawn(async move {
2500            while let Some(i) = n.incoming().await {
2501                let n = n.clone();
2502                tokio::spawn(async move {
2503                    let _ = n.handle(i).await;
2504                });
2505            }
2506        });
2507    }
2508
2509    fn ticket(admin: &Node, role: &str, ttl_ms: i64, uses: u32) -> Ticket {
2510        let home = admin.home();
2511        let m = Membership::load(home).unwrap().unwrap();
2512        let invite = invite::issue(home, &admin.me, &m, role, ttl_ms, uses, borg::learn::now_ms()).unwrap();
2513        Ticket::decode(&Ticket { invite, relay: admin.relay.clone(), hints: admin.hints() }.encode()).unwrap()
2514    }
2515
2516    /// Bob and carol join alice's collective with tickets alone (no key exchanged by hand, no file
2517    /// handed over): each gets the signed membership with the ticket's role and alice's lesson at
2518    /// once. A spent ticket is refused for carol but welcomes bob again; an expired one is refused.
2519    #[tokio::test(flavor = "multi_thread")]
2520    async fn three_nodes_join_by_ticket_and_sync_a_lesson() {
2521        let pa = crew("ticket", &["alice"]).remove(0);
2522        let (pb, pc) = (node("ticket-bob", "team"), node("ticket-carol", "team"));
2523        let l1 = lesson("L1", 1, "who owns the lock?");
2524        write(&home(&pa), "lessons.jsonl", std::slice::from_ref(&l1));
2525        let alice = std::sync::Arc::new(Node::bind(&pa, LOCAL.parse().unwrap()).await.unwrap());
2526        answer(alice.clone());
2527
2528        let one = ticket(&alice, "member", 60_000, 1);
2529        let bob = Node::joining(&pb, LOCAL.parse().unwrap()).await.unwrap().quiet();
2530        let (m, rep) = bob.join(&one).await.unwrap();
2531        assert_eq!(m.role_of(&bob.public()), Some("member"));
2532        assert_eq!(rep.received, 1, "bob syncs at once");
2533        assert!(holds(&home(&pb), "lessons.jsonl", &l1));
2534        assert_eq!(peers::load(&home(&pb)).get(&alice.public()), Some(&addr_of(&alice)), "bob knows where alice is");
2535        assert!(peers::load(&home(&pa)).contains_key(&bob.public()), "alice knows bob, by key at least");
2536
2537        let carol = Node::joining(&pc, LOCAL.parse().unwrap()).await.unwrap().quiet();
2538        let err = carol.join(&one).await.unwrap_err();
2539        assert!(err.contains("already used"), "{err}");
2540        assert!(Membership::load(&home(&pc)).unwrap().is_none() && !home(&pc).join("lessons.jsonl").exists(), "carol got nothing");
2541        assert!(bob.join(&one).await.is_ok(), "bob again (a lost answer): welcomed");
2542
2543        let gone = ticket(&alice, "member", 1, 1);
2544        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2545        assert!(carol.join(&gone).await.unwrap_err().contains("expired"));
2546
2547        let two = ticket(&alice, "observe", 60_000, 1);
2548        let (m, rep) = carol.join(&two).await.unwrap();
2549        assert_eq!(m.role_of(&carol.public()), Some("observe"));
2550        assert_eq!(rep.received, 1);
2551        let held = Membership::load(&home(&pa)).unwrap().unwrap();
2552        assert_eq!((held.role_of(&bob.public()), held.role_of(&carol.public())), (Some("member"), Some("observe")));
2553        assert!(invite::pending(alice.home(), borg::learn::now_ms()).is_empty(), "both tickets spent");
2554
2555        // Bob learns of carol at his next sync with alice: the signed chain spreads as before.
2556        assert_eq!(Membership::load(&home(&pb)).unwrap().unwrap().role_of(&carol.public()), None);
2557        bob.sync(&format!("{}@{}", alice.public(), addr_of(&alice))).await.unwrap();
2558        assert_eq!(Membership::load(&home(&pb)).unwrap().unwrap().role_of(&carol.public()), Some("observe"));
2559        for n in [bob, carol] {
2560            n.close().await;
2561        }
2562        alice.close().await;
2563    }
2564
2565    /// The NAT case as far as one machine can show it: neither node has a direct address (no UDP
2566    /// socket at all), the ticket carries none, so the join and the sync can only go through the
2567    /// (local, self-signed) relay both use.
2568    #[tokio::test(flavor = "multi_thread")]
2569    async fn join_and_sync_go_through_the_relay_when_no_direct_address_is_known() {
2570        let (_map, url, _server) = iroh::test_utils::run_relay_server().await.unwrap();
2571        let pa = crew("relay", &["alice"]).remove(0);
2572        let pb = node("relay-bob", "team");
2573        for p in [&pa, &pb] {
2574            let cfg = json!({"default": "off", "collectives": [{"name": "work", "paths": [p.dir], "share": "team", "relay": url.to_string()}]});
2575            std::fs::write(&p.config, cfg.to_string()).unwrap();
2576        }
2577        let l1 = lesson("L1", 1, "does this cross the NAT?");
2578        write(&home(&pa), "lessons.jsonl", std::slice::from_ref(&l1));
2579        let trust = |b: Builder| b.ca_tls_config(iroh::tls::CaTlsConfig::insecure_skip_verify());
2580        let alice = std::sync::Arc::new(Node::open(&pa, None, true, trust).await.unwrap());
2581        let bob = Node::open(&pb, None, false, trust).await.unwrap().quiet();
2582        assert!(matches!(alice.relay(), Relay::Custom(_)));
2583        assert!(alice.tickets().is_empty() && bob.tickets().is_empty(), "no direct address on either side");
2584        let wait = std::time::Duration::from_secs(10);
2585        assert!(alice.online(wait).await && bob.online(wait).await, "both reach the relay");
2586        answer(alice.clone());
2587
2588        let mut t = ticket(&alice, "member", 60_000, 1);
2589        t.hints.retain(|h| !matches!(h, Hint::Direct(_)));
2590        let (m, rep) = bob.join(&t).await.unwrap();
2591        assert_eq!(m.role_of(&bob.public()), Some("member"));
2592        assert_eq!(rep.received, 1);
2593        assert!(holds(&home(&pb), "lessons.jsonl", &l1), "the lesson crossed through the relay");
2594        // And a later sync by key alone: the configured relay is the only way.
2595        let l2 = lesson("L2", 1, "and back?");
2596        append_lines(&home(&pb), "lessons.jsonl", std::slice::from_ref(&l2));
2597        assert_eq!(bob.sync(&alice.public()).await.unwrap().sent, 1);
2598        assert!(holds(&home(&pa), "lessons.jsonl", &l2));
2599        bob.close().await;
2600        alice.close().await;
2601    }
2602}