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 fn enabled() -> bool {
67 std::env::var("BORG_VETO").as_deref() != Ok("off")
68}
69
70pub 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
77pub 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
89pub struct Said<'a> {
91 pub session: &'a str,
92 pub repo: &'a str,
93 pub engineer: &'a str,
94 pub task: &'a str,
96 pub agent: &'a str,
98 pub reason: &'a str,
99 pub ms: i64,
100 pub via: &'static str,
102}
103
104#[derive(Debug, Clone, PartialEq)]
107pub struct Veto {
108 pub episode: String,
109 pub credited: Vec<String>,
110 pub missed: bool,
111}
112
113pub 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
157fn 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
166pub 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 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 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
210pub 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
230pub 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
241pub 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
268pub 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
300pub const FILE: &str = "constraints.jsonl";
302
303pub fn constraints_path(store: &Path) -> PathBuf {
306 store.join(FILE)
307}
308
309pub 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
335pub 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
341fn 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
363pub 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 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 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 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 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 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 assert!(!stats::events(&home).iter().any(|e| e.kind == stats::CORRECTION));
561 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 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 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 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 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 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 assert_eq!((c["repo"].as_str(), c["reach"].as_str()), (Some("app"), Some(crate::reach::REPO)));
649 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}