Skip to main content

borg_mesh/
lines.rs

1//! A collective's append-only logs as sets of content-addressed lines.
2//!
3//! Every line of `lessons.jsonl` is addressed by its blake3 hash, so two
4//! nodes compare what they hold without reading it, and a merge is a plain union: appending lines
5//! the store lacks never rewrites or loses a line. Only complete JSON lines count, so a line a
6//! hook is appending right now is picked up by the next sync rather than copied half-written.
7//!
8//! Lessons are versioned in place (a consolidation appends the same id with `version + 1`) and
9//! `conscience::load` keeps the *last* line per id. After a union the last line must therefore be
10//! the winning version on every node, whatever order the lines arrived in: highest version, ties
11//! (two nodes consolidated the same lesson concurrently) broken by the higher hash. `settle`
12//! re-appends a winner that is not last; that repeats an existing line, so the hash set (and what
13//! peers see) is unchanged.
14
15use std::collections::{BTreeMap, HashMap};
16use std::io::Write;
17use std::path::Path;
18
19use serde_json::Value;
20
21/// The logs a mesh syncs, relative to the collective's store.
22///
23/// Lessons, and the daily usage rollups (borg stats.rs): versioned records like lessons, one bucket per
24/// (node, engineer, day), counts only. Not episodes: a lesson already carries its `episodes` ids and `engineers`; the raw episodes stay
25/// on the node that captured them, so exactly one node learns each episode. Synced episodes were
26/// pending on every node (learn.rs skips only episodes cited by a lesson or rejected locally), so
27/// each member paid a model call per peer episode, and two members could learn the same correction
28/// into two lessons under different ids. Episodes also hold the engineer's raw words.
29///
30/// And the team's outcomes (borg outcome.rs `publish`): redacted lines, one per result, with the kind,
31/// lesson ids, time, asking propensity, hashed session and repo, and the node; never raw sessions,
32/// repos or text. Plain lines (no `id` + `version`), so `settle` leaves them alone; signed and
33/// quarantined like every synced log.
34///
35/// And the ratified constraints (borg veto.rs `ratify`): standing lessons every member's sessions get
36/// (conscience.rs `standing`), lines holding what a lesson does (the redacted invariant, its lesson,
37/// who ratified it). Plain lines (no `version`); the conscience keeps the earliest ratification per id,
38/// so arrival order does not matter.
39///
40/// And the messages between sessions (borg msg.rs): plain lines, each naming its author's node, and the
41/// `seen` lines their recipients write (msg.rs `Seen`, which pace the senders). That name is a claim,
42/// so a line travels only under an origin of the key it names (`travels`), and only while it is fresh:
43/// a line one node pruned would otherwise come back from every peer. A 0.3.0 peer (`borg/mesh/12`)
44/// never reconciles this one.
45///
46/// And the absorbs and releases (`ABSORBED`, borg `memory absorb` and `release`): plain lines saying a
47/// lesson is now held outside Borg, or is no longer. Each must be one borg could have written and
48/// folds (`travels`), because a line a peer gives never passes the write path. Last in the list: a
49/// 0.4.0 to 0.6.x peer (`borg/mesh/13`, `/14`) never reconciles this one.
50///
51/// The removed playbooks' log is not listed: a session still steps through it after the outcomes
52/// and keeps nothing (sync.rs `PLAYBOOKS`).
53pub const SYNCED: [&str; 6] = ["lessons.jsonl", "stats.jsonl", borg::outcome::TEAM_FILE, borg::veto::FILE, borg::msg::FILE, ABSORBED];
54
55/// The absorbs and releases of the collective's lessons (`SYNCED`, borg absorb.rs `FILE`): one line per
56/// id and command, `{"id", "kind": "absorb" | "release", "ms", "by", "note"?, "version"?}`. The mesh
57/// checks a line as borg reads it (`travels`), never what the note says.
58pub const ABSORBED: &str = borg::absorb::FILE;
59
60/// Redacted reasoning excerpts behind git notes (`borg notes`): replicated by their author to
61/// `sync::REPLICAS` members, not reconciled with everyone (sync.rs, step 4).
62pub const REASONING: &str = "reasoning.jsonl";
63
64pub type Hash = [u8; 32];
65
66/// The logs whose lines carry origins, in `home`: the synced ones and reasoning. Stamping (origin.rs
67/// `stamp`), quarantine (`origin::apply`) and the daemon's change fingerprint all cover what this lists.
68pub fn logs(home: &Path) -> Vec<std::path::PathBuf> {
69    let mut out: Vec<std::path::PathBuf> = SYNCED.iter().map(|f| home.join(f)).collect();
70    out.push(home.join(REASONING));
71    out
72}
73
74/// Whether `line` of the log `file` may be offered or stored under an origin of `key`, at `now`. Any
75/// line but an absorb's or a message's. An absorb or a release must be a line borg `memory absorb` and
76/// `release` could have written at `now` (borg absorb.rs `written`: the record borg folds, its note at
77/// most `NOTE_CHARS`, no other field, stamped no further ahead than `SKEW_MS`), whoever stamped it. A
78/// message or a seen line must be one
79/// borg msg.rs could have written (`Line::parse`), name `key` as its node (the member key its sender or
80/// reader wrote, as `borg top` names a remote node), and be fresh (msg.rs `fresh_at`: neither past
81/// `TTL_MS` nor stamped further ahead of this clock than `SKEW_MS`): a member's forged node, or a
82/// far-future line that would outlive every prune, never reaches another member, and no node
83/// acknowledges for another's sessions. A
84/// message to the sessions of its own node (`local`, msg.rs `stays_here`) never travels: it is for
85/// that machine alone.
86pub fn travels(file: &str, line: &str, key: &str, now: i64) -> bool {
87    if file == ABSORBED {
88        return borg::absorb::written(line, now).is_some();
89    }
90    if file != borg::msg::FILE {
91        return true;
92    }
93    let Some(l) = borg::msg::Line::parse(line) else { return false };
94    l.node() == key && borg::msg::fresh_at(l.ms(), now) && l.message().is_none_or(|m| !m.stays_here())
95}
96
97pub fn hash(line: &str) -> Hash {
98    *blake3::hash(line.as_bytes()).as_bytes()
99}
100
101fn complete(line: &str) -> bool {
102    !line.trim().is_empty() && serde_json::from_str::<Value>(line).is_ok()
103}
104
105/// The distinct complete lines of `path` by hash (empty if the file does not exist).
106pub fn read(path: &Path) -> BTreeMap<Hash, String> {
107    let text = std::fs::read_to_string(path).unwrap_or_default();
108    text.lines().filter(|l| complete(l)).map(|l| (hash(l), l.to_string())).collect()
109}
110
111/// One hash for the whole set: equal digests mean there is nothing to exchange.
112pub fn digest(set: &BTreeMap<Hash, String>) -> Hash {
113    let mut h = blake3::Hasher::new();
114    for k in set.keys() {
115        h.update(k);
116    }
117    *h.finalize().as_bytes()
118}
119
120/// Appends the `incoming` lines `path` lacks, then settles lesson versions. Returns how many lines
121/// were new.
122pub fn merge(path: &Path, incoming: &[String]) -> Result<usize, String> {
123    let mut known = read(path);
124    let new: Vec<&String> = incoming.iter().filter(|l| complete(l) && known.insert(hash(l), (*l).clone()).is_none()).collect();
125    append(path, &new)?;
126    settle(path)?;
127    Ok(new.len())
128}
129
130fn append(path: &Path, lines: &[&String]) -> Result<(), String> {
131    if lines.is_empty() {
132        return Ok(());
133    }
134    if let Some(d) = path.parent() {
135        std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
136    }
137    let ends_open = std::fs::read(path).map(|b| b.last().is_some_and(|c| *c != b'\n')).unwrap_or(false);
138    let mut out = String::from(if ends_open { "\n" } else { "" });
139    for l in lines {
140        out.push_str(l);
141        out.push('\n');
142    }
143    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| format!("{}: {e}", path.display()))?;
144    f.write_all(out.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
145}
146
147/// Makes the winning version of every versioned record the last line for its id (module doc).
148/// Also after lines were removed (quarantine): the line that is now last may be an older version.
149/// Not the absorbs (`ABSORBED`): an absorb names its lesson's `id` and `version`, but it is no version
150/// of a record, and borg folds every line of that log whatever their order.
151pub fn settle(path: &Path) -> Result<(), String> {
152    if path.file_name().is_some_and(|f| f == ABSORBED) {
153        return Ok(());
154    }
155    let text = std::fs::read_to_string(path).unwrap_or_default();
156    let mut winner: HashMap<String, ((u64, Hash), &str)> = HashMap::new();
157    let mut last: HashMap<String, Hash> = HashMap::new();
158    for l in text.lines() {
159        let Ok(v) = serde_json::from_str::<Value>(l) else { continue };
160        let (Some(id), Some(version)) = (v["id"].as_str(), v["version"].as_u64()) else { continue };
161        let rank = (version, hash(l));
162        last.insert(id.to_string(), rank.1);
163        if winner.get(id).is_none_or(|(r, _)| rank > *r) {
164            winner.insert(id.to_string(), (rank, l));
165        }
166    }
167    let mut stale: Vec<(&String, String)> = winner.iter().filter(|(id, (r, _))| last.get(*id) != Some(&r.1)).map(|(id, (_, l))| (id, l.to_string())).collect();
168    stale.sort();
169    append(path, &stale.iter().map(|(_, l)| l).collect::<Vec<_>>())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn lesson(id: &str, version: u32, q: &str) -> String {
177        serde_json::json!({"id": id, "origin_ms": 1, "question": q, "version": version}).to_string()
178    }
179
180    fn tmp(tag: &str) -> std::path::PathBuf {
181        let d = std::env::temp_dir().join(format!("borg-mesh-lines-{tag}-{}", std::process::id()));
182        let _ = std::fs::remove_dir_all(&d);
183        std::fs::create_dir_all(&d).unwrap();
184        d
185    }
186
187    /// `local` is this machine's sessions: its line never travels; `collective` (written `all`) does.
188    #[test]
189    fn a_local_broadcast_never_travels() {
190        let key = "ab".repeat(32);
191        let from = borg::msg::Sender { by: "s1".into(), name: "One of Two".into(), harness: "claude".into(), node: key.clone() };
192        let line = |to: &str| serde_json::to_string(&borg::msg::Message::new(borg::msg::TELL, &from, to, None, 1_000, "freeze merges")).unwrap();
193        assert!(!travels(borg::msg::FILE, &line(&format!("{}@{key}", borg::msg::LOCAL)), &key, 1_000));
194        assert!(travels(borg::msg::FILE, &line(borg::msg::ALL), &key, 1_000));
195        assert!(travels(borg::msg::FILE, &line(&format!("s2@{}", "cd".repeat(32))), &key, 1_000));
196    }
197
198    /// A seen line travels under its own node's key while fresh: no node acknowledges for another's
199    /// sessions, and a malformed one never travels.
200    #[test]
201    fn a_seen_line_travels_only_under_its_node() {
202        let (key, other) = ("ab".repeat(32), "cd".repeat(32));
203        let seen = |node: &str, ms: i64, ids: &[&str]| serde_json::to_string(&borg::msg::Seen::new("s2", node, ms, ids)).unwrap();
204        assert!(travels(borg::msg::FILE, &seen(&key, 1_000, &["m0123456789ab"]), &key, 1_000));
205        assert!(!travels(borg::msg::FILE, &seen(&key, 1_000, &["m0123456789ab"]), &other, 1_000), "signed by another node");
206        assert!(!travels(borg::msg::FILE, &seen(&key, 1_000, &["m0123456789ab"]), &key, 1_000 + borg::msg::TTL_MS + 1), "expired");
207        assert!(!travels(borg::msg::FILE, &seen(&key, 1_000, &["not an id"]), &key, 1_000), "malformed");
208    }
209
210    /// An absorb or release travels only as a line borg could have written and folds, whoever signed
211    /// it: a peer's line never passes the write path.
212    #[test]
213    fn an_absorb_travels_only_well_formed() {
214        let key = "ab".repeat(32);
215        let ok = |v: Value| travels(ABSORBED, &v.to_string(), &key, 10);
216        const ABSORB_NOTE: usize = borg::absorb::NOTE_CHARS;
217        let absorb = serde_json::json!({"id": "L1", "kind": "absorb", "ms": 5, "by": "lead@x", "note": "skills/review@2026-10-30", "version": 2});
218        assert!(ok(absorb.clone()));
219        assert!(ok(serde_json::json!({"id": "L1", "kind": "release", "ms": 6, "by": "lead@x"})));
220        assert!(ok(serde_json::json!({"id": "L1", "kind": "release", "ms": 6, "by": "lead@x", "note": null, "version": null})), "null is absent");
221        assert!(ok(serde_json::json!({"id": "L1", "kind": "absorb", "ms": 5, "by": "lead@x", "note": "é".repeat(ABSORB_NOTE)})), "characters, not bytes");
222        let with = |k: &str, v: Value| {
223            let mut a = absorb.clone();
224            a[k] = v;
225            a
226        };
227        for (what, bad) in [
228            ("an oversize note", with("note", Value::from("n".repeat(ABSORB_NOTE + 1)))),
229            ("another kind", with("kind", Value::from("delete"))),
230            ("no kind", serde_json::json!({"id": "L1", "ms": 5, "by": "lead@x"})),
231            ("a numeric id", with("id", Value::from(1))),
232            ("an empty id", with("id", Value::from(""))),
233            ("a blank id", with("id", Value::from(" "))),
234            ("a version borg cannot read", with("version", Value::from(5_000_000_000_u64))),
235            ("stamped past the clock skew", with("ms", Value::from(10 + borg::msg::SKEW_MS + 1))),
236            ("a text ms", with("ms", Value::from("5"))),
237            ("a fractional ms", with("ms", Value::from(5.5))),
238            ("no by", serde_json::json!({"id": "L1", "kind": "absorb", "ms": 5})),
239            ("a note that is no text", with("note", serde_json::json!({"x": 1}))),
240            ("a negative version", with("version", Value::from(-1))),
241            ("an unknown field", with("reach", Value::from("team"))),
242            ("no object", serde_json::json!(["L1", "absorb"])),
243        ] {
244            assert!(!ok(bad), "{what} is refused");
245        }
246        assert!(!travels(ABSORBED, "not json", &key, 1));
247        assert!(!travels(ABSORBED, r#"{"id":"A","id":"B","kind":"absorb","ms":1,"by":"a@x"}"#, &key, 10), "a duplicate key");
248        assert!(logs(Path::new("s")).contains(&Path::new("s").join(ABSORBED)), "stamped, signed and quarantined like every synced log");
249    }
250
251    /// Absorbs name a lesson's `id` and `version` but are no versions of a record: a merge appends what
252    /// is new and never re-appends an earlier line to settle them.
253    #[test]
254    fn absorbs_are_not_settled_as_versions() {
255        let d = tmp("absorbed");
256        let p = d.join(ABSORBED);
257        let a = |version: u32, ms: i64| serde_json::json!({"id": "L1", "kind": "absorb", "ms": ms, "by": "lead@x", "version": version}).to_string();
258        merge(&p, &[a(3, 1)]).unwrap();
259        merge(&p, &[a(2, 2)]).unwrap();
260        assert_eq!(std::fs::read_to_string(&p).unwrap(), format!("{}\n{}\n", a(3, 1), a(2, 2)));
261        let _ = std::fs::remove_dir_all(d);
262    }
263
264    #[test]
265    fn union_skips_known_and_torn_lines() {
266        let d = tmp("union");
267        let p = d.join("lessons.jsonl");
268        std::fs::write(&p, format!("{}\n{{\"id\":\"L2\",\"ver", lesson("L1", 1, "a"))).unwrap();
269        assert_eq!(read(&p).len(), 1, "a half-written line does not count");
270        let n = merge(&p, &[lesson("L1", 1, "a"), lesson("L3", 1, "c"), "garbage".into()]).unwrap();
271        assert_eq!(n, 1, "only the unknown complete line is new");
272        assert_eq!(merge(&p, &[lesson("L3", 1, "c")]).unwrap(), 0, "idempotent");
273        let _ = std::fs::remove_dir_all(d);
274    }
275
276    #[test]
277    fn ratified_constraints_sync_and_load_as_the_same_standing_lesson_everywhere() {
278        let d = tmp("constraints");
279        assert!(logs(&d).contains(&d.join(borg::veto::FILE)), "stamped, signed and quarantined like the lessons");
280        let c = |by: &str, ms: i64| {
281            serde_json::json!({"id": "cabc", "text": "merge lookups go through the review API", "lesson": "L1", "by": by, "ms": ms, "source": "veto"})
282                .to_string()
283        };
284        let (a, b) = (d.join("a"), d.join("b"));
285        for (home, own) in [(&a, c("bob@x", 9)), (&b, c("ann@x", 5))] {
286            std::fs::create_dir_all(home).unwrap();
287            std::fs::write(home.join("lessons.jsonl"), format!("{}\n", lesson("L1", 1, "lookup?"))).unwrap();
288            merge(&home.join(borg::veto::FILE), &[own]).unwrap();
289        }
290        merge(&a.join(borg::veto::FILE), &read(&b.join(borg::veto::FILE)).into_values().collect::<Vec<_>>()).unwrap();
291        merge(&b.join(borg::veto::FILE), &read(&a.join(borg::veto::FILE)).into_values().collect::<Vec<_>>()).unwrap();
292        let standing = |h: &Path| {
293            let v: Vec<_> = borg::conscience::load(&h.join("lessons.jsonl"), None)
294                .into_iter()
295                .filter(|c| c.standing())
296                .map(|c| (c.id, c.engineers, c.consolidated))
297                .collect();
298            v
299        };
300        assert_eq!(standing(&a), [("cabc".to_string(), vec!["ann@x".to_string()], true)], "the earliest ratification, whatever the order");
301        assert_eq!(standing(&a), standing(&b));
302        let _ = std::fs::remove_dir_all(d);
303    }
304
305    #[test]
306    fn concurrent_versions_settle_on_the_same_winner_everywhere() {
307        let d = tmp("settle");
308        let (a, b) = (d.join("a/lessons.jsonl"), d.join("b/lessons.jsonl"));
309        let (v1, v2a, v2b) = (lesson("L1", 1, "orig"), lesson("L1", 2, "alice's merge"), lesson("L1", 2, "bob's merge"));
310        merge(&a, &[v1.clone(), v2a.clone()]).unwrap();
311        merge(&b, &[v1.clone(), v2b.clone()]).unwrap();
312        merge(&a, &read(&b).into_values().collect::<Vec<_>>()).unwrap();
313        merge(&b, &read(&a).into_values().collect::<Vec<_>>()).unwrap();
314        assert_eq!(digest(&read(&a)), digest(&read(&b)), "same set");
315        let q = |p: &Path| borg::conscience::load(p, None).into_iter().map(|c| c.question).collect::<Vec<_>>();
316        assert_eq!(q(&a), q(&b), "the conscience asks the same version on both nodes");
317        assert_ne!(q(&a), vec!["orig".to_string()], "an older version never wins");
318        // Arriving out of order (newest first) still leaves the newest last.
319        let c = d.join("c/lessons.jsonl");
320        merge(&c, &[v2a]).unwrap();
321        merge(&c, &[v1]).unwrap();
322        assert_eq!(q(&c), vec!["alice's merge".to_string()]);
323        let _ = std::fs::remove_dir_all(d);
324    }
325}