Skip to main content

majordomus_cli/reasoning/
record.rs

1//! The records a session writes while it reasons: typed engineering artefacts, never a
2//! transcript and never hidden reasoning. What is kept is what a later session needs to
3//! avoid rediscovering a decision — facts, evidence references, assumptions, questions,
4//! positions, conclusions, validation — and nothing an advisor said verbatim beyond the
5//! summary its adapter normalised.
6//!
7//! Inputs are what a caller authors; the stored body adds what only the writer may
8//! compute: a plan is computed from the availability at the moment it is recorded, a
9//! conclusion's review count from the consultations it cites. A caller cannot supply
10//! either, because a review count a caller can type is a review a caller can invent.
11//!
12//! The lifecycle of one record: a caller authors a [`ReasoningInput`] (usually as JSON,
13//! tagged by `kind`), the store admits it into a [`ReasoningBody`], and the writer wraps
14//! that body in a [`ReasoningRecord`] stamped with an id, a task and a time.
15//!
16//! ```
17//! use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
18//! use majordomus_cli::reasoning::record::{
19//!     ReasoningBody, ReasoningInput, ReasoningRecord, RECORD_SCHEMA, RECORD_VERSION,
20//! };
21//! use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
22//!
23//! let input: ReasoningInput = serde_json::from_value(serde_json::json!({
24//!     "kind": "assessment",
25//!     "subject": "whether the cache survives a restart",
26//!     "materiality": "low",
27//! }))
28//! .unwrap();
29//! let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
30//! let admission = Admission { mode: &mode, availability: &[] };
31//! let body: ReasoningBody = admit(input, &Loaded::default(), &admission).unwrap();
32//! assert_eq!(body.kind(), "assessment");
33//!
34//! let record = ReasoningRecord {
35//!     schema: RECORD_SCHEMA.into(),
36//!     version: RECORD_VERSION,
37//!     id: "assessment-20261001T000000Z-abcdef".into(),
38//!     task: "untasked".into(),
39//!     recorded_at: "2026-10-01T00:00:00Z".into(),
40//!     head: None,
41//!     redacted: vec![],
42//!     body,
43//! };
44//! let json = serde_json::to_value(&record).unwrap();
45//! assert_eq!(json["body"]["kind"], "assessment");
46//! assert_eq!(json["schema"], "majordomus/reasoning-record");
47//! ```
48
49use schemars::JsonSchema;
50use serde::{Deserialize, Serialize};
51
52use super::availability::AdvisorState;
53use super::policy::{Materiality, ReasoningConfidence, ReviewPlan};
54
55/// The schema name every stored record carries.
56pub const RECORD_SCHEMA: &str = "majordomus/reasoning-record";
57/// The record format version.
58pub const RECORD_VERSION: u32 = 1;
59
60/// What a piece of evidence is.
61///
62/// The kind tells a reader how to follow the reference: open a file, run a test, read an
63/// ADR or a rule, show a commit. A prior external opinion is a kind of its own so that an
64/// advisor's earlier conclusion, handed to another advisor, is never read as a fact.
65///
66/// ```
67/// use majordomus_cli::reasoning::record::ReasoningEvidenceKind;
68///
69/// let kind: ReasoningEvidenceKind = serde_json::from_str("\"prior_external_opinion\"").unwrap();
70/// assert_eq!(kind, ReasoningEvidenceKind::PriorExternalOpinion);
71/// assert_eq!(serde_json::to_string(&ReasoningEvidenceKind::Adr).unwrap(), "\"adr\"");
72/// ```
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(rename_all = "snake_case")]
75pub enum ReasoningEvidenceKind {
76    /// A file of the repository, optionally with a line: `src/x.rs:42`.
77    File,
78    /// A test case or test name.
79    Test,
80    /// An architecture decision record.
81    Adr,
82    /// A rule of the layer.
83    Rule,
84    /// A commit.
85    Commit,
86    /// A command that was run, and what it showed.
87    Command,
88    /// Another reasoning record.
89    Record,
90    /// Documentation.
91    Document,
92    /// A runtime observation: a log, a measurement, a diagnostic.
93    Runtime,
94    /// A prior external opinion: an advisor's conclusion handed to another advisor.
95    /// Marked so it is never mistaken for a fact.
96    PriorExternalOpinion,
97}
98
99/// A reference to something that supports a claim.
100///
101/// Evidence is a pointer, never a copy: a path, a case, an id or a command line, with an
102/// optional one-line note of what it shows. Unknown fields are refused, so a misspelt
103/// field is an error rather than a silently dropped claim.
104///
105/// ```
106/// use majordomus_cli::reasoning::record::{ReasoningEvidence, ReasoningEvidenceKind};
107///
108/// let e: ReasoningEvidence = serde_json::from_value(serde_json::json!({
109///     "kind": "file",
110///     "reference": "src/worker.rs:42",
111/// }))
112/// .unwrap();
113/// assert_eq!(e.kind, ReasoningEvidenceKind::File);
114/// assert_eq!(e.note, None);
115///
116/// let typo = serde_json::from_value::<ReasoningEvidence>(serde_json::json!({
117///     "kind": "file", "reference": "x", "notes": "misspelt",
118/// }));
119/// assert!(typo.is_err());
120/// ```
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
122#[serde(deny_unknown_fields)]
123pub struct ReasoningEvidence {
124    /// What it is.
125    pub kind: ReasoningEvidenceKind,
126    /// Where it is: a path, a case, an id, a command line.
127    pub reference: String,
128    /// What it shows, in one line.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub note: Option<String>,
131}
132
133/// A material uncertainty, stated with the evidence gathered before anyone was asked.
134///
135/// The assessment opens a chain: plans, consultations, disagreements and conclusions all
136/// name it. Only `subject` is required; materiality defaults to low and confidence to
137/// medium. The store refuses a material assessment that carries no evidence.
138///
139/// ```
140/// use majordomus_cli::reasoning::record::AssessmentInput;
141/// use majordomus_cli::reasoning::policy::{Materiality, ReasoningConfidence};
142///
143/// let a: AssessmentInput =
144///     serde_json::from_value(serde_json::json!({ "subject": "retry policy" })).unwrap();
145/// assert_eq!(a.materiality, Materiality::Low);
146/// assert_eq!(a.confidence, ReasoningConfidence::Medium);
147/// assert!(a.evidence.is_empty() && !a.new_evidence);
148/// ```
149#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
150#[serde(deny_unknown_fields)]
151pub struct AssessmentInput {
152    /// What is uncertain, as one line. Later assessments of the same subject find the
153    /// conclusions of earlier ones through it.
154    pub subject: String,
155    /// How much the decision could matter.
156    #[serde(default)]
157    pub materiality: Materiality,
158    /// How sure the executor is of its hypothesis.
159    #[serde(default)]
160    pub confidence: ReasoningConfidence,
161    /// The current hypothesis.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub hypothesis: Option<String>,
164    /// What the decision could affect: `public_api`, `persistent_data`, `security`,
165    /// `concurrency`, `architecture`, ...
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub affects: Vec<String>,
168    /// What is known.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub facts: Vec<String>,
171    /// What is assumed.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub assumptions: Vec<String>,
174    /// What is still open.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub questions: Vec<String>,
177    /// The evidence gathered locally.
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub evidence: Vec<ReasoningEvidence>,
180    /// The advisory capabilities review would need; empty means `independent_reasoning`.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub capabilities: Vec<String>,
183    /// The assessment brings evidence a standing conclusion on the subject did not have,
184    /// so that conclusion does not answer it.
185    #[serde(default)]
186    pub new_evidence: bool,
187}
188
189/// Ask the writer to make and record the plan for an assessment.
190///
191/// The input names only the assessment: the plan itself is computed by the store from
192/// the mode and the availability at the moment it is admitted, so a caller cannot write
193/// a plan that selects an advisor nobody could ask.
194///
195/// ```
196/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
197/// use majordomus_cli::reasoning::record::{PlanInput, ReasoningInput};
198/// use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
199///
200/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
201/// let admission = Admission { mode: &mode, availability: &[] };
202/// let input = ReasoningInput::Plan(PlanInput { assessment: "assessment-1".into() });
203/// let refused = admit(input, &Loaded::default(), &admission).unwrap_err();
204/// assert_eq!(refused.0, "no assessment assessment-1 is recorded");
205/// ```
206#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
207#[serde(deny_unknown_fields)]
208pub struct PlanInput {
209    /// The assessment the plan answers.
210    pub assessment: String,
211}
212
213/// A plan as stored: the decision and the availability it was made against.
214///
215/// The store builds it when it admits a [`PlanInput`]; nothing else does. The snapshot of
216/// availability is what a later consultation is admitted against, so an answer that
217/// arrives after the advisor's circuit opened still belongs to the plan that asked for it.
218///
219/// ```
220/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
221/// use majordomus_cli::reasoning::policy::PlanOutcome;
222/// use majordomus_cli::reasoning::record::*;
223/// use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
224///
225/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
226/// let admission = Admission { mode: &mode, availability: &[] };
227/// let mut loaded = Loaded::default();
228/// loaded.records.push(ReasoningRecord {
229///     schema: RECORD_SCHEMA.into(),
230///     version: RECORD_VERSION,
231///     id: "assessment-1".into(),
232///     task: "t".into(),
233///     recorded_at: "2026-10-01T00:00:01Z".into(),
234///     head: None,
235///     redacted: vec![],
236///     body: ReasoningBody::Assessment(AssessmentInput {
237///         subject: "a name".into(),
238///         ..Default::default()
239///     }),
240/// });
241/// let input = ReasoningInput::Plan(PlanInput { assessment: "assessment-1".into() });
242/// let ReasoningBody::Plan(stored) = admit(input, &loaded, &admission).unwrap() else {
243///     panic!("a plan input is stored as a plan");
244/// };
245/// let stored: StoredPlan = stored;
246/// assert_eq!(stored.assessment, "assessment-1");
247/// assert_eq!(stored.plan.outcome, PlanOutcome::DecideLocally);
248/// assert!(stored.availability.is_empty());
249/// ```
250#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
251pub struct StoredPlan {
252    /// The assessment it answers.
253    pub assessment: String,
254    /// The decision.
255    pub plan: ReviewPlan,
256    /// Every advisor's standing at the moment of the plan: the snapshot a later
257    /// consultation is admitted against.
258    pub availability: Vec<AdvisorState>,
259}
260
261/// How a consultation ended, as the adapter classified it.
262///
263/// Only a completed consultation carries advice and counts as review; every other status
264/// is a degradation that is recorded so the state can report it, never hidden. The wire
265/// form is the snake_case word that [`ConsultationStatus::as_str`] returns.
266///
267/// ```
268/// use majordomus_cli::reasoning::record::ConsultationStatus;
269///
270/// let s: ConsultationStatus = serde_json::from_str("\"rate_limited\"").unwrap();
271/// assert_eq!(s, ConsultationStatus::RateLimited);
272/// assert!(!s.is_transient());
273/// assert!(ConsultationStatus::Timeout.is_transient());
274/// ```
275#[derive(
276    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
277)]
278#[serde(rename_all = "snake_case")]
279pub enum ConsultationStatus {
280    /// An answer was received and normalised.
281    Completed,
282    /// No answer within the time allowed.
283    Timeout,
284    /// The advisor refused for rate.
285    RateLimited,
286    /// The credential was refused.
287    AuthFailed,
288    /// An answer arrived that could not be normalised.
289    Malformed,
290    /// An answer arrived with nothing in it.
291    Empty,
292    /// The advisor could not be reached: not running, not answering, gone.
293    Unavailable,
294    /// The session withdrew the request.
295    Cancelled,
296    /// Anything else the adapter could not classify.
297    Error,
298}
299
300impl ConsultationStatus {
301    /// A failure that says the advisor may not be askable right now.
302    ///
303    /// A timeout, an unreachable advisor and an unclassified error are transient: they
304    /// count towards opening the advisor's circuit. A refused credential, a rate limit or
305    /// a malformed answer say something more specific and are not.
306    ///
307    /// ```
308    /// use majordomus_cli::reasoning::record::ConsultationStatus;
309    ///
310    /// assert!(ConsultationStatus::Unavailable.is_transient());
311    /// assert!(!ConsultationStatus::Completed.is_transient());
312    /// assert!(!ConsultationStatus::AuthFailed.is_transient());
313    /// ```
314    pub fn is_transient(self) -> bool {
315        matches!(self, Self::Timeout | Self::Unavailable | Self::Error)
316    }
317
318    /// The status as the snake_case word the records and refusals spell it with.
319    ///
320    /// It is the same word serde writes, so a refusal message and a stored record never
321    /// disagree about how a status is named.
322    ///
323    /// ```
324    /// use majordomus_cli::reasoning::record::ConsultationStatus;
325    ///
326    /// let s = ConsultationStatus::AuthFailed;
327    /// assert_eq!(s.as_str(), "auth_failed");
328    /// assert_eq!(serde_json::to_string(&s).unwrap(), format!("\"{}\"", s.as_str()));
329    /// ```
330    pub fn as_str(self) -> &'static str {
331        match self {
332            Self::Completed => "completed",
333            Self::Timeout => "timeout",
334            Self::RateLimited => "rate_limited",
335            Self::AuthFailed => "auth_failed",
336            Self::Malformed => "malformed",
337            Self::Empty => "empty",
338            Self::Unavailable => "unavailable",
339            Self::Cancelled => "cancelled",
340            Self::Error => "error",
341        }
342    }
343}
344
345/// Where an advisor's answer stands relative to the executor's hypothesis.
346///
347/// A completed consultation must state its stance; the store refuses one without it. The
348/// stance is a label for the timeline, not a vote: agreement settles nothing.
349///
350/// ```
351/// use majordomus_cli::reasoning::record::Stance;
352///
353/// let s: Stance = serde_json::from_str("\"alternative\"").unwrap();
354/// assert_eq!(s, Stance::Alternative);
355/// assert!(serde_json::from_str::<Stance>("\"agrees\"").is_err());
356/// ```
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
358#[serde(rename_all = "snake_case")]
359pub enum Stance {
360    /// It supports the hypothesis.
361    Supports,
362    /// It argues against it.
363    Opposes,
364    /// It proposes something else.
365    Alternative,
366    /// It does not decide.
367    Inconclusive,
368}
369
370/// Token counts an adapter reported. Absent when the transport reports none; never
371/// estimated.
372///
373/// The state sums these into its totals. A missing count reads as zero, so an adapter
374/// that reports only one of the two still records what it reported.
375///
376/// ```
377/// use majordomus_cli::reasoning::record::AdvisorUsage;
378///
379/// let u: AdvisorUsage = serde_json::from_str(r#"{"input_tokens": 120}"#).unwrap();
380/// assert_eq!(u, AdvisorUsage { input_tokens: 120, output_tokens: 0 });
381/// ```
382#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
383#[serde(deny_unknown_fields)]
384pub struct AdvisorUsage {
385    /// Input tokens.
386    #[serde(default)]
387    pub input_tokens: u64,
388    /// Output tokens.
389    #[serde(default)]
390    pub output_tokens: u64,
391}
392
393/// One normalised advisor exchange: what was asked, how it ended, and what came back.
394///
395/// The store admits it only when the plan it names selected this advisor. A completed
396/// consultation carries a conclusion and a stance; a failed one carries neither, only its
397/// status and the adapter's diagnostic. The full prompt and answer are never kept.
398///
399/// ```
400/// use majordomus_cli::reasoning::record::{ConsultationInput, ConsultationStatus};
401///
402/// let c: ConsultationInput = serde_json::from_value(serde_json::json!({
403///     "plan": "plan-1",
404///     "advisor": "alpha",
405///     "status": "timeout",
406///     "diagnostic": "timeout: no answer in 60s",
407/// }))
408/// .unwrap();
409/// assert_eq!(c.status, ConsultationStatus::Timeout);
410/// assert!(c.conclusion.is_none() && c.stance.is_none());
411/// ```
412#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
413#[serde(deny_unknown_fields)]
414pub struct ConsultationInput {
415    /// The recorded plan that selected this advisor.
416    pub plan: String,
417    /// The advisor asked.
418    pub advisor: String,
419    /// How it ended.
420    pub status: ConsultationStatus,
421    /// The question asked, in one line (the full prompt is not kept).
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub question: Option<String>,
424    /// The evidence the question carried.
425    #[serde(default, skip_serializing_if = "Vec::is_empty")]
426    pub evidence: Vec<ReasoningEvidence>,
427    /// The advisor's conclusion, summarised. Required when completed, refused otherwise.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub conclusion: Option<String>,
430    /// Where it stands relative to the hypothesis.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub stance: Option<Stance>,
433    /// Assumptions it identified.
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub assumptions: Vec<String>,
436    /// Risks it identified.
437    #[serde(default, skip_serializing_if = "Vec::is_empty")]
438    pub risks: Vec<String>,
439    /// Actions it recommended.
440    #[serde(default, skip_serializing_if = "Vec::is_empty")]
441    pub actions: Vec<String>,
442    /// What would falsify its conclusion.
443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
444    pub falsifiers: Vec<String>,
445    /// Its stated confidence.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub confidence: Option<ReasoningConfidence>,
448    /// How long it took, milliseconds.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub duration_ms: Option<u64>,
451    /// What the transport reported it cost.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub usage: Option<AdvisorUsage>,
454    /// The adapter's diagnostic for a failure: a class and a sentence, never a payload.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub diagnostic: Option<String>,
457    /// A rate limit's retry-after, seconds.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub retry_after_seconds: Option<u64>,
460}
461
462/// One side of a disagreement: whose position it is, and the position in one line.
463///
464/// The source is `primary` for the executor, or the id of a completed consultation; the
465/// store refuses a position sourced from a consultation that did not complete.
466///
467/// ```
468/// use majordomus_cli::reasoning::record::DisagreementPosition;
469///
470/// let p = DisagreementPosition { source: "primary".into(), position: "keep the lock".into() };
471/// let json = serde_json::to_value(&p).unwrap();
472/// assert_eq!(json, serde_json::json!({ "source": "primary", "position": "keep the lock" }));
473/// ```
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
475#[serde(deny_unknown_fields)]
476pub struct DisagreementPosition {
477    /// Whose position: `primary`, or a completed consultation's record id.
478    pub source: String,
479    /// The position, in one line.
480    pub position: String,
481}
482
483/// Advisors, or an advisor and the executor, disagree.
484///
485/// A disagreement names the question whose answer decides between the positions; until
486/// a [`ResolutionInput`] settles it with evidence, the store refuses every conclusion on
487/// its assessment. It needs at least two positions, each from its own source.
488///
489/// ```
490/// use majordomus_cli::reasoning::availability::{ModeResolution, ReasoningMode};
491/// use majordomus_cli::reasoning::record::*;
492/// use majordomus_cli::reasoning::store::{admit, Admission, Loaded};
493///
494/// let mode = ModeResolution { mode: ReasoningMode::Standard, source: "default".into() };
495/// let admission = Admission { mode: &mode, availability: &[] };
496/// let mut loaded = Loaded::default();
497/// loaded.records.push(ReasoningRecord {
498///     schema: RECORD_SCHEMA.into(),
499///     version: RECORD_VERSION,
500///     id: "assessment-1".into(),
501///     task: "t".into(),
502///     recorded_at: "2026-10-01T00:00:01Z".into(),
503///     head: None,
504///     redacted: vec![],
505///     body: ReasoningBody::Assessment(AssessmentInput {
506///         subject: "lock scope".into(),
507///         ..Default::default()
508///     }),
509/// });
510/// let lonely = DisagreementInput {
511///     assessment: "assessment-1".into(),
512///     positions: vec![DisagreementPosition {
513///         source: "primary".into(),
514///         position: "keep the lock".into(),
515///     }],
516///     divergent_assumptions: vec![],
517///     discriminating_question: "does any caller hold it across an await?".into(),
518///     experiment: None,
519/// };
520/// let refused = admit(ReasoningInput::Disagreement(lonely), &loaded, &admission).unwrap_err();
521/// assert_eq!(refused.0, "a disagreement has at least two positions");
522/// ```
523#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
524#[serde(deny_unknown_fields)]
525pub struct DisagreementInput {
526    /// The assessment it is about.
527    pub assessment: String,
528    /// The positions; at least two.
529    pub positions: Vec<DisagreementPosition>,
530    /// The assumptions the positions divide on.
531    #[serde(default, skip_serializing_if = "Vec::is_empty")]
532    pub divergent_assumptions: Vec<String>,
533    /// The question whose answer decides between them.
534    pub discriminating_question: String,
535    /// The experiment proposed to answer it.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub experiment: Option<String>,
538}
539
540/// A disagreement settled by evidence: what was run or read, and which position it favours.
541///
542/// The resolution is the only way past a disagreement. It must cite at least one piece of
543/// evidence, because a disagreement is settled by an experiment, never by counting who
544/// agrees; it favours one of the disagreement's sources, or `neither`.
545///
546/// ```
547/// use majordomus_cli::reasoning::record::ResolutionInput;
548///
549/// let r: ResolutionInput = serde_json::from_value(serde_json::json!({
550///     "disagreement": "disagreement-1",
551///     "experiment": "run the supervision test",
552///     "outcome": "no caller retries",
553///     "favours": "neither",
554///     "evidence": [{ "kind": "test", "reference": "test/supervision.rs" }],
555/// }))
556/// .unwrap();
557/// assert_eq!(r.favours, "neither");
558/// assert_eq!(r.evidence.len(), 1);
559/// ```
560#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
561#[serde(deny_unknown_fields)]
562pub struct ResolutionInput {
563    /// The disagreement it settles.
564    pub disagreement: String,
565    /// The experiment performed.
566    pub experiment: String,
567    /// What it showed.
568    pub outcome: String,
569    /// The position it favours: a `source` of the disagreement, or `neither`.
570    pub favours: String,
571    /// The evidence it produced; at least one reference.
572    pub evidence: Vec<ReasoningEvidence>,
573}
574
575/// The engineering conclusion an assessment reaches.
576///
577/// A conclusion names its decision and why. On material uncertainty it must also carry
578/// evidence and a validation plan, and it must weigh every completed consultation of its
579/// assessment. The review count is not part of the input: the store computes it.
580///
581/// ```
582/// use majordomus_cli::reasoning::policy::ReasoningConfidence;
583/// use majordomus_cli::reasoning::record::ConclusionInput;
584///
585/// let c = ConclusionInput {
586///     assessment: "assessment-1".into(),
587///     decision: "keep the lock".into(),
588///     rationale: "no caller holds it across an await".into(),
589///     ..Default::default()
590/// };
591/// assert_eq!(c.confidence, ReasoningConfidence::Medium);
592/// assert!(c.consultations.is_empty() && c.supersedes.is_none());
593/// // a review count is computed by the store, so the input has no field for one
594/// let typed = serde_json::json!({
595///     "assessment": "a", "decision": "d", "rationale": "r", "independent_review_count": 3,
596/// });
597/// assert!(serde_json::from_value::<ConclusionInput>(typed).is_err());
598/// ```
599#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
600#[serde(deny_unknown_fields)]
601pub struct ConclusionInput {
602    /// The assessment it concludes.
603    pub assessment: String,
604    /// The decision.
605    pub decision: String,
606    /// Why, from the evidence.
607    pub rationale: String,
608    /// The evidence it rests on. Required for material uncertainty.
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub evidence: Vec<ReasoningEvidence>,
611    /// The completed consultations it weighed.
612    #[serde(default, skip_serializing_if = "Vec::is_empty")]
613    pub consultations: Vec<String>,
614    /// The resolved disagreements it rests on.
615    #[serde(default, skip_serializing_if = "Vec::is_empty")]
616    pub disagreements: Vec<String>,
617    /// Alternatives considered and rejected, each with why.
618    #[serde(default, skip_serializing_if = "Vec::is_empty")]
619    pub rejected: Vec<String>,
620    /// Assumptions it still depends on.
621    #[serde(default, skip_serializing_if = "Vec::is_empty")]
622    pub assumptions: Vec<String>,
623    /// Risks that remain.
624    #[serde(default, skip_serializing_if = "Vec::is_empty")]
625    pub risks: Vec<String>,
626    /// How the decision will be proven. Required for material uncertainty.
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    pub validation_plan: Vec<String>,
629    /// Confidence in it.
630    #[serde(default)]
631    pub confidence: ReasoningConfidence,
632    /// An earlier conclusion this one replaces, because new evidence overturned it.
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub supersedes: Option<String>,
635}
636
637/// A conclusion as stored: the input and what only the writer computes.
638///
639/// The input is flattened, so a stored conclusion reads as the input plus two fields:
640/// the advisors whose completed consultations it cites, each once and in canonical
641/// order, and their count. Both are computed at admission and checked again by
642/// `reasoning.check`, so a hand-edited count is reported.
643///
644/// ```
645/// use majordomus_cli::reasoning::record::{ConclusionInput, StoredConclusion};
646///
647/// let stored = StoredConclusion {
648///     input: ConclusionInput {
649///         assessment: "assessment-1".into(),
650///         decision: "keep the lock".into(),
651///         rationale: "measured".into(),
652///         ..Default::default()
653///     },
654///     reviewed_by: vec!["alpha".into()],
655///     independent_review_count: 1,
656/// };
657/// let json = serde_json::to_value(&stored).unwrap();
658/// assert_eq!(json["decision"], "keep the lock");
659/// assert_eq!(json["independent_review_count"], 1);
660/// ```
661#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
662pub struct StoredConclusion {
663    /// What the executor concluded.
664    #[serde(flatten)]
665    pub input: ConclusionInput,
666    /// The advisors whose completed consultations it cites, each once. Computed.
667    pub reviewed_by: Vec<String>,
668    /// How many independent advisors reviewed it: `reviewed_by`'s length. Computed.
669    pub independent_review_count: usize,
670}
671
672/// Whether a performed validation step passed or failed.
673///
674/// One failed validation is enough to mark the assessment's standing as
675/// `validation_failed` in the derived state, whatever else passed.
676///
677/// ```
678/// use majordomus_cli::reasoning::record::ValidationOutcome;
679///
680/// let o: ValidationOutcome = serde_json::from_str("\"fail\"").unwrap();
681/// assert_eq!(o, ValidationOutcome::Fail);
682/// assert_eq!(serde_json::to_string(&ValidationOutcome::Pass).unwrap(), "\"pass\"");
683/// ```
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
685#[serde(rename_all = "snake_case")]
686pub enum ValidationOutcome {
687    /// It passed.
688    Pass,
689    /// It failed.
690    Fail,
691}
692
693/// A step of a conclusion's validation plan, performed.
694///
695/// It names the conclusion it validates, what was checked, optionally the command, and
696/// how it came out. The store refuses one whose conclusion is not recorded or whose check
697/// is empty.
698///
699/// ```
700/// use majordomus_cli::reasoning::record::{ValidationInput, ValidationOutcome};
701///
702/// let v: ValidationInput = serde_json::from_value(serde_json::json!({
703///     "conclusion": "conclusion-1",
704///     "check": "the worker test passes",
705///     "command": "cargo test worker",
706///     "outcome": "pass",
707/// }))
708/// .unwrap();
709/// assert_eq!(v.outcome, ValidationOutcome::Pass);
710/// assert_eq!(v.command.as_deref(), Some("cargo test worker"));
711/// ```
712#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
713#[serde(deny_unknown_fields)]
714pub struct ValidationInput {
715    /// The conclusion validated.
716    pub conclusion: String,
717    /// What was checked, in one line.
718    pub check: String,
719    /// The command that checked it.
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub command: Option<String>,
722    /// How it came out.
723    pub outcome: ValidationOutcome,
724    /// What it produced.
725    #[serde(default, skip_serializing_if = "Vec::is_empty")]
726    pub evidence: Vec<ReasoningEvidence>,
727}
728
729/// A failed implementation attempt and what it taught.
730///
731/// An attempt may stand alone or bear on an assessment. It records the observed failure
732/// and, when known, the assumption the failure invalidated, so that a later session does
733/// not try the same thing again.
734///
735/// ```
736/// use majordomus_cli::reasoning::record::AttemptInput;
737///
738/// let a: AttemptInput = serde_json::from_value(serde_json::json!({
739///     "attempt": "share the client across threads",
740///     "observed_failure": "the borrow checker refused the handle",
741///     "assumption_invalidated": "the client is Sync",
742/// }))
743/// .unwrap();
744/// assert!(a.assessment.is_none());
745/// assert_eq!(a.assumption_invalidated.as_deref(), Some("the client is Sync"));
746/// ```
747#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
748#[serde(deny_unknown_fields)]
749pub struct AttemptInput {
750    /// The assessment it bears on, when there is one.
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub assessment: Option<String>,
753    /// What was tried.
754    pub attempt: String,
755    /// What failed, observed.
756    pub observed_failure: String,
757    /// The assumption the failure invalidated.
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub assumption_invalidated: Option<String>,
760    /// Evidence the failure produced.
761    #[serde(default, skip_serializing_if = "Vec::is_empty")]
762    pub evidence: Vec<ReasoningEvidence>,
763    /// The hypothesis now.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub updated_hypothesis: Option<String>,
766}
767
768/// Anything a session records, as authored.
769///
770/// The JSON form is tagged by `kind`, with the variant's fields beside the tag. This is
771/// what the CLI, HTTP and MCP surfaces accept; [`crate::reasoning::store::admit`] turns
772/// it into the [`ReasoningBody`] that is stored, or refuses it.
773///
774/// ```
775/// use majordomus_cli::reasoning::record::ReasoningInput;
776///
777/// let input: ReasoningInput = serde_json::from_value(serde_json::json!({
778///     "kind": "plan",
779///     "assessment": "assessment-1",
780/// }))
781/// .unwrap();
782/// assert!(matches!(input, ReasoningInput::Plan(ref p) if p.assessment == "assessment-1"));
783/// let unknown = serde_json::json!({ "kind": "opinion", "text": "x" });
784/// assert!(serde_json::from_value::<ReasoningInput>(unknown).is_err());
785/// ```
786#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
787#[serde(tag = "kind", rename_all = "snake_case")]
788pub enum ReasoningInput {
789    /// A material uncertainty.
790    Assessment(AssessmentInput),
791    /// Make and record the plan for an assessment.
792    Plan(PlanInput),
793    /// One advisor exchange.
794    Consultation(ConsultationInput),
795    /// A disagreement.
796    Disagreement(DisagreementInput),
797    /// A disagreement settled by evidence.
798    Resolution(ResolutionInput),
799    /// An engineering conclusion.
800    Conclusion(ConclusionInput),
801    /// A validation step performed.
802    Validation(ValidationInput),
803    /// A failed attempt.
804    Attempt(AttemptInput),
805}
806
807/// A stored record's body: the admitted input, with whatever the writer computed.
808///
809/// It mirrors [`ReasoningInput`] variant for variant, except that a plan is stored as a
810/// [`StoredPlan`] and a conclusion as a [`StoredConclusion`]. Only the store produces one,
811/// by admitting an input; its JSON form is tagged by `kind` like the input's.
812///
813/// ```
814/// use majordomus_cli::reasoning::record::{AssessmentInput, ReasoningBody};
815///
816/// let body = ReasoningBody::Assessment(AssessmentInput {
817///     subject: "lock scope".into(),
818///     ..Default::default()
819/// });
820/// assert_eq!(body.kind(), "assessment");
821/// assert_eq!(serde_json::to_value(&body).unwrap()["kind"], "assessment");
822/// ```
823#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
824#[serde(tag = "kind", rename_all = "snake_case")]
825pub enum ReasoningBody {
826    /// A material uncertainty.
827    Assessment(AssessmentInput),
828    /// A plan, with its availability snapshot.
829    Plan(StoredPlan),
830    /// One advisor exchange.
831    Consultation(ConsultationInput),
832    /// A disagreement.
833    Disagreement(DisagreementInput),
834    /// A disagreement settled by evidence.
835    Resolution(ResolutionInput),
836    /// An engineering conclusion, with its computed review.
837    Conclusion(StoredConclusion),
838    /// A validation step performed.
839    Validation(ValidationInput),
840    /// A failed attempt.
841    Attempt(AttemptInput),
842}
843
844impl ReasoningBody {
845    /// The kind word, which is also the id prefix.
846    ///
847    /// It is the same word as the serde `kind` tag, so a record's id, its JSON and every
848    /// refusal that names its kind agree.
849    ///
850    /// ```
851    /// use majordomus_cli::reasoning::record::{AttemptInput, ReasoningBody};
852    ///
853    /// let body = ReasoningBody::Attempt(AttemptInput {
854    ///     assessment: None,
855    ///     attempt: "inline the call".into(),
856    ///     observed_failure: "the test still fails".into(),
857    ///     assumption_invalidated: None,
858    ///     evidence: vec![],
859    ///     updated_hypothesis: None,
860    /// });
861    /// assert_eq!(body.kind(), "attempt");
862    /// assert_eq!(serde_json::to_value(&body).unwrap()["kind"], body.kind());
863    /// ```
864    pub fn kind(&self) -> &'static str {
865        match self {
866            Self::Assessment(_) => "assessment",
867            Self::Plan(_) => "plan",
868            Self::Consultation(_) => "consultation",
869            Self::Disagreement(_) => "disagreement",
870            Self::Resolution(_) => "resolution",
871            Self::Conclusion(_) => "conclusion",
872            Self::Validation(_) => "validation",
873            Self::Attempt(_) => "attempt",
874        }
875    }
876}
877
878/// A stored record: a body with the envelope only the writer stamps.
879///
880/// The envelope carries the schema and version, a unique id, the task, the time it was
881/// recorded, the commit the checkout was on and the secret shapes redacted from its text.
882/// One record is one JSON file in the store; the store reads back only records whose
883/// schema and version match.
884///
885/// ```
886/// use majordomus_cli::reasoning::record::*;
887///
888/// let record = ReasoningRecord {
889///     schema: RECORD_SCHEMA.into(),
890///     version: RECORD_VERSION,
891///     id: "attempt-20261001T000000Z-abcdef".into(),
892///     task: "untasked".into(),
893///     recorded_at: "2026-10-01T00:00:00Z".into(),
894///     head: None,
895///     redacted: vec![],
896///     body: ReasoningBody::Attempt(AttemptInput {
897///         assessment: None,
898///         attempt: "inline the call".into(),
899///         observed_failure: "the test still fails".into(),
900///         assumption_invalidated: None,
901///         evidence: vec![],
902///         updated_hypothesis: None,
903///     }),
904/// };
905/// let text = serde_json::to_string(&record).unwrap();
906/// assert!(!text.contains("\"head\""), "an absent head is not written");
907/// let back: ReasoningRecord = serde_json::from_str(&text).unwrap();
908/// assert_eq!(back.id, record.id);
909/// assert_eq!(back.body.kind(), "attempt");
910/// ```
911#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
912pub struct ReasoningRecord {
913    /// [`RECORD_SCHEMA`].
914    pub schema: String,
915    /// [`RECORD_VERSION`].
916    pub version: u32,
917    /// `<kind>-<yyyymmddThhmmssZ>-<hex>`, unique in the store.
918    pub id: String,
919    /// The task it was recorded under, or `untasked`.
920    pub task: String,
921    /// When it was recorded, RFC 3339; stamped by the writer, never supplied.
922    pub recorded_at: String,
923    /// The commit the checkout was on.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub head: Option<String>,
926    /// Secret shapes the writer removed from the text, by shape name.
927    #[serde(default, skip_serializing_if = "Vec::is_empty")]
928    pub redacted: Vec<String>,
929    /// The record.
930    pub body: ReasoningBody,
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    #[test]
938    fn every_body_kind_is_its_serde_tag() {
939        let bodies = vec![
940            ReasoningBody::Assessment(AssessmentInput::default()),
941            ReasoningBody::Conclusion(StoredConclusion {
942                input: ConclusionInput::default(),
943                reviewed_by: vec![],
944                independent_review_count: 0,
945            }),
946            ReasoningBody::Validation(ValidationInput {
947                conclusion: "c".into(),
948                check: "x".into(),
949                command: None,
950                outcome: ValidationOutcome::Pass,
951                evidence: vec![],
952            }),
953        ];
954        for body in bodies {
955            let json = serde_json::to_value(&body).unwrap();
956            assert_eq!(json["kind"], body.kind());
957        }
958    }
959
960    #[test]
961    fn a_status_word_is_its_serde_word() {
962        for s in [
963            ConsultationStatus::Completed,
964            ConsultationStatus::Timeout,
965            ConsultationStatus::RateLimited,
966            ConsultationStatus::AuthFailed,
967            ConsultationStatus::Malformed,
968            ConsultationStatus::Empty,
969            ConsultationStatus::Unavailable,
970            ConsultationStatus::Cancelled,
971            ConsultationStatus::Error,
972        ] {
973            assert_eq!(
974                serde_json::to_value(s).unwrap(),
975                serde_json::Value::String(s.as_str().into())
976            );
977        }
978    }
979
980    #[test]
981    fn a_caller_cannot_type_a_review_count() {
982        let typed = serde_json::json!({
983            "kind": "conclusion",
984            "assessment": "a",
985            "decision": "d",
986            "rationale": "r",
987            "reviewed_by": ["anyone"],
988        });
989        assert!(serde_json::from_value::<ReasoningInput>(typed).is_err());
990    }
991}