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}