Skip to main content

borg_mesh/
steer.rs

1//! Steering over the mesh: which pause, resume and veto messages of another node count here (the
2//! hook side, the admissions file and delivery are borg steer.rs).
3//!
4//! - **Authenticated by origin.** A privileged message (borg msg.rs `Kind::privileged`) is a line of
5//!   `messages.jsonl` like any other, and travels only under a signed origin of the key its `node`
6//!   names (lines.rs `travels`). That origin is the sender's node, verified; no second signature.
7//! - **Admit** (`admit`), when the mesh stores such a line from another node: in force here only
8//!   under the role the held membership gives that node, `admin` or `steer` (borg steer.rs
9//!   `Role::of_member`). Any other role, an unknown or a revoked key is refused (`Refusal`) and
10//!   logged to `<store>/messages/dropped.jsonl`. The line stays in the log, as the union merge
11//!   keeps it, but without an admission it is neither shown nor in force anywhere.
12//! - **Own lines.** This node's lines are admitted by borg when sent (`Role::Local`: the engineer at
13//!   this machine steers its own sessions, whatever its member role). A `share: local` collective
14//!   never syncs, so only this machine's key ever steers it.
15//! - **Enforce** (`enforce`), every round and after the membership changed: an admission whose node
16//!   lost the right to steer goes, so its pause no longer holds here, and so does an expired one
17//!   (borg steer.rs `keep_bounded`), so the file goes and the hook is back to one failed stat.
18//! - **Veto**: when a round admitted any, and at every `enforce` (a failed record, or one made while
19//!   learning was off, is retried), borg veto.rs `apply_messages` records each veto for a session of
20//!   this node once.
21//!
22//! 0.3.0 sent signed directives in its `Live` frame; they are ignored (sync.rs `Live`).
23
24use std::fmt;
25use std::path::Path;
26
27use borg::msg::Message;
28use borg::steer::{Admission, Dropped, Role};
29
30use crate::membership::Membership;
31
32/// Why another node's privileged message does not count here.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Refusal {
35    /// A member whose role may not steer (`member`, `observe`).
36    Role(String),
37    Revoked,
38    Unknown,
39}
40
41impl fmt::Display for Refusal {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Refusal::Role(r) => write!(f, "role {r} may not steer"),
45            Refusal::Revoked => write!(f, "revoked node"),
46            Refusal::Unknown => write!(f, "unknown node"),
47        }
48    }
49}
50
51/// The role a privileged message from `node` counts under in `m`.
52///
53/// # Errors
54/// The `Refusal` when it does not count.
55pub fn check(m: &Membership, node: &str) -> Result<Role, Refusal> {
56    match m.role_of(node) {
57        Some(r) => Role::of_member(r).ok_or_else(|| Refusal::Role(r.to_string())),
58        None if m.revoked.iter().any(|x| x.key == node) => Err(Refusal::Revoked),
59        None => Err(Refusal::Unknown),
60    }
61}
62
63/// What a round admitted and refused.
64#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
65pub struct Admitted {
66    pub new: usize,
67    pub dropped: usize,
68}
69
70/// Admits the privileged messages among `got`, lines new to this node `me` whose origin verified as
71/// their node, under `m` (module doc). Refused ones are logged. The caller records the vetoes among
72/// them (`veto::apply_messages`) once it holds no lock, as that reads the whole log and the outcomes.
73///
74/// # Errors
75/// When the admissions cannot be written.
76pub fn admit(home: &Path, m: &Membership, me: &str, got: &[Message], now: i64) -> std::io::Result<Admitted> {
77    let mut ok = Vec::new();
78    let mut dropped = 0;
79    for msg in got.iter().filter(|x| x.kind.privileged() && x.node != me) {
80        match check(m, &msg.node) {
81            Ok(role) => ok.push(Admission::of(msg, role)),
82            Err(why) => {
83                dropped += 1;
84                let d = Dropped { ms: now, id: msg.id.clone(), node: msg.node.clone(), engineer: msg.name.clone(), kind: msg.kind, why: why.to_string() };
85                borg::steer::log_dropped(home, &d);
86            }
87        }
88    }
89    let new = if ok.is_empty() { 0 } else { borg::steer::admit(home, &ok, now)? };
90    Ok(Admitted { new, dropped })
91}
92
93/// Every round and after the membership changed: drops the admissions of nodes that may no longer
94/// steer under `m` (this node's own, `Role::Local`, stay) and the expired ones at `now`, then retries
95/// the admitted vetoes not yet recorded. Returns how many admissions went.
96pub fn enforce(home: &Path, m: &Membership, now: i64) -> usize {
97    if !borg::steer::active(home) {
98        return 0;
99    }
100    let gone = borg::steer::retain(home, now, |a| a.role == Role::Local || check(m, &a.node).is_ok()).unwrap_or(0);
101    borg::veto::apply_messages(home, now);
102    gone
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::identity::Identity;
109    use borg::msg::{Kind, Sender};
110    use std::path::PathBuf;
111
112    fn tmp(tag: &str) -> PathBuf {
113        let d = std::env::temp_dir().join(format!("borg-mesh-steer-{tag}-{}", std::process::id()));
114        let _ = std::fs::remove_dir_all(&d);
115        std::fs::create_dir_all(&d).unwrap();
116        d
117    }
118
119    struct Team {
120        m: Membership,
121        admin: Identity,
122        steerer: Identity,
123        observer: Identity,
124        member: Identity,
125        home: PathBuf,
126    }
127
128    fn team(tag: &str) -> Team {
129        let home = tmp(tag);
130        let id = |n: &str| Identity::load_or_create(&home.join(n)).unwrap();
131        let (admin, steerer, observer, member) = (id("a"), id("s"), id("o"), id("m"));
132        let mut m = Membership::found("corp", &admin);
133        m.invite(&admin, &steerer.public(), "steer").unwrap();
134        m.invite(&admin, &observer.public(), "observe").unwrap();
135        m.invite(&admin, &member.public(), "member").unwrap();
136        Team { m, admin, steerer, observer, member, home: home.join("store") }
137    }
138
139    fn steer(by: &Identity, kind: Kind, to: &str, now: i64) -> Message {
140        let from = Sender { by: "engineer:x@y".into(), name: "x@y".into(), harness: "terminal".into(), node: by.public() };
141        let text = if kind == Kind::Veto { "no" } else { "" };
142        Message::new(kind, &from, to, None, now, text)
143    }
144
145    #[test]
146    fn only_admin_and_steer_nodes_are_admitted() {
147        let mut t = team("roles");
148        assert_eq!(check(&t.m, &t.admin.public()), Ok(Role::Admin));
149        assert_eq!(check(&t.m, &t.steerer.public()), Ok(Role::Steer));
150        assert_eq!(check(&t.m, &t.observer.public()), Err(Refusal::Role("observe".into())));
151        assert_eq!(check(&t.m, &t.member.public()), Err(Refusal::Role("member".into())));
152        assert_eq!(check(&t.m, &"0".repeat(64)), Err(Refusal::Unknown));
153        t.m.revoke(&t.admin, &t.steerer.public(), 900).unwrap();
154        assert_eq!(check(&t.m, &t.steerer.public()), Err(Refusal::Revoked));
155    }
156
157    #[test]
158    fn a_round_admits_steerers_logs_the_rest_and_skips_tells_and_own_lines() {
159        let t = team("admit");
160        let now = borg::learn::now_ms();
161        let me = t.member.public();
162        let tell = Message::new(
163            Kind::Tell,
164            &Sender { by: "s1".into(), name: "One".into(), harness: "claude".into(), node: t.observer.public() },
165            "all",
166            None,
167            now,
168            "hi",
169        );
170        let got = [steer(&t.steerer, Kind::Pause, "all", now), steer(&t.observer, Kind::Pause, "all", now), steer(&t.member, Kind::Pause, "all", now), tell];
171        assert_eq!(admit(&t.home, &t.m, &me, &got, now).unwrap(), Admitted { new: 1, dropped: 1 });
172        let held = borg::steer::read_admitted(&t.home);
173        assert_eq!((held.len(), held[0].role, held[0].node.as_str()), (1, Role::Steer, t.steerer.public().as_str()));
174        let log = std::fs::read_to_string(borg::steer::dropped_path(&t.home)).unwrap();
175        assert!(log.contains("role observe may not steer") && log.contains(&got[1].id), "{log}");
176        assert_eq!(admit(&t.home, &t.m, &me, &got[..1], now).unwrap(), Admitted { new: 0, dropped: 0 }, "admitted once");
177    }
178
179    #[test]
180    fn a_demoted_or_revoked_node_stops_steering_and_own_admissions_stay() {
181        let mut t = team("enforce");
182        let now = borg::learn::now_ms();
183        let me = t.member.public();
184        admit(&t.home, &t.m, &me, &[steer(&t.steerer, Kind::Pause, "all", now), steer(&t.admin, Kind::Pause, "all", now + 1)], now).unwrap();
185        let own = steer(&t.member, Kind::Pause, "all", now);
186        borg::steer::admit(&t.home, &[Admission::of(&own, Role::Local)], now).unwrap();
187        assert_eq!(enforce(&t.home, &t.m, now), 0);
188        t.m.revoke(&t.admin, &t.steerer.public(), now).unwrap();
189        assert_eq!(enforce(&t.home, &t.m, now), 1);
190        let left: Vec<Role> = borg::steer::read_admitted(&t.home).into_iter().map(|a| a.role).collect();
191        assert_eq!(left, [Role::Admin, Role::Local]);
192        let late = now + 1 + borg::steer::TTL_MS + borg::steer::SLACK_MS + 1;
193        assert_eq!(enforce(&t.home, &t.m, late), 2, "expired admissions go without a new steer");
194        assert!(!borg::steer::active(&t.home), "back to the hook's failed stat");
195    }
196
197    #[test]
198    fn a_veto_not_recorded_when_admitted_is_retried_at_the_next_enforce() {
199        let t = team("veto-retry");
200        let now = borg::learn::now_ms();
201        let me = borg::msg::node(&t.home);
202        let veto = steer(&t.steerer, Kind::Veto, &format!("s1@{me}"), now);
203        std::fs::create_dir_all(&t.home).unwrap();
204        std::fs::write(borg::msg::path(&t.home), format!("{}\n", serde_json::to_string(&veto).unwrap())).unwrap();
205        assert_eq!(admit(&t.home, &t.m, &me, std::slice::from_ref(&veto), now).unwrap().new, 1);
206        let episodes = t.home.join("episodes.jsonl");
207        assert!(!episodes.exists(), "s1 is not known here yet: nothing recorded");
208        let asked = serde_json::json!({"kind": "asked", "session": "s1", "lessons": ["L1"], "ms": now - 10, "repo": "r"});
209        std::fs::write(t.home.join("outcomes.jsonl"), format!("{asked}\n")).unwrap();
210        assert_eq!(enforce(&t.home, &t.m, now), 0);
211        let recorded = std::fs::read_to_string(&episodes).unwrap();
212        assert_eq!(recorded.lines().count(), 1, "{recorded}");
213        enforce(&t.home, &t.m, now);
214        assert_eq!(std::fs::read_to_string(&episodes).unwrap().lines().count(), 1, "once");
215    }
216}