Skip to main content

borg/
veto.rs

1//! `borg no`: the engineer's veto, the strongest negative signal.
2//!
3//! An engineer says no to a drone's work, with a reason: `borg no <reason>` from a terminal (the drone
4//! of the current repo, or the one `--session` names), or a prompt that starts with "borg no" inside the
5//! session. Saying it costs 0 tokens and has three effects:
6//! - **Credit.** Every lesson shown in the session so far (asked at a stop or live, handed over early or
7//!   queued) gets a `vetoed` outcome (outcome.rs), which memory.rs weighs above `asked_then_corrected`.
8//! - **A lesson.** The reason is an episode of kind `veto`; `borg learn` generalises it with its own
9//!   prompt at the end of any session, so a veto from a terminal never waits for its session. A veto
10//!   merged into a lesson an earlier veto made is counted as `veto_repeat`: the same "no" said twice,
11//!   the rate learning is to bring to zero.
12//! - **A proposed constraint.** When the reason names an invariant of the system, `borg learn` queues it
13//!   as a queen question (`Kind::Constraint`). Only an engineer's answer ratifies it into
14//!   `<store>/constraints.jsonl`; learning never applies a constraint. A ratified constraint is a
15//!   standing lesson (conscience.rs `standing`): every session gets it early once its scope is in play
16//!   (an unscoped one at the first prompt), whatever the match, and the mesh syncs the file signed like
17//!   the lessons. Nothing blocks an edit or a stop on a constraint yet.
18//!
19//! - **A missed escalation**, when the vetoed work had already merged and the queen never escalated
20//!   from that session (escalation.rs, the queen's attention reward; logged and shown, never optimised).
21//!
22//! **Across the mesh.** A drone on another node is vetoed by naming it (`--session`, a designation or a
23//! session id, as `borg pause` takes it): `borg no` then sends a `veto` message to that session of that
24//! node (msg.rs `Kind::Veto`). It counts there only under a steering role the mesh admitted this node
25//! with (steer.rs), as any pause. The drone's node records it once when it arrives (`apply_messages`):
26//! the credit, the episode and the stat land where the shown lessons and the session are, and its
27//! drone is told at its next prompt or tool call, labelled with the sender and role. The issuing node
28//! records nothing but `steer_issued`; the `vetoed` outcomes reach it as team outcomes (outcome.rs
29//! `SHARED_KINDS`), so both nodes' beliefs count the veto once.
30//!
31//! In the session the drone is told to stop the vetoed approach and not argue it back. A veto prompt is
32//! never the session's task and never doubles as a correction. BORG_VETO=off makes such a prompt an
33//! ordinary one (the command still works).
34
35use std::path::{Path, PathBuf};
36use std::sync::LazyLock;
37
38use regex::Regex;
39use serde_json::json;
40
41use crate::msg::{self, Address, Kind, Message};
42use crate::outcome::{self, Outcome};
43use crate::presence::State;
44use crate::{collective, escalation, learn, presence, queen, stats, steer, top};
45
46/// `Episode::kind` of a veto.
47pub const KIND: &str = "veto";
48
49/// The veto prompt's opening; `learn::schema_for` picks the veto schema by it.
50pub const OPENING: &str = "An engineer vetoed a coding agent's work: they said no to what it was doing, with the reason below. \
51Decide whether the reason expresses a reusable engineering lesson: a critical concern about quality, design, correctness, reuse, \
52spec conformance or root cause that this engineer would plausibly raise again about similar future work (a veto is rarely \
53operational; \"stop\", \"later\" are NOT lessons).";
54
55/// The extra field line in the veto prompt.
56pub const INVARIANT: &str = "- invariant: if the reason names an invariant of the system (a property that must always hold, e.g. \
57\"merge-to-main lookups go through the review API, never the commit log\"), state it as one rule; otherwise empty. Never invent one.";
58
59/// The answers a constraint question offers: the default never applies anything.
60pub const KEEP_OPTION: &str = "keep it a lesson only";
61pub const RATIFY_OPTION: &str = "ratify: make it a constraint (only an engineer can)";
62
63static PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)^\s*borg\s+no\b[\s:,.-]*(.*)$").unwrap());
64
65/// False when BORG_VETO=off: a prompt starting "borg no" is an ordinary prompt.
66pub fn enabled() -> bool {
67    std::env::var("BORG_VETO").as_deref() != Ok("off")
68}
69
70/// The reason of a prompt that is a veto ("borg no: never …"), trimmed, at most 1500 characters; None
71/// when it is not a veto or its reason is shorter than 4 characters.
72pub fn reason_in(prompt: &str) -> Option<String> {
73    let reason = PREFIX.captures(prompt)?.get(1)?.as_str().trim();
74    (reason.chars().count() >= 4).then(|| reason.chars().take(1500).collect())
75}
76
77/// The lessons the drone was shown in `session` so far, sorted and unique: its `asked` outcomes (stop
78/// or live) and its stats events of the delivering kinds (early, queued, live asks, blocks).
79pub fn shown(home: &Path, session: &str) -> Vec<String> {
80    const DELIVERED: [&str; 4] = [stats::EARLY, stats::QUEUED_FREE, stats::LIVE_ASK, stats::BLOCK];
81    let asked = outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == outcome::ASKED && o.session == session).flat_map(|o| o.lessons);
82    let delivered = stats::events(home).into_iter().filter(|e| e.session == session && DELIVERED.contains(&e.kind.as_str())).flat_map(|e| e.lessons);
83    let mut out: Vec<String> = asked.chain(delivered).filter(|l| !l.is_empty()).collect();
84    out.sort();
85    out.dedup();
86    out
87}
88
89/// One veto as said.
90pub struct Said<'a> {
91    pub session: &'a str,
92    pub repo: &'a str,
93    pub engineer: &'a str,
94    /// The session's task, as the hooks kept it.
95    pub task: &'a str,
96    /// The drone's last proposal; empty from a terminal (no transcript there).
97    pub agent: &'a str,
98    pub reason: &'a str,
99    pub ms: i64,
100    /// `cli`, `prompt` or `mesh` (a signed directive from another node, `apply`).
101    pub via: &'static str,
102}
103
104/// What `record` wrote: the episode's id, the lessons credited `vetoed` now, and whether the veto
105/// counted as a missed escalation (escalation.rs).
106#[derive(Debug, Clone, PartialEq)]
107pub struct Veto {
108    pub episode: String,
109    pub credited: Vec<String>,
110    pub missed: bool,
111}
112
113/// Records one veto in `home`: the episode, a `vetoed` outcome per shown lesson not yet credited in
114/// the session (one per lesson keeps `outcome::has` idempotent; nothing when none, so a session with
115/// nothing shown adds no clock tick), and one `veto` stat for every call. A veto of work that had
116/// already merged, from a session the queen never escalated, is also a missed escalation
117/// (escalation.rs `missed`). The caller checks `learn::learning()` first.
118///
119/// # Errors
120/// When the episode or an outcome cannot be appended.
121pub fn record(home: &Path, s: &Said) -> std::io::Result<Veto> {
122    let e = learn::Episode {
123        id: format!("no-{}-{}", s.session.chars().take(8).collect::<String>(), s.ms),
124        ms: s.ms,
125        repo: s.repo.into(),
126        session: s.session.into(),
127        task: s.task.into(),
128        agent: s.agent.into(),
129        rejected_tool: None,
130        correction: s.reason.into(),
131        engineer: s.engineer.into(),
132        kind: KIND.into(),
133        ..learn::Episode::default()
134    };
135    learn::record_episode_in(home, &e)?;
136    let path = home.join("outcomes.jsonl");
137    let outcomes = outcome::read(&path);
138    let credited: Vec<String> = shown(home, s.session).into_iter().filter(|l| !outcome::has(&outcomes, outcome::VETOED, s.session, l)).collect();
139    for l in &credited {
140        let o =
141            Outcome { kind: outcome::VETOED.into(), session: s.session.into(), lessons: vec![l.clone()], ms: s.ms, repo: s.repo.into(), ..Default::default() };
142        outcome::append(&path, &o)?;
143    }
144    stats::count(home, stats::VETO, s.session, |ev| {
145        ev.ms = s.ms;
146        ev.engineer = s.engineer.into();
147        ev.repo = s.repo.into();
148        ev.lessons = credited.clone();
149        ev.n = credited.len() as u64;
150        ev.why = s.via.into();
151        ev.text = stats::excerpt(s.reason, 160);
152    });
153    let missed = escalation::merged(&crate::notes::state_dir(), s.session) && escalation::missed(home, s.session, KIND, &e.id, s.repo, s.ms);
154    Ok(Veto { episode: e.id, credited, missed })
155}
156
157/// The session's task as the hooks kept it (`<state>/<session>.task`), else `fallback` (its last prompt).
158fn task_of(home: &Path, session: &str, fallback: &str) -> String {
159    let state = std::env::var_os("BORG_STATE").map_or_else(|| home.join("state"), PathBuf::from);
160    std::fs::read_to_string(state.join(format!("{}.task", presence::safe(session))))
161        .ok()
162        .filter(|t| !t.trim().is_empty())
163        .unwrap_or_else(|| fallback.to_string())
164}
165
166/// Records the veto a message carries (msg.rs `Kind::Veto`), once, on the node `here` whose session it
167/// names: `borg no` for a drone on another machine sends it from there (`remote`), the mesh carries it
168/// here like any message, and this node holds what the veto credits (the lessons the session was
169/// shown) and learns its episode, so its beliefs and its repeat-veto count see it. The caller checks
170/// that it counts (steer.rs `Roles`). The issuing node records nothing but its `steer_issued` stat;
171/// the `vetoed` outcomes reach it as team outcomes. Once: `<store>/messages/vetoed/<id>` is created
172/// exclusively first (removed again if recording fails, so the next round retries). None for any other
173/// message, one for another node, or one already recorded.
174///
175/// # Errors
176/// When the marker or the veto cannot be written.
177pub fn apply(home: &Path, m: &Message, here: &str) -> std::io::Result<Option<Veto>> {
178    let Some(Address::Session { id: session, node: Some(node) }) = Address::parse(&m.to) else { return Ok(None) };
179    if m.kind != Kind::Veto || node != here || m.text.trim().is_empty() {
180        return Ok(None);
181    }
182    // A session unknown here is another node's, or none.
183    let session = session.as_str();
184    let p = presence::read(home, session);
185    if p.is_none() && !outcome::read(&home.join("outcomes.jsonl")).iter().any(|o| o.session == session) {
186        return Ok(None);
187    }
188    let dir = msg::dir(home).join("vetoed");
189    std::fs::create_dir_all(&dir)?;
190    let marker = dir.join(presence::safe(&m.id));
191    match std::fs::OpenOptions::new().write(true).create_new(true).open(&marker) {
192        Ok(_) => {}
193        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
194        Err(e) => return Err(e),
195    }
196    let repo = p.as_ref().map(|p| p.repo.clone()).unwrap_or_default();
197    let task = task_of(home, session, p.as_ref().map_or("", |p| p.prompt.as_str()));
198    let reason: String = m.text.trim().chars().take(1500).collect();
199    // The engineer's email when an engineer sent it, else the sending session's name.
200    let engineer = match Address::parse(&m.by) {
201        Some(Address::Engineer { email }) if !email.is_empty() => email,
202        _ => m.name.clone(),
203    };
204    let said = Said { session, repo: &repo, engineer: &engineer, task: &task, agent: "", reason: &reason, ms: m.ms, via: "mesh" };
205    record(home, &said).map(Some).inspect_err(|_| {
206        let _ = std::fs::remove_file(&marker);
207    })
208}
209
210/// Records every admitted veto message for a session of this node not recorded yet (`apply`); the mesh
211/// calls it after it admits a round's messages (steer.rs `admit`) and at every `enforce`, which
212/// retries what failed. Nothing with learning off or while nobody steers. Returns how many it recorded.
213pub fn apply_messages(home: &Path, now: i64) -> usize {
214    if !learn::learning() {
215        return 0;
216    }
217    let roles = steer::roles(home);
218    if roles.is_empty() {
219        return 0;
220    }
221    let here = msg::node(home);
222    let lines: Vec<Message> = crate::jsonl::read_lines(&msg::path(home));
223    lines
224        .iter()
225        .filter(|m| m.kind == Kind::Veto && roles.has(&m.id) && m.well_formed() && roles.of(m, &here, now).is_some())
226        .filter_map(|m| apply(home, m, &here).ok().flatten())
227        .count()
228}
229
230/// What the drone is told when the engineer vetoes in the session.
231pub fn render(reason: &str) -> String {
232    format!(
233        "[borg] The engineer said no to this work: \"{reason}\". Stop the vetoed approach now; do not argue it back. If the reason is unclear, ask one short clarifying question, then redo the work within it."
234    )
235}
236
237fn short(session: &str) -> String {
238    session.chars().take(8).collect()
239}
240
241/// The drone a terminal veto names (pure). `named`: a designation, number or session id (prefix), as
242/// `borg pause` takes it, of a local drone or one on another node (`main` then sends it as a veto
243/// message). Otherwise the local drone working in `repo`: the one not ended, else the most recently
244/// seen ended one (a veto right after the session ended); several active ones are an error, never a
245/// guess. A remote drone is never picked unnamed.
246///
247/// # Errors
248/// What to name instead.
249pub fn target<'a>(drones: &'a [top::Drone], repo: &str, named: Option<&str>) -> Result<&'a top::Drone, String> {
250    if let Some(n) = named {
251        return match top::resolve(drones, n)? {
252            None => Err("a veto names one drone, not local, collective or all".into()),
253            Some(d) => Ok(d),
254        };
255    }
256    let here: Vec<&top::Drone> = drones.iter().filter(|d| d.node.is_empty() && d.p.repo == repo).collect();
257    let active: Vec<&top::Drone> = here.iter().copied().filter(|d| d.state != State::Ended).collect();
258    match active.as_slice() {
259        [d] => Ok(d),
260        [] => here.into_iter().max_by_key(|d| d.p.ms).ok_or_else(|| format!("no drone in {repo} (`borg top`): name a session with --session")),
261        many => {
262            let names: Vec<String> = many.iter().map(|d| format!("{} {}", d.designation, short(&d.p.session))).collect();
263            Err(format!("{} drones work in {repo}: name one with --session ({})", many.len(), names.join(", ")))
264        }
265    }
266}
267
268/// Queues the invariant a veto's lesson named as a `Kind::Constraint` question, never a constraint: the
269/// default keeps it a lesson only. Dedups by key and near-duplicate (`queen::seed`). Returns whether it
270/// was queued.
271///
272/// # Errors
273/// When the queue cannot be appended.
274pub fn propose(home: &Path, lesson: &learn::Lesson, invariant: &str, e: &learn::Episode, now: i64) -> std::io::Result<bool> {
275    let key = queen::constraint_key(&lesson.repo, invariant);
276    let entry = queen::Entry {
277        id: format!("q{}", &key[..8]),
278        decision_key: key,
279        kind: queen::Kind::Constraint,
280        question: format!("Make this a constraint of the collective? \"{}\"", stats::excerpt(invariant, 300)),
281        options: vec![KEEP_OPTION.into(), RATIFY_OPTION.into()],
282        default: KEEP_OPTION.into(),
283        evidence: vec![format!("invariant: {invariant}"), format!("lesson {}", lesson.id), format!("veto: {}", stats::excerpt(&e.correction, 200))],
284        session: e.session.clone(),
285        cwd: String::new(),
286        repo: lesson.repo.clone(),
287        engineer: e.engineer.clone(),
288        ms: now,
289        answer: None,
290        answered_by: None,
291        answered_ms: None,
292        disagreeing: Vec::new(),
293        dismissed: None,
294        dismissed_ms: None,
295        payload: None,
296    };
297    queen::seed(home, &entry)
298}
299
300/// The ratified constraints of a store, beside its lessons (`constraints_path`).
301pub const FILE: &str = "constraints.jsonl";
302
303/// The constraints file of a store. Written on ratification, read with the lessons as standing ones
304/// (conscience.rs `load`) and synced by the mesh (borg-mesh lines.rs `SYNCED`).
305pub fn constraints_path(store: &Path) -> PathBuf {
306    store.join(FILE)
307}
308
309/// The answer to a constraint question (queen.rs `answer`): an answer starting with "ratify" (option
310/// b) appends the invariant to `constraints.jsonl` and returns true; anything else writes nothing.
311/// The line syncs to the team, so it carries what a lesson does and no more: the invariant (redacted,
312/// as a lesson is generalised), the lesson it came from and who ratified it, never the veto's raw words.
313/// It also carries who should hear it: the lesson's scope, repo and reach (reach.rs), or, the lesson
314/// unknown here, the question's repo with reach `repo`, so a node without the lesson never widens it.
315///
316/// # Errors
317/// When the constraints file cannot be appended.
318pub fn ratify(store: &Path, e: &queen::Entry, answer: &str, author: &str, now: i64) -> Result<bool, String> {
319    if !answer.trim().to_lowercase().starts_with("ratify") {
320        return Ok(false);
321    }
322    let field = |prefix: &str| e.evidence.iter().find_map(|x| x.strip_prefix(prefix)).unwrap_or_default().to_string();
323    let lesson = field("lesson ");
324    let from = crate::conscience::load_unweighted(&store.join("lessons.jsonl"), None).into_iter().find(|l| l.id == lesson);
325    let (scope, repo, reach) = match from {
326        Some(l) => (l.scope, if l.repo.is_empty() { e.repo.clone() } else { l.repo }, l.reach),
327        None => (Vec::new(), e.repo.clone(), crate::reach::REPO.to_string()),
328    };
329    let line = json!({"id": format!("c{}", &e.decision_key[..8.min(e.decision_key.len())]), "text": crate::notes::redact(&field("invariant: ")),
330        "lesson": lesson, "scope": scope, "repo": repo, "reach": reach, "by": author, "ms": now, "source": "veto", "check": ""});
331    learn::append_jsonl(&constraints_path(store), &line).map_err(|err| err.to_string())?;
332    Ok(true)
333}
334
335/// `borg no`'s flags (completions.rs).
336pub const FLAGS: &[&str] = &["--session", "--dir", "--json"];
337
338const USAGE: &str = "usage: borg no [--session ID|DRONE] [--dir DIR] [--json] <reason…>
339  says no to a drone's work: the lessons it was shown lose credit, and the reason becomes a lesson";
340
341/// A veto of the drone `d` on another node: a veto message to its session of its node (the drone's node
342/// records it, `apply_messages`).
343fn remote(home: &Path, drones: &[top::Drone], d: &top::Drone, cwd: &str, reason: &str, now: i64) -> i32 {
344    let to = Address::Session { id: d.p.session.clone(), node: Some(d.node.clone()) };
345    let sent = msg::sender(home, drones, None, cwd).and_then(|from| msg::send(home, Message::new(Kind::Veto, &from, &to.to_string(), None, now, reason), now));
346    match sent {
347        Ok(m) => {
348            println!(
349                "vetoed {} ({}) on another machine: {}, recorded there after the next mesh round (`borg mesh serve`)",
350                d.designation,
351                short(&d.p.session),
352                m.id
353            );
354            0
355        }
356        Err(e) => {
357            eprintln!("borg no: cannot send the veto: {e}");
358            1
359        }
360    }
361}
362
363/// `borg no [--session ID|DRONE] [--dir DIR] [--json] <reason…>`.
364pub fn main(args: &[&str]) -> i32 {
365    let value = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
366    let mut words = Vec::new();
367    let mut i = 0;
368    while i < args.len() {
369        match args[i] {
370            "--session" | "--dir" => i += 2,
371            "--json" => i += 1,
372            w => {
373                words.push(w);
374                i += 1;
375            }
376        }
377    }
378    let reason = words.join(" ").trim().to_string();
379    if reason.is_empty() {
380        eprintln!("{USAGE}");
381        return 2;
382    }
383    // Routing compares path text, so a relative --dir is made absolute first, as hooks' cwd is.
384    let dir = value("--dir").map_or_else(|| std::env::current_dir().unwrap_or_default(), PathBuf::from);
385    let cwd = std::path::absolute(&dir).unwrap_or(dir).display().to_string();
386    let route = match collective::load() {
387        Ok(cfg) => collective::route(cfg.as_ref(), &cwd, &learn::home()),
388        Err(e) => {
389            eprintln!("borg no: invalid collectives config: {e}");
390            return 1;
391        }
392    };
393    let collective::Route::Collective { home, .. } = &route else {
394        eprintln!("off: {cwd} belongs to no collective; borg records nothing here");
395        return 1;
396    };
397    if !learn::learning() {
398        println!("learning is off (BORG_LEARN=off): nothing recorded");
399        return 0;
400    }
401    let now = learn::now_ms();
402    // The same drones and designations as `borg top`; remote ones are listed but never targeted.
403    let drones = top::drones(home, collective::may_sync(&route), now);
404    let d = match target(&drones, &queen::repo_name(&cwd), value("--session")) {
405        Ok(d) => d,
406        Err(e) => {
407            eprintln!("borg no: {e}");
408            return 1;
409        }
410    };
411    let session = d.p.session.as_str();
412    if !d.node.is_empty() {
413        return remote(home, &drones, d, &cwd, &reason, now);
414    }
415    let task = task_of(home, session, &d.p.prompt);
416    let engineer = learn::engineer(&cwd);
417    let said = Said { session, repo: &d.p.repo, engineer: &engineer, task: &task, agent: "", reason: &reason, ms: now, via: "cli" };
418    let v = match record(home, &said) {
419        Ok(v) => v,
420        Err(e) => {
421            eprintln!("borg no: cannot record the veto: {e}");
422            return 1;
423        }
424    };
425    if args.contains(&"--json") {
426        println!("{}", json!({"session": session, "episode": v.episode, "credited": v.credited}));
427        return 0;
428    }
429    let lost = match v.credited.len() {
430        0 => "no shown lessons lose credit".to_string(),
431        n => format!("{n} shown lesson(s) lose credit ({})", v.credited.join(", ")),
432    };
433    println!("vetoed {} ({}): {lost}; the reason becomes a lesson when borg learn runs", d.designation, short(session));
434    println!("to stop it now: borg pause {}", short(session));
435    0
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::presence::Presence;
442
443    fn temp(name: &str) -> PathBuf {
444        let d = std::env::temp_dir().join(format!("borg-veto-{name}-{}", std::process::id()));
445        let _ = std::fs::remove_dir_all(&d);
446        std::fs::create_dir_all(&d).unwrap();
447        d
448    }
449
450    #[test]
451    fn a_veto_prompt_starts_with_borg_no_and_carries_a_reason() {
452        assert_eq!(reason_in("borg no: never read the commit log").as_deref(), Some("never read the commit log"));
453        assert_eq!(reason_in("Borg no, don't").as_deref(), Some("don't"));
454        assert_eq!(reason_in("  BORG NO\nthe schema is public").as_deref(), Some("the schema is public"));
455        assert_eq!(reason_in("borg now run the tests"), None);
456        assert_eq!(reason_in("no, use X"), None, "a correction, not a veto");
457        assert_eq!(reason_in("borg no"), None);
458        assert_eq!(reason_in("borg no: ok"), None, "too short a reason");
459        assert_eq!(reason_in(&format!("borg no {}", "x".repeat(2000))).map(|r| r.chars().count()), Some(1500));
460    }
461
462    fn drone(session: &str, repo: &str, state: State, ms: i64, node: &str, number: usize) -> top::Drone {
463        top::Drone {
464            designation: format!("{} of Five", top::word(number)),
465            number,
466            node: node.into(),
467            state,
468            paused: false,
469            p: Presence {
470                session: session.into(),
471                harness: "claude".into(),
472                engineer: "a@x".into(),
473                repo: repo.into(),
474                collective: "corp".into(),
475                prompt: "p".into(),
476                files: vec![],
477                event: "prompt".into(),
478                ms,
479                started: ms,
480                pid: None,
481            },
482        }
483    }
484
485    #[test]
486    fn a_terminal_veto_targets_the_one_local_drone_of_the_repo_or_asks() {
487        let one = vec![drone("aaaa1111", "app", State::Working, 10, "", 1), drone("bbbb2222", "other", State::Working, 20, "", 2)];
488        assert_eq!(target(&one, "app", None).unwrap().p.session, "aaaa1111");
489        assert!(target(&one, "nope", None).unwrap_err().contains("no drone in nope"));
490        let two = vec![drone("aaaa1111", "app", State::Working, 10, "", 1), drone("cccc3333", "app", State::Idle, 30, "", 2)];
491        let err = target(&two, "app", None).unwrap_err();
492        assert!(err.contains("2 drones work in app") && err.contains("aaaa1111") && err.contains("cccc3333") && err.contains("One of Five"), "{err}");
493        assert_eq!(target(&two, "app", Some("Two")).unwrap().p.session, "cccc3333");
494        assert_eq!(target(&two, "app", Some("aaaa")).unwrap().p.session, "aaaa1111");
495        assert!(["all", "collective", "local"].iter().all(|w| target(&two, "app", Some(w)).is_err()), "a veto names one drone");
496        // Only ended ones: the most recently seen (a veto right after the session ended).
497        let ended = vec![drone("aaaa1111", "app", State::Ended, 50, "", 1), drone("cccc3333", "app", State::Ended, 40, "", 2)];
498        assert_eq!(target(&ended, "app", None).unwrap().p.session, "aaaa1111");
499        // A remote drone is never picked unnamed; named, it is (`main` signs the veto for its node).
500        let remote = vec![drone("dddd4444", "app", State::Working, 10, "beef", 1)];
501        assert!(target(&remote, "app", None).is_err());
502        assert_eq!(target(&remote, "app", Some("dddd")).unwrap().node, "beef");
503    }
504
505    fn asked(home: &Path, session: &str, lessons: &[&str]) {
506        let o = Outcome {
507            kind: outcome::ASKED.into(),
508            session: session.into(),
509            lessons: lessons.iter().map(|l| l.to_string()).collect(),
510            ms: 1,
511            ..Default::default()
512        };
513        outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
514    }
515
516    fn event(home: &Path, kind: &str, session: &str, lessons: &[&str]) {
517        let ev =
518            stats::Event { kind: kind.into(), ms: 2, session: session.into(), lessons: lessons.iter().map(|l| l.to_string()).collect(), ..Default::default() };
519        stats::record(home, &ev);
520    }
521
522    #[test]
523    fn shown_is_every_lesson_the_session_was_shown() {
524        let home = temp("shown");
525        asked(&home, "s1", &["C", "A"]);
526        asked(&home, "s2", &["X"]);
527        event(&home, stats::EARLY, "s1", &["B", "A"]);
528        event(&home, stats::QUEUED_FREE, "s1", &["D"]);
529        event(&home, stats::EARLY, "s2", &["Y"]);
530        event(&home, stats::CHECK, "s1", &["Z"]);
531        assert_eq!(shown(&home, "s1"), ["A", "B", "C", "D"]);
532        assert!(shown(&home, "s3").is_empty());
533        let _ = std::fs::remove_dir_all(home);
534    }
535
536    fn said<'a>(session: &'a str, reason: &'a str, ms: i64) -> Said<'a> {
537        Said { session, repo: "app", engineer: "a@x", task: "fix the merge lookup", agent: "", reason, ms, via: "cli" }
538    }
539
540    #[test]
541    fn a_veto_records_its_episode_credits_shown_lessons_once_and_counts_every_call() {
542        let home = temp("record");
543        asked(&home, "s1", &["A"]);
544        event(&home, stats::EARLY, "s1", &["B"]);
545        let v = record(&home, &said("s1", "never read the commit log for merges", 100)).unwrap();
546        assert_eq!(v, Veto { episode: "no-s1-100".into(), credited: vec!["A".into(), "B".into()], missed: false });
547        let eps: Vec<learn::Episode> =
548            std::fs::read_to_string(home.join("episodes.jsonl")).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
549        assert_eq!((eps.len(), eps[0].kind.as_str(), eps[0].correction.as_str()), (1, KIND, "never read the commit log for merges"));
550        let vetoed = || outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == outcome::VETOED).count();
551        assert_eq!(vetoed(), 2);
552        // A second veto in the same session: no duplicate credit, but a second veto stat.
553        let again = record(&home, &said("s1", "and not the reflog either", 200)).unwrap();
554        assert!(again.credited.is_empty());
555        assert_eq!(vetoed(), 2);
556        let vetoes: Vec<stats::Event> = stats::events(&home).into_iter().filter(|e| e.kind == stats::VETO).collect();
557        assert_eq!(vetoes.len(), 2);
558        assert_eq!((vetoes[0].lessons.clone(), vetoes[0].n, vetoes[0].why.as_str()), (vec!["A".to_string(), "B".to_string()], 2, "cli"));
559        // No correction is counted for a veto: it has its own stat.
560        assert!(!stats::events(&home).iter().any(|e| e.kind == stats::CORRECTION));
561        // A session that was shown nothing adds no outcome line (no tick on the memory clock).
562        let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
563        record(&home, &said("s9", "never deploy on fridays", 300)).unwrap();
564        assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
565        let _ = std::fs::remove_dir_all(home);
566    }
567
568    const PEER: &str = "abababababababababababababababababababababababababababababababab";
569
570    /// A message from the peer node `PEER`, merged into the log as the mesh would, admitted under
571    /// `role` (`None`: not admitted).
572    fn arrive(home: &Path, kind: Kind, to: &str, text: &str, ms: i64, role: Option<steer::Role>) -> Message {
573        let from = msg::Sender { by: "engineer:lead@x".into(), name: "lead@x".into(), harness: "terminal".into(), node: PEER.into() };
574        let m = Message::new(kind, &from, to, None, ms, text);
575        learn::append_line(&msg::path(home), &serde_json::to_string(&m).unwrap()).unwrap();
576        if let Some(r) = role {
577            steer::admit(home, &[steer::Admission::of(&m, r)], ms).unwrap();
578        }
579        m
580    }
581
582    #[test]
583    fn a_veto_from_another_node_is_recorded_once_where_its_drone_runs() {
584        let home = temp("apply");
585        asked(&home, "s1", &["A"]);
586        let here = msg::node(&home);
587        let s1 = format!("s1@{here}");
588        let v = arrive(&home, Kind::Veto, &s1, "never read the commit log for merges", 100, Some(steer::Role::Steer));
589        let got = apply(&home, &v, &here).unwrap().unwrap();
590        assert_eq!((got.episode.as_str(), got.credited.clone(), got.missed), ("no-s1-100", vec!["A".to_string()], false));
591        assert_eq!(apply(&home, &v, &here).unwrap(), None, "the same message is recorded once");
592        // Through the log (the mesh's way in): already recorded, and a tell or an unadmitted veto never records.
593        arrive(&home, Kind::Tell, &s1, "run the tests", 110, Some(steer::Role::Steer));
594        arrive(&home, Kind::Veto, &s1, "not admitted", 120, None);
595        assert_eq!(apply_messages(&home, 200), 0);
596        let eps = std::fs::read_to_string(home.join("episodes.jsonl")).unwrap();
597        assert_eq!(eps.lines().count(), 1);
598        let e: learn::Episode = serde_json::from_str(eps.lines().next().unwrap()).unwrap();
599        assert_eq!((e.kind.as_str(), e.engineer.as_str(), e.session.as_str()), (KIND, "lead@x", "s1"), "the sender's veto, of its session");
600        let vetoes: Vec<stats::Event> = stats::events(&home).into_iter().filter(|e| e.kind == stats::VETO).collect();
601        assert_eq!((vetoes.len(), vetoes[0].why.as_str()), (1, "mesh"));
602        assert_eq!(outcome::read(&home.join("outcomes.jsonl")).iter().filter(|o| o.kind == outcome::VETOED).count(), 1);
603        // A new admitted one through the log is recorded there, once; past the TTL it no longer counts.
604        arrive(&home, Kind::Veto, &s1, "and not the reflog either", 130, Some(steer::Role::Admin));
605        assert_eq!(apply_messages(&home, 200), 1);
606        assert_eq!(apply_messages(&home, 200), 0);
607        arrive(&home, Kind::Veto, &s1, "too late", 140, Some(steer::Role::Admin));
608        assert_eq!(apply_messages(&home, 140 + steer::TTL_MS + 1), 0);
609        // A veto of a session this node never held, or of another node's, is not this node's to learn.
610        let stranger = arrive(&home, Kind::Veto, &format!("zz@{here}"), "not here", 150, Some(steer::Role::Steer));
611        assert_eq!(apply(&home, &stranger, &here).unwrap(), None);
612        let elsewhere = arrive(&home, Kind::Veto, "s1@n2", "elsewhere", 160, Some(steer::Role::Steer));
613        assert_eq!(apply(&home, &elsewhere, &here).unwrap(), None);
614        assert_eq!(std::fs::read_to_string(home.join("episodes.jsonl")).unwrap().lines().count(), 2);
615        let _ = std::fs::remove_dir_all(home);
616    }
617
618    #[test]
619    fn an_invariant_is_proposed_and_only_ratifying_writes_a_constraint() {
620        let home = temp("propose");
621        let l = learn::Lesson { id: "Lno-s1-1".into(), repo: "app".into(), question: "q".into(), ..Default::default() };
622        let e = learn::Episode {
623            id: "no-s1-1".into(),
624            session: "s1".into(),
625            engineer: "a@x".into(),
626            correction: "never the commit log".into(),
627            ..Default::default()
628        };
629        let rule = "merge lookups go through the review API";
630        assert!(propose(&home, &l, rule, &e, 5).unwrap());
631        assert!(!propose(&home, &l, "Merge lookups go through the review API.", &e, 6).unwrap(), "the same invariant once");
632        let q = queen::entries(&home).into_iter().find(|x| x.kind == queen::Kind::Constraint).unwrap();
633        assert_eq!((q.default.as_str(), q.options.len(), q.repo.as_str()), (KEEP_OPTION, 2, "app"));
634        assert!(q.question.contains(rule) && q.evidence[0] == format!("invariant: {rule}"));
635        assert!(!constraints_path(&home).exists(), "proposing never applies");
636        // Keeping it a lesson writes nothing; ratifying writes exactly one constraint.
637        assert!(!ratify(&home, &q, KEEP_OPTION, "lead@x", 7).unwrap());
638        assert!(!constraints_path(&home).exists());
639        assert!(ratify(&home, &q, RATIFY_OPTION, "lead@x", 8).unwrap());
640        let text = std::fs::read_to_string(constraints_path(&home)).unwrap();
641        assert_eq!(text.lines().count(), 1);
642        let c: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
643        assert_eq!(
644            (c["text"].as_str(), c["lesson"].as_str(), c["by"].as_str(), c["source"].as_str()),
645            (Some(rule), Some("Lno-s1-1"), Some("lead@x"), Some("veto"))
646        );
647        // The lesson unknown here: bound to the question's repo, never everywhere.
648        assert_eq!((c["repo"].as_str(), c["reach"].as_str()), (Some("app"), Some(crate::reach::REPO)));
649        // The lesson known: its scope, repo and reach.
650        let lesson = serde_json::json!({"id": "Lno-s1-1", "origin_ms": 1, "question": "q", "repo": "app", "scope": ["Orion"]});
651        std::fs::write(home.join("lessons.jsonl"), format!("{lesson}\n")).unwrap();
652        std::fs::remove_file(constraints_path(&home)).unwrap();
653        assert!(ratify(&home, &q, RATIFY_OPTION, "lead@x", 9).unwrap());
654        let c: serde_json::Value = serde_json::from_str(std::fs::read_to_string(constraints_path(&home)).unwrap().trim()).unwrap();
655        assert_eq!((c["scope"].clone(), c["repo"].as_str(), c["reach"].as_str()), (serde_json::json!(["Orion"]), Some("app"), Some("")));
656        let _ = std::fs::remove_dir_all(home);
657    }
658}