Skip to main content

borg/
absorb.rs

1//! Absorbed lessons: long-term knowledge an outside store now holds, so Borg stops delivering it.
2//!
3//! A team may keep its settled knowledge somewhere else (a reviewed skills repo, a handbook). Once a
4//! lesson is held there, Borg delivering it too is a second copy that drifts. `borg memory absorb <id>`
5//! says so, `borg memory release <id>` undoes it; `borg memory export` (memory_export.rs) shows the
6//! evidence an engineer decides on. Borg never names, reads or resolves the outside store: the note an
7//! absorb carries ("skills/review@2026-10-30") is an opaque label it stores, syncs and shows.
8//!
9//! `<store>/absorbed.jsonl` (`FILE`) is append-only, one plain line per id and command: `{"id", "kind":
10//! "absorb" | "release", "ms", "by", "note"?, "version"?}` (`Record`). `by` is the engineer's git email,
11//! `note` (absorb only) is redacted (notes.rs `redact`) and at most `NOTE_CHARS` characters, `version`
12//! (absorb only) the lesson's version when it was absorbed, which `borg memory export` counts the
13//! lesson's recurrence from. Any member may write it. A lesson's state is its record that sorts last by
14//! (ms, kind with release after absorb, the line's text) (`state`), so neither line order nor the order
15//! lines arrived in over the mesh decides. An id not in the store (yet) is accepted and applies once the
16//! lesson arrives. A malformed line is skipped, and a missing or unreadable file is no absorb: a hook
17//! never fails on it.
18//!
19//! **What an absorb does.** It withholds the lesson from every path that hands lessons to a drone, on
20//! every harness (`withhold`, applied by hook.rs `lessons`, which early and standing delivery, live and
21//! stop checks, the queue and the vector shortlist all read, and by `borg debug recall`, `prefilter`,
22//! `vectors --query` and `borg why`). Lessons it hid as their successor stay hidden. An absorbed lesson
23//! that a live lesson hides stays in the list, hidden: it is never scored or delivered, and its match
24//! still counts for that successor (conscience.rs `rank`). It never widens a lesson's reach, applies
25//! only in the store it was written to, and in a private collective (`share: local`) stays on this
26//! machine as everything there does. A constraint ratified from the lesson (veto.rs `ratify`) is its own
27//! standing item: absorbing the lesson leaves it delivered. Learning still sees an absorbed lesson:
28//! `borg learn` offers it for merging (learn.rs `similar`), so a correction of the same concern merges
29//! into it and counts as a recurrence, the sign the outside copy did not hold; and the judge still
30//! gives a delivery made before the absorb its verdict (hook.rs `Work::judged`).
31//!
32//! A record stamped further ahead of this clock than msg.rs `SKEW_MS` is ignored (`latest`), and a
33//! command is stamped after the records it sees (`stamp`), so a skewed or hand-written clock never
34//! makes an absorb that no release undoes. The mesh syncs the file signed and quarantined like the
35//! other logs (borg-mesh lines.rs `SYNCED`, protocol `borg/mesh/15`), and lets a peer's line in only
36//! when `written` accepts it, the check this module reads by.
37
38use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42
43use crate::conscience::Challenge;
44
45/// The absorbs and releases of a store, beside its lessons.
46pub const FILE: &str = "absorbed.jsonl";
47
48/// `Record::kind` of an absorb, and of a release.
49pub const ABSORB: &str = "absorb";
50pub const RELEASE: &str = "release";
51
52/// Protocol: the longest note an absorb carries, in characters. Guard: a note is a label (a path and a
53/// version, a link), not a copy of the knowledge; the mesh refuses a longer one from a peer.
54pub const NOTE_CHARS: usize = 1000;
55
56/// One line of `FILE`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Record {
59    pub id: String,
60    pub kind: String,
61    pub ms: i64,
62    #[serde(default)]
63    pub by: String,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub note: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub version: Option<u32>,
68}
69
70impl Record {
71    /// What the write path guarantees, checked again on read: an id, kind absorb or release, a note of
72    /// at most `NOTE_CHARS` characters. A line that fails it was not written by borg and is skipped.
73    pub fn well_formed(&self) -> bool {
74        !self.id.trim().is_empty() && (self.kind == ABSORB || self.kind == RELEASE) && self.note.as_ref().is_none_or(|n| n.chars().count() <= NOTE_CHARS)
75    }
76
77    pub fn absorbed(&self) -> bool {
78        self.kind == ABSORB
79    }
80}
81
82/// The absorbed log of the store `store`.
83pub fn path(store: &Path) -> PathBuf {
84    store.join(FILE)
85}
86
87/// The absorbed log beside a lessons file (hook.rs `lessons_path`, which BORG_CONSCIENCE may move).
88pub fn beside(lessons: &Path) -> PathBuf {
89    lessons.with_file_name(FILE)
90}
91
92/// A line of `FILE` as borg writes it, at `now`: it parses as `Record`, is `well_formed`, names `by`,
93/// holds no other field, and is stamped no further ahead of `now` than msg.rs `SKEW_MS`. The mesh lets
94/// a peer's line in only through it (borg-mesh lines.rs `travels`), so a line it stores is one every
95/// node folds.
96pub fn written(line: &str, now: i64) -> Option<Record> {
97    let r = serde_json::from_str::<Record>(line).ok().filter(|r| r.well_formed() && r.ms <= now.saturating_add(crate::msg::SKEW_MS))?;
98    let Ok(serde_json::Value::Object(o)) = serde_json::from_str::<serde_json::Value>(line) else { return None };
99    let known = ["id", "kind", "ms", "by", "note", "version"];
100    (o.contains_key("by") && o.keys().all(|k| known.contains(&k.as_str()))).then_some(r)
101}
102
103/// Every lesson id's last record in the log `file` (written before `before_ms`, when given): the one
104/// that sorts last by (ms, kind with release after absorb, the line's text), whatever the line order.
105/// A record stamped further ahead of this clock than msg.rs `SKEW_MS` is ignored. Absent or
106/// unreadable: empty.
107pub fn latest(file: &Path, before_ms: Option<i64>) -> HashMap<String, Record> {
108    let text = std::fs::read_to_string(file).unwrap_or_default();
109    let ahead = crate::learn::now_ms().saturating_add(crate::msg::SKEW_MS);
110    let mut by: HashMap<String, (i64, u8, &str, Record)> = HashMap::new();
111    for line in text.lines() {
112        let Ok(r) = serde_json::from_str::<Record>(line) else { continue };
113        if !r.well_formed() || r.ms > ahead || before_ms.is_some_and(|b| r.ms >= b) {
114            continue;
115        }
116        let key = (r.ms, u8::from(r.kind == RELEASE), line);
117        if by.get(&r.id).is_none_or(|(ms, k, l, _)| (*ms, *k, *l) < key) {
118            by.insert(r.id.clone(), (key.0, key.1, key.2, r));
119        }
120    }
121    by.into_iter().map(|(id, (.., r))| (id, r)).collect()
122}
123
124/// The lessons absorbed now in the log `file` (before `before_ms`): their absorb record, by id.
125pub fn state(file: &Path, before_ms: Option<i64>) -> HashMap<String, Record> {
126    let mut all = latest(file, before_ms);
127    all.retain(|_, r| r.absorbed());
128    all
129}
130
131/// The delivery filter: drops from `lessons` every absorbed one, and every lesson it hid as the end of
132/// its chain of hiding supersessions (trust.rs `successors`), so the lessons an absorbed one replaced
133/// do not come back in its place. An absorbed lesson whose chain ends at a lesson that stays is kept,
134/// hidden: no path scores or delivers a hidden lesson, and conscience.rs `rank` gives its match to that
135/// live successor, which an absorb must not weaken. A standing lesson (a ratified constraint) is never
136/// dropped, whatever its id. The caller passes the lessons as loaded, with their trust (conscience.rs
137/// `load`). Returns the lessons dropped.
138pub fn withhold(lessons: &mut Vec<Challenge>, absorbed: &HashMap<String, Record>) -> Vec<Challenge> {
139    let held = |c: &Challenge| !c.standing() && absorbed.contains_key(&c.id);
140    if !lessons.iter().any(held) {
141        return Vec::new();
142    }
143    let hidden = crate::trust::successors(lessons);
144    // The chain ends that go: absorbed and hidden by nothing.
145    let ends: std::collections::HashSet<String> = lessons.iter().filter(|c| held(c) && !hidden.contains_key(&c.id)).map(|c| c.id.clone()).collect();
146    let gone = |c: &Challenge| held(c) && hidden.get(&c.id).is_none_or(|end| ends.contains(end));
147    let mut dropped: Vec<Challenge> = Vec::new();
148    let mut keep = Vec::with_capacity(lessons.len());
149    for c in lessons.drain(..) {
150        if gone(&c) { dropped.push(c) } else { keep.push(c) }
151    }
152    *lessons = keep;
153    // What the dropped hid comes back unless something that stays still hides it; until none does, as
154    // a lesson that comes back and goes may have hidden others in turn.
155    loop {
156        let still = crate::trust::successors(lessons);
157        let (back, stay): (Vec<Challenge>, Vec<Challenge>) = lessons.drain(..).partition(|c| hidden.contains_key(&c.id) && !still.contains_key(&c.id));
158        *lessons = stay;
159        if back.is_empty() {
160            return dropped;
161        }
162        dropped.extend(back);
163    }
164}
165
166/// `withhold` with the log beside the lessons file `lessons_path`, as of `before_ms`.
167pub fn withhold_beside(lessons_path: &Path, lessons: &mut Vec<Challenge>, before_ms: Option<i64>) -> Vec<Challenge> {
168    withhold(lessons, &state(&beside(lessons_path), before_ms))
169}
170
171/// The ms to stamp a command on `ids` with at `now`: after every record of theirs in `latest`, so the
172/// command takes effect over the state it sees, however far ahead (within `SKEW_MS`) a skewed clock
173/// stamped that.
174pub fn stamp(latest: &HashMap<String, Record>, ids: &[&str], now: i64) -> i64 {
175    ids.iter().filter_map(|i| latest.get(i.trim())).map(|r| r.ms.saturating_add(1)).fold(now, i64::max)
176}
177
178/// The note an absorb stores: trimmed and redacted (notes.rs `redact`); empty is none. Refused when the
179/// note or its redaction is longer than `NOTE_CHARS` characters.
180///
181/// # Errors
182/// What to shorten.
183pub fn note(text: Option<&str>) -> Result<Option<String>, String> {
184    let Some(t) = text.map(str::trim).filter(|t| !t.is_empty()) else { return Ok(None) };
185    let redacted = crate::notes::redact(t);
186    let n = t.chars().count().max(redacted.chars().count());
187    if n > NOTE_CHARS {
188        return Err(format!("the note has {n} characters, at most {NOTE_CHARS}: name where the lesson is held, do not copy it"));
189    }
190    Ok(Some(redacted))
191}
192
193/// Appends one record per id (in order, each once) to the store's log, each line in one write
194/// (learn.rs `append_line`). `version` gives a lesson's current version for an absorb (None: not in the
195/// store yet); a release carries neither note nor version. Returns the records written.
196///
197/// # Errors
198/// When a line cannot be appended; the records before it are written.
199pub fn write(
200    store: &Path,
201    ids: &[&str],
202    kind: &str,
203    note: Option<&str>,
204    by: &str,
205    now: i64,
206    version: &dyn Fn(&str) -> Option<u32>,
207) -> std::io::Result<Vec<Record>> {
208    let mut out: Vec<Record> = Vec::new();
209    for id in ids.iter().map(|i| i.trim()).filter(|i| !i.is_empty()) {
210        if out.iter().any(|r| r.id == id) {
211            continue;
212        }
213        let absorb = kind == ABSORB;
214        let r = Record {
215            id: id.into(),
216            kind: kind.into(),
217            ms: now,
218            by: by.into(),
219            note: note.filter(|_| absorb).map(str::to_string),
220            version: if absorb { version(id) } else { None },
221        };
222        crate::learn::append_jsonl(&path(store), &r)?;
223        out.push(r);
224    }
225    Ok(out)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::conscience;
232
233    fn temp(name: &str) -> PathBuf {
234        let d = std::env::temp_dir().join(format!("borg-absorb-{name}-{}", std::process::id()));
235        let _ = std::fs::remove_dir_all(&d);
236        std::fs::create_dir_all(&d).unwrap();
237        d
238    }
239
240    fn line(id: &str, kind: &str, ms: i64, note: Option<&str>) -> String {
241        serde_json::to_string(&Record { id: id.into(), kind: kind.into(), ms, by: "a@x".into(), note: note.map(str::to_string), version: None }).unwrap()
242    }
243
244    #[test]
245    fn the_state_is_the_last_record_by_time_then_kind_then_text_in_any_line_order() {
246        let lines = [
247            line("A", ABSORB, 10, Some("skills/review@1")),
248            line("A", RELEASE, 20, None),
249            line("B", ABSORB, 10, None),
250            // The same ms: a release sorts after an absorb, so it wins.
251            line("C", ABSORB, 30, None),
252            line("C", RELEASE, 30, None),
253            // Two absorbs at the same ms: the line's text decides, the same on every node.
254            line("D", ABSORB, 40, Some("b")),
255            line("D", ABSORB, 40, Some("a")),
256        ];
257        let mut orders = vec![lines.to_vec()];
258        let mut rev = lines.to_vec();
259        rev.reverse();
260        orders.push(rev);
261        let dir = temp("order");
262        for order in orders {
263            std::fs::write(path(&dir), order.join("\n") + "\n").unwrap();
264            let s = state(&path(&dir), None);
265            let mut ids: Vec<&str> = s.keys().map(String::as_str).collect();
266            ids.sort_unstable();
267            assert_eq!(ids, ["B", "D"]);
268            assert_eq!(s["D"].note.as_deref(), Some("b"));
269        }
270        // Before the release: A is absorbed (an eval's cut-off, BORG_BEFORE).
271        assert!(state(&path(&dir), Some(15)).contains_key("A"));
272        let _ = std::fs::remove_dir_all(dir);
273    }
274
275    #[test]
276    fn a_missing_file_or_malformed_lines_are_no_absorb() {
277        let dir = temp("malformed");
278        assert!(state(&path(&dir), None).is_empty());
279        let long = "x".repeat(NOTE_CHARS + 1);
280        let text = [
281            "not json".to_string(),
282            r#"{"id": "", "kind": "absorb", "ms": 1}"#.into(),
283            r#"{"id": "E", "kind": "forget", "ms": 1}"#.into(),
284            r#"{"id": "F", "kind": "absorb"}"#.into(),
285            line("G", ABSORB, 1, Some(&long)),
286            line("H", ABSORB, 1, None),
287        ];
288        std::fs::write(path(&dir), text.join("\n")).unwrap();
289        assert_eq!(state(&path(&dir), None).keys().collect::<Vec<_>>(), ["H"]);
290        let _ = std::fs::remove_dir_all(dir);
291    }
292
293    #[test]
294    fn a_note_is_redacted_and_bounded() {
295        assert_eq!(note(None), Ok(None));
296        assert_eq!(note(Some("  ")), Ok(None));
297        assert_eq!(note(Some(" skills/review@2026-10-30 ")), Ok(Some("skills/review@2026-10-30".into())));
298        let secret = note(Some("held in skills/review, token ghp_0123456789abcdefghijABCDEFGHIJ012345")).unwrap().unwrap();
299        assert!(!secret.contains("ghp_0123456789"), "{secret}");
300        assert!(note(Some(&"x".repeat(NOTE_CHARS))).is_ok());
301        assert!(note(Some(&"x".repeat(NOTE_CHARS + 1))).unwrap_err().contains("at most 1000"));
302    }
303
304    #[test]
305    fn write_appends_one_line_per_id_and_a_release_carries_no_note_or_version() {
306        let dir = temp("write");
307        let version = |id: &str| (id == "A").then_some(3);
308        let w = write(&dir, &["A", "Z", "A", " "], ABSORB, Some("skills/review@1"), "a@x", 5, &version).unwrap();
309        assert_eq!(w.iter().map(|r| (r.id.as_str(), r.version)).collect::<Vec<_>>(), [("A", Some(3)), ("Z", None)]);
310        let r = write(&dir, &["A"], RELEASE, Some("ignored"), "b@x", 6, &version).unwrap();
311        assert_eq!((r[0].note.clone(), r[0].version), (None, None));
312        let text = std::fs::read_to_string(path(&dir)).unwrap();
313        assert_eq!(text.lines().count(), 3);
314        assert_eq!(text.lines().next().unwrap(), r#"{"id":"A","kind":"absorb","ms":5,"by":"a@x","note":"skills/review@1","version":3}"#);
315        assert_eq!(text.lines().nth(2).unwrap(), r#"{"id":"A","kind":"release","ms":6,"by":"b@x"}"#);
316        let s = state(&path(&dir), None);
317        assert!(!s.contains_key("A") && s.contains_key("Z"), "released; Z applies when it arrives");
318        let _ = std::fs::remove_dir_all(dir);
319    }
320
321    fn lesson(id: &str, ms: i64, supersedes: &[&str]) -> Challenge {
322        Challenge {
323            id: id.into(),
324            origin_ms: ms,
325            question: format!("q {id}"),
326            supersedes: supersedes.iter().map(|s| s.to_string()).collect(),
327            ..Default::default()
328        }
329    }
330
331    fn absorbed(ids: &[&str]) -> HashMap<String, Record> {
332        ids.iter().map(|i| (i.to_string(), Record { id: i.to_string(), kind: ABSORB.into(), ms: 1, by: String::new(), note: None, version: None })).collect()
333    }
334
335    fn ids(cs: &[Challenge]) -> Vec<&str> {
336        cs.iter().map(|c| c.id.as_str()).collect()
337    }
338
339    #[test]
340    fn withhold_drops_the_absorbed_and_what_they_hid_never_a_constraint() {
341        // Old is hidden by New (queen-decided); New is absorbed: Old must not come back in its place.
342        let mut new = lesson("New", 2, &["Old"]);
343        new.source = "queen".into();
344        let constraint = Challenge { id: "cX".into(), source: crate::conscience::CONSTRAINT.into(), ..lesson("cX", 3, &[]) };
345        let mut all = vec![lesson("Old", 1, &[]), new, lesson("Free", 4, &[]), constraint];
346        withhold(&mut all, &absorbed(&["New", "cX"]));
347        assert_eq!(ids(&all), ["Free", "cX"]);
348        // Nothing absorbed here: unchanged.
349        let mut all = vec![lesson("Old", 1, &[]), lesson("Free", 4, &[])];
350        withhold(&mut all, &absorbed(&["Gone"]));
351        assert_eq!(ids(&all), ["Old", "Free"]);
352        // An absorbed lesson that was itself hidden: what it hid stays hidden by the live successor.
353        let mut mid = lesson("Mid", 2, &["Old"]);
354        mid.source = "queen".into();
355        let mut top = lesson("Top", 3, &["Mid"]);
356        top.source = "queen".into();
357        let mut all = vec![lesson("Old", 1, &[]), mid.clone(), top.clone()];
358        let dropped = withhold(&mut all, &absorbed(&["Mid"]));
359        assert_eq!(ids(&all), ["Old", "Mid", "Top"], "Mid stays, hidden by Top, as Old does through it");
360        assert!(dropped.is_empty());
361        assert_eq!(crate::trust::successors(&all).len(), 2, "both still hidden: neither is scored or delivered");
362        // The whole chain absorbed: all of it goes.
363        let mut all = vec![lesson("Old", 1, &[]), mid, top];
364        let dropped = withhold(&mut all, &absorbed(&["Mid", "Top"]));
365        assert!(all.is_empty() && dropped.len() == 3, "{:?}", ids(&all));
366    }
367
368    #[test]
369    fn what_an_absorbed_end_hid_never_comes_back_through_a_lesson_that_goes() {
370        // Older -> Old -> New, New absorbed: Old comes back without New and goes, so Older, which only
371        // Old hid then, goes too.
372        let mut old = lesson("Old", 2, &["Older"]);
373        old.source = "queen".into();
374        let mut new = lesson("New", 3, &["Old"]);
375        new.source = "queen".into();
376        let mut all = vec![lesson("Older", 1, &[]), old, new, lesson("Free", 4, &[])];
377        let dropped = withhold(&mut all, &absorbed(&["New"]));
378        assert_eq!(ids(&all), ["Free"]);
379        assert_eq!(dropped.len(), 3);
380    }
381
382    /// Absorbing a lesson a live one hides leaves that successor's rank as it was: the hidden lesson's
383    /// match still flows to it.
384    #[test]
385    fn absorbing_a_hidden_lesson_keeps_its_live_successors_rank() {
386        let mut old = Challenge { question: "Is the socket buffer drained before the reconnect?".into(), ..lesson("Old", 1, &[]) };
387        old.consequence = "a reconnect replays stale frames".into();
388        let mut new = Challenge { question: "Is the retry budget shared across tenants?".into(), ..lesson("New", 2, &["Old"]) };
389        new.source = "queen".into();
390        let context = "drain the socket buffer before the reconnect, or stale frames replay";
391        let score = |all: &[Challenge]| conscience::rank_scoped(all, context, context).iter().find(|(_, c)| c.id == "New").map(|(s, _)| *s);
392        let mut all = vec![old, new, lesson("Free", 3, &[])];
393        let before = score(&all);
394        assert!(before.is_some_and(|s| s > 0.0), "New ranks at Old's match");
395        withhold(&mut all, &absorbed(&["Old"]));
396        assert_eq!(score(&all), before);
397        assert!(conscience::rank_scoped(&all, context, context).iter().all(|(_, c)| c.id != "Old"), "Old itself is never ranked");
398    }
399
400    #[test]
401    fn written_is_what_the_read_folds_and_bounds_the_clock() {
402        let now = 1_000_000;
403        let ok = line("L1", ABSORB, now, Some("skills/review@1"));
404        assert!(written(&ok, now).is_some());
405        assert!(written(&line("L1", RELEASE, now + crate::msg::SKEW_MS, None), now).is_some(), "within the skew");
406        for (what, bad) in [
407            ("ahead of the clock", line("L1", ABSORB, now + crate::msg::SKEW_MS + 1, None)),
408            ("a blank id", line(" ", ABSORB, now, None)),
409            ("a version past u32", r#"{"id":"L1","kind":"absorb","ms":1,"by":"a@x","version":5000000000}"#.into()),
410            ("a duplicate key", r#"{"id":"A","id":"B","kind":"absorb","ms":1,"by":"a@x"}"#.into()),
411            ("no by", r#"{"id":"L1","kind":"release","ms":1}"#.into()),
412            ("an unknown field", r#"{"id":"L1","kind":"absorb","ms":1,"by":"a@x","reach":"team"}"#.into()),
413            ("an oversize note", line("L1", ABSORB, 1, Some(&"n".repeat(NOTE_CHARS + 1)))),
414        ] {
415            assert!(written(&bad, now).is_none(), "{what} is refused");
416        }
417    }
418
419    /// A member whose clock ran far ahead cannot absorb for good: the read ignores the record, and a
420    /// command is stamped after what it sees.
421    #[test]
422    fn a_future_absorb_never_outlives_a_release() {
423        let dir = temp("future");
424        let far = crate::learn::now_ms() + 365 * 24 * 3_600_000;
425        std::fs::write(path(&dir), line("L1", ABSORB, far, None) + "\n").unwrap();
426        assert!(state(&path(&dir), None).is_empty(), "stamped a year ahead: ignored");
427        let near = crate::learn::now_ms() + crate::msg::SKEW_MS / 2;
428        std::fs::write(path(&dir), line("L1", ABSORB, near, None) + "\n").unwrap();
429        let seen = latest(&path(&dir), None);
430        let now = crate::learn::now_ms();
431        let at = stamp(&seen, &["L1"], now);
432        assert!(at > near);
433        write(&dir, &["L1"], RELEASE, None, "b@x", at, &|_| None).unwrap();
434        assert!(state(&path(&dir), None).is_empty(), "the release wins over the absorb it saw");
435        assert_eq!(stamp(&seen, &["Other"], now), now);
436        let _ = std::fs::remove_dir_all(dir);
437    }
438}