majordomus_cli/reasoning/store.rs
1//! The one writer of reasoning records, and the rules a record must satisfy before it
2//! becomes durable.
3//!
4//! Records live under `.ai/local/state/reasoning/<task>/`, one JSON file per record,
5//! written to a temporary name and renamed into place so that a reader never meets half a
6//! record and two writers never share a file. They are checkout state (ADR 0005): never
7//! tracked, never a source, never published.
8//!
9//! Admission is where the design's promises become refusals:
10//!
11//! * a material assessment carries evidence — evidence before opinion;
12//! * a plan is computed here, from the availability at this moment, never supplied;
13//! * a consultation names an advisor its recorded plan selected — no review nobody planned,
14//! and no claim of review from an advisor that was not available (the plan's own
15//! snapshot is the proof); a completed one carries a conclusion, a failed one none;
16//! * a disagreement is settled by a resolution that cites evidence, and a conclusion is
17//! refused while a disagreement on its assessment is unsettled — there is no path from
18//! "two advisors said A" to a decision that skips the evidence;
19//! * a conclusion on material uncertainty carries evidence and a validation plan, weighs
20//! every completed consultation of its assessment, and has its review count computed.
21//!
22//! The lifecycle of a write: [`Store::load`] reads what is durable, [`admit`] checks the
23//! input against it, [`redact`] removes secret shapes, [`mint_id`] names the record, and
24//! [`Store::write`] makes it durable where the next [`Store::load`] finds it.
25//!
26//! ```
27//! use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
28//! use majordomus_cli::reasoning::record::*;
29//! use majordomus_cli::reasoning::store::{admit, mint_id, redact, Admission, Store};
30//!
31//! let dir = tempfile::tempdir().unwrap();
32//! let store = Store::at(dir.path());
33//! let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
34//! let admission = Admission { mode: &mode, availability: &[] };
35//!
36//! let input = ReasoningInput::Assessment(AssessmentInput {
37//! subject: "whether the cache survives a restart".into(),
38//! ..Default::default()
39//! });
40//! let (body, redacted) = redact(admit(input, &store.load(), &admission).unwrap());
41//! let at = "2026-10-01T00:00:00Z";
42//! let record = ReasoningRecord {
43//! schema: RECORD_SCHEMA.into(),
44//! version: RECORD_VERSION,
45//! id: mint_id(body.kind(), at),
46//! task: store.current_task(),
47//! recorded_at: at.into(),
48//! head: None,
49//! redacted,
50//! body,
51//! };
52//! store.write(&record).unwrap();
53//!
54//! let loaded = store.load();
55//! assert_eq!(loaded.records.len(), 1);
56//! assert_eq!(loaded.records[0].task, "untasked");
57//! assert!(loaded.get(&record.id).is_some());
58//! ```
59
60use std::path::{Path, PathBuf};
61
62use super::availability::{AdvisorState, ModeResolution};
63use super::policy::{plan, Materiality, PlanRequest};
64use super::record::*;
65
66/// The store's directory, relative to the repository root.
67pub const STATE_DIR: &str = ".ai/local/state/reasoning";
68/// The task name of a record written while no task was open.
69pub const UNTASKED: &str = "untasked";
70
71/// Why a record was refused: one sentence a caller can act on.
72///
73/// Every rule [`admit`] enforces ends in a refusal rather than a repair, and the sentence
74/// names what is missing or wrong. It displays as the sentence alone, so a surface can
75/// print it without wrapping.
76///
77/// ```
78/// use majordomus_cli::reasoning::store::Refusal;
79///
80/// let r = Refusal("a disagreement has at least two positions".into());
81/// assert_eq!(r.to_string(), "a disagreement has at least two positions");
82/// ```
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Refusal(pub String);
85
86impl std::fmt::Display for Refusal {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(&self.0)
89 }
90}
91
92fn refuse<T>(reason: impl Into<String>) -> Result<T, Refusal> {
93 Err(Refusal(reason.into()))
94}
95
96/// Records are in the order they were written: by their millisecond stamp, then by id.
97/// The stamps are fixed-width, so the label orders as time does.
98impl crate::order::Ordered for ReasoningRecord {
99 fn order_key(&self) -> crate::order::OrderKey<'_> {
100 crate::order::OrderKey::plain(&self.recorded_at, &self.id)
101 }
102}
103
104/// The records of a store, read.
105///
106/// Records are kept in canonical order, `recorded_at` then id, and every query of this
107/// module reads them in that order. A file that does not parse, or that carries another
108/// schema or version, is listed in `unreadable` instead of being dropped silently. The
109/// default is the empty store, which is what admission sees before anything is written.
110///
111/// ```
112/// use majordomus_cli::reasoning::store::{Loaded, Store};
113///
114/// let dir = tempfile::tempdir().unwrap();
115/// let task = dir.path().join(".ai/local/state/reasoning/t");
116/// std::fs::create_dir_all(&task).unwrap();
117/// std::fs::write(task.join("broken.json"), "{ not json").unwrap();
118///
119/// let loaded: Loaded = Store::at(dir.path()).load();
120/// assert!(loaded.records.is_empty());
121/// assert_eq!(loaded.unreadable, ["t/broken.json"]);
122/// ```
123#[derive(Debug, Default)]
124pub struct Loaded {
125 /// Every record that parsed, ordered by `recorded_at` then id.
126 pub records: Vec<ReasoningRecord>,
127 /// Files that did not parse, by path relative to the store.
128 pub unreadable: Vec<String>,
129}
130
131impl Loaded {
132 /// The record with this id, or `None` when no loaded record carries it.
133 ///
134 /// Ids are unique in a store, so the first match is the only one.
135 ///
136 /// ```
137 /// use majordomus_cli::reasoning::record::*;
138 /// use majordomus_cli::reasoning::store::Loaded;
139 ///
140 /// let mut loaded = Loaded::default();
141 /// loaded.records.push(ReasoningRecord {
142 /// schema: RECORD_SCHEMA.into(),
143 /// version: RECORD_VERSION,
144 /// id: "assessment-1".into(),
145 /// task: "t".into(),
146 /// recorded_at: "2026-10-01T00:00:01Z".into(),
147 /// head: None,
148 /// redacted: vec![],
149 /// body: ReasoningBody::Assessment(AssessmentInput::default()),
150 /// });
151 /// assert_eq!(loaded.get("assessment-1").map(|r| r.body.kind()), Some("assessment"));
152 /// assert!(loaded.get("assessment-2").is_none());
153 /// ```
154 pub fn get(&self, id: &str) -> Option<&ReasoningRecord> {
155 self.records.iter().find(|r| r.id == id)
156 }
157}
158
159/// The store of one checkout: the one place reasoning records are read from and written to.
160///
161/// It is a handle on a repository root and nothing more; constructing one touches no
162/// file. Records live under [`STATE_DIR`], one directory per task and one JSON file per
163/// record.
164///
165/// ```
166/// use majordomus_cli::reasoning::store::{Store, STATE_DIR};
167///
168/// let dir = tempfile::tempdir().unwrap();
169/// let store = Store::at(dir.path());
170/// assert_eq!(store.dir(), dir.path().join(STATE_DIR));
171/// assert!(store.load().records.is_empty(), "an absent store reads as empty");
172/// ```
173#[derive(Debug, Clone)]
174pub struct Store {
175 root: PathBuf,
176}
177
178impl Store {
179 /// The store of the repository at `root`.
180 ///
181 /// Nothing is read or created until the store is loaded or written.
182 ///
183 /// ```
184 /// use majordomus_cli::reasoning::store::Store;
185 ///
186 /// let dir = tempfile::tempdir().unwrap();
187 /// let store = Store::at(dir.path());
188 /// assert!(store.dir().starts_with(dir.path()));
189 /// assert!(!store.dir().exists());
190 /// ```
191 pub fn at(root: &Path) -> Self {
192 Self {
193 root: root.to_path_buf(),
194 }
195 }
196
197 /// The directory the records live in: [`STATE_DIR`] under the repository root.
198 ///
199 /// ```
200 /// use std::path::Path;
201 /// use majordomus_cli::reasoning::store::Store;
202 ///
203 /// let store = Store::at(Path::new("/repo"));
204 /// assert_eq!(store.dir(), Path::new("/repo/.ai/local/state/reasoning"));
205 /// ```
206 pub fn dir(&self) -> PathBuf {
207 self.root.join(STATE_DIR)
208 }
209
210 /// The task open in this checkout, from `.ai/local/state/current.yaml`, or
211 /// [`UNTASKED`].
212 ///
213 /// The task id is the `id:` line of that file, quotes trimmed. A missing file, a file
214 /// with no such line, or an empty id all mean no task is open.
215 ///
216 /// ```
217 /// use majordomus_cli::reasoning::store::{Store, UNTASKED};
218 ///
219 /// let dir = tempfile::tempdir().unwrap();
220 /// let store = Store::at(dir.path());
221 /// assert_eq!(store.current_task(), UNTASKED);
222 ///
223 /// let state = dir.path().join(".ai/local/state");
224 /// std::fs::create_dir_all(&state).unwrap();
225 /// std::fs::write(state.join("current.yaml"), "id: \"task-42\"\n").unwrap();
226 /// assert_eq!(store.current_task(), "task-42");
227 /// ```
228 pub fn current_task(&self) -> String {
229 std::fs::read_to_string(self.root.join(".ai/local/state/current.yaml"))
230 .ok()
231 .and_then(|text| {
232 text.lines().find_map(|l| {
233 l.strip_prefix("id:")
234 .map(|v| v.trim().trim_matches('"').to_string())
235 })
236 })
237 .filter(|id| !id.is_empty())
238 .unwrap_or_else(|| UNTASKED.to_string())
239 }
240
241 /// Every record of every task, in canonical order.
242 ///
243 /// Only `.json` files are read, so a temporary file of a write in progress is never
244 /// met. An absent store is an empty one; a file that does not parse is reported in
245 /// [`Loaded::unreadable`].
246 ///
247 /// ```
248 /// use majordomus_cli::reasoning::record::*;
249 /// use majordomus_cli::reasoning::store::Store;
250 ///
251 /// let dir = tempfile::tempdir().unwrap();
252 /// let store = Store::at(dir.path());
253 /// for (id, at) in [("attempt-b", "2026-10-01T00:00:02Z"), ("attempt-a", "2026-10-01T00:00:01Z")] {
254 /// store.write(&ReasoningRecord {
255 /// schema: RECORD_SCHEMA.into(),
256 /// version: RECORD_VERSION,
257 /// id: id.into(),
258 /// task: "t".into(),
259 /// recorded_at: at.into(),
260 /// head: None,
261 /// redacted: vec![],
262 /// body: ReasoningBody::Attempt(AttemptInput {
263 /// assessment: None,
264 /// attempt: "x".into(),
265 /// observed_failure: "y".into(),
266 /// assumption_invalidated: None,
267 /// evidence: vec![],
268 /// updated_hypothesis: None,
269 /// }),
270 /// })
271 /// .unwrap();
272 /// }
273 /// let ids: Vec<String> = store.load().records.into_iter().map(|r| r.id).collect();
274 /// assert_eq!(ids, ["attempt-a", "attempt-b"], "ordered by when, not by name");
275 /// ```
276 pub fn load(&self) -> Loaded {
277 let mut out = Loaded::default();
278 let Ok(tasks) = std::fs::read_dir(self.dir()) else {
279 return out;
280 };
281 for task in tasks.flatten() {
282 let Ok(files) = std::fs::read_dir(task.path()) else {
283 continue;
284 };
285 for file in files.flatten() {
286 let path = file.path();
287 if path.extension().and_then(|e| e.to_str()) != Some("json") {
288 continue;
289 }
290 let parsed = std::fs::read_to_string(&path)
291 .ok()
292 .and_then(|t| serde_json::from_str::<ReasoningRecord>(&t).ok())
293 .filter(|r| r.schema == RECORD_SCHEMA && r.version == RECORD_VERSION);
294 match parsed {
295 Some(r) => out.records.push(r),
296 None => out.unreadable.push(
297 path.strip_prefix(self.dir())
298 .unwrap_or(&path)
299 .display()
300 .to_string(),
301 ),
302 }
303 }
304 }
305 crate::order::canonical(&mut out.records);
306 crate::order::canonical(&mut out.unreadable);
307 out
308 }
309
310 /// Make a record durable: temporary name, then rename. Returns its path.
311 ///
312 /// The file is `<task>/<id>.json` under the store. A record whose file already exists
313 /// is refused with `AlreadyExists`: a record is written once and never overwritten.
314 ///
315 /// ```
316 /// use majordomus_cli::reasoning::record::*;
317 /// use majordomus_cli::reasoning::store::Store;
318 ///
319 /// let dir = tempfile::tempdir().unwrap();
320 /// let store = Store::at(dir.path());
321 /// let record = ReasoningRecord {
322 /// schema: RECORD_SCHEMA.into(),
323 /// version: RECORD_VERSION,
324 /// id: "assessment-1".into(),
325 /// task: "t".into(),
326 /// recorded_at: "2026-10-01T00:00:01Z".into(),
327 /// head: None,
328 /// redacted: vec![],
329 /// body: ReasoningBody::Assessment(AssessmentInput::default()),
330 /// };
331 /// let path = store.write(&record).unwrap();
332 /// assert_eq!(path, store.dir().join("t/assessment-1.json"));
333 /// let again = store.write(&record).unwrap_err();
334 /// assert_eq!(again.kind(), std::io::ErrorKind::AlreadyExists);
335 /// ```
336 pub fn write(&self, record: &ReasoningRecord) -> std::io::Result<PathBuf> {
337 let dir = self.dir().join(&record.task);
338 std::fs::create_dir_all(&dir)?;
339 let name = format!("{}.json", record.id);
340 let path = dir.join(&name);
341 if path.exists() {
342 return Err(std::io::Error::new(
343 std::io::ErrorKind::AlreadyExists,
344 format!("{} already exists", path.display()),
345 ));
346 }
347 let tmp = dir.join(format!(".{name}.tmp"));
348 let text = serde_json::to_string_pretty(record).map_err(std::io::Error::other)?;
349 std::fs::write(&tmp, format!("{text}\n"))?;
350 std::fs::rename(&tmp, &path)?;
351 Ok(path)
352 }
353}
354
355/// What admission needs besides the records.
356///
357/// The mode and the availability of this moment are read only when a plan is admitted:
358/// they are what the plan is computed from and what its snapshot records. Every other
359/// input is judged against the records alone, so an empty availability is a complete
360/// admission context for them.
361///
362/// ```
363/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
364/// use majordomus_cli::reasoning::record::{AssessmentInput, ReasoningInput};
365/// use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
366///
367/// let mode = ModeResolution { mode: ReasoningMode::Ci, source: "CI".into() };
368/// let admission = Admission { mode: &mode, availability: &[] };
369/// let input = ReasoningInput::Assessment(AssessmentInput {
370/// subject: "a name".into(),
371/// ..Default::default()
372/// });
373/// assert!(admit(input, &Loaded::default(), &admission).is_ok());
374/// ```
375pub struct Admission<'a> {
376 /// The mode in force.
377 pub mode: &'a ModeResolution,
378 /// Every advisor's standing now.
379 pub availability: &'a [AdvisorState],
380}
381
382fn subject_key(s: &str) -> String {
383 s.split_whitespace()
384 .collect::<Vec<_>>()
385 .join(" ")
386 .to_lowercase()
387}
388
389/// The conclusion that stands for an assessment: its latest not superseded by another.
390///
391/// A conclusion stops standing when any later conclusion names it in `supersedes`. When
392/// none stands, the assessment is still open to a conclusion without `supersedes`.
393///
394/// ```
395/// use majordomus_cli::reasoning::record::*;
396/// use majordomus_cli::reasoning::store::{standing_conclusion, Loaded};
397///
398/// fn conclusion(id: &str, at: &str, supersedes: Option<&str>) -> ReasoningRecord {
399/// ReasoningRecord {
400/// schema: RECORD_SCHEMA.into(),
401/// version: RECORD_VERSION,
402/// id: id.into(),
403/// task: "t".into(),
404/// recorded_at: at.into(),
405/// head: None,
406/// redacted: vec![],
407/// body: ReasoningBody::Conclusion(StoredConclusion {
408/// input: ConclusionInput {
409/// assessment: "assessment-1".into(),
410/// decision: id.into(),
411/// rationale: "r".into(),
412/// supersedes: supersedes.map(String::from),
413/// ..Default::default()
414/// },
415/// reviewed_by: vec![],
416/// independent_review_count: 0,
417/// }),
418/// }
419/// }
420/// let mut loaded = Loaded::default();
421/// loaded.records.push(conclusion("conclusion-1", "2026-10-01T00:00:01Z", None));
422/// assert_eq!(standing_conclusion(&loaded, "assessment-1").unwrap().id, "conclusion-1");
423///
424/// loaded.records.push(conclusion("conclusion-2", "2026-10-01T00:00:02Z", Some("conclusion-1")));
425/// assert_eq!(standing_conclusion(&loaded, "assessment-1").unwrap().id, "conclusion-2");
426/// assert!(standing_conclusion(&loaded, "assessment-9").is_none());
427/// ```
428pub fn standing_conclusion<'a>(
429 loaded: &'a Loaded,
430 assessment: &str,
431) -> Option<&'a ReasoningRecord> {
432 let superseded: Vec<&str> = loaded
433 .records
434 .iter()
435 .filter_map(|r| match &r.body {
436 ReasoningBody::Conclusion(c) => c.input.supersedes.as_deref(),
437 _ => None,
438 })
439 .collect();
440 loaded.records.iter().rev().find(|r| {
441 matches!(&r.body, ReasoningBody::Conclusion(c) if c.input.assessment == assessment)
442 && !superseded.contains(&r.id.as_str())
443 })
444}
445
446/// A standing conclusion on the same subject as an assessment, from any task: what a
447/// later session reuses instead of asking again.
448///
449/// Subjects match when they are equal after collapsing whitespace and ignoring case. The
450/// latest other assessment with a standing conclusion wins; an assessment never finds
451/// itself, and a record that is not an assessment finds nothing.
452///
453/// ```
454/// use majordomus_cli::reasoning::record::*;
455/// use majordomus_cli::reasoning::store::{prior_on_subject, Loaded};
456///
457/// fn record(id: &str, at: &str, body: ReasoningBody) -> ReasoningRecord {
458/// ReasoningRecord {
459/// schema: RECORD_SCHEMA.into(),
460/// version: RECORD_VERSION,
461/// id: id.into(),
462/// task: "t".into(),
463/// recorded_at: at.into(),
464/// head: None,
465/// redacted: vec![],
466/// body,
467/// }
468/// }
469/// let assessment = |s: &str| {
470/// ReasoningBody::Assessment(AssessmentInput { subject: s.into(), ..Default::default() })
471/// };
472/// let mut loaded = Loaded::default();
473/// loaded.records.push(record("assessment-1", "2026-10-01T00:00:01Z", assessment("Lock scope")));
474/// loaded.records.push(record(
475/// "conclusion-1",
476/// "2026-10-01T00:00:02Z",
477/// ReasoningBody::Conclusion(StoredConclusion {
478/// input: ConclusionInput {
479/// assessment: "assessment-1".into(),
480/// decision: "keep the lock".into(),
481/// rationale: "measured".into(),
482/// ..Default::default()
483/// },
484/// reviewed_by: vec![],
485/// independent_review_count: 0,
486/// }),
487/// ));
488/// let later = record("assessment-2", "2026-10-02T00:00:00Z", assessment("lock scope"));
489/// assert_eq!(prior_on_subject(&loaded, &later).unwrap().id, "conclusion-1");
490///
491/// let other = record("assessment-3", "2026-10-02T00:00:00Z", assessment("retry policy"));
492/// assert!(prior_on_subject(&loaded, &other).is_none());
493/// ```
494pub fn prior_on_subject<'a>(
495 loaded: &'a Loaded,
496 assessment: &ReasoningRecord,
497) -> Option<&'a ReasoningRecord> {
498 let ReasoningBody::Assessment(a) = &assessment.body else {
499 return None;
500 };
501 let key = subject_key(&a.subject);
502 loaded
503 .records
504 .iter()
505 .filter(|r| r.id != assessment.id)
506 .filter(
507 |r| matches!(&r.body, ReasoningBody::Assessment(o) if subject_key(&o.subject) == key),
508 )
509 .rev()
510 .find_map(|r| standing_conclusion(loaded, &r.id))
511}
512
513fn kind_of<'a>(loaded: &'a Loaded, id: &str, kind: &str) -> Result<&'a ReasoningRecord, Refusal> {
514 match loaded.get(id) {
515 Some(r) if r.body.kind() == kind => Ok(r),
516 Some(r) => refuse(format!("{id} is a {}, not a {kind}", r.body.kind())),
517 None => refuse(format!("no {kind} {id} is recorded")),
518 }
519}
520
521fn nonempty(field: &str, value: &str) -> Result<(), Refusal> {
522 if value.trim().is_empty() {
523 return refuse(format!("{field} is empty"));
524 }
525 Ok(())
526}
527
528/// The assessment a record belongs to, when it belongs to one.
529///
530/// Records that name their assessment answer directly; a consultation answers through
531/// its plan, a resolution through its disagreement and a validation through its
532/// conclusion. A reference to an unrecorded record, and an attempt that names no
533/// assessment, belong to none.
534///
535/// ```
536/// use majordomus_cli::reasoning::record::*;
537/// use majordomus_cli::reasoning::store::{assessment_of, Loaded};
538///
539/// fn record(id: &str, body: ReasoningBody) -> ReasoningRecord {
540/// ReasoningRecord {
541/// schema: RECORD_SCHEMA.into(),
542/// version: RECORD_VERSION,
543/// id: id.into(),
544/// task: "t".into(),
545/// recorded_at: "2026-10-01T00:00:00Z".into(),
546/// head: None,
547/// redacted: vec![],
548/// body,
549/// }
550/// }
551/// let mut loaded = Loaded::default();
552/// loaded.records.push(record(
553/// "conclusion-1",
554/// ReasoningBody::Conclusion(StoredConclusion {
555/// input: ConclusionInput { assessment: "assessment-1".into(), ..Default::default() },
556/// reviewed_by: vec![],
557/// independent_review_count: 0,
558/// }),
559/// ));
560/// let validation = |conclusion: &str| {
561/// record(
562/// "validation-1",
563/// ReasoningBody::Validation(ValidationInput {
564/// conclusion: conclusion.into(),
565/// check: "the test passes".into(),
566/// command: None,
567/// outcome: ValidationOutcome::Pass,
568/// evidence: vec![],
569/// }),
570/// )
571/// };
572/// let found = assessment_of(&loaded, &validation("conclusion-1"));
573/// assert_eq!(found.as_deref(), Some("assessment-1"));
574/// assert_eq!(assessment_of(&loaded, &validation("conclusion-9")), None);
575/// ```
576pub fn assessment_of(loaded: &Loaded, record: &ReasoningRecord) -> Option<String> {
577 match &record.body {
578 ReasoningBody::Assessment(_) => Some(record.id.clone()),
579 ReasoningBody::Plan(p) => Some(p.assessment.clone()),
580 ReasoningBody::Consultation(c) => {
581 loaded.get(&c.plan).and_then(|p| assessment_of(loaded, p))
582 }
583 ReasoningBody::Disagreement(d) => Some(d.assessment.clone()),
584 ReasoningBody::Resolution(r) => loaded
585 .get(&r.disagreement)
586 .and_then(|d| assessment_of(loaded, d)),
587 ReasoningBody::Conclusion(c) => Some(c.input.assessment.clone()),
588 ReasoningBody::Validation(v) => loaded
589 .get(&v.conclusion)
590 .and_then(|c| assessment_of(loaded, c)),
591 ReasoningBody::Attempt(a) => a.assessment.clone(),
592 }
593}
594
595/// Admit an input against the records already durable: the body to store, or why not.
596///
597/// This is where every rule of the module header is enforced. Admission reads, never
598/// writes: the caller redacts, names and writes the body it returns. A plan is computed
599/// here from `admission`; a conclusion's review count is computed here from the
600/// consultations it cites.
601///
602/// ```
603/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
604/// use majordomus_cli::reasoning::policy::Materiality;
605/// use majordomus_cli::reasoning::record::{AssessmentInput, ReasoningInput};
606/// use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
607///
608/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
609/// let admission = Admission { mode: &mode, availability: &[] };
610///
611/// // evidence comes before opinion: a material assessment without it is refused
612/// let bare = ReasoningInput::Assessment(AssessmentInput {
613/// subject: "the wire format of a public record".into(),
614/// materiality: Materiality::Material,
615/// ..Default::default()
616/// });
617/// let refused = admit(bare, &Loaded::default(), &admission).unwrap_err();
618/// assert!(refused.0.starts_with("a material assessment carries the evidence"));
619///
620/// // trivia needs none
621/// let trivial = ReasoningInput::Assessment(AssessmentInput {
622/// subject: "a local variable's name".into(),
623/// materiality: Materiality::Trivial,
624/// ..Default::default()
625/// });
626/// assert_eq!(admit(trivial, &Loaded::default(), &admission).unwrap().kind(), "assessment");
627/// ```
628pub fn admit(
629 input: ReasoningInput,
630 loaded: &Loaded,
631 admission: &Admission<'_>,
632) -> Result<ReasoningBody, Refusal> {
633 Ok(match input {
634 ReasoningInput::Assessment(a) => {
635 nonempty("subject", &a.subject)?;
636 if a.materiality >= Materiality::Material && a.evidence.is_empty() {
637 return refuse(
638 "a material assessment carries the evidence gathered before anyone is asked: `evidence` is empty",
639 );
640 }
641 ReasoningBody::Assessment(a)
642 }
643 ReasoningInput::Plan(p) => {
644 let assessment = kind_of(loaded, &p.assessment, "assessment")?;
645 let ReasoningBody::Assessment(a) = &assessment.body else {
646 unreachable!()
647 };
648 let prior = prior_on_subject(loaded, assessment).map(|r| r.id.clone());
649 let request = PlanRequest {
650 materiality: a.materiality,
651 confidence: a.confidence,
652 capabilities: a.capabilities.clone(),
653 prior_conclusion: prior,
654 new_evidence: a.new_evidence,
655 };
656 ReasoningBody::Plan(StoredPlan {
657 assessment: p.assessment,
658 plan: plan(&request, admission.mode, admission.availability),
659 availability: admission.availability.to_vec(),
660 })
661 }
662 ReasoningInput::Consultation(c) => {
663 let plan_record = kind_of(loaded, &c.plan, "plan")?;
664 let ReasoningBody::Plan(p) = &plan_record.body else {
665 unreachable!()
666 };
667 if !p.plan.selected.iter().any(|s| s.advisor == c.advisor) {
668 let selected: Vec<&str> =
669 p.plan.selected.iter().map(|s| s.advisor.as_str()).collect();
670 return refuse(format!(
671 "plan {} did not select advisor '{}' (it selected: {}); a consultation no plan selected would be a review nobody planned",
672 c.plan,
673 c.advisor,
674 if selected.is_empty() { "none".to_string() } else { selected.join(", ") }
675 ));
676 }
677 let has_conclusion = c
678 .conclusion
679 .as_deref()
680 .is_some_and(|s| !s.trim().is_empty());
681 match (c.status, has_conclusion) {
682 (ConsultationStatus::Completed, false) => {
683 return refuse("a completed consultation carries the advisor's conclusion")
684 }
685 (ConsultationStatus::Completed, true) if c.stance.is_none() => {
686 return refuse(
687 "a completed consultation says where the advice stands: `stance`",
688 )
689 }
690 (s, true) if s != ConsultationStatus::Completed => {
691 return refuse(format!(
692 "a {} consultation received no answer, so it carries no conclusion",
693 s.as_str()
694 ))
695 }
696 _ => {}
697 }
698 if c.status == ConsultationStatus::Completed {
699 let twice = loaded.records.iter().any(|r| {
700 matches!(&r.body, ReasoningBody::Consultation(o)
701 if o.plan == c.plan && o.advisor == c.advisor && o.status == ConsultationStatus::Completed)
702 });
703 if twice {
704 return refuse(format!(
705 "advisor '{}' already answered plan {}; one plan, one answer per advisor",
706 c.advisor, c.plan
707 ));
708 }
709 }
710 ReasoningBody::Consultation(c)
711 }
712 ReasoningInput::Disagreement(d) => {
713 kind_of(loaded, &d.assessment, "assessment")?;
714 nonempty("discriminating_question", &d.discriminating_question)?;
715 if d.positions.len() < 2 {
716 return refuse("a disagreement has at least two positions");
717 }
718 let sources: std::collections::BTreeSet<&str> =
719 d.positions.iter().map(|p| p.source.as_str()).collect();
720 if sources.len() != d.positions.len() {
721 return refuse("each position of a disagreement has its own source");
722 }
723 for p in &d.positions {
724 nonempty("position", &p.position)?;
725 if p.source != "primary" {
726 let r = kind_of(loaded, &p.source, "consultation")?;
727 if !matches!(&r.body, ReasoningBody::Consultation(c) if c.status == ConsultationStatus::Completed)
728 {
729 return refuse(format!(
730 "{} did not complete, so it holds no position",
731 p.source
732 ));
733 }
734 }
735 }
736 ReasoningBody::Disagreement(d)
737 }
738 ReasoningInput::Resolution(r) => {
739 let d = kind_of(loaded, &r.disagreement, "disagreement")?;
740 let ReasoningBody::Disagreement(dis) = &d.body else {
741 unreachable!()
742 };
743 nonempty("experiment", &r.experiment)?;
744 nonempty("outcome", &r.outcome)?;
745 if r.evidence.is_empty() {
746 return refuse(
747 "a disagreement is settled by evidence, never by a count: `evidence` is empty",
748 );
749 }
750 if r.favours != "neither" && !dis.positions.iter().any(|p| p.source == r.favours) {
751 return refuse(format!(
752 "'{}' is not a position of {}; favour one of its sources or `neither`",
753 r.favours, r.disagreement
754 ));
755 }
756 if resolution_of(loaded, &r.disagreement).is_some() {
757 return refuse(format!("{} is already resolved", r.disagreement));
758 }
759 ReasoningBody::Resolution(r)
760 }
761 ReasoningInput::Conclusion(c) => {
762 let assessment = kind_of(loaded, &c.assessment, "assessment")?;
763 let ReasoningBody::Assessment(a) = &assessment.body else {
764 unreachable!()
765 };
766 nonempty("decision", &c.decision)?;
767 nonempty("rationale", &c.rationale)?;
768 if a.materiality >= Materiality::Material {
769 if c.evidence.is_empty() {
770 return refuse("a conclusion on material uncertainty rests on evidence: `evidence` is empty");
771 }
772 if c.validation_plan.is_empty() {
773 return refuse("a conclusion on material uncertainty says how it will be proven: `validation_plan` is empty");
774 }
775 }
776 let mut reviewed_by: Vec<String> = Vec::new();
777 for id in &c.consultations {
778 let r = kind_of(loaded, id, "consultation")?;
779 let ReasoningBody::Consultation(k) = &r.body else {
780 unreachable!()
781 };
782 if k.status != ConsultationStatus::Completed {
783 return refuse(format!(
784 "{id} ended {}, so no advice came from it to weigh",
785 k.status.as_str()
786 ));
787 }
788 if assessment_of(loaded, r).as_deref() != Some(c.assessment.as_str()) {
789 return refuse(format!("{id} was asked about another assessment"));
790 }
791 if !reviewed_by.contains(&k.advisor) {
792 reviewed_by.push(k.advisor.clone());
793 }
794 }
795 for r in &loaded.records {
796 if let ReasoningBody::Consultation(k) = &r.body {
797 if k.status == ConsultationStatus::Completed
798 && assessment_of(loaded, r).as_deref() == Some(c.assessment.as_str())
799 && !c.consultations.contains(&r.id)
800 {
801 return refuse(format!(
802 "{} (advisor '{}') answered this assessment and the conclusion does not weigh it; cite it, and reject it in `rejected` if it is wrong",
803 r.id, k.advisor
804 ));
805 }
806 }
807 }
808 for id in &c.disagreements {
809 kind_of(loaded, id, "disagreement")?;
810 }
811 for r in &loaded.records {
812 if matches!(&r.body, ReasoningBody::Disagreement(d) if d.assessment == c.assessment)
813 {
814 if resolution_of(loaded, &r.id).is_none() {
815 return refuse(format!(
816 "disagreement {} is unresolved: settle it with evidence (a `resolution`), not by counting who agrees",
817 r.id
818 ));
819 }
820 if !c.disagreements.contains(&r.id) {
821 return refuse(format!(
822 "disagreement {} bears on this assessment and the conclusion does not cite it",
823 r.id
824 ));
825 }
826 }
827 }
828 match &c.supersedes {
829 Some(prior) => {
830 kind_of(loaded, prior, "conclusion")?;
831 }
832 None => {
833 if let Some(standing) = standing_conclusion(loaded, &c.assessment) {
834 return refuse(format!(
835 "{} already concludes this assessment; a new conclusion names it in `supersedes`",
836 standing.id
837 ));
838 }
839 }
840 }
841 crate::order::canonical(&mut reviewed_by);
842 ReasoningBody::Conclusion(StoredConclusion {
843 independent_review_count: reviewed_by.len(),
844 reviewed_by,
845 input: c,
846 })
847 }
848 ReasoningInput::Validation(v) => {
849 kind_of(loaded, &v.conclusion, "conclusion")?;
850 nonempty("check", &v.check)?;
851 ReasoningBody::Validation(v)
852 }
853 ReasoningInput::Attempt(a) => {
854 if let Some(id) = &a.assessment {
855 kind_of(loaded, id, "assessment")?;
856 }
857 nonempty("attempt", &a.attempt)?;
858 nonempty("observed_failure", &a.observed_failure)?;
859 ReasoningBody::Attempt(a)
860 }
861 })
862}
863
864/// The resolution of a disagreement, when there is one.
865///
866/// Admission allows at most one resolution per disagreement, so the first match is the
867/// only one. A disagreement without a resolution blocks every conclusion on its
868/// assessment.
869///
870/// ```
871/// use majordomus_cli::reasoning::record::*;
872/// use majordomus_cli::reasoning::store::{resolution_of, Loaded};
873///
874/// let mut loaded = Loaded::default();
875/// assert!(resolution_of(&loaded, "disagreement-1").is_none());
876/// loaded.records.push(ReasoningRecord {
877/// schema: RECORD_SCHEMA.into(),
878/// version: RECORD_VERSION,
879/// id: "resolution-1".into(),
880/// task: "t".into(),
881/// recorded_at: "2026-10-01T00:00:01Z".into(),
882/// head: None,
883/// redacted: vec![],
884/// body: ReasoningBody::Resolution(ResolutionInput {
885/// disagreement: "disagreement-1".into(),
886/// experiment: "run the supervision test".into(),
887/// outcome: "no caller retries".into(),
888/// favours: "neither".into(),
889/// evidence: vec![],
890/// }),
891/// });
892/// assert_eq!(resolution_of(&loaded, "disagreement-1").unwrap().id, "resolution-1");
893/// ```
894pub fn resolution_of<'a>(loaded: &'a Loaded, disagreement: &str) -> Option<&'a ReasoningRecord> {
895 loaded
896 .records
897 .iter()
898 .find(|r| matches!(&r.body, ReasoningBody::Resolution(x) if x.disagreement == disagreement))
899}
900
901/// A fresh record id: the kind, the stamp, and six hex digits of the moment and process.
902///
903/// The stamp is `recorded_at` with every character that is not ASCII alphanumeric
904/// removed. The suffix hashes the clock, the process id and a counter, so two ids minted
905/// by one process in the same instant still differ.
906///
907/// ```
908/// use majordomus_cli::reasoning::store::mint_id;
909///
910/// let a = mint_id("plan", "2026-10-01T00:00:00Z");
911/// let b = mint_id("plan", "2026-10-01T00:00:00Z");
912/// assert!(a.starts_with("plan-20261001T000000Z-"));
913/// assert_eq!(a.len(), "plan-20261001T000000Z-".len() + 6);
914/// assert_ne!(a, b);
915/// ```
916pub fn mint_id(kind: &str, recorded_at: &str) -> String {
917 use std::sync::atomic::{AtomicU64, Ordering};
918 static COUNTER: AtomicU64 = AtomicU64::new(0);
919 let nanos = std::time::SystemTime::now()
920 .duration_since(std::time::UNIX_EPOCH)
921 .map(|d| d.as_nanos())
922 .unwrap_or(0);
923 let seed = format!(
924 "{nanos}-{}-{}",
925 std::process::id(),
926 COUNTER.fetch_add(1, Ordering::Relaxed)
927 );
928 let compact: String = recorded_at
929 .chars()
930 .filter(|c| c.is_ascii_alphanumeric())
931 .collect();
932 format!(
933 "{kind}-{compact}-{}",
934 &crate::policy::sha256_hex(&seed)[..6]
935 )
936}
937
938/// A text without control characters: ANSI escape sequences dropped whole, every other
939/// control character but the newline dropped.
940///
941/// A diagnostic captured from a terminal can carry colour codes; every surface renders
942/// record text, and not all of them escape it, so none of it reaches a record.
943///
944/// ```
945/// use majordomus_cli::reasoning::store::strip_controls;
946///
947/// assert_eq!(strip_controls("\u{1b}[31mred\u{1b}[0m\tdone\nnext"), "reddone\nnext");
948/// ```
949pub fn strip_controls(s: &str) -> String {
950 let mut out = String::with_capacity(s.len());
951 let mut chars = s.chars().peekable();
952 while let Some(c) = chars.next() {
953 if c == '\u{1b}' {
954 if chars.peek() == Some(&'[') {
955 chars.next();
956 while let Some(&n) = chars.peek() {
957 chars.next();
958 if n.is_ascii_alphabetic() {
959 break;
960 }
961 }
962 }
963 } else if !c.is_control() || c == '\n' {
964 out.push(c);
965 }
966 }
967 out
968}
969
970/// Remove every secret shape from every string of a body; the shapes that fired.
971///
972/// Every string field is first stripped of control characters and then passed through
973/// the crate's secret redaction; the names of the shapes that fired come back once each,
974/// in canonical order, for the record's `redacted` field. A body without secrets comes
975/// back unchanged with no shapes.
976///
977/// ```
978/// use majordomus_cli::reasoning::record::{AssessmentInput, ReasoningBody};
979/// use majordomus_cli::reasoning::store::redact;
980///
981/// // assembled at run time, so that no committed file carries a credential's shape
982/// let key = format!("{}{}", "sk-ant-", "b".repeat(24));
983/// let body = ReasoningBody::Assessment(AssessmentInput {
984/// subject: format!("the key {key} leaks"),
985/// ..Default::default()
986/// });
987/// let (body, kinds) = redact(body);
988/// let ReasoningBody::Assessment(a) = body else { panic!("the kind is kept") };
989/// assert!(!a.subject.contains("sk-ant-"));
990/// assert_eq!(kinds, ["anthropic-key"]);
991/// ```
992pub fn redact(body: ReasoningBody) -> (ReasoningBody, Vec<String>) {
993 fn walk(v: &mut serde_json::Value, kinds: &mut Vec<String>) {
994 match v {
995 serde_json::Value::String(s) => {
996 // Control characters (a terminal's colour codes in a diagnostic) reach no
997 // record: every surface renders these strings, and not all of them escape.
998 if s.chars().any(|c| c.is_control() && c != '\n') {
999 *s = strip_controls(s);
1000 }
1001 let r = crate::redaction::redact_secrets(s);
1002 if !r.kinds.is_empty() {
1003 kinds.extend(r.kinds.iter().map(|k| k.to_string()));
1004 *s = r.text;
1005 }
1006 }
1007 serde_json::Value::Array(a) => a.iter_mut().for_each(|x| walk(x, kinds)),
1008 serde_json::Value::Object(o) => o.values_mut().for_each(|x| walk(x, kinds)),
1009 _ => {}
1010 }
1011 }
1012 let Ok(mut value) = serde_json::to_value(&body) else {
1013 return (body, Vec::new());
1014 };
1015 let mut kinds = Vec::new();
1016 walk(&mut value, &mut kinds);
1017 crate::order::canonical(&mut kinds);
1018 kinds.dedup();
1019 match serde_json::from_value(value) {
1020 Ok(b) => (b, kinds),
1021 Err(_) => (body, kinds),
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use crate::reasoning::availability::{AdvisorStatus, ReasoningMode};
1029 use crate::reasoning::catalogue::AdvisorTransport;
1030
1031 struct Fixture {
1032 loaded: Loaded,
1033 mode: ModeResolution,
1034 states: Vec<AdvisorState>,
1035 clock: u32,
1036 }
1037
1038 fn advisor(id: &str, adapter: &str) -> AdvisorState {
1039 AdvisorState {
1040 id: id.into(),
1041 title: id.into(),
1042 transport: AdvisorTransport::Api,
1043 adapter: adapter.into(),
1044 capabilities: vec!["independent_reasoning".into()],
1045 model: None,
1046 executable: None,
1047 credential: None,
1048 status: AdvisorStatus::Available,
1049 reason: "present".into(),
1050 probe: String::new(),
1051 recovering: false,
1052 circuit: None,
1053 }
1054 }
1055
1056 impl Fixture {
1057 fn new(states: Vec<AdvisorState>, mode: ReasoningMode) -> Self {
1058 Self {
1059 loaded: Loaded::default(),
1060 mode: ModeResolution {
1061 mode,
1062 source: "test".into(),
1063 },
1064 states,
1065 clock: 0,
1066 }
1067 }
1068
1069 fn add(&mut self, input: ReasoningInput) -> Result<String, Refusal> {
1070 let body = admit(
1071 input,
1072 &self.loaded,
1073 &Admission {
1074 mode: &self.mode,
1075 availability: &self.states,
1076 },
1077 )?;
1078 self.clock += 1;
1079 let at = format!("2026-10-01T00:00:{:02}Z", self.clock);
1080 let id = format!("{}-{}", body.kind(), self.clock);
1081 self.loaded.records.push(ReasoningRecord {
1082 schema: RECORD_SCHEMA.into(),
1083 version: RECORD_VERSION,
1084 id: id.clone(),
1085 task: "t".into(),
1086 recorded_at: at,
1087 head: None,
1088 redacted: vec![],
1089 body,
1090 });
1091 Ok(id)
1092 }
1093
1094 fn conclusion(&self, id: &str) -> StoredConclusion {
1095 match &self.loaded.get(id).unwrap().body {
1096 ReasoningBody::Conclusion(c) => c.clone(),
1097 _ => panic!("not a conclusion"),
1098 }
1099 }
1100 }
1101
1102 fn evidence(r: &str) -> Vec<ReasoningEvidence> {
1103 vec![ReasoningEvidence {
1104 kind: ReasoningEvidenceKind::File,
1105 reference: r.into(),
1106 note: None,
1107 }]
1108 }
1109
1110 fn assessment(m: Materiality) -> ReasoningInput {
1111 ReasoningInput::Assessment(AssessmentInput {
1112 subject: "timeout semantics of the worker".into(),
1113 materiality: m,
1114 hypothesis: Some("A: the timeout is a typed domain error".into()),
1115 evidence: evidence("src/worker.rs:10"),
1116 ..Default::default()
1117 })
1118 }
1119
1120 fn consultation(plan: &str, advisor: &str, stance: Stance, says: &str) -> ReasoningInput {
1121 ReasoningInput::Consultation(ConsultationInput {
1122 plan: plan.into(),
1123 advisor: advisor.into(),
1124 status: ConsultationStatus::Completed,
1125 question: None,
1126 evidence: vec![],
1127 conclusion: Some(says.into()),
1128 stance: Some(stance),
1129 assumptions: vec![],
1130 risks: vec![],
1131 actions: vec![],
1132 falsifiers: vec![],
1133 confidence: None,
1134 duration_ms: None,
1135 usage: None,
1136 diagnostic: None,
1137 retry_after_seconds: None,
1138 })
1139 }
1140
1141 fn conclude(a: &str, decision: &str) -> ConclusionInput {
1142 ConclusionInput {
1143 assessment: a.into(),
1144 decision: decision.into(),
1145 rationale: "the failure tests show it".into(),
1146 evidence: evidence("test/worker_timeout.rs"),
1147 validation_plan: vec!["cargo test worker".into()],
1148 ..Default::default()
1149 }
1150 }
1151
1152 #[test]
1153 fn zero_advisors_still_reaches_a_conclusion_with_no_review_claimed() {
1154 let mut f = Fixture::new(vec![], ReasoningMode::Standard);
1155 let a = f.add(assessment(Materiality::High)).unwrap();
1156 let p = f
1157 .add(ReasoningInput::Plan(PlanInput {
1158 assessment: a.clone(),
1159 }))
1160 .unwrap();
1161 // no plan selected anyone, so no consultation can be claimed
1162 let err = f
1163 .add(consultation(&p, "anyone", Stance::Supports, "yes"))
1164 .unwrap_err();
1165 assert!(err.0.contains("did not select"), "{err}");
1166 let k = f
1167 .add(ReasoningInput::Conclusion(conclude(&a, "A")))
1168 .unwrap();
1169 let c = f.conclusion(&k);
1170 assert_eq!(c.independent_review_count, 0);
1171 assert!(c.reviewed_by.is_empty());
1172 f.add(ReasoningInput::Validation(ValidationInput {
1173 conclusion: k,
1174 check: "cargo test worker".into(),
1175 command: Some("cargo test worker".into()),
1176 outcome: ValidationOutcome::Pass,
1177 evidence: vec![],
1178 }))
1179 .unwrap();
1180 }
1181
1182 #[test]
1183 fn evidence_comes_before_opinion() {
1184 let mut f = Fixture::new(vec![], ReasoningMode::Standard);
1185 let err = f
1186 .add(ReasoningInput::Assessment(AssessmentInput {
1187 subject: "x".into(),
1188 materiality: Materiality::Material,
1189 ..Default::default()
1190 }))
1191 .unwrap_err();
1192 assert!(err.0.contains("evidence"));
1193 // trivia needs none
1194 f.add(ReasoningInput::Assessment(AssessmentInput {
1195 subject: "a name".into(),
1196 materiality: Materiality::Trivial,
1197 ..Default::default()
1198 }))
1199 .unwrap();
1200 }
1201
1202 #[test]
1203 fn agreement_is_not_a_verdict_a_disagreement_is_settled_by_evidence() {
1204 // Two advisors from independent sources; one supports A, one proposes B.
1205 let mut f = Fixture::new(
1206 vec![advisor("one", "x"), advisor("two", "y")],
1207 ReasoningMode::Standard,
1208 );
1209 let a = f.add(assessment(Materiality::High)).unwrap();
1210 let p = f
1211 .add(ReasoningInput::Plan(PlanInput {
1212 assessment: a.clone(),
1213 }))
1214 .unwrap();
1215 let c1 = f
1216 .add(consultation(&p, "one", Stance::Supports, "A"))
1217 .unwrap();
1218 let c2 = f
1219 .add(consultation(
1220 &p,
1221 "two",
1222 Stance::Opposes,
1223 "B: exit and let the supervisor restart",
1224 ))
1225 .unwrap();
1226 let d = f
1227 .add(ReasoningInput::Disagreement(DisagreementInput {
1228 assessment: a.clone(),
1229 positions: vec![
1230 DisagreementPosition { source: "primary".into(), position: "A".into() },
1231 DisagreementPosition { source: c1.clone(), position: "A".into() },
1232 DisagreementPosition { source: c2.clone(), position: "B".into() },
1233 ],
1234 divergent_assumptions: vec!["whether a timeout is an infrastructure failure".into()],
1235 discriminating_question: "do callers retry on the domain error?".into(),
1236 experiment: Some("read the callers and run the supervision test".into()),
1237 }))
1238 .unwrap();
1239 // Two of three positions say A: that decides nothing.
1240 let mut premature = conclude(&a, "A");
1241 premature.consultations = vec![c1.clone(), c2.clone()];
1242 premature.disagreements = vec![d.clone()];
1243 let err = f.add(ReasoningInput::Conclusion(premature)).unwrap_err();
1244 assert!(err.0.contains("unresolved"), "{err}");
1245 // A resolution without evidence is refused.
1246 let err = f
1247 .add(ReasoningInput::Resolution(ResolutionInput {
1248 disagreement: d.clone(),
1249 experiment: "we voted".into(),
1250 outcome: "2 to 1".into(),
1251 favours: "primary".into(),
1252 evidence: vec![],
1253 }))
1254 .unwrap_err();
1255 assert!(err.0.contains("never by a count"));
1256 // The experiment favours the minority position, B.
1257 f.add(ReasoningInput::Resolution(ResolutionInput {
1258 disagreement: d.clone(),
1259 experiment: "run the supervision failure test".into(),
1260 outcome: "no caller retries; the supervisor restart is the recovery".into(),
1261 favours: c2.clone(),
1262 evidence: evidence("test/supervision.rs"),
1263 }))
1264 .unwrap();
1265 let mut decided = conclude(&a, "B");
1266 decided.consultations = vec![c1.clone(), c2.clone()];
1267 decided.disagreements = vec![d];
1268 decided.rejected = vec!["A: callers never handle the domain error".into()];
1269 let k = f.add(ReasoningInput::Conclusion(decided)).unwrap();
1270 let c = f.conclusion(&k);
1271 assert_eq!(c.input.decision, "B");
1272 assert_eq!(c.reviewed_by, ["one", "two"]);
1273 assert_eq!(c.independent_review_count, 2);
1274 }
1275
1276 #[test]
1277 fn a_conclusion_weighs_every_answer_it_received() {
1278 let mut f = Fixture::new(vec![advisor("one", "x")], ReasoningMode::Standard);
1279 let a = f.add(assessment(Materiality::Material)).unwrap();
1280 let p = f
1281 .add(ReasoningInput::Plan(PlanInput {
1282 assessment: a.clone(),
1283 }))
1284 .unwrap();
1285 f.add(consultation(&p, "one", Stance::Opposes, "B"))
1286 .unwrap();
1287 let err = f
1288 .add(ReasoningInput::Conclusion(conclude(&a, "A")))
1289 .unwrap_err();
1290 assert!(err.0.contains("does not weigh it"), "{err}");
1291 }
1292
1293 #[test]
1294 fn a_failed_consultation_is_recordable_and_is_not_review() {
1295 let mut f = Fixture::new(vec![advisor("one", "x")], ReasoningMode::Standard);
1296 let a = f.add(assessment(Materiality::Material)).unwrap();
1297 let p = f
1298 .add(ReasoningInput::Plan(PlanInput {
1299 assessment: a.clone(),
1300 }))
1301 .unwrap();
1302 let mut timeout = consultation(&p, "one", Stance::Supports, "yes");
1303 if let ReasoningInput::Consultation(c) = &mut timeout {
1304 c.status = ConsultationStatus::Timeout;
1305 }
1306 assert!(f
1307 .add(timeout.clone())
1308 .unwrap_err()
1309 .0
1310 .contains("carries no conclusion"));
1311 if let ReasoningInput::Consultation(c) = &mut timeout {
1312 c.conclusion = None;
1313 c.stance = None;
1314 }
1315 let t = f.add(timeout).unwrap();
1316 let mut cited = conclude(&a, "A");
1317 cited.consultations = vec![t];
1318 assert!(f
1319 .add(ReasoningInput::Conclusion(cited))
1320 .unwrap_err()
1321 .0
1322 .contains("ended timeout"));
1323 let k = f
1324 .add(ReasoningInput::Conclusion(conclude(&a, "A")))
1325 .unwrap();
1326 assert_eq!(f.conclusion(&k).independent_review_count, 0);
1327 }
1328
1329 #[test]
1330 fn a_later_assessment_of_the_same_subject_reuses_the_standing_conclusion() {
1331 let mut f = Fixture::new(vec![advisor("one", "x")], ReasoningMode::Standard);
1332 let a = f.add(assessment(Materiality::Material)).unwrap();
1333 let p = f
1334 .add(ReasoningInput::Plan(PlanInput {
1335 assessment: a.clone(),
1336 }))
1337 .unwrap();
1338 let c = f
1339 .add(consultation(&p, "one", Stance::Supports, "A"))
1340 .unwrap();
1341 let mut k = conclude(&a, "A");
1342 k.consultations = vec![c];
1343 let k1 = f.add(ReasoningInput::Conclusion(k)).unwrap();
1344 // session B: the same subject, no new evidence
1345 let b = f.add(assessment(Materiality::Material)).unwrap();
1346 let pb = f
1347 .add(ReasoningInput::Plan(PlanInput {
1348 assessment: b.clone(),
1349 }))
1350 .unwrap();
1351 let ReasoningBody::Plan(stored) = &f.loaded.get(&pb).unwrap().body else {
1352 panic!()
1353 };
1354 assert_eq!(
1355 stored.plan.outcome,
1356 crate::reasoning::PlanOutcome::ReusePrior
1357 );
1358 assert!(stored.plan.reason.contains(&k1));
1359 // new evidence reopens it
1360 let mut fresh = assessment(Materiality::Material);
1361 if let ReasoningInput::Assessment(x) = &mut fresh {
1362 x.new_evidence = true;
1363 }
1364 let n = f.add(fresh).unwrap();
1365 let pn = f
1366 .add(ReasoningInput::Plan(PlanInput { assessment: n }))
1367 .unwrap();
1368 let ReasoningBody::Plan(stored) = &f.loaded.get(&pn).unwrap().body else {
1369 panic!()
1370 };
1371 assert_eq!(stored.plan.outcome, crate::reasoning::PlanOutcome::Consult);
1372 }
1373
1374 #[test]
1375 fn a_result_arriving_after_the_circuit_opened_is_still_admitted_by_its_plan() {
1376 // Admission reads the plan's snapshot, not the availability of the moment.
1377 let mut f = Fixture::new(vec![advisor("one", "x")], ReasoningMode::Standard);
1378 let a = f.add(assessment(Materiality::Material)).unwrap();
1379 let p = f
1380 .add(ReasoningInput::Plan(PlanInput { assessment: a }))
1381 .unwrap();
1382 f.states[0].status = AdvisorStatus::TemporarilyFailed;
1383 f.add(consultation(&p, "one", Stance::Supports, "A"))
1384 .unwrap();
1385 }
1386
1387 #[test]
1388 fn terminal_escapes_never_reach_the_store() {
1389 assert_eq!(
1390 strip_controls("\u{1b}[31mred\u{1b}[0m\ttab\nline"),
1391 "redtab\nline"
1392 );
1393 }
1394
1395 #[test]
1396 fn secrets_never_reach_the_store() {
1397 let key = format!("{}{}", "sk-ant-", "b".repeat(24));
1398 let body = ReasoningBody::Assessment(AssessmentInput {
1399 subject: format!("the key {key} leaks"),
1400 ..Default::default()
1401 });
1402 let (body, kinds) = redact(body);
1403 let ReasoningBody::Assessment(a) = body else {
1404 panic!()
1405 };
1406 assert!(!a.subject.contains("sk-ant-"));
1407 assert_eq!(kinds, ["anthropic-key"]);
1408 }
1409}