Skip to main content

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}