1use 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
46pub const KIND: &str = "veto";
48
49pub 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
55pub 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
59pub 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
65pub const REASON_CHARS: usize = crate::learn::EXCERPT_CHARS;
68const REASON_MIN: usize = 4;
70const EVIDENCE_CHARS: usize = 200;
73
74pub fn enabled() -> bool {
76 std::env::var("BORG_VETO").as_deref() != Ok("off")
77}
78
79pub fn reason_in(prompt: &str) -> Option<String> {
82 let reason = PREFIX.captures(prompt)?.get(1)?.as_str().trim();
83 (reason.chars().count() >= REASON_MIN).then(|| reason.chars().take(REASON_CHARS).collect())
84}
85
86pub fn shown(home: &Path, session: &str) -> Vec<String> {
89 const DELIVERED: [&str; 4] = [stats::EARLY, stats::QUEUED_FREE, stats::LIVE_ASK, stats::BLOCK];
90 let asked = outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == outcome::ASKED && o.session == session).flat_map(|o| o.lessons);
91 let delivered = stats::events(home).into_iter().filter(|e| e.session == session && DELIVERED.contains(&e.kind.as_str())).flat_map(|e| e.lessons);
92 let mut out: Vec<String> = asked.chain(delivered).filter(|l| !l.is_empty()).collect();
93 out.sort();
94 out.dedup();
95 out
96}
97
98pub struct Said<'a> {
100 pub session: &'a str,
101 pub repo: &'a str,
102 pub engineer: &'a str,
103 pub task: &'a str,
105 pub agent: &'a str,
107 pub reason: &'a str,
108 pub ms: i64,
109 pub via: &'static str,
111}
112
113#[derive(Debug, Clone, PartialEq)]
116pub struct Veto {
117 pub episode: String,
118 pub credited: Vec<String>,
119 pub missed: bool,
120}
121
122pub fn record(home: &Path, s: &Said) -> std::io::Result<Veto> {
131 let e = learn::Episode {
132 id: format!("no-{}-{}", s.session.chars().take(8).collect::<String>(), s.ms),
133 ms: s.ms,
134 repo: s.repo.into(),
135 session: s.session.into(),
136 task: s.task.into(),
137 agent: s.agent.into(),
138 rejected_tool: None,
139 correction: s.reason.into(),
140 engineer: s.engineer.into(),
141 kind: KIND.into(),
142 ..learn::Episode::default()
143 };
144 learn::record_episode_in(home, &e)?;
145 let path = home.join("outcomes.jsonl");
146 let outcomes = outcome::read(&path);
147 let credited: Vec<String> = shown(home, s.session).into_iter().filter(|l| !outcome::has(&outcomes, outcome::VETOED, s.session, l)).collect();
148 for l in &credited {
149 let o =
150 Outcome { kind: outcome::VETOED.into(), session: s.session.into(), lessons: vec![l.clone()], ms: s.ms, repo: s.repo.into(), ..Default::default() };
151 outcome::append(&path, &o)?;
152 }
153 stats::count(home, stats::VETO, s.session, |ev| {
154 ev.ms = s.ms;
155 ev.engineer = s.engineer.into();
156 ev.repo = s.repo.into();
157 ev.lessons = credited.clone();
158 ev.n = credited.len() as u64;
159 ev.why = s.via.into();
160 ev.text = stats::excerpt(s.reason, stats::FEED_CHARS);
161 });
162 let missed = escalation::merged(&crate::notes::state_dir(), s.session) && escalation::missed(home, s.session, KIND, &e.id, s.repo, s.ms);
163 Ok(Veto { episode: e.id, credited, missed })
164}
165
166fn task_of(home: &Path, session: &str, fallback: &str) -> String {
168 let state = std::env::var_os("BORG_STATE").map_or_else(|| home.join("state"), PathBuf::from);
169 std::fs::read_to_string(state.join(format!("{}.task", presence::safe(session))))
170 .ok()
171 .filter(|t| !t.trim().is_empty())
172 .unwrap_or_else(|| fallback.to_string())
173}
174
175pub fn apply(home: &Path, m: &Message, here: &str) -> std::io::Result<Option<Veto>> {
187 let Some(Address::Session { id: session, node: Some(node) }) = Address::parse(&m.to) else { return Ok(None) };
188 if m.kind != Kind::Veto || node != here || m.text.trim().is_empty() {
189 return Ok(None);
190 }
191 let session = session.as_str();
193 let p = presence::read(home, session);
194 if p.is_none() && !outcome::read(&home.join("outcomes.jsonl")).iter().any(|o| o.session == session) {
195 return Ok(None);
196 }
197 let dir = msg::dir(home).join("vetoed");
198 std::fs::create_dir_all(&dir)?;
199 let marker = dir.join(presence::safe(&m.id));
200 match std::fs::OpenOptions::new().write(true).create_new(true).open(&marker) {
201 Ok(_) => {}
202 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
203 Err(e) => return Err(e),
204 }
205 let repo = p.as_ref().map(|p| p.repo.clone()).unwrap_or_default();
206 let task = task_of(home, session, p.as_ref().map_or("", |p| p.prompt.as_str()));
207 let reason: String = m.text.trim().chars().take(REASON_CHARS).collect();
208 let engineer = match Address::parse(&m.by) {
210 Some(Address::Engineer { email }) if !email.is_empty() => email,
211 _ => m.name.clone(),
212 };
213 let said = Said { session, repo: &repo, engineer: &engineer, task: &task, agent: "", reason: &reason, ms: m.ms, via: "mesh" };
214 record(home, &said).map(Some).inspect_err(|_| {
215 let _ = std::fs::remove_file(&marker);
216 })
217}
218
219pub fn apply_messages(home: &Path, now: i64) -> usize {
223 if !learn::learning() {
224 return 0;
225 }
226 let roles = steer::roles(home);
227 if roles.is_empty() {
228 return 0;
229 }
230 let here = msg::node(home);
231 let lines: Vec<Message> = crate::jsonl::read_lines(&msg::path(home));
232 lines
233 .iter()
234 .filter(|m| m.kind == Kind::Veto && roles.has(&m.id) && m.well_formed() && roles.of(m, &here, now).is_some())
235 .filter_map(|m| apply(home, m, &here).ok().flatten())
236 .count()
237}
238
239pub fn render(reason: &str) -> String {
241 format!(
242 "[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."
243 )
244}
245
246fn short(session: &str) -> String {
248 crate::msg::short_session(session)
249}
250
251pub fn target<'a>(drones: &'a [top::Drone], repo: &str, named: Option<&str>) -> Result<&'a top::Drone, String> {
260 if let Some(n) = named {
261 return match top::resolve(drones, n)? {
262 None => Err("a veto names one drone, not local, collective or all".into()),
263 Some(d) => Ok(d),
264 };
265 }
266 let here: Vec<&top::Drone> = drones.iter().filter(|d| d.node.is_empty() && d.p.repo == repo).collect();
267 let active: Vec<&top::Drone> = here.iter().copied().filter(|d| d.state != State::Ended).collect();
268 match active.as_slice() {
269 [d] => Ok(d),
270 [] => 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")),
271 many => {
272 let names: Vec<String> = many.iter().map(|d| format!("{} {}", d.designation, short(&d.p.session))).collect();
273 Err(format!("{} drones work in {repo}: name one with --session ({})", many.len(), names.join(", ")))
274 }
275 }
276}
277
278pub fn propose(home: &Path, lesson: &learn::Lesson, invariant: &str, e: &learn::Episode, now: i64) -> std::io::Result<bool> {
285 let key = queen::constraint_key(&lesson.repo, invariant);
286 let entry = queen::Entry {
287 id: format!("q{}", &key[..8]),
288 decision_key: key,
289 kind: queen::Kind::Constraint,
290 question: format!("Make this a constraint of the collective? \"{}\"", stats::excerpt(invariant, queen::QUOTE_CHARS)),
291 options: vec![KEEP_OPTION.into(), RATIFY_OPTION.into()],
292 default: KEEP_OPTION.into(),
293 evidence: vec![format!("invariant: {invariant}"), format!("lesson {}", lesson.id), format!("veto: {}", stats::excerpt(&e.correction, EVIDENCE_CHARS))],
294 session: e.session.clone(),
295 cwd: String::new(),
296 repo: lesson.repo.clone(),
297 engineer: e.engineer.clone(),
298 ms: now,
299 answer: None,
300 answered_by: None,
301 answered_ms: None,
302 disagreeing: Vec::new(),
303 dismissed: None,
304 dismissed_ms: None,
305 payload: None,
306 };
307 queen::seed(home, &entry)
308}
309
310pub const FILE: &str = "constraints.jsonl";
312
313pub fn constraints_path(store: &Path) -> PathBuf {
316 store.join(FILE)
317}
318
319pub fn ratify(store: &Path, e: &queen::Entry, answer: &str, author: &str, now: i64) -> Result<bool, String> {
329 if !answer.trim().to_lowercase().starts_with("ratify") {
330 return Ok(false);
331 }
332 let field = |prefix: &str| e.evidence.iter().find_map(|x| x.strip_prefix(prefix)).unwrap_or_default().to_string();
333 let lesson = field("lesson ");
334 let from = crate::conscience::load_unweighted(&store.join("lessons.jsonl"), None).into_iter().find(|l| l.id == lesson);
335 let (scope, repo, reach) = match from {
336 Some(l) => (l.scope, if l.repo.is_empty() { e.repo.clone() } else { l.repo }, l.reach),
337 None => (Vec::new(), e.repo.clone(), crate::reach::REPO.to_string()),
338 };
339 let line = json!({"id": format!("c{}", &e.decision_key[..8.min(e.decision_key.len())]), "text": crate::notes::redact(&field("invariant: ")),
340 "lesson": lesson, "scope": scope, "repo": repo, "reach": reach, "by": author, "ms": now, "source": "veto", "check": ""});
341 learn::append_jsonl(&constraints_path(store), &line).map_err(|err| err.to_string())?;
342 Ok(true)
343}
344
345pub const FLAGS: &[&str] = &["--session", "--dir", "--json"];
347
348const USAGE: &str = "usage: borg no [--session ID|DRONE] [--dir DIR] [--json] <reason…>
349 says no to a drone's work: the lessons it was shown lose credit, and the reason becomes a lesson";
350
351fn remote(home: &Path, drones: &[top::Drone], d: &top::Drone, cwd: &str, reason: &str, now: i64) -> i32 {
354 let to = Address::Session { id: d.p.session.clone(), node: Some(d.node.clone()) };
355 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));
356 match sent {
357 Ok(m) => {
358 println!(
359 "vetoed {} ({}) on another machine: {}, recorded there after the next mesh round (`borg mesh serve`)",
360 d.designation,
361 short(&d.p.session),
362 m.id
363 );
364 0
365 }
366 Err(e) => {
367 eprintln!("borg no: cannot send the veto: {e}");
368 1
369 }
370 }
371}
372
373pub fn main(args: &[&str]) -> i32 {
375 let value = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
376 let mut words = Vec::new();
377 let mut i = 0;
378 while i < args.len() {
379 match args[i] {
380 "--session" | "--dir" => i += 2,
381 "--json" => i += 1,
382 w => {
383 words.push(w);
384 i += 1;
385 }
386 }
387 }
388 let reason = words.join(" ").trim().to_string();
389 if reason.is_empty() {
390 eprintln!("{USAGE}");
391 return 2;
392 }
393 let dir = value("--dir").map_or_else(|| std::env::current_dir().unwrap_or_default(), PathBuf::from);
395 let cwd = std::path::absolute(&dir).unwrap_or(dir).display().to_string();
396 let route = match collective::load() {
397 Ok(cfg) => collective::route(cfg.as_ref(), &cwd, &learn::home()),
398 Err(e) => {
399 eprintln!("borg no: invalid collectives config: {e}");
400 return 1;
401 }
402 };
403 let collective::Route::Collective { home, .. } = &route else {
404 eprintln!("off: {cwd} belongs to no collective; borg records nothing here");
405 return 1;
406 };
407 if !learn::learning() {
408 println!("learning is off (BORG_LEARN=off): nothing recorded");
409 return 0;
410 }
411 let now = learn::now_ms();
412 let drones = top::drones(home, collective::may_sync(&route), now);
414 let d = match target(&drones, &queen::repo_name(&cwd), value("--session")) {
415 Ok(d) => d,
416 Err(e) => {
417 eprintln!("borg no: {e}");
418 return 1;
419 }
420 };
421 let session = d.p.session.as_str();
422 if !d.node.is_empty() {
423 return remote(home, &drones, d, &cwd, &reason, now);
424 }
425 let task = task_of(home, session, &d.p.prompt);
426 let engineer = learn::engineer(&cwd);
427 let said = Said { session, repo: &d.p.repo, engineer: &engineer, task: &task, agent: "", reason: &reason, ms: now, via: "cli" };
428 let v = match record(home, &said) {
429 Ok(v) => v,
430 Err(e) => {
431 eprintln!("borg no: cannot record the veto: {e}");
432 return 1;
433 }
434 };
435 if args.contains(&"--json") {
436 println!("{}", json!({"session": session, "episode": v.episode, "credited": v.credited}));
437 return 0;
438 }
439 let lost = match v.credited.len() {
440 0 => "no shown lessons lose credit".to_string(),
441 n => format!("{n} shown lesson(s) lose credit ({})", v.credited.join(", ")),
442 };
443 println!("vetoed {} ({}): {lost}; the reason becomes a lesson when borg learn runs", d.designation, short(session));
444 println!("to stop it now: borg pause {}", short(session));
445 0
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::presence::Presence;
452
453 fn temp(name: &str) -> PathBuf {
454 let d = std::env::temp_dir().join(format!("borg-veto-{name}-{}", std::process::id()));
455 let _ = std::fs::remove_dir_all(&d);
456 std::fs::create_dir_all(&d).unwrap();
457 d
458 }
459
460 #[test]
461 fn a_veto_prompt_starts_with_borg_no_and_carries_a_reason() {
462 assert_eq!(reason_in("borg no: never read the commit log").as_deref(), Some("never read the commit log"));
463 assert_eq!(reason_in("Borg no, don't").as_deref(), Some("don't"));
464 assert_eq!(reason_in(" BORG NO\nthe schema is public").as_deref(), Some("the schema is public"));
465 assert_eq!(reason_in("borg now run the tests"), None);
466 assert_eq!(reason_in("no, use X"), None, "a correction, not a veto");
467 assert_eq!(reason_in("borg no"), None);
468 assert_eq!(reason_in("borg no: ok"), None, "too short a reason");
469 assert_eq!(reason_in(&format!("borg no {}", "x".repeat(2000))).map(|r| r.chars().count()), Some(1500));
470 }
471
472 fn drone(session: &str, repo: &str, state: State, ms: i64, node: &str, number: usize) -> top::Drone {
473 top::Drone {
474 designation: format!("{} of Five", top::word(number)),
475 number,
476 node: node.into(),
477 state,
478 paused: false,
479 p: Presence {
480 session: session.into(),
481 harness: "claude".into(),
482 engineer: "a@x".into(),
483 repo: repo.into(),
484 collective: "corp".into(),
485 prompt: "p".into(),
486 files: vec![],
487 event: "prompt".into(),
488 ms,
489 started: ms,
490 pid: None,
491 },
492 }
493 }
494
495 #[test]
496 fn a_terminal_veto_targets_the_one_local_drone_of_the_repo_or_asks() {
497 let one = vec![drone("aaaa1111", "app", State::Working, 10, "", 1), drone("bbbb2222", "other", State::Working, 20, "", 2)];
498 assert_eq!(target(&one, "app", None).unwrap().p.session, "aaaa1111");
499 assert!(target(&one, "nope", None).unwrap_err().contains("no drone in nope"));
500 let two = vec![drone("aaaa1111", "app", State::Working, 10, "", 1), drone("cccc3333", "app", State::Idle, 30, "", 2)];
501 let err = target(&two, "app", None).unwrap_err();
502 assert!(err.contains("2 drones work in app") && err.contains("aaaa1111") && err.contains("cccc3333") && err.contains("One of Five"), "{err}");
503 assert_eq!(target(&two, "app", Some("Two")).unwrap().p.session, "cccc3333");
504 assert_eq!(target(&two, "app", Some("aaaa")).unwrap().p.session, "aaaa1111");
505 assert!(["all", "collective", "local"].iter().all(|w| target(&two, "app", Some(w)).is_err()), "a veto names one drone");
506 let ended = vec![drone("aaaa1111", "app", State::Ended, 50, "", 1), drone("cccc3333", "app", State::Ended, 40, "", 2)];
508 assert_eq!(target(&ended, "app", None).unwrap().p.session, "aaaa1111");
509 let remote = vec![drone("dddd4444", "app", State::Working, 10, "beef", 1)];
511 assert!(target(&remote, "app", None).is_err());
512 assert_eq!(target(&remote, "app", Some("dddd")).unwrap().node, "beef");
513 }
514
515 fn asked(home: &Path, session: &str, lessons: &[&str]) {
516 let o = Outcome {
517 kind: outcome::ASKED.into(),
518 session: session.into(),
519 lessons: lessons.iter().map(|l| l.to_string()).collect(),
520 ms: 1,
521 ..Default::default()
522 };
523 outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
524 }
525
526 fn event(home: &Path, kind: &str, session: &str, lessons: &[&str]) {
527 let ev =
528 stats::Event { kind: kind.into(), ms: 2, session: session.into(), lessons: lessons.iter().map(|l| l.to_string()).collect(), ..Default::default() };
529 stats::record(home, &ev);
530 }
531
532 #[test]
533 fn shown_is_every_lesson_the_session_was_shown() {
534 let home = temp("shown");
535 asked(&home, "s1", &["C", "A"]);
536 asked(&home, "s2", &["X"]);
537 event(&home, stats::EARLY, "s1", &["B", "A"]);
538 event(&home, stats::QUEUED_FREE, "s1", &["D"]);
539 event(&home, stats::EARLY, "s2", &["Y"]);
540 event(&home, stats::CHECK, "s1", &["Z"]);
541 assert_eq!(shown(&home, "s1"), ["A", "B", "C", "D"]);
542 assert!(shown(&home, "s3").is_empty());
543 let _ = std::fs::remove_dir_all(home);
544 }
545
546 fn said<'a>(session: &'a str, reason: &'a str, ms: i64) -> Said<'a> {
547 Said { session, repo: "app", engineer: "a@x", task: "fix the merge lookup", agent: "", reason, ms, via: "cli" }
548 }
549
550 #[test]
551 fn a_veto_records_its_episode_credits_shown_lessons_once_and_counts_every_call() {
552 let home = temp("record");
553 asked(&home, "s1", &["A"]);
554 event(&home, stats::EARLY, "s1", &["B"]);
555 let v = record(&home, &said("s1", "never read the commit log for merges", 100)).unwrap();
556 assert_eq!(v, Veto { episode: "no-s1-100".into(), credited: vec!["A".into(), "B".into()], missed: false });
557 let eps: Vec<learn::Episode> =
558 std::fs::read_to_string(home.join("episodes.jsonl")).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
559 assert_eq!((eps.len(), eps[0].kind.as_str(), eps[0].correction.as_str()), (1, KIND, "never read the commit log for merges"));
560 let vetoed = || outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind == outcome::VETOED).count();
561 assert_eq!(vetoed(), 2);
562 let again = record(&home, &said("s1", "and not the reflog either", 200)).unwrap();
564 assert!(again.credited.is_empty());
565 assert_eq!(vetoed(), 2);
566 let vetoes: Vec<stats::Event> = stats::events(&home).into_iter().filter(|e| e.kind == stats::VETO).collect();
567 assert_eq!(vetoes.len(), 2);
568 assert_eq!((vetoes[0].lessons.clone(), vetoes[0].n, vetoes[0].why.as_str()), (vec!["A".to_string(), "B".to_string()], 2, "cli"));
569 assert!(!stats::events(&home).iter().any(|e| e.kind == stats::CORRECTION));
571 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
573 record(&home, &said("s9", "never deploy on fridays", 300)).unwrap();
574 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
575 let _ = std::fs::remove_dir_all(home);
576 }
577
578 const PEER: &str = "abababababababababababababababababababababababababababababababab";
579
580 fn arrive(home: &Path, kind: Kind, to: &str, text: &str, ms: i64, role: Option<steer::Role>) -> Message {
583 let from = msg::Sender { by: "engineer:lead@x".into(), name: "lead@x".into(), harness: "terminal".into(), node: PEER.into() };
584 let m = Message::new(kind, &from, to, None, ms, text);
585 learn::append_line(&msg::path(home), &serde_json::to_string(&m).unwrap()).unwrap();
586 if let Some(r) = role {
587 steer::admit(home, &[steer::Admission::of(&m, r)], ms).unwrap();
588 }
589 m
590 }
591
592 #[test]
593 fn a_veto_from_another_node_is_recorded_once_where_its_drone_runs() {
594 let home = temp("apply");
595 asked(&home, "s1", &["A"]);
596 let here = msg::node(&home);
597 let s1 = format!("s1@{here}");
598 let v = arrive(&home, Kind::Veto, &s1, "never read the commit log for merges", 100, Some(steer::Role::Steer));
599 let got = apply(&home, &v, &here).unwrap().unwrap();
600 assert_eq!((got.episode.as_str(), got.credited.clone(), got.missed), ("no-s1-100", vec!["A".to_string()], false));
601 assert_eq!(apply(&home, &v, &here).unwrap(), None, "the same message is recorded once");
602 arrive(&home, Kind::Tell, &s1, "run the tests", 110, Some(steer::Role::Steer));
604 arrive(&home, Kind::Veto, &s1, "not admitted", 120, None);
605 assert_eq!(apply_messages(&home, 200), 0);
606 let eps = std::fs::read_to_string(home.join("episodes.jsonl")).unwrap();
607 assert_eq!(eps.lines().count(), 1);
608 let e: learn::Episode = serde_json::from_str(eps.lines().next().unwrap()).unwrap();
609 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");
610 let vetoes: Vec<stats::Event> = stats::events(&home).into_iter().filter(|e| e.kind == stats::VETO).collect();
611 assert_eq!((vetoes.len(), vetoes[0].why.as_str()), (1, "mesh"));
612 assert_eq!(outcome::read(&home.join("outcomes.jsonl")).iter().filter(|o| o.kind == outcome::VETOED).count(), 1);
613 arrive(&home, Kind::Veto, &s1, "and not the reflog either", 130, Some(steer::Role::Admin));
615 assert_eq!(apply_messages(&home, 200), 1);
616 assert_eq!(apply_messages(&home, 200), 0);
617 arrive(&home, Kind::Veto, &s1, "too late", 140, Some(steer::Role::Admin));
618 assert_eq!(apply_messages(&home, 140 + steer::TTL_MS + 1), 0);
619 let stranger = arrive(&home, Kind::Veto, &format!("zz@{here}"), "not here", 150, Some(steer::Role::Steer));
621 assert_eq!(apply(&home, &stranger, &here).unwrap(), None);
622 let elsewhere = arrive(&home, Kind::Veto, "s1@n2", "elsewhere", 160, Some(steer::Role::Steer));
623 assert_eq!(apply(&home, &elsewhere, &here).unwrap(), None);
624 assert_eq!(std::fs::read_to_string(home.join("episodes.jsonl")).unwrap().lines().count(), 2);
625 let _ = std::fs::remove_dir_all(home);
626 }
627
628 #[test]
629 fn an_invariant_is_proposed_and_only_ratifying_writes_a_constraint() {
630 let home = temp("propose");
631 let l = learn::Lesson { id: "Lno-s1-1".into(), repo: "app".into(), question: "q".into(), ..Default::default() };
632 let e = learn::Episode {
633 id: "no-s1-1".into(),
634 session: "s1".into(),
635 engineer: "a@x".into(),
636 correction: "never the commit log".into(),
637 ..Default::default()
638 };
639 let rule = "merge lookups go through the review API";
640 assert!(propose(&home, &l, rule, &e, 5).unwrap());
641 assert!(!propose(&home, &l, "Merge lookups go through the review API.", &e, 6).unwrap(), "the same invariant once");
642 let q = queen::entries(&home).into_iter().find(|x| x.kind == queen::Kind::Constraint).unwrap();
643 assert_eq!((q.default.as_str(), q.options.len(), q.repo.as_str()), (KEEP_OPTION, 2, "app"));
644 assert!(q.question.contains(rule) && q.evidence[0] == format!("invariant: {rule}"));
645 assert!(!constraints_path(&home).exists(), "proposing never applies");
646 assert!(!ratify(&home, &q, KEEP_OPTION, "lead@x", 7).unwrap());
648 assert!(!constraints_path(&home).exists());
649 assert!(ratify(&home, &q, RATIFY_OPTION, "lead@x", 8).unwrap());
650 let text = std::fs::read_to_string(constraints_path(&home)).unwrap();
651 assert_eq!(text.lines().count(), 1);
652 let c: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
653 assert_eq!(
654 (c["text"].as_str(), c["lesson"].as_str(), c["by"].as_str(), c["source"].as_str()),
655 (Some(rule), Some("Lno-s1-1"), Some("lead@x"), Some("veto"))
656 );
657 assert_eq!((c["repo"].as_str(), c["reach"].as_str()), (Some("app"), Some(crate::reach::REPO)));
659 let lesson = serde_json::json!({"id": "Lno-s1-1", "origin_ms": 1, "question": "q", "repo": "app", "scope": ["Orion"]});
661 std::fs::write(home.join("lessons.jsonl"), format!("{lesson}\n")).unwrap();
662 std::fs::remove_file(constraints_path(&home)).unwrap();
663 assert!(ratify(&home, &q, RATIFY_OPTION, "lead@x", 9).unwrap());
664 let c: serde_json::Value = serde_json::from_str(std::fs::read_to_string(constraints_path(&home)).unwrap().trim()).unwrap();
665 assert_eq!((c["scope"].clone(), c["repo"].as_str(), c["reach"].as_str()), (serde_json::json!(["Orion"]), Some("app"), Some("")));
666 let _ = std::fs::remove_dir_all(home);
667 }
668}