Skip to main content

majordomus_cli/
intent.rs

1//! Intents: what must become true above the milestones that realise it, and whether the
2//! recorded evidence says it has.
3//!
4//! An intent is a record under `.ai/repo/project/intents/` (kind `intent`, schema
5//! `majordomus.intent/v1`): a statement, the invariants that must stay true, the milestones
6//! that realise it, and the satisfaction criteria that settle it, each naming its evidence.
7//!
8//! **This is a derivation, not a lifecycle.** No stage is stored, nothing here transitions
9//! and nothing here writes. The stage of an intent is a pure function of two things that
10//! already exist: the status [`crate::plan::Plan`] derives for each milestone the intent
11//! names, and the proof state the evidence ledger holds for each criterion's test or
12//! claim. A second task model beside the plan would be a second source of truth about the
13//! same work, which is the defect the plan was built to remove (ADR 0070).
14//!
15//! The stages, in order, each implying the one before:
16//!
17//! | stage       | derived when                                                        |
18//! |-------------|---------------------------------------------------------------------|
19//! | `declared`  | a named milestone does not resolve, or none is named                 |
20//! | `planned`   | every milestone resolves                                             |
21//! | `executing` | any milestone is ACTIVE, VERIFY or DONE                              |
22//! | `verifying` | every milestone that is not cancelled or superseded is DONE          |
23//! | `satisfied` | verifying, and every criterion has current evidence                  |
24//!
25//! `cancelled` and `superseded` are what the record says happened to it, as for a
26//! milestone. Nothing else is.
27//!
28//! Beside the stage stands the **verdict** (ADR 0107): the evidence question alone, derived
29//! from the criteria and never from the plan. It is `satisfied` when every criterion is met,
30//! `unsatisfied` when a criterion the ledger or the claim join can settle (`test`, `claim`) is
31//! not, and `unknown` when every unmet criterion is of a kind the ledger cannot yet settle
32//! (`command`, `deployment`) or the intent declares none. DONE neither implies it nor is
33//! required for it, so an intent whose criteria all hold while a milestone is open reads a
34//! `satisfied` verdict at stage `executing` — a disagreement the realization reports as drift.
35//!
36//! ```
37//! use majordomus_cli::intent::{IntentStage, IntentVerdictState};
38//! assert_eq!(IntentStage::Satisfied.as_str(), "satisfied");
39//! assert!(IntentStage::Verifying < IntentStage::Satisfied);
40//! assert_eq!(IntentVerdictState::Unknown.as_str(), "unknown");
41//! ```
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::path::{Path, PathBuf};
45
46use schemars::JsonSchema;
47use serde::{Deserialize, Serialize};
48use serde_json::Value;
49
50use crate::evidence::{
51    self, compare, freshness, Comparison, EvidenceReport, Ledger, Presented, ProofState, Recorded,
52    TestId, TreeState,
53};
54use crate::index::Index;
55use crate::intent_plan::{coverage, IntentCoverage, IntentOutline};
56use crate::intent_review::{
57    observed_satisfied, review, CritiqueFinding, CritiqueRecord, GapCondition, GapRecord,
58    ResolutionState,
59};
60use crate::plan::{overlap, Plan};
61
62/// The kind of an intent record.
63pub const INTENT: &str = "intent";
64
65/// The directory intent records live in, repository-relative.
66pub const INTENTS_DIR: &str = ".ai/repo/project/intents";
67
68/// A failure: the intent model is invalid.
69pub const FAIL: &str = "FAIL";
70/// A warning: legal, and worth reading.
71pub const WARN: &str = "WARN";
72
73// ---------------------------------------------------------------- the derived vocabulary
74
75/// Where an intent stands, derived on every read and stored nowhere.
76///
77/// ```
78/// use majordomus_cli::intent::IntentStage;
79/// // one word every surface prints, and an order that follows the work
80/// assert_eq!(IntentStage::Executing.as_str(), "executing");
81/// assert!(IntentStage::Declared < IntentStage::Satisfied);
82/// ```
83#[derive(
84    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
85)]
86#[serde(rename_all = "snake_case")]
87pub enum IntentStage {
88    /// A named milestone does not resolve, or none is named.
89    Declared,
90    /// Every milestone resolves and none has started.
91    Planned,
92    /// A milestone is under way.
93    Executing,
94    /// Every milestone that counts is DONE; satisfaction is not yet evidenced.
95    Verifying,
96    /// Verifying, and every criterion has current evidence.
97    Satisfied,
98    /// The record says it was abandoned.
99    Cancelled,
100    /// The record names the intent that replaced it.
101    Superseded,
102}
103
104impl IntentStage {
105    /// The word every surface prints for this stage: the command line, the API, MCP and the
106    /// generated documentation all say the same one, so no surface spells a stage its own way.
107    ///
108    /// ```
109    /// use majordomus_cli::intent::IntentStage;
110    /// assert_eq!(IntentStage::Declared.as_str(), "declared");
111    /// ```
112    pub fn as_str(self) -> &'static str {
113        match self {
114            IntentStage::Declared => "declared",
115            IntentStage::Planned => "planned",
116            IntentStage::Executing => "executing",
117            IntentStage::Verifying => "verifying",
118            IntentStage::Satisfied => "satisfied",
119            IntentStage::Cancelled => "cancelled",
120            IntentStage::Superseded => "superseded",
121        }
122    }
123}
124
125/// What the evidence behind one criterion says, as a word. Only `Current` meets a criterion:
126/// a stale, failing, unrecorded or underivable reference never does, and neither does one that
127/// resolves to nothing.
128///
129/// Whether a recorded run is current is not decided here. A `test` or `claim` criterion is
130/// judged by the evidence module's one truth table ([`evidence::freshness()`]) at the working
131/// tree and read through [`evidence::current`], the same judgement the claim report and the
132/// subject pages make; [`IntentEvidenceState::judged`] is the whole of the translation. So
133/// the state follows the evidence both ways: a criterion met today reads stale or failing
134/// the moment the ledger or the tree says so, and met again once a current pass is recorded.
135///
136/// ```
137/// use majordomus_cli::intent::IntentEvidenceState;
138/// assert_eq!(serde_json::to_string(&IntentEvidenceState::NotRun).unwrap(), "\"not_run\"");
139/// assert_ne!(IntentEvidenceState::Stale, IntentEvidenceState::Current);
140/// ```
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142#[serde(rename_all = "snake_case")]
143pub enum IntentEvidenceState {
144    /// A passing run that is current evidence at the working tree: its commit is in this
145    /// history, it measured a tree that was its commit, the test still hashes to what ran,
146    /// and nothing the criterion names has changed since — for a test, its source and the
147    /// source and implementation of every claim that test proves; for a claim, what the
148    /// claim names. A change the criterion does not name, the evidence ledger's own included,
149    /// leaves it current. A test that proves no claim naming an implementation names no code
150    /// under test, so for it nothing but the ledger may have changed since the run. The only
151    /// state that meets a criterion; the criterion's `proof` says whether it rests on
152    /// `proven` or on `inputs_unchanged`.
153    Current,
154    /// A pass that is not current evidence: something the criterion names has changed since
155    /// it, it names no code under test and anything but the ledger and the plan's own records
156    /// has changed since, the
157    /// test no longer hashes to what ran, the run's commit is not in this history (or git
158    /// could not compare it), or the run measured a tree that was not its commit — a run on a
159    /// dirty tree, which no later tree can be matched against.
160    Stale,
161    /// The latest recorded run failed, timed out or errored.
162    Failing,
163    /// The reference resolves and no run counts for it: none was recorded, the latest run
164    /// declined to run (a skip), or the claim names no test a runner drives.
165    NotRun,
166    /// The reference resolves, and this kind of evidence (`command`, `deployment`) is not
167    /// derivable from the ledger, so it never meets a criterion by itself.
168    NotDerivable,
169    /// The reference is empty or names nothing this repository holds.
170    Unresolved,
171}
172
173impl IntentEvidenceState {
174    /// The word every surface prints for this state, the same one serde writes.
175    ///
176    /// ```
177    /// use majordomus_cli::intent::IntentEvidenceState;
178    /// assert_eq!(IntentEvidenceState::NotDerivable.as_str(), "not_derivable");
179    /// ```
180    pub fn as_str(self) -> &'static str {
181        match self {
182            IntentEvidenceState::Current => "current",
183            IntentEvidenceState::Stale => "stale",
184            IntentEvidenceState::Failing => "failing",
185            IntentEvidenceState::NotRun => "not_run",
186            IntentEvidenceState::NotDerivable => "not_derivable",
187            IntentEvidenceState::Unresolved => "unresolved",
188        }
189    }
190
191    /// A verdict of the evidence module's truth table, in this vocabulary: current only when
192    /// [`evidence::current`] says so, of a run on the tree `recorded` (the execution's own
193    /// `working_tree`; [`TreeState::Unknown`] when there is none). A pass that is not current
194    /// is stale — including a run on a dirty tree whose inputs read unchanged — a skip and an
195    /// absence are not run, and a failure is failing.
196    ///
197    /// ```
198    /// use majordomus_cli::evidence::{ProofState, TreeState};
199    /// use majordomus_cli::intent::IntentEvidenceState;
200    ///
201    /// let judged = IntentEvidenceState::judged;
202    /// assert_eq!(judged(ProofState::InputsUnchanged, TreeState::Clean), IntentEvidenceState::Current);
203    /// // a run on a tree that was not its commit proves nothing about a later one
204    /// assert_eq!(judged(ProofState::InputsUnchanged, TreeState::Dirty), IntentEvidenceState::Stale);
205    /// assert_eq!(judged(ProofState::Failing, TreeState::Clean), IntentEvidenceState::Failing);
206    /// ```
207    pub fn judged(state: ProofState, recorded: TreeState) -> IntentEvidenceState {
208        if evidence::current(state, recorded, Presented::WorkingTree.tree()) {
209            return IntentEvidenceState::Current;
210        }
211        match state {
212            ProofState::Proven | ProofState::InputsUnchanged | ProofState::Stale => {
213                IntentEvidenceState::Stale
214            }
215            ProofState::Failing => IntentEvidenceState::Failing,
216            ProofState::NotRun | ProofState::Unrunnable | ProofState::NoTest => {
217                IntentEvidenceState::NotRun
218            }
219        }
220    }
221}
222
223/// What the evidence alone says about an intent, as a word: derived from its criteria and
224/// never from the plan, so it can read `satisfied` while a milestone is still open (ADR 0107).
225///
226/// ```
227/// use majordomus_cli::intent::IntentVerdictState;
228/// let word = serde_json::to_string(&IntentVerdictState::Unsatisfied).unwrap();
229/// assert_eq!(word, "\"unsatisfied\"");
230/// assert_eq!(IntentVerdictState::Satisfied.as_str(), "satisfied");
231/// ```
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "snake_case")]
234pub enum IntentVerdictState {
235    /// Every criterion has current evidence.
236    Satisfied,
237    /// A criterion the ledger or the claim join can settle (`test`, `claim`) is not met.
238    Unsatisfied,
239    /// Every unmet criterion is of a kind the ledger cannot yet settle (`command`,
240    /// `deployment`), or the intent declares no criterion: the evidence cannot answer.
241    Unknown,
242}
243
244impl IntentVerdictState {
245    /// The word every surface prints for this verdict, the same one serde writes.
246    ///
247    /// ```
248    /// use majordomus_cli::intent::IntentVerdictState;
249    /// assert_eq!(IntentVerdictState::Unsatisfied.as_str(), "unsatisfied");
250    /// ```
251    pub fn as_str(self) -> &'static str {
252        match self {
253            IntentVerdictState::Satisfied => "satisfied",
254            IntentVerdictState::Unsatisfied => "unsatisfied",
255            IntentVerdictState::Unknown => "unknown",
256        }
257    }
258}
259
260/// One criterion that holds a verdict back from `satisfied`: which one, the kind of evidence
261/// it names, and the state that evidence is in — the criterion's own words, not new ones.
262///
263/// ```
264/// use majordomus_cli::intent::{IntentEvidenceState, IntentVerdictReason};
265/// let r = IntentVerdictReason {
266///     criterion: "probe-passes".into(),
267///     evidence: "test".into(),
268///     state: IntentEvidenceState::NotRun,
269/// };
270/// assert_eq!(r.state, IntentEvidenceState::NotRun);
271/// ```
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273pub struct IntentVerdictReason {
274    /// The criterion's id.
275    pub criterion: String,
276    /// Its kind of evidence: `test`, `claim`, `command` or `deployment`.
277    pub evidence: String,
278    /// The state of that evidence, as the criterion reports it.
279    pub state: IntentEvidenceState,
280}
281
282/// Whether the evidence alone settles an intent, and which criteria hold it back: every
283/// unmet criterion, in the order the record declares them. Empty when the verdict is
284/// `satisfied`, and when it is `unknown` because the intent declares no criterion.
285///
286/// ```
287/// use majordomus_cli::intent::{verdict, IntentVerdict, IntentVerdictState};
288/// // an intent that declares no criterion cannot be settled by evidence
289/// let v: IntentVerdict = verdict(&[]);
290/// assert_eq!(v.state, IntentVerdictState::Unknown);
291/// assert!(v.reasons.is_empty());
292/// ```
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
294pub struct IntentVerdict {
295    /// Derived: what the evidence says.
296    pub state: IntentVerdictState,
297    /// Every criterion that is not met, with the state of its evidence.
298    pub reasons: Vec<IntentVerdictReason>,
299}
300
301/// Whether the ledger or the claim join can settle a criterion of this evidence kind.
302fn ledger_settles(evidence: &str) -> bool {
303    matches!(evidence, "test" | "claim")
304}
305
306/// The verdict of an intent from its criteria alone. Pure: no plan, no index, no ledger — the
307/// criteria already carry what the evidence said.
308///
309/// ```
310/// use majordomus_cli::intent::{verdict, IntentCriterion, IntentEvidenceState, IntentVerdictState};
311/// let c = |evidence: &str, state: IntentEvidenceState| IntentCriterion {
312///     id: evidence.into(), criterion: "c".into(), evidence: evidence.into(),
313///     reference: "r".into(), met: state == IntentEvidenceState::Current, state,
314///     proof: None, reproduce: None,
315/// };
316/// let met = c("test", IntentEvidenceState::Current);
317/// assert_eq!(verdict(&[met.clone()]).state, IntentVerdictState::Satisfied);
318/// // a command the ledger cannot run leaves the evidence unable to answer
319/// let cmd = c("command", IntentEvidenceState::NotDerivable);
320/// assert_eq!(verdict(&[met.clone(), cmd.clone()]).state, IntentVerdictState::Unknown);
321/// // a test the ledger can settle, and has not, says no
322/// let unmet = c("test", IntentEvidenceState::NotRun);
323/// let v = verdict(&[met, cmd, unmet]);
324/// assert_eq!(v.state, IntentVerdictState::Unsatisfied);
325/// assert_eq!(v.reasons.len(), 2);
326/// ```
327pub fn verdict(criteria: &[IntentCriterion]) -> IntentVerdict {
328    let reasons: Vec<IntentVerdictReason> = criteria
329        .iter()
330        .filter(|c| !c.met)
331        .map(|c| IntentVerdictReason {
332            criterion: c.id.clone(),
333            evidence: c.evidence.clone(),
334            state: c.state,
335        })
336        .collect();
337    let state = if criteria.is_empty() {
338        IntentVerdictState::Unknown
339    } else if reasons.is_empty() {
340        IntentVerdictState::Satisfied
341    } else if reasons.iter().any(|r| ledger_settles(&r.evidence)) {
342        IntentVerdictState::Unsatisfied
343    } else {
344        IntentVerdictState::Unknown
345    };
346    IntentVerdict { state, reasons }
347}
348
349// ---------------------------------------------------------------- the record as authored
350
351/// One satisfaction criterion as the record declares it: what must be observably true, the
352/// kind of evidence that settles it, and what that evidence names.
353///
354/// ```
355/// use majordomus_cli::intent::CriterionRecord;
356/// let c = CriterionRecord {
357///     id: "surfaces-agree".into(),
358///     criterion: "the command line, HTTP and MCP answer the same intents".into(),
359///     evidence: "test".into(),
360///     reference: "apps/majordomus-cli/tests/intent.rs".into(),
361/// };
362/// assert_eq!(c.evidence, "test");
363/// ```
364#[derive(Debug, Clone, PartialEq, Eq, Default)]
365pub struct CriterionRecord {
366    /// Unique within the intent.
367    pub id: String,
368    /// What is observably true when the criterion is met.
369    pub criterion: String,
370    /// `test`, `claim`, `command` or `deployment`.
371    pub evidence: String,
372    /// What the evidence names.
373    pub reference: String,
374}
375
376/// One intent as its file declares it: the statement that must become true, the invariants that
377/// must stay true, the milestones that realise it and the criteria that settle it. Nothing
378/// derived is stored here.
379///
380/// ```
381/// use majordomus_cli::intent::IntentRecord;
382/// let r = IntentRecord::from_metadata(".ai/repo/project/intents/x.yaml", &serde_json::json!({
383///     "id": "x", "title": "X", "statement": "It becomes true.", "milestones": ["m"],
384///     "satisfaction": [{"id": "c", "criterion": "c", "evidence": "test", "ref": "t"}],
385/// }));
386/// assert_eq!(r.id, "x");
387/// assert!(!r.cancelled);
388/// ```
389#[derive(Debug, Clone, PartialEq, Eq, Default)]
390pub struct IntentRecord {
391    /// The identity.
392    pub id: String,
393    /// Repository-relative path of the file.
394    pub source: String,
395    /// One line.
396    pub title: String,
397    /// What must become true.
398    pub statement: String,
399    /// What must stay true.
400    pub invariants: Vec<String>,
401    /// The criteria.
402    pub satisfaction: Vec<CriterionRecord>,
403    /// Milestone ids.
404    pub milestones: Vec<String>,
405    /// Governance references.
406    pub governance: Vec<String>,
407    /// What is deliberately not required.
408    pub non_goals: Vec<String>,
409    /// Abandoned.
410    pub cancelled: bool,
411    /// The replacement, when there is one.
412    pub superseded_by: String,
413}
414
415fn text(meta: &Value, key: &str) -> String {
416    meta.get(key)
417        .and_then(Value::as_str)
418        .unwrap_or_default()
419        .to_string()
420}
421
422fn texts(meta: &Value, key: &str) -> Vec<String> {
423    meta.get(key)
424        .and_then(Value::as_array)
425        .map(|a| {
426            a.iter()
427                .filter_map(Value::as_str)
428                .map(str::to_string)
429                .collect()
430        })
431        .unwrap_or_default()
432}
433
434impl IntentRecord {
435    /// The record an object of kind `intent` carries. The schema has already refused a
436    /// malformed file by the time the index holds it, so this reads values, never guesses.
437    ///
438    /// ```
439    /// use majordomus_cli::intent::IntentRecord;
440    /// use serde_json::json;
441    /// let r = IntentRecord::from_metadata(
442    ///     ".ai/repo/project/intents/x.yaml",
443    ///     &json!({"id": "x", "title": "X", "milestones": ["m"],
444    ///             "satisfaction": [{"id": "c", "criterion": "c", "evidence": "test",
445    ///                               "ref": "test/cases/1_x.sh"}]}),
446    /// );
447    /// assert_eq!(r.milestones, ["m"]);
448    /// assert_eq!(r.satisfaction[0].reference, "test/cases/1_x.sh");
449    /// ```
450    pub fn from_metadata(source: &str, meta: &Value) -> IntentRecord {
451        let satisfaction = meta
452            .get("satisfaction")
453            .and_then(Value::as_array)
454            .map(|a| {
455                a.iter()
456                    .map(|c| CriterionRecord {
457                        id: text(c, "id"),
458                        criterion: text(c, "criterion"),
459                        evidence: text(c, "evidence"),
460                        reference: text(c, "ref"),
461                    })
462                    .collect()
463            })
464            .unwrap_or_default();
465        IntentRecord {
466            id: text(meta, "id"),
467            source: source.to_string(),
468            title: text(meta, "title"),
469            statement: text(meta, "statement"),
470            invariants: texts(meta, "invariants"),
471            satisfaction,
472            milestones: texts(meta, "milestones"),
473            governance: texts(meta, "governance"),
474            non_goals: texts(meta, "non_goals"),
475            cancelled: meta
476                .get("cancelled")
477                .and_then(Value::as_bool)
478                .unwrap_or(false),
479            superseded_by: text(meta, "superseded_by"),
480        }
481    }
482
483    /// Every intent record an index holds, in identity order, so nothing downstream depends
484    /// on the order the files were read.
485    ///
486    /// ```
487    /// use majordomus_cli::index::Index;
488    /// use majordomus_cli::intent::IntentRecord;
489    /// # fn demo(index: &Index) {
490    /// let records = IntentRecord::all(index);
491    /// assert!(records.windows(2).all(|w| w[0].id <= w[1].id));
492    /// # }
493    /// ```
494    pub fn all(index: &Index) -> Vec<IntentRecord> {
495        let by_id: BTreeMap<&str, IntentRecord> = index
496            .objects
497            .iter()
498            .filter(|o| o.kind == INTENT)
499            .map(|o| {
500                (
501                    o.identity.as_str(),
502                    IntentRecord::from_metadata(&o.provenance.path, &o.metadata),
503                )
504            })
505            .collect();
506        by_id.into_values().collect()
507    }
508}
509
510// ---------------------------------------------------------------- where evidence comes from
511
512/// What one test's recorded evidence says: whether its source is in the tree at all, and what
513/// its latest recorded run came to. A test that is not there cannot settle anything.
514///
515/// ```
516/// use majordomus_cli::intent::{IntentEvidenceState, TestStanding};
517/// let standing = TestStanding {
518///     present: false,
519///     state: IntentEvidenceState::Unresolved,
520///     proof: None,
521/// };
522/// assert!(!standing.present);
523/// ```
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub struct TestStanding {
526    /// Whether the test's source is in the tree.
527    pub present: bool,
528    /// The state of its latest recorded run.
529    pub state: IntentEvidenceState,
530    /// The evidence module's own verdict behind `state`, when the run was judged by it.
531    pub proof: Option<ProofState>,
532}
533
534/// What a declared claim's evidence says: the criterion's state, and the claim report's own
535/// verdict behind it — `proven` and `inputs_unchanged` both meet a criterion, and stay two
536/// answers.
537///
538/// ```
539/// use majordomus_cli::evidence::ProofState;
540/// use majordomus_cli::intent::{ClaimStanding, IntentEvidenceState};
541/// let standing = ClaimStanding {
542///     state: IntentEvidenceState::Current,
543///     proof: ProofState::InputsUnchanged,
544/// };
545/// assert_ne!(standing.proof, ProofState::Proven);
546/// ```
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct ClaimStanding {
549    /// What the claim's evidence means for a criterion.
550    pub state: IntentEvidenceState,
551    /// The claim report's verdict.
552    pub proof: ProofState,
553}
554
555/// The facts the derivation reads beyond the plan. The repository answers them from the
556/// ledger, the claim matrix and the tree; a test answers them from a table.
557///
558/// ```
559/// use majordomus_cli::evidence::TestId;
560/// use majordomus_cli::intent::{ClaimStanding, EvidenceLookup, IntentEvidenceState, TestStanding};
561/// struct NothingRecorded;
562/// impl EvidenceLookup for NothingRecorded {
563///     fn test(&self, _: &TestId, _: &[String]) -> TestStanding {
564///         TestStanding { present: true, state: IntentEvidenceState::NotRun, proof: None }
565///     }
566///     fn claim(&self, _: &str) -> Option<ClaimStanding> { None }
567///     fn deployment(&self, _: &str) -> bool { false }
568///     fn object(&self, _: &str, _: &str) -> bool { false }
569///     fn file(&self, _: &str) -> bool { false }
570/// }
571/// let id = TestId::of("test/cases/00_x.sh").unwrap();
572/// assert_eq!(NothingRecorded.test(&id, &[]).state, IntentEvidenceState::NotRun);
573/// ```
574pub trait EvidenceLookup {
575    /// What the ledger holds for one test, by its identity: whether its source is in the tree
576    /// and what its latest run came to. `scope` is the code the plan declares for the
577    /// criterion — the `scope` of every issue that `serves` it — which the judgement counts
578    /// among the test's inputs beside what the claims that test proves name.
579    ///
580    /// ```
581    /// use majordomus_cli::evidence::TestId;
582    /// use majordomus_cli::intent::{EvidenceLookup, TestStanding};
583    /// # fn demo(ev: &dyn EvidenceLookup) {
584    /// let id = TestId::of("test/cases/00_x.sh").unwrap();
585    /// let standing: TestStanding = ev.test(&id, &["lib/a".to_string()]);
586    /// assert!(standing.present || !standing.present);
587    /// # }
588    /// ```
589    fn test(&self, id: &TestId, scope: &[String]) -> TestStanding;
590    /// What a claim's evidence says, judged as [`IntentEvidenceState::judged`] judges it,
591    /// with the claim report's verdict behind it; `None` when no such claim is declared —
592    /// which is a refusal, not an unproven claim.
593    ///
594    /// ```
595    /// use majordomus_cli::intent::EvidenceLookup;
596    /// # fn demo(ev: &dyn EvidenceLookup) {
597    /// assert!(ev.claim("no-such-claim-exists").is_none());
598    /// # }
599    /// ```
600    fn claim(&self, id: &str) -> Option<ClaimStanding>;
601    /// Whether a deployment object with this id exists. A `deployment` criterion resolves
602    /// through this and is still never counted as met.
603    ///
604    /// ```
605    /// use majordomus_cli::intent::EvidenceLookup;
606    /// # fn demo(ev: &dyn EvidenceLookup) {
607    /// assert!(!ev.deployment("no-such-deployment"));
608    /// # }
609    /// ```
610    fn deployment(&self, id: &str) -> bool;
611    /// Whether an object of `kind` answers to `identity` (`rule` matches with or without
612    /// its `@version`). This is what makes a `governance:` entry resolve or fail.
613    ///
614    /// ```
615    /// use majordomus_cli::intent::EvidenceLookup;
616    /// # fn demo(ev: &dyn EvidenceLookup) {
617    /// assert!(!ev.object("rule", "project.no-such-rule"));
618    /// # }
619    /// ```
620    fn object(&self, kind: &str, identity: &str) -> bool;
621    /// Whether a repository-relative file exists, for a `file:` governance entry and for the
622    /// test sources a criterion names.
623    ///
624    /// ```
625    /// use majordomus_cli::intent::EvidenceLookup;
626    /// # fn demo(ev: &dyn EvidenceLookup) {
627    /// assert!(!ev.file("no/such/file.md"));
628    /// # }
629    /// ```
630    fn file(&self, path: &str) -> bool;
631}
632
633/// The evidence of a real repository: its ledger, its claim matrix joined to that ledger,
634/// its index and its tree, judged at the working tree by the evidence module's own
635/// derivation — [`evidence::report`] for a claim, [`evidence::freshness()`] over the same
636/// [`evidence::compare`] for a test — so a criterion is current exactly when the rest of
637/// Majordomus would call its evidence current. The claim join and the comparisons run git,
638/// so each is computed once, and only when a criterion or a governance entry asks.
639///
640/// ```
641/// use majordomus_cli::index::Index;
642/// use majordomus_cli::intent::{EvidenceLookup, RepositoryEvidence};
643/// # fn demo(index: &Index) {
644/// let evidence = RepositoryEvidence::load(index).expect("the ledger is readable");
645/// // a file the repository does not hold is answered without running git
646/// assert!(!evidence.file("no/such/file.md"));
647/// # }
648/// ```
649pub struct RepositoryEvidence<'a> {
650    index: &'a Index,
651    root: PathBuf,
652    ledger: Ledger,
653    claims: std::cell::OnceCell<EvidenceReport>,
654    comparisons: std::cell::RefCell<BTreeMap<String, Comparison>>,
655}
656
657impl<'a> RepositoryEvidence<'a> {
658    /// The evidence of the repository an index was built from. A ledger this executable
659    /// cannot read is an error, never an empty ledger: a misread one could report a pass
660    /// that was never recorded.
661    ///
662    /// ```
663    /// use majordomus_cli::index::Index;
664    /// use majordomus_cli::intent::RepositoryEvidence;
665    /// # fn demo(index: &Index) {
666    /// // a ledger that cannot be read is an error here, never an empty ledger
667    /// let evidence = RepositoryEvidence::load(index).expect("the ledger is readable");
668    /// let _ = &evidence;
669    /// # }
670    /// ```
671    pub fn load(index: &'a Index) -> crate::error::Result<RepositoryEvidence<'a>> {
672        let root = PathBuf::from(&index.repository.root);
673        let ledger = Ledger::load(&root)?;
674        Ok(RepositoryEvidence {
675            index,
676            root,
677            ledger,
678            claims: std::cell::OnceCell::new(),
679            comparisons: std::cell::RefCell::new(BTreeMap::new()),
680        })
681    }
682
683    fn root(&self) -> &Path {
684        &self.root
685    }
686
687    /// The claim join at the working tree, computed once.
688    fn claims(&self) -> &EvidenceReport {
689        self.claims
690            .get_or_init(|| evidence::report(self.index, &self.ledger))
691    }
692
693    /// The comparison of one evidence commit with the working tree, computed once per commit.
694    fn comparison(&self, commit: &str) -> Comparison {
695        self.comparisons
696            .borrow_mut()
697            .entry(commit.to_string())
698            .or_insert_with(|| compare(self.root(), commit, &Presented::WorkingTree))
699            .clone()
700    }
701
702    /// What a test criterion names: the test's own source; the source and implementation of
703    /// every claim that same test proves; and every path, among those `changed` since the run,
704    /// under the `scope` of an issue serving the criterion — the code under test, as the
705    /// repository declares it. A criterion names nothing else, so a change elsewhere, the
706    /// plan's own records included, does not move it.
707    ///
708    /// `None` when no claim that test proves names an implementation and no issue serving the
709    /// criterion declares a scope: the repository then declares nothing about the code under
710    /// test, and the test's own file is not that code. The truth table reads `None` as a route
711    /// that names no inputs (its row 14), so any change since the run, the code under test
712    /// included, leaves the pass stale rather than current; a run at the checkout's own commit
713    /// is still `proven`.
714    fn inputs(
715        &self,
716        id: &TestId,
717        scope: &[String],
718        changed: Option<&BTreeSet<String>>,
719    ) -> Option<Vec<String>> {
720        let test = id.as_string();
721        let mut inputs = vec![id.source()];
722        let mut declared = !scope.is_empty();
723        let mut push = |p: &String| {
724            if !inputs.contains(p) {
725                inputs.push(p.clone());
726            }
727        };
728        for c in &self.claims().claims {
729            if c.test.as_deref() == Some(test.as_str()) {
730                declared |= c.implementation.is_some();
731                [&c.source, &c.implementation]
732                    .into_iter()
733                    .flatten()
734                    .for_each(&mut push);
735            }
736        }
737        changed
738            .into_iter()
739            .flatten()
740            .filter(|p| scope.iter().any(|s| within(p, s)))
741            .for_each(&mut push);
742        declared.then_some(inputs)
743    }
744}
745
746/// Where the plan keeps its records: a change there is a change to the plan, never to the code a
747/// criterion's test exercises.
748const PLAN_RECORDS: &str = ".ai/repo/project";
749
750impl EvidenceLookup for RepositoryEvidence<'_> {
751    fn test(&self, id: &TestId, scope: &[String]) -> TestStanding {
752        let source = id.source();
753        let present = self.root().join(&source).is_file();
754        let execution = self.ledger.latest(&id.as_string());
755        let Some(e) = execution else {
756            return TestStanding {
757                present,
758                state: IntentEvidenceState::NotRun,
759                proof: Some(ProofState::NotRun),
760            };
761        };
762        // the claim report's own reading of a run: a test that no longer hashes to what ran
763        // did not run in the form it is in now, whatever the diff says
764        let moved = e.outcome.proves() && e.digest_matches(self.root()) == Some(false);
765        // the plan's own records are not the code under test: closing the work a criterion is
766        // served by is a change to `.ai/repo/project/`, and it must not stale the pass that
767        // proves the criterion, whether or not the repository declares the code under test
768        let mut comparison = self.comparison(&e.commit);
769        // a comparison git could not make has no list to filter, and stays unknown
770        comparison
771            .changed
772            .iter_mut()
773            .for_each(|changed| changed.retain(|p| !within(p, PLAN_RECORDS)));
774        let inputs = self.inputs(id, scope, comparison.changed.as_ref());
775        let judgement = freshness(
776            Recorded::Ran(e),
777            inputs.as_deref(),
778            &source,
779            moved,
780            Some(&comparison),
781            Presented::WorkingTree.tree(),
782        );
783        TestStanding {
784            present,
785            state: IntentEvidenceState::judged(judgement.state, TreeState::parse(&e.working_tree)),
786            proof: Some(judgement.state),
787        }
788    }
789
790    fn claim(&self, id: &str) -> Option<ClaimStanding> {
791        let c = self.claims().claims.iter().find(|c| c.id == id)?;
792        let recorded = c
793            .execution
794            .as_ref()
795            .map_or(TreeState::Unknown, |e| TreeState::parse(&e.working_tree));
796        Some(ClaimStanding {
797            state: IntentEvidenceState::judged(c.state, recorded),
798            proof: c.state,
799        })
800    }
801
802    fn deployment(&self, id: &str) -> bool {
803        self.object("deployment", id)
804    }
805
806    fn object(&self, kind: &str, identity: &str) -> bool {
807        self.index.objects.iter().any(|o| {
808            o.kind == kind
809                && (o.identity == identity
810                    || (kind == "rule"
811                        && o.identity
812                            .strip_prefix(identity)
813                            .is_some_and(|rest| rest.starts_with('@'))))
814        })
815    }
816
817    fn file(&self, path: &str) -> bool {
818        !path.contains("..") && self.root().join(path).exists()
819    }
820}
821
822// ---------------------------------------------------------------- the derived model
823
824/// One milestone an intent names, as the plan derives it. A milestone that does not resolve is
825/// carried with `resolved: false` rather than dropped, because a name that points at nothing is
826/// a finding and not an absence.
827///
828/// ```
829/// use majordomus_cli::intent::IntentMilestone;
830/// let named = IntentMilestone { id: "no-such".into(), resolved: false, status: None };
831/// assert!(!named.resolved);
832/// assert!(named.status.is_none());
833/// ```
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
835pub struct IntentMilestone {
836    /// The id the intent names.
837    pub id: String,
838    /// Whether the plan holds a milestone with that id.
839    pub resolved: bool,
840    /// The status the plan derives for it; absent when it does not resolve.
841    #[serde(skip_serializing_if = "Option::is_none")]
842    pub status: Option<String>,
843}
844
845/// One criterion with the evidence behind it: what the record declared, what the ledger says
846/// about it, whether that meets it, and the command that would produce the evidence again.
847///
848/// ```
849/// use majordomus_cli::evidence::ProofState;
850/// use majordomus_cli::intent::{IntentCriterion, IntentEvidenceState};
851/// let c = IntentCriterion {
852///     id: "stage-is-derived".into(),
853///     criterion: "the stage follows the plan".into(),
854///     evidence: "test".into(),
855///     reference: "apps/majordomus-cli/tests/intent.rs".into(),
856///     state: IntentEvidenceState::NotRun,
857///     met: false,
858///     proof: Some(ProofState::NotRun),
859///     reproduce: None,
860/// };
861/// // nothing recorded is not a pass
862/// assert!(!c.met);
863/// ```
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
865pub struct IntentCriterion {
866    /// Unique within the intent.
867    pub id: String,
868    /// What is observably true when it is met.
869    pub criterion: String,
870    /// `test`, `claim`, `command` or `deployment`.
871    pub evidence: String,
872    /// What the evidence names, as authored.
873    #[serde(rename = "ref")]
874    pub reference: String,
875    /// Derived: what the evidence says.
876    pub state: IntentEvidenceState,
877    /// Derived: the state is `current`.
878    pub met: bool,
879    /// Derived: the evidence module's own verdict behind `state`, for a `test` or `claim`
880    /// criterion whose reference resolves. A met criterion rests on `proven` (a pass at this
881    /// revision) or `inputs_unchanged` (a pass whose named inputs have not moved since, which
882    /// is not proof at this revision), and every surface shows which, so `met` is never the
883    /// one tick for both.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub proof: Option<ProofState>,
886    /// The command that produces the evidence again, when one is known.
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub reproduce: Option<String>,
889}
890
891/// One intent, as its record declares it and as the plan and the ledger derive it: the
892/// statement and invariants as authored, each milestone with its derived status, each criterion
893/// with the state of its evidence, the stage that follows from both, and the verdict that
894/// follows from the evidence alone.
895///
896/// ```
897/// use majordomus_cli::intent::{verdict, IntentStage, IntentVerdictState, IntentView};
898/// let view = IntentView {
899///     id: "intent-lifecycle".into(),
900///     title: "Work is traceable to the intent it serves".into(),
901///     statement: "A reader can ask what work is for.".into(),
902///     invariants: vec!["no intent status is stored".into()],
903///     stage: IntentStage::Declared,
904///     milestones: vec![],
905///     satisfaction: vec![],
906///     met: 0,
907///     verdict: verdict(&[]),
908///     governance: vec!["adr:adr-0070".into()],
909///     non_goals: vec![],
910///     superseded_by: None,
911///     source: ".ai/repo/project/intents/intent-lifecycle.yaml".into(),
912/// };
913/// // a record naming no milestone that resolves has not been planned
914/// assert_eq!(view.stage, IntentStage::Declared);
915/// // and one declaring no criterion cannot be settled by evidence
916/// assert_eq!(view.verdict.state, IntentVerdictState::Unknown);
917/// ```
918#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
919pub struct IntentView {
920    /// The identity, which is also the file name.
921    pub id: String,
922    /// One line.
923    pub title: String,
924    /// What must become true.
925    pub statement: String,
926    /// What must stay true.
927    pub invariants: Vec<String>,
928    /// Derived: the stage.
929    pub stage: IntentStage,
930    /// The milestones, each with its derived status.
931    pub milestones: Vec<IntentMilestone>,
932    /// The criteria, each with its evidence state.
933    pub satisfaction: Vec<IntentCriterion>,
934    /// Derived: how many criteria are met.
935    pub met: usize,
936    /// Derived from the criteria alone, never from the plan: whether the evidence settles the
937    /// intent, and which criteria hold it back.
938    pub verdict: IntentVerdict,
939    /// The governance a worker on this intent loads.
940    pub governance: Vec<String>,
941    /// What is deliberately not required.
942    pub non_goals: Vec<String>,
943    /// The replacement, when the record names one.
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub superseded_by: Option<String>,
946    /// Repository-relative path of the record.
947    pub source: String,
948}
949
950/// One validation finding: its level, a stable code a reader greps for, the intent or milestone
951/// it is about, one line of what is wrong, and the command that shows it again.
952///
953/// ```
954/// use majordomus_cli::intent::{IntentFinding, FAIL};
955/// let f = IntentFinding {
956///     level: FAIL.into(),
957///     code: "unknown_milestone".into(),
958///     subject: "intent-lifecycle".into(),
959///     message: "`no-such` is not a milestone".into(),
960///     reproduce: "majordomus intent validate".into(),
961/// };
962/// assert_eq!(f.level, "FAIL");
963/// assert_eq!(f.reproduce, "majordomus intent validate");
964/// ```
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
966pub struct IntentFinding {
967    /// `FAIL` or `WARN`. A failure means the intent model is invalid.
968    pub level: String,
969    /// The stable code a reader greps for.
970    pub code: String,
971    /// The intent or milestone the finding is about.
972    pub subject: String,
973    /// What is wrong, in one line.
974    pub message: String,
975    /// The command that shows it again.
976    pub reproduce: String,
977}
978
979/// Every intent, derived, with every finding: what `majordomus intent validate` answers out of,
980/// and what the list, the record and the preflight are read from. Nothing here is stored.
981///
982/// ```
983/// use majordomus_cli::intent::Intents;
984/// let empty = Intents { intents: vec![], findings: vec![] };
985/// // a repository that declares no intent is valid, and says so
986/// assert_eq!(empty.failures(), 0);
987/// assert!(empty.intent("intent-lifecycle").is_none());
988/// ```
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
990pub struct Intents {
991    /// Every intent, in identity order.
992    pub intents: Vec<IntentView>,
993    /// Every finding: per intent in identity order, then the milestones no intent serves.
994    pub findings: Vec<IntentFinding>,
995}
996
997/// The command that shows every intent finding again.
998pub const REPRODUCE: &str = "majordomus intent validate";
999
1000fn finding(out: &mut Vec<IntentFinding>, level: &str, code: &str, subject: &str, msg: String) {
1001    out.push(IntentFinding {
1002        level: level.into(),
1003        code: code.into(),
1004        subject: subject.into(),
1005        message: msg,
1006        reproduce: REPRODUCE.into(),
1007    });
1008}
1009
1010/// The milestone statuses that mean nothing is required of the milestone any more.
1011fn retired(status: &str) -> bool {
1012    matches!(status, "CANCELLED" | "SUPERSEDED")
1013}
1014
1015/// The stage of an intent from the facts it rests on. Pure: no index, no ledger.
1016///
1017/// ```
1018/// use majordomus_cli::intent::{stage, IntentStage};
1019/// let st = |s: &str| Some(s.to_string());
1020/// assert_eq!(stage(false, false, &[None], true), IntentStage::Declared);
1021/// assert_eq!(stage(false, false, &[st("PLANNED")], true), IntentStage::Planned);
1022/// assert_eq!(stage(false, false, &[st("ACTIVE"), st("PLANNED")], true), IntentStage::Executing);
1023/// assert_eq!(stage(false, false, &[st("DONE")], false), IntentStage::Verifying);
1024/// assert_eq!(stage(false, false, &[st("DONE")], true), IntentStage::Satisfied);
1025/// ```
1026pub fn stage(
1027    cancelled: bool,
1028    superseded: bool,
1029    milestones: &[Option<String>],
1030    all_met: bool,
1031) -> IntentStage {
1032    if cancelled {
1033        return IntentStage::Cancelled;
1034    }
1035    if superseded {
1036        return IntentStage::Superseded;
1037    }
1038    if milestones.is_empty() || milestones.iter().any(Option::is_none) {
1039        return IntentStage::Declared;
1040    }
1041    let statuses: Vec<&str> = milestones.iter().flatten().map(String::as_str).collect();
1042    let counted: Vec<&str> = statuses.iter().copied().filter(|s| !retired(s)).collect();
1043    if !counted.is_empty() && counted.iter().all(|s| *s == "DONE") {
1044        return if all_met {
1045            IntentStage::Satisfied
1046        } else {
1047            IntentStage::Verifying
1048        };
1049    }
1050    if counted
1051        .iter()
1052        .any(|s| matches!(*s, "ACTIVE" | "VERIFY" | "DONE"))
1053    {
1054        return IntentStage::Executing;
1055    }
1056    IntentStage::Planned
1057}
1058
1059fn criterion_of(
1060    intent: &str,
1061    c: &CriterionRecord,
1062    scope: &[String],
1063    ev: &dyn EvidenceLookup,
1064    findings: &mut Vec<IntentFinding>,
1065) -> IntentCriterion {
1066    let r = c.reference.trim();
1067    let mut reproduce = None;
1068    let mut proof = None;
1069    let unresolved = |findings: &mut Vec<IntentFinding>, why: String| {
1070        finding(
1071            findings,
1072            FAIL,
1073            "unresolved_evidence_ref",
1074            intent,
1075            format!("criterion `{}`: {why}", c.id),
1076        );
1077        IntentEvidenceState::Unresolved
1078    };
1079    let state = if r.is_empty() {
1080        finding(
1081            findings,
1082            FAIL,
1083            "criterion_without_ref",
1084            intent,
1085            format!(
1086                "criterion `{}` names no {} evidence, so nothing could ever settle it",
1087                c.id, c.evidence
1088            ),
1089        );
1090        IntentEvidenceState::Unresolved
1091    } else {
1092        match c.evidence.as_str() {
1093            "test" => match test_id(r) {
1094                None => unresolved(
1095                    findings,
1096                    format!(
1097                        "`{r}` names no test: give a path under test/cases/ or \
1098                         apps/majordomus-cli/tests/, or `suite:<case>` / `crate:<binary>`"
1099                    ),
1100                ),
1101                Some(id) => {
1102                    let standing = ev.test(&id, scope);
1103                    if standing.present {
1104                        reproduce = Some(id.reproduce());
1105                        proof = standing.proof;
1106                        standing.state
1107                    } else {
1108                        unresolved(
1109                            findings,
1110                            format!("the test `{r}` is not in the tree ({})", id.source()),
1111                        )
1112                    }
1113                }
1114            },
1115            "claim" => match ev.claim(r) {
1116                None => unresolved(
1117                    findings,
1118                    format!("`{r}` is not a claim of docs/CLAIMS.yaml"),
1119                ),
1120                Some(standing) => {
1121                    proof = Some(standing.proof);
1122                    standing.state
1123                }
1124            },
1125            "deployment" => {
1126                if ev.deployment(r) {
1127                    IntentEvidenceState::NotDerivable
1128                } else {
1129                    unresolved(
1130                        findings,
1131                        format!("`{r}` is not a deployment of this repository"),
1132                    )
1133                }
1134            }
1135            // `command`: the schema admits nothing else
1136            _ => {
1137                reproduce = Some(r.to_string());
1138                IntentEvidenceState::NotDerivable
1139            }
1140        }
1141    };
1142    IntentCriterion {
1143        id: c.id.clone(),
1144        criterion: c.criterion.clone(),
1145        evidence: c.evidence.clone(),
1146        reference: c.reference.clone(),
1147        met: state == IntentEvidenceState::Current,
1148        state,
1149        proof,
1150        reproduce,
1151    }
1152}
1153
1154/// The code the plan declares for one criterion: the `scope` of every issue that `serves`
1155/// `<intent>#<criterion>`, in plan order, each path once.
1156fn serving_scope(plan: &Plan, intent: &str, criterion: &str) -> Vec<String> {
1157    let link = format!("{intent}#{criterion}");
1158    let mut scope: Vec<String> = Vec::new();
1159    for i in plan
1160        .issues
1161        .iter()
1162        .filter(|i| i.serves.iter().any(|s| s.trim() == link))
1163    {
1164        for p in &i.scope {
1165            let p = p.trim().trim_end_matches('/').to_string();
1166            if !p.is_empty() && !scope.contains(&p) {
1167                scope.push(p);
1168            }
1169        }
1170    }
1171    scope
1172}
1173
1174/// Whether a repository path is a declared scope entry or lies under it; `.` is the whole tree.
1175fn within(path: &str, scope: &str) -> bool {
1176    scope == "."
1177        || path == scope
1178        || path
1179            .strip_prefix(scope)
1180            .is_some_and(|rest| rest.starts_with('/'))
1181}
1182
1183fn test_id(r: &str) -> Option<TestId> {
1184    TestId::of(r).or_else(|| {
1185        let (prefix, name) = r.split_once(':')?;
1186        let runner = match prefix {
1187            "suite" => evidence::Runner::Suite,
1188            "crate" => evidence::Runner::Crate,
1189            _ => return None,
1190        };
1191        TestId::named(runner, name)
1192    })
1193}
1194
1195fn governance_resolves(g: &str, ev: &dyn EvidenceLookup) -> bool {
1196    let Some((kind, what)) = g.split_once(':') else {
1197        return false;
1198    };
1199    match kind {
1200        "rule" => ev.object("rule", what),
1201        "adr" => ev.object("adr", what),
1202        "claim" => ev.claim(what).is_some(),
1203        "file" => ev.file(what),
1204        _ => false,
1205    }
1206}
1207
1208/// What the planning half needs of each record: its criteria, its milestones, whether it is
1209/// retired, and the criteria its recorded gap observed already satisfied.
1210///
1211/// ```
1212/// use majordomus_cli::intent::{outlines, IntentRecord};
1213/// use serde_json::json;
1214/// let r = IntentRecord::from_metadata(".ai/repo/project/intents/x.yaml", &json!({
1215///     "id": "x", "milestones": ["m"],
1216///     "satisfaction": [{"id": "c", "evidence": "test", "ref": "t"}]}));
1217/// let o = outlines(std::slice::from_ref(&r), &[]);
1218/// assert_eq!(o[0].criteria, ["c"]);
1219/// assert!(!o[0].retired);
1220/// ```
1221pub fn outlines(records: &[IntentRecord], gaps: &[GapRecord]) -> Vec<IntentOutline> {
1222    let observed = observed_satisfied(gaps);
1223    records
1224        .iter()
1225        .map(|r| IntentOutline {
1226            id: r.id.clone(),
1227            criteria: r.satisfaction.iter().map(|c| c.id.clone()).collect(),
1228            milestones: r.milestones.clone(),
1229            retired: r.cancelled || !r.superseded_by.is_empty(),
1230            observed_satisfied: observed.get(&r.id).cloned().unwrap_or_default(),
1231        })
1232        .collect()
1233}
1234
1235impl Intents {
1236    /// Derive every intent of an index against its plan and its evidence. Nothing is read
1237    /// but what the three already hold, and nothing is written.
1238    ///
1239    /// The findings are the record's own (this module) followed by the planning half's
1240    /// (ADR 0073): whether the plan carries every criterion, why each issue exists, and
1241    /// whether the recorded gap and critique hold and were made before execution.
1242    ///
1243    /// ```
1244    /// use majordomus_cli::index::Index;
1245    /// use majordomus_cli::intent::{Intents, RepositoryEvidence};
1246    /// use majordomus_cli::plan::Plan;
1247    /// # fn demo(index: &Index) {
1248    /// let plan = Plan::build(index);
1249    /// let evidence = RepositoryEvidence::load(index).expect("the ledger is readable");
1250    /// let intents = Intents::build(index, &plan, &evidence);
1251    /// // the record's findings, then the coverage of its criteria by the plan
1252    /// assert!(intents.failures() >= intents.findings.iter().filter(|f| f.level == "FAIL").count());
1253    /// # }
1254    /// ```
1255    pub fn build(index: &Index, plan: &Plan, ev: &dyn EvidenceLookup) -> Intents {
1256        let records = IntentRecord::all(index);
1257        let gaps = GapRecord::all(index);
1258        let critiques = CritiqueRecord::all(index);
1259        let outlines = outlines(&records, &gaps);
1260        let mut out = Intents::derive(records, plan, ev);
1261        out.findings.extend(coverage(&outlines, plan).findings);
1262        out.findings
1263            .extend(review(&outlines, plan, &gaps, &critiques));
1264        out
1265    }
1266
1267    /// The coverage of every criterion by the plan, and the reason every issue exists: the
1268    /// same derivation [`Intents::build`] reports findings from, answered on its own so a
1269    /// reader can ask which work carries which criterion (ADR 0073).
1270    ///
1271    /// ```
1272    /// use majordomus_cli::index::Index;
1273    /// use majordomus_cli::intent::Intents;
1274    /// use majordomus_cli::plan::Plan;
1275    /// # fn demo(index: &Index) {
1276    /// let plan = Plan::build(index);
1277    /// let coverage = Intents::coverage(index, &plan);
1278    /// // every issue of the plan answers why it exists, including the maintenance ones
1279    /// assert_eq!(coverage.issues.len(), plan.issues.len());
1280    /// # }
1281    /// ```
1282    pub fn coverage(index: &Index, plan: &Plan) -> IntentCoverage {
1283        let records = IntentRecord::all(index);
1284        coverage(&outlines(&records, &GapRecord::all(index)), plan)
1285    }
1286
1287    /// Derive from records already read: what [`Intents::build`] does after reading them,
1288    /// and what a test calls with records it wrote.
1289    ///
1290    /// ```
1291    /// use majordomus_cli::evidence::TestId;
1292    /// use majordomus_cli::intent::{
1293    ///     ClaimStanding, EvidenceLookup, IntentEvidenceState, IntentRecord, Intents,
1294    ///     TestStanding,
1295    /// };
1296    /// use majordomus_cli::plan::Plan;
1297    /// struct NothingRecorded;
1298    /// impl EvidenceLookup for NothingRecorded {
1299    ///     fn test(&self, _: &TestId, _: &[String]) -> TestStanding {
1300    ///         TestStanding { present: true, state: IntentEvidenceState::NotRun, proof: None }
1301    ///     }
1302    ///     fn claim(&self, _: &str) -> Option<ClaimStanding> { None }
1303    ///     fn deployment(&self, _: &str) -> bool { false }
1304    ///     fn object(&self, _: &str, _: &str) -> bool { false }
1305    ///     fn file(&self, _: &str) -> bool { false }
1306    /// }
1307    /// # let plan: Plan = serde_json::from_value(serde_json::json!({
1308    /// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
1309    /// #                 "active_milestone": ""},
1310    /// #     "statuses": {"issue": [], "milestone": []},
1311    /// #     "milestones": [], "issues": [], "waves": [], "edges": [],
1312    /// #     "milestone_edges": [], "findings": []})).unwrap();
1313    /// let record = IntentRecord::from_metadata(
1314    ///     ".ai/repo/project/intents/x.yaml",
1315    ///     &serde_json::json!({"id": "x", "title": "X", "milestones": ["absent"],
1316    ///         "satisfaction": [{"id": "c", "criterion": "c", "evidence": "test",
1317    ///                           "ref": "test/cases/00_x.sh"}]}),
1318    /// );
1319    /// let intents = Intents::derive(vec![record], &plan, &NothingRecorded);
1320    /// // the milestone it names is not in the plan, and that is a failure by name
1321    /// assert!(intents.findings.iter().any(|f| f.code == "unknown_milestone"));
1322    /// ```
1323    pub fn derive(records: Vec<IntentRecord>, plan: &Plan, ev: &dyn EvidenceLookup) -> Intents {
1324        let mut findings = Vec::new();
1325        let ids: BTreeSet<&str> = records.iter().map(|r| r.id.as_str()).collect();
1326        let mut served: BTreeSet<String> = BTreeSet::new();
1327        let mut intents = Vec::with_capacity(records.len());
1328
1329        for r in &records {
1330            let expected = format!("{INTENTS_DIR}/{}.yaml", r.id);
1331            if !r.source.is_empty() && r.source != expected {
1332                finding(
1333                    &mut findings,
1334                    FAIL,
1335                    "file_name_mismatch",
1336                    &r.id,
1337                    format!("the record is at {} and its id says {expected}", r.source),
1338                );
1339            }
1340            // A proposal names neither a milestone nor a criterion yet (ADR 0144): its stage is
1341            // `declared`, nothing can serve it, and the two absences are warnings until it is
1342            // planned. An intent that names one and not the other is half planned, and each
1343            // absence stays a failure.
1344            let proposed = r.milestones.is_empty() && r.satisfaction.is_empty();
1345            let unplanned = if proposed { WARN } else { FAIL };
1346            if r.milestones.is_empty() {
1347                finding(
1348                    &mut findings,
1349                    unplanned,
1350                    "intent_without_milestone",
1351                    &r.id,
1352                    if proposed {
1353                        "a proposed intent: it names no milestone yet, so nothing realises it and no work may serve it until it is planned".into()
1354                    } else {
1355                        "the intent names no milestone, so nothing realises it".into()
1356                    },
1357                );
1358            }
1359            let milestones: Vec<IntentMilestone> = r
1360                .milestones
1361                .iter()
1362                .map(|m| {
1363                    served.insert(m.clone());
1364                    let status = plan.milestone(m).map(|pm| pm.status.clone());
1365                    if status.is_none() {
1366                        finding(
1367                            &mut findings,
1368                            FAIL,
1369                            "unknown_milestone",
1370                            &r.id,
1371                            format!("`{m}` is not a milestone under .ai/repo/project/milestones/"),
1372                        );
1373                    }
1374                    IntentMilestone {
1375                        id: m.clone(),
1376                        resolved: status.is_some(),
1377                        status,
1378                    }
1379                })
1380                .collect();
1381
1382            let mut seen = BTreeSet::new();
1383            for c in &r.satisfaction {
1384                if !seen.insert(c.id.as_str()) {
1385                    finding(
1386                        &mut findings,
1387                        FAIL,
1388                        "duplicate_criterion",
1389                        &r.id,
1390                        format!("criterion `{}` is declared twice", c.id),
1391                    );
1392                }
1393            }
1394            if r.satisfaction.is_empty() {
1395                finding(
1396                    &mut findings,
1397                    unplanned,
1398                    "intent_without_criterion",
1399                    &r.id,
1400                    if proposed {
1401                        "a proposed intent: it declares no satisfaction criterion yet, so it cannot be satisfied until it is planned".into()
1402                    } else {
1403                        "the intent declares no satisfaction criterion, so it can never be satisfied"
1404                            .into()
1405                    },
1406                );
1407            }
1408            let satisfaction: Vec<IntentCriterion> = r
1409                .satisfaction
1410                .iter()
1411                .map(|c| {
1412                    let scope = serving_scope(plan, &r.id, &c.id);
1413                    criterion_of(&r.id, c, &scope, ev, &mut findings)
1414                })
1415                .collect();
1416
1417            for g in &r.governance {
1418                if !governance_resolves(g, ev) {
1419                    finding(
1420                        &mut findings,
1421                        FAIL,
1422                        "unresolved_governance",
1423                        &r.id,
1424                        format!("governance `{g}` names nothing this repository holds"),
1425                    );
1426                }
1427            }
1428            if !r.superseded_by.is_empty() && !ids.contains(r.superseded_by.as_str()) {
1429                finding(
1430                    &mut findings,
1431                    FAIL,
1432                    "unknown_successor",
1433                    &r.id,
1434                    format!("superseded_by `{}` is not an intent", r.superseded_by),
1435                );
1436            }
1437
1438            let met = satisfaction.iter().filter(|c| c.met).count();
1439            let all_met = !satisfaction.is_empty() && met == satisfaction.len();
1440            let statuses: Vec<Option<String>> =
1441                milestones.iter().map(|m| m.status.clone()).collect();
1442            intents.push(IntentView {
1443                id: r.id.clone(),
1444                title: r.title.clone(),
1445                statement: r.statement.clone(),
1446                invariants: r.invariants.clone(),
1447                stage: stage(r.cancelled, !r.superseded_by.is_empty(), &statuses, all_met),
1448                milestones,
1449                verdict: verdict(&satisfaction),
1450                satisfaction,
1451                met,
1452                governance: r.governance.clone(),
1453                non_goals: r.non_goals.clone(),
1454                superseded_by: (!r.superseded_by.is_empty()).then(|| r.superseded_by.clone()),
1455                source: r.source.clone(),
1456            });
1457        }
1458
1459        for m in &plan.milestones {
1460            if !served.contains(&m.id) && !retired(&m.status) {
1461                finding(
1462                    &mut findings,
1463                    WARN,
1464                    "milestone_serves_no_intent",
1465                    &m.id,
1466                    format!("the milestone is {} and no intent names it", m.status),
1467                );
1468            }
1469        }
1470        Intents { intents, findings }
1471    }
1472
1473    /// One intent by id, or `None` when this repository declares no such intent — which the
1474    /// capability turns into a refusal naming what it looked for.
1475    ///
1476    /// ```
1477    /// use majordomus_cli::intent::Intents;
1478    /// let intents = Intents { intents: vec![], findings: vec![] };
1479    /// assert!(intents.intent("absent").is_none());
1480    /// ```
1481    pub fn intent(&self, id: &str) -> Option<&IntentView> {
1482        self.intents.iter().find(|i| i.id == id)
1483    }
1484
1485    /// How many of the findings are failures rather than warnings: the count that decides
1486    /// whether the model is valid and whether `intent validate` exits 10.
1487    ///
1488    /// ```
1489    /// use majordomus_cli::intent::Intents;
1490    /// assert_eq!(Intents { intents: vec![], findings: vec![] }.failures(), 0);
1491    /// ```
1492    pub fn failures(&self) -> usize {
1493        self.findings.iter().filter(|f| f.level == FAIL).count()
1494    }
1495
1496    /// How many of the findings are warnings: reported, never fatal — a milestone no intent
1497    /// serves, an intent nobody has planned yet.
1498    ///
1499    /// ```
1500    /// use majordomus_cli::intent::Intents;
1501    /// assert_eq!(Intents { intents: vec![], findings: vec![] }.warnings(), 0);
1502    /// ```
1503    pub fn warnings(&self) -> usize {
1504        self.findings.iter().filter(|f| f.level == WARN).count()
1505    }
1506
1507    /// The intents that name a milestone, in identity order: the intents whose plan an issue
1508    /// under that milestone is part of, whatever it declares it serves.
1509    ///
1510    /// ```
1511    /// use majordomus_cli::intent::Intents;
1512    /// let intents = Intents { intents: vec![], findings: vec![] };
1513    /// // a milestone no intent names carries maintenance work
1514    /// assert!(intents.serving("fixture-milestone").is_empty());
1515    /// ```
1516    pub fn serving(&self, milestone: &str) -> Vec<&IntentView> {
1517        self.intents
1518            .iter()
1519            .filter(|i| i.milestones.iter().any(|m| m.id == milestone))
1520            .collect()
1521    }
1522}
1523
1524// ---------------------------------------------------------------- preflight
1525
1526/// What a preflight concluded, about one issue or about the whole piece of work.
1527///
1528/// ```
1529/// use majordomus_cli::intent::IntentPreflightVerdict;
1530/// // the words every surface answers with
1531/// assert_eq!(
1532///     serde_json::to_string(&IntentPreflightVerdict::Maintenance).unwrap(),
1533///     "\"maintenance\""
1534/// );
1535/// assert_ne!(IntentPreflightVerdict::Serves, IntentPreflightVerdict::Refused);
1536/// ```
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1538#[serde(rename_all = "snake_case")]
1539pub enum IntentPreflightVerdict {
1540    /// The work serves at least one criterion of a live intent, every link holds, and the
1541    /// plan of every intent it serves was critiqued with no blocking finding left open.
1542    Serves,
1543    /// The work sits under milestones no live intent names and serves no criterion of a live
1544    /// intent: operational work, allowed where `majordomus intent validate` allows it.
1545    Maintenance,
1546    /// A link is missing or points at nothing, or an intent it serves may not be executed yet.
1547    Refused,
1548}
1549
1550/// Why a preflight refused, as a word a reader greps for.
1551///
1552/// ```
1553/// use majordomus_cli::intent::IntentPreflightCause;
1554/// assert_eq!(
1555///     serde_json::to_string(&IntentPreflightCause::OpenBlockingFinding).unwrap(),
1556///     "\"open_blocking_finding\""
1557/// );
1558/// assert_ne!(
1559///     IntentPreflightCause::IntentNotCritiqued,
1560///     IntentPreflightCause::IssueServesNothing
1561/// );
1562/// ```
1563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1564#[serde(rename_all = "snake_case")]
1565pub enum IntentPreflightCause {
1566    /// The issue named is not an issue of the plan.
1567    UnknownIssue,
1568    /// No open issue's scope covers any of the paths.
1569    NoIssueCoversPaths,
1570    /// The issue's milestone realises a live intent and the issue serves none of its criteria.
1571    IssueServesNothing,
1572    /// The issue serves a criterion of an intent whose milestones do not include its own.
1573    ServesAnotherIntent,
1574    /// The issue serves an intent or a criterion that does not exist, or a token that is not
1575    /// `<intent>#<criterion>`.
1576    ServesUnknownCriterion,
1577    /// An intent the issue serves has no critique: its plan was never reviewed.
1578    IntentNotCritiqued,
1579    /// The critique of an intent the issue serves has a blocking finding still open.
1580    OpenBlockingFinding,
1581}
1582
1583/// One reason a preflight refused: which issue, the cause, and one sentence of what is wrong.
1584///
1585/// ```
1586/// use majordomus_cli::intent::{IntentPreflightCause, IntentPreflightRefusal};
1587/// let r = IntentPreflightRefusal::new(
1588///     Some("I1900"),
1589///     IntentPreflightCause::IntentNotCritiqued,
1590///     "intent x has no critique".into(),
1591/// );
1592/// assert_eq!(r.cause, IntentPreflightCause::IntentNotCritiqued);
1593/// assert_eq!(r.issue.as_deref(), Some("I1900"));
1594/// ```
1595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1596pub struct IntentPreflightRefusal {
1597    /// The issue it is about; absent when no issue was resolved at all.
1598    #[serde(skip_serializing_if = "Option::is_none")]
1599    pub issue: Option<String>,
1600    /// The cause.
1601    pub cause: IntentPreflightCause,
1602    /// What is wrong, in one sentence.
1603    pub message: String,
1604}
1605
1606impl IntentPreflightRefusal {
1607    /// A refusal about `issue`, or about no issue when none was resolved.
1608    ///
1609    /// ```
1610    /// use majordomus_cli::intent::{IntentPreflightCause, IntentPreflightRefusal};
1611    /// let r = IntentPreflightRefusal::new(None, IntentPreflightCause::UnknownIssue, "x".into());
1612    /// assert!(r.issue.is_none());
1613    /// ```
1614    pub fn new(issue: Option<&str>, cause: IntentPreflightCause, message: String) -> Self {
1615        IntentPreflightRefusal {
1616            issue: issue.map(str::to_string),
1617            cause,
1618            message,
1619        }
1620    }
1621}
1622
1623/// One issue the work resolved to, and what the preflight concluded about it.
1624///
1625/// ```
1626/// use majordomus_cli::intent::{IntentPreflightIssue, IntentPreflightVerdict};
1627/// let i = IntentPreflightIssue {
1628///     issue: "I0007".into(),
1629///     milestone: "ops".into(),
1630///     verdict: IntentPreflightVerdict::Maintenance,
1631///     serves: vec![],
1632///     intents: vec![],
1633/// };
1634/// // a milestone no intent names carries maintenance, which is not a refusal
1635/// assert_eq!(i.verdict, IntentPreflightVerdict::Maintenance);
1636/// ```
1637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1638pub struct IntentPreflightIssue {
1639    /// The issue id.
1640    pub issue: String,
1641    /// Its milestone.
1642    pub milestone: String,
1643    /// What the preflight concluded about this issue.
1644    pub verdict: IntentPreflightVerdict,
1645    /// The `<intent>#<criterion>` references it declares, as declared.
1646    pub serves: Vec<String>,
1647    /// The live intents whose milestones include its milestone, sorted.
1648    pub intents: Vec<String>,
1649}
1650
1651/// One intent a piece of work serves, and the link that says so: issue to the criteria it
1652/// declares in `serves`, each criterion's intent naming the issue's milestone.
1653///
1654/// ```
1655/// use majordomus_cli::intent::{IntentPreflightMatch, IntentStage};
1656/// let m = IntentPreflightMatch {
1657///     intent: "intent-lifecycle".into(),
1658///     title: "Work is traceable to the intent it serves".into(),
1659///     stage: IntentStage::Executing,
1660///     milestone: "intent-lifecycle".into(),
1661///     issue: "I1900".into(),
1662///     criteria: vec!["stage-is-derived".into()],
1663/// };
1664/// assert_eq!(m.issue, "I1900");
1665/// ```
1666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1667pub struct IntentPreflightMatch {
1668    /// The intent.
1669    pub intent: String,
1670    /// Its title.
1671    pub title: String,
1672    /// Its derived stage.
1673    pub stage: IntentStage,
1674    /// The milestone that links the work to it.
1675    pub milestone: String,
1676    /// The issue that links the work to that milestone.
1677    pub issue: String,
1678    /// The criteria of the intent the issue declares it serves, in the intent's order.
1679    pub criteria: Vec<String>,
1680}
1681
1682/// The critique of an intent as a preflight reads it: where it is, when and by whom the plan
1683/// was reviewed, and the blocking findings still open.
1684///
1685/// ```
1686/// use majordomus_cli::intent::IntentPreflightCritique;
1687/// let c = IntentPreflightCritique {
1688///     source: ".ai/repo/project/critiques/x.yaml".into(),
1689///     reviewed_at: "c3f20da".into(),
1690///     reviewed_by: "another session".into(),
1691///     open_blocking: vec![],
1692/// };
1693/// assert!(c.open_blocking.is_empty());
1694/// ```
1695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1696pub struct IntentPreflightCritique {
1697    /// Repository-relative path of the record.
1698    pub source: String,
1699    /// The commit the plan was reviewed at.
1700    pub reviewed_at: String,
1701    /// Who or what reviewed it.
1702    pub reviewed_by: String,
1703    /// Every blocking finding whose resolution is `open`; work may not start while one is.
1704    pub open_blocking: Vec<CritiqueFinding>,
1705}
1706
1707/// The recorded gap of an intent as a preflight reads it, bounded to the criteria the work
1708/// serves: what was observed of each, at which commit, and the risks.
1709///
1710/// ```
1711/// use majordomus_cli::intent::IntentPreflightGap;
1712/// let g = IntentPreflightGap {
1713///     source: ".ai/repo/project/gaps/x.yaml".into(),
1714///     observed_at: "c3f20da".into(),
1715///     conditions: vec![],
1716///     risks: vec!["the schema may refuse old records".into()],
1717/// };
1718/// assert_eq!(g.risks.len(), 1);
1719/// ```
1720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1721pub struct IntentPreflightGap {
1722    /// Repository-relative path of the record.
1723    pub source: String,
1724    /// The commit the observations were made on.
1725    pub observed_at: String,
1726    /// The gap's answer for each criterion the work serves, in the gap's order.
1727    pub conditions: Vec<GapCondition>,
1728    /// The risks it recorded.
1729    pub risks: Vec<String>,
1730}
1731
1732/// One intent the work is held to, with what a worker needs before touching it: the statement,
1733/// the criteria the work serves with the live state of their evidence, the invariants and
1734/// non-goals, the governance, the critique and the gap. Only the intents the work reaches are
1735/// carried, never every intent.
1736///
1737/// ```
1738/// use majordomus_cli::intent::{IntentPreflightIntent, IntentStage};
1739/// let i = IntentPreflightIntent {
1740///     id: "x".into(),
1741///     title: "X".into(),
1742///     stage: IntentStage::Planned,
1743///     statement: "It becomes true.".into(),
1744///     criteria: vec![],
1745///     invariants: vec![],
1746///     non_goals: vec![],
1747///     governance: vec![],
1748///     critique: None,
1749///     gap: None,
1750/// };
1751/// // no critique recorded is carried as an absence, never as an empty review
1752/// assert!(i.critique.is_none());
1753/// ```
1754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1755pub struct IntentPreflightIntent {
1756    /// The identity.
1757    pub id: String,
1758    /// One line.
1759    pub title: String,
1760    /// Its derived stage.
1761    pub stage: IntentStage,
1762    /// What must become true.
1763    pub statement: String,
1764    /// The criteria the work serves, each with the state of its evidence now, in the intent's
1765    /// order.
1766    pub criteria: Vec<IntentCriterion>,
1767    /// What must stay true.
1768    pub invariants: Vec<String>,
1769    /// What is deliberately not required.
1770    pub non_goals: Vec<String>,
1771    /// The governance a worker on this intent loads.
1772    pub governance: Vec<String>,
1773    /// The critique of its plan; absent when none was ever recorded.
1774    #[serde(skip_serializing_if = "Option::is_none")]
1775    pub critique: Option<IntentPreflightCritique>,
1776    /// Its recorded gap, bounded to the criteria above; absent when none was recorded.
1777    #[serde(skip_serializing_if = "Option::is_none")]
1778    pub gap: Option<IntentPreflightGap>,
1779}
1780
1781/// Which intent a piece of work serves, whether it is maintenance, or why it may not proceed:
1782/// the issues it resolved to with a verdict each, every intent reached with its link, what
1783/// those intents require of the worker, and every refusal with its cause.
1784///
1785/// ```
1786/// use majordomus_cli::intent::{
1787///     IntentPreflight, IntentPreflightCause, IntentPreflightRefusal, IntentPreflightVerdict,
1788/// };
1789/// let refused = IntentPreflight {
1790///     verdict: IntentPreflightVerdict::Refused,
1791///     issues: vec![],
1792///     matches: vec![],
1793///     intents: vec![],
1794///     governance: vec![],
1795///     refusals: vec![IntentPreflightRefusal::new(
1796///         None,
1797///         IntentPreflightCause::NoIssueCoversPaths,
1798///         "no open issue's scope covers README.md".into(),
1799///     )],
1800///     refusal: Some("no open issue's scope covers README.md".into()),
1801/// };
1802/// assert_eq!(refused.verdict, IntentPreflightVerdict::Refused);
1803/// assert_eq!(refused.refusals[0].cause, IntentPreflightCause::NoIssueCoversPaths);
1804/// ```
1805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1806pub struct IntentPreflight {
1807    /// `serves`, `maintenance` or `refused`: refused when any issue is, else serves when any
1808    /// issue serves an intent, else maintenance.
1809    pub verdict: IntentPreflightVerdict,
1810    /// The issues the work was resolved to — the one named, or the open issues whose scope
1811    /// covers a path — each with its own verdict.
1812    pub issues: Vec<IntentPreflightIssue>,
1813    /// Every intent an issue serves, with the link.
1814    pub matches: Vec<IntentPreflightMatch>,
1815    /// The intents the work serves, and no other, each with what it asks of the worker.
1816    pub intents: Vec<IntentPreflightIntent>,
1817    /// The governance of those intents, each entry once, in first-seen order.
1818    pub governance: Vec<String>,
1819    /// Every reason the preflight refused, in issue order; empty unless the verdict is
1820    /// `refused`.
1821    pub refusals: Vec<IntentPreflightRefusal>,
1822    /// When refused: every refusal's sentence, joined, for a reader who wants one line.
1823    #[serde(skip_serializing_if = "Option::is_none")]
1824    pub refusal: Option<String>,
1825}
1826
1827/// The coverage codes of the failures about an issue's own link, each as the preflight cause it
1828/// is. These are every `FAIL` finding coverage makes with an issue as its subject (its other
1829/// failure, `criterion_uncovered`, is about a criterion), so every broken link validation
1830/// reports is refused here. A failure coverage learns to make about an issue belongs in this
1831/// table too, or the preflight would let that broken link through.
1832const LINK_CAUSES: [(&str, IntentPreflightCause); 5] = [
1833    (
1834        "issue_without_purpose",
1835        IntentPreflightCause::IssueServesNothing,
1836    ),
1837    (
1838        "serves_outside_milestone",
1839        IntentPreflightCause::ServesAnotherIntent,
1840    ),
1841    (
1842        "malformed_serves",
1843        IntentPreflightCause::ServesUnknownCriterion,
1844    ),
1845    (
1846        "serves_unknown_intent",
1847        IntentPreflightCause::ServesUnknownCriterion,
1848    ),
1849    (
1850        "serves_unknown_criterion",
1851        IntentPreflightCause::ServesUnknownCriterion,
1852    ),
1853];
1854
1855/// The coverage code of a finding about an issue's own link, as the preflight cause it is.
1856fn link_cause(code: &str) -> Option<IntentPreflightCause> {
1857    LINK_CAUSES
1858        .iter()
1859        .find(|(c, _)| *c == code)
1860        .map(|(_, cause)| *cause)
1861}
1862
1863impl Intents {
1864    /// Which intent work on `issue`, or on `paths`, serves; whether it is maintenance; or why
1865    /// it may not proceed. This is the one join a session or a transition consumes, answered
1866    /// on every surface by the `intents.preflight` capability.
1867    ///
1868    /// ```
1869    /// use majordomus_cli::intent::{IntentPreflightCause, IntentPreflightVerdict, Intents};
1870    /// use majordomus_cli::plan::Plan;
1871    /// # let plan: Plan = serde_json::from_value(serde_json::json!({
1872    /// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
1873    /// #                 "active_milestone": ""},
1874    /// #     "statuses": {"issue": [], "milestone": []},
1875    /// #     "milestones": [], "issues": [], "waves": [], "edges": [],
1876    /// #     "milestone_edges": [], "findings": []})).unwrap();
1877    /// let intents = Intents { intents: vec![], findings: vec![] };
1878    /// let answer = intents.preflight(&plan, &[], &[], Some("I0001"), &[]);
1879    /// // an issue this plan does not hold is the first missing link, and it is named
1880    /// assert_eq!(answer.verdict, IntentPreflightVerdict::Refused);
1881    /// assert_eq!(answer.refusals[0].cause, IntentPreflightCause::UnknownIssue);
1882    /// assert!(answer.refusal.unwrap().contains("I0001"));
1883    /// ```
1884    ///
1885    /// An issue is followed through the criteria it declares in `serves` — never through its
1886    /// milestone alone — and each link is judged by the same coverage `intent validate`
1887    /// reports from, so every link validation fails is refused here too, and the preflight
1888    /// is at least as strict (it also refuses work whose only links point at retired intents):
1889    /// an issue
1890    /// that serves nothing while its milestone realises an intent, a criterion of an intent
1891    /// that does not name its milestone, a criterion or an intent that does not exist. An
1892    /// issue under milestones no live intent names, serving nothing, is maintenance. Every
1893    /// intent served must have a critique with no blocking finding open. Paths are followed to
1894    /// the open issues whose scope covers one, and every one of them is judged: one refused
1895    /// issue refuses the work.
1896    pub fn preflight(
1897        &self,
1898        plan: &Plan,
1899        gaps: &[GapRecord],
1900        critiques: &[CritiqueRecord],
1901        issue: Option<&str>,
1902        paths: &[String],
1903    ) -> IntentPreflight {
1904        let refused = |r: IntentPreflightRefusal| IntentPreflight {
1905            verdict: IntentPreflightVerdict::Refused,
1906            issues: Vec::new(),
1907            matches: Vec::new(),
1908            intents: Vec::new(),
1909            governance: Vec::new(),
1910            refusal: Some(r.message.clone()),
1911            refusals: vec![r],
1912        };
1913        let issues: Vec<&crate::plan::PlanIssue> = match issue {
1914            Some(id) => match plan.issue(id) {
1915                Some(i) => vec![i],
1916                None => {
1917                    return refused(IntentPreflightRefusal::new(
1918                        Some(id),
1919                        IntentPreflightCause::UnknownIssue,
1920                        format!("`{id}` is not an issue under .ai/repo/project/issues/"),
1921                    ))
1922                }
1923            },
1924            None => plan
1925                .issues
1926                .iter()
1927                .filter(|i| !matches!(i.status.as_str(), "DONE" | "CANCELLED"))
1928                .filter(|i| i.scope.iter().any(|s| paths.iter().any(|p| overlap(s, p))))
1929                .collect(),
1930        };
1931        if issues.is_empty() {
1932            return refused(IntentPreflightRefusal::new(
1933                None,
1934                IntentPreflightCause::NoIssueCoversPaths,
1935                format!(
1936                    "no open issue's scope covers {}; work that names no issue serves no \
1937                     declared intent",
1938                    paths.join(", ")
1939                ),
1940            ));
1941        }
1942        self.preflight_issues(plan, gaps, critiques, &issues)
1943    }
1944
1945    /// The preflight over issues already resolved: what [`Intents::preflight`] answers once
1946    /// it knows which issues the work executes, and what [`crate::intent_binding`] asks when
1947    /// it resolved them another way — the open issues serving an intent a worker named.
1948    pub(crate) fn preflight_issues(
1949        &self,
1950        plan: &Plan,
1951        gaps: &[GapRecord],
1952        critiques: &[CritiqueRecord],
1953        issues: &[&crate::plan::PlanIssue],
1954    ) -> IntentPreflight {
1955        // The links are judged by the coverage validation reports from, over outlines of the
1956        // intents already derived, so a link `intent validate` calls broken is refused here
1957        // too; the preflight may refuse more, never less.
1958        let observed = observed_satisfied(gaps);
1959        let outlines: Vec<IntentOutline> = self
1960            .intents
1961            .iter()
1962            .map(|i| IntentOutline {
1963                id: i.id.clone(),
1964                criteria: i.satisfaction.iter().map(|c| c.id.clone()).collect(),
1965                milestones: i.milestones.iter().map(|m| m.id.clone()).collect(),
1966                retired: matches!(i.stage, IntentStage::Cancelled | IntentStage::Superseded),
1967                observed_satisfied: observed.get(&i.id).cloned().unwrap_or_default(),
1968            })
1969            .collect();
1970        let cov = coverage(&outlines, plan);
1971
1972        let mut out_issues = Vec::with_capacity(issues.len());
1973        let mut matches = Vec::new();
1974        let mut refusals = Vec::new();
1975        // intent id -> the criteria the work serves of it, in first-reached order: the payload
1976        // is bounded to these, never every intent
1977        let mut reached: Vec<(String, BTreeSet<String>)> = Vec::new();
1978
1979        for i in issues {
1980            let mut mine: Vec<IntentPreflightRefusal> = cov
1981                .findings
1982                .iter()
1983                .filter(|f| f.level == FAIL && f.subject == i.id)
1984                .filter_map(|f| {
1985                    link_cause(&f.code).map(|cause| {
1986                        IntentPreflightRefusal::new(
1987                            Some(&i.id),
1988                            cause,
1989                            format!("{} {}", i.id, f.message),
1990                        )
1991                    })
1992                })
1993                .collect();
1994            let under: Vec<String> = cov
1995                .purpose(&i.id)
1996                .map(|p| p.intents.clone())
1997                .unwrap_or_default();
1998            // the criteria this issue carries, each a link coverage accepted
1999            let mut served: Vec<(&str, &str)> = cov
2000                .criteria
2001                .iter()
2002                .filter(|c| c.issues.iter().any(|x| x.id == i.id))
2003                .map(|c| (c.intent.as_str(), c.criterion.as_str()))
2004                .collect();
2005            // a cancelled issue is not live, so coverage lists it under no criterion; its
2006            // declared links are still what it was for
2007            if served.is_empty() && i.status == "CANCELLED" {
2008                served = i
2009                    .serves
2010                    .iter()
2011                    .filter_map(|t| t.split_once('#'))
2012                    .filter(|(iid, cid)| {
2013                        outlines.iter().any(|o| {
2014                            o.id == *iid
2015                                && !o.retired
2016                                && o.milestones.contains(&i.milestone)
2017                                && o.criteria.iter().any(|c| c == cid)
2018                        })
2019                    })
2020                    .collect();
2021            }
2022            if served.is_empty() && !under.is_empty() && mine.is_empty() {
2023                mine.push(IntentPreflightRefusal::new(
2024                    Some(&i.id),
2025                    IntentPreflightCause::IssueServesNothing,
2026                    format!(
2027                        "{} is under milestone {}, which realises {}, but serves none of its \
2028                         live criteria",
2029                        i.id,
2030                        i.milestone,
2031                        under.join(", ")
2032                    ),
2033                ));
2034            }
2035            let mut by_intent: Vec<(&str, Vec<String>)> = Vec::new();
2036            for (iid, cid) in &served {
2037                match reached.iter_mut().find(|(x, _)| x == iid) {
2038                    Some((_, cs)) => {
2039                        cs.insert((*cid).to_string());
2040                    }
2041                    None => {
2042                        reached.push(((*iid).to_string(), BTreeSet::from([(*cid).to_string()])))
2043                    }
2044                }
2045                match by_intent.iter_mut().find(|e| e.0 == *iid) {
2046                    Some((_, cs)) => cs.push((*cid).to_string()),
2047                    None => by_intent.push((*iid, vec![(*cid).to_string()])),
2048                }
2049            }
2050            // every intent reached is one of these intents: coverage lists criteria only of the
2051            // outlines built from them, and a cancelled issue's links are filtered by the same
2052            for (iid, view, criteria) in by_intent
2053                .into_iter()
2054                .filter_map(|(iid, criteria)| self.intent(iid).map(|view| (iid, view, criteria)))
2055            {
2056                matches.push(IntentPreflightMatch {
2057                    intent: view.id.clone(),
2058                    title: view.title.clone(),
2059                    stage: view.stage,
2060                    milestone: i.milestone.clone(),
2061                    issue: i.id.clone(),
2062                    criteria,
2063                });
2064                match critiques.iter().find(|c| c.intent == iid) {
2065                    None => mine.push(IntentPreflightRefusal::new(
2066                        Some(&i.id),
2067                        IntentPreflightCause::IntentNotCritiqued,
2068                        format!(
2069                            "{} serves intent {iid}, whose plan was never critiqued; record \
2070                             .ai/repo/project/critiques/{iid}.yaml before its work starts",
2071                            i.id
2072                        ),
2073                    )),
2074                    Some(c) => {
2075                        let open: Vec<&str> = open_blocking(c).map(|f| f.id.as_str()).collect();
2076                        if !open.is_empty() {
2077                            mine.push(IntentPreflightRefusal::new(
2078                                Some(&i.id),
2079                                IntentPreflightCause::OpenBlockingFinding,
2080                                format!(
2081                                    "{} serves intent {iid}, whose critique has blocking \
2082                                     finding(s) {} open; plan or reject them first",
2083                                    i.id,
2084                                    open.join(", ")
2085                                ),
2086                            ));
2087                        }
2088                    }
2089                }
2090            }
2091
2092            let verdict = if !mine.is_empty() {
2093                IntentPreflightVerdict::Refused
2094            } else if !served.is_empty() {
2095                IntentPreflightVerdict::Serves
2096            } else {
2097                IntentPreflightVerdict::Maintenance
2098            };
2099            refusals.extend(mine);
2100            out_issues.push(IntentPreflightIssue {
2101                issue: i.id.clone(),
2102                milestone: i.milestone.clone(),
2103                verdict,
2104                serves: i.serves.clone(),
2105                intents: under,
2106            });
2107        }
2108
2109        let intents: Vec<IntentPreflightIntent> = reached
2110            .iter()
2111            .filter_map(|(id, criteria)| {
2112                self.intent(id)
2113                    .map(|view| self.held_to(view, criteria, gaps, critiques))
2114            })
2115            .collect();
2116        let mut governance: Vec<String> = Vec::new();
2117        for g in intents.iter().flat_map(|i| i.governance.iter()) {
2118            if !governance.contains(g) {
2119                governance.push(g.clone());
2120            }
2121        }
2122        let verdict = if !refusals.is_empty() {
2123            IntentPreflightVerdict::Refused
2124        } else if out_issues
2125            .iter()
2126            .any(|i| i.verdict == IntentPreflightVerdict::Serves)
2127        {
2128            IntentPreflightVerdict::Serves
2129        } else {
2130            IntentPreflightVerdict::Maintenance
2131        };
2132        let refusal = (!refusals.is_empty()).then(|| {
2133            refusals
2134                .iter()
2135                .map(|r| r.message.as_str())
2136                .collect::<Vec<_>>()
2137                .join("; ")
2138        });
2139        IntentPreflight {
2140            verdict,
2141            issues: out_issues,
2142            matches,
2143            intents,
2144            governance,
2145            refusals,
2146            refusal,
2147        }
2148    }
2149
2150    /// One intent as the work is held to it: the criteria it serves, and the critique and the
2151    /// gap bounded to the same criteria.
2152    fn held_to(
2153        &self,
2154        view: &IntentView,
2155        served: &BTreeSet<String>,
2156        gaps: &[GapRecord],
2157        critiques: &[CritiqueRecord],
2158    ) -> IntentPreflightIntent {
2159        let wanted = |c: &str| served.contains(c);
2160        IntentPreflightIntent {
2161            id: view.id.clone(),
2162            title: view.title.clone(),
2163            stage: view.stage,
2164            statement: view.statement.clone(),
2165            criteria: view
2166                .satisfaction
2167                .iter()
2168                .filter(|c| wanted(&c.id))
2169                .cloned()
2170                .collect(),
2171            invariants: view.invariants.clone(),
2172            non_goals: view.non_goals.clone(),
2173            governance: view.governance.clone(),
2174            critique: critiques.iter().find(|c| c.intent == view.id).map(|c| {
2175                IntentPreflightCritique {
2176                    source: c.source.clone(),
2177                    reviewed_at: c.reviewed_at.clone(),
2178                    reviewed_by: c.reviewed_by.clone(),
2179                    open_blocking: open_blocking(c).cloned().collect(),
2180                }
2181            }),
2182            gap: gaps
2183                .iter()
2184                .find(|g| g.intent == view.id)
2185                .map(|g| IntentPreflightGap {
2186                    source: g.source.clone(),
2187                    observed_at: g.observed_at.clone(),
2188                    conditions: g
2189                        .conditions
2190                        .iter()
2191                        .filter(|c| wanted(&c.criterion))
2192                        .cloned()
2193                        .collect(),
2194                    risks: g.risks.clone(),
2195                }),
2196        }
2197    }
2198}
2199
2200/// The blocking findings of a critique still open: the ones that refuse execution.
2201fn open_blocking(c: &CritiqueRecord) -> impl Iterator<Item = &CritiqueFinding> {
2202    c.findings
2203        .iter()
2204        .filter(|f| f.blocking && f.resolution == Some(ResolutionState::Open))
2205}
2206
2207// ---------------------------------------------------------------- proposing an intent
2208
2209/// The longest identity [`propose`] derives from a title, in characters. A file name a
2210/// person can read in a listing, and the slug of a sentence rather than the sentence.
2211const PROPOSED_ID_MAX: usize = 64;
2212
2213/// What [`propose`] wrote: the identity, the file, and the record as it now reads.
2214///
2215/// ```
2216/// use majordomus_cli::intent::Proposed;
2217/// let p: Proposed = serde_json::from_str(
2218///     r#"{"id": "fast-ui", "path": ".ai/repo/project/intents/fast-ui.yaml",
2219///         "title": "Fast UI", "statement": "The UI answers at once"}"#,
2220/// )
2221/// .unwrap();
2222/// // the path is the identity under the intents directory: one answer to where it lives
2223/// assert_eq!(p.path, format!(".ai/repo/project/intents/{}.yaml", p.id));
2224/// ```
2225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2226pub struct Proposed {
2227    /// The identity, which is also the file name.
2228    pub id: String,
2229    /// Repository-relative path of the record written.
2230    pub path: String,
2231    /// One line.
2232    pub title: String,
2233    /// What must become true, as it was given.
2234    pub statement: String,
2235}
2236
2237/// Why [`propose`] wrote nothing: the input cannot make a record, a record of that identity
2238/// already exists, or the file could not be written. Each carries the sentence a caller is
2239/// shown, and the three are kept apart so that a transport can answer invalid input, a
2240/// refusal and its own failure differently.
2241///
2242/// ```
2243/// use majordomus_cli::intent::{propose, ProposeError};
2244/// let nowhere = std::path::Path::new("/nonexistent");
2245/// // judged before anything is touched: an empty statement never reaches the disk
2246/// let e = propose(nowhere, "  ", None, None, "2026-10-12").unwrap_err();
2247/// assert!(matches!(e, ProposeError::Invalid(_)));
2248/// assert_eq!(e.to_string(), "an intent needs a statement: what must become true");
2249/// ```
2250#[derive(Debug, Clone, PartialEq, Eq)]
2251pub enum ProposeError {
2252    /// The input cannot make a record: an empty statement, or an id that is not a slug.
2253    Invalid(String),
2254    /// A record with this identity exists; a proposal never overwrites one.
2255    Exists(String),
2256    /// The file could not be written.
2257    Write(String),
2258}
2259
2260impl std::fmt::Display for ProposeError {
2261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2262        match self {
2263            ProposeError::Invalid(m) | ProposeError::Exists(m) | ProposeError::Write(m) => {
2264                f.write_str(m)
2265            }
2266        }
2267    }
2268}
2269
2270/// The identity a proposal takes when none is given: the slug of its title, cut at a word
2271/// boundary within `PROPOSED_ID_MAX` (64) characters.
2272///
2273/// ```
2274/// use majordomus_cli::intent::proposed_id;
2275/// assert_eq!(proposed_id("Make the UI mobile-first!"), "make-the-ui-mobile-first");
2276/// let long = "word ".repeat(40);
2277/// assert!(proposed_id(&long).len() <= 64 && !proposed_id(&long).ends_with('-'));
2278/// ```
2279pub fn proposed_id(title: &str) -> String {
2280    let full = crate::entity::slug(title);
2281    if full.len() <= PROPOSED_ID_MAX {
2282        return full;
2283    }
2284    let cut = &full[..PROPOSED_ID_MAX];
2285    match cut.rfind('-') {
2286        Some(i) if i > 0 => cut[..i].to_string(),
2287        _ => cut.to_string(),
2288    }
2289}
2290
2291/// A YAML scalar that reads back as exactly `s`: a JSON string is a YAML double-quoted
2292/// scalar, so the escaping is serde_json's and no hand-written quoting rule can drift from it.
2293fn yaml_scalar(s: &str) -> String {
2294    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
2295}
2296
2297/// Write a new intent record that says what must become true and nothing else yet (ADR 0144).
2298///
2299/// The record names no milestone and no criterion: its stage derives as `declared`, `intent
2300/// validate` reports the two absences as warnings, and no issue can serve it until a person
2301/// or a later step plans it. Nothing is stored about its standing; the stage stays derived
2302/// (ADR 0070). The title defaults to the statement's first line, the identity to the
2303/// title's slug. An existing record is never overwritten, and the file appears whole or not
2304/// at all: it is written beside its final name and renamed into place.
2305///
2306/// ```
2307/// use majordomus_cli::intent::{propose, ProposeError};
2308/// let root = std::env::temp_dir().join(format!("mj-propose-doc-{}", std::process::id()));
2309/// let p = propose(&root, "The UI answers within 100 ms warm", None, None, "2026-10-12").unwrap();
2310/// assert_eq!(p.id, "the-ui-answers-within-100-ms-warm");
2311/// assert_eq!(p.path, ".ai/repo/project/intents/the-ui-answers-within-100-ms-warm.yaml");
2312/// let text = std::fs::read_to_string(root.join(&p.path)).unwrap();
2313/// assert!(text.contains("milestones: []") && text.contains("satisfaction: []"));
2314/// // a second proposal of the same identity is refused, and the first record is untouched
2315/// let again = propose(&root, "something else", None, Some(p.id.as_str()), "2026-10-12");
2316/// assert!(matches!(again, Err(ProposeError::Exists(_))));
2317/// assert_eq!(std::fs::read_to_string(root.join(&p.path)).unwrap(), text);
2318/// std::fs::remove_dir_all(&root).unwrap();
2319/// ```
2320pub fn propose(
2321    root: &Path,
2322    statement: &str,
2323    title: Option<&str>,
2324    id: Option<&str>,
2325    today: &str,
2326) -> Result<Proposed, ProposeError> {
2327    let statement = statement.trim();
2328    if statement.is_empty() {
2329        return Err(ProposeError::Invalid(
2330            "an intent needs a statement: what must become true".into(),
2331        ));
2332    }
2333    let title = match title.map(str::trim).filter(|t| !t.is_empty()) {
2334        Some(t) => t.to_string(),
2335        None => statement
2336            .lines()
2337            .next()
2338            .unwrap_or(statement)
2339            .trim()
2340            .to_string(),
2341    };
2342    let id = match id.map(str::trim).filter(|i| !i.is_empty()) {
2343        Some(i) => {
2344            if crate::entity::slug(i) != i || i.len() > PROPOSED_ID_MAX {
2345                return Err(ProposeError::Invalid(format!(
2346                    "`{i}` is not an intent identity: lowercase letters, digits and single hyphens, at most {PROPOSED_ID_MAX} characters"
2347                )));
2348            }
2349            i.to_string()
2350        }
2351        None => proposed_id(&title),
2352    };
2353    if id.is_empty() {
2354        return Err(ProposeError::Invalid(
2355            "the title has no letter or digit to name the intent by; give an id".into(),
2356        ));
2357    }
2358    let rel = format!("{INTENTS_DIR}/{id}.yaml");
2359    let path = root.join(&rel);
2360    if path.exists() {
2361        return Err(ProposeError::Exists(format!(
2362            "an intent `{id}` exists at {rel}; a proposal never overwrites a record"
2363        )));
2364    }
2365    let text = format!(
2366        "id: {id}\ntitle: {title}\nstatement: {statement}\ninvariants: []\nmilestones: []\nsatisfaction: []\ncreated_at: {today}\nupdated_at: {today}\n",
2367        title = yaml_scalar(&title),
2368        statement = yaml_scalar(statement),
2369    );
2370    let dir = path.parent().unwrap_or(root);
2371    std::fs::create_dir_all(dir)
2372        .map_err(|e| ProposeError::Write(format!("{}: {e}", dir.display())))?;
2373    let tmp = dir.join(format!(".{id}.yaml.tmp.{}", std::process::id()));
2374    std::fs::write(&tmp, &text)
2375        .map_err(|e| ProposeError::Write(format!("{}: {e}", tmp.display())))?;
2376    // a hard link refuses an existing name where a rename would replace it, so two
2377    // proposals of one identity racing each other cannot both succeed
2378    let placed = std::fs::hard_link(&tmp, &path);
2379    let _ = std::fs::remove_file(&tmp);
2380    placed.map_err(|e| match e.kind() {
2381        std::io::ErrorKind::AlreadyExists => ProposeError::Exists(format!(
2382            "an intent `{id}` exists at {rel}; a proposal never overwrites a record"
2383        )),
2384        _ => ProposeError::Write(format!("{}: {e}", path.display())),
2385    })?;
2386    Ok(Proposed {
2387        id,
2388        path: rel,
2389        title,
2390        statement: statement.to_string(),
2391    })
2392}
2393
2394#[cfg(test)]
2395mod tests {
2396    use super::*;
2397    use crate::plan::{PlanCounts, PlanIssue, PlanMilestone, PlanProject, PlanVocabulary};
2398
2399    struct Table {
2400        tests: BTreeMap<String, TestStanding>,
2401        claims: BTreeMap<String, ClaimStanding>,
2402    }
2403
2404    impl Table {
2405        fn new() -> Self {
2406            Table {
2407                tests: BTreeMap::new(),
2408                claims: BTreeMap::new(),
2409            }
2410        }
2411        fn with_test(mut self, id: &str, state: IntentEvidenceState) -> Self {
2412            self.tests.insert(
2413                id.into(),
2414                TestStanding {
2415                    present: true,
2416                    state,
2417                    proof: None,
2418                },
2419            );
2420            self
2421        }
2422        fn with_claim(mut self, id: &str, state: IntentEvidenceState) -> Self {
2423            let proof = match state {
2424                IntentEvidenceState::Current => ProofState::Proven,
2425                IntentEvidenceState::Stale => ProofState::Stale,
2426                IntentEvidenceState::Failing => ProofState::Failing,
2427                _ => ProofState::NotRun,
2428            };
2429            self.claims
2430                .insert(id.into(), ClaimStanding { state, proof });
2431            self
2432        }
2433    }
2434
2435    impl EvidenceLookup for Table {
2436        fn test(&self, id: &TestId, _: &[String]) -> TestStanding {
2437            self.tests
2438                .get(&id.as_string())
2439                .cloned()
2440                .unwrap_or(TestStanding {
2441                    present: false,
2442                    state: IntentEvidenceState::NotRun,
2443                    proof: None,
2444                })
2445        }
2446        fn claim(&self, id: &str) -> Option<ClaimStanding> {
2447            self.claims.get(id).copied()
2448        }
2449        fn deployment(&self, id: &str) -> bool {
2450            id == "fly"
2451        }
2452        fn object(&self, kind: &str, identity: &str) -> bool {
2453            kind == "rule"
2454                && (identity == "project.alpha" || identity.starts_with("project.alpha@"))
2455        }
2456        fn file(&self, path: &str) -> bool {
2457            path == "docs/INTENT.md"
2458        }
2459    }
2460
2461    fn milestone(id: &str, status: &str) -> PlanMilestone {
2462        PlanMilestone {
2463            id: id.into(),
2464            status: status.into(),
2465            order: 0,
2466            priority: "p1".into(),
2467            title: id.into(),
2468            slug: id.into(),
2469            version: String::new(),
2470            rank: 0,
2471            depends_on: vec![],
2472            blocked_by: vec![],
2473            dependents: vec![],
2474            claims: vec![],
2475            counts: PlanCounts {
2476                total: 0,
2477                required: 0,
2478                by_status: BTreeMap::new(),
2479            },
2480            issues: vec![],
2481            outcome: String::new(),
2482        }
2483    }
2484
2485    fn issue(id: &str, milestone: &str, status: &str, scope: &[&str]) -> PlanIssue {
2486        PlanIssue {
2487            id: id.into(),
2488            milestone: milestone.into(),
2489            status: status.into(),
2490            wave: 0,
2491            priority: "p1".into(),
2492            profile: "implementation".into(),
2493            parallel_safe: true,
2494            title: id.into(),
2495            slug: id.into(),
2496            serves: vec![],
2497            depends_on: vec![],
2498            blocked_by: vec![],
2499            dependents: vec![],
2500            scope: scope.iter().map(|s| s.to_string()).collect(),
2501            objective: String::new(),
2502            evidence_have: 0,
2503            evidence_need: 0,
2504            started_at: String::new(),
2505            verified_at: String::new(),
2506            completed_at: String::new(),
2507        }
2508    }
2509
2510    fn plan(milestones: Vec<PlanMilestone>, issues: Vec<PlanIssue>) -> Plan {
2511        Plan {
2512            project: PlanProject {
2513                name: "p".into(),
2514                repository: "o/p".into(),
2515                default_branch: "master".into(),
2516                active_milestone: String::new(),
2517            },
2518            statuses: PlanVocabulary {
2519                issue: vec![],
2520                milestone: vec![],
2521            },
2522            milestones,
2523            issues,
2524            waves: vec![],
2525            edges: vec![],
2526            milestone_edges: vec![],
2527            findings: vec![],
2528        }
2529    }
2530
2531    fn record(id: &str, milestones: &[&str], criteria: &[(&str, &str, &str)]) -> IntentRecord {
2532        IntentRecord {
2533            id: id.into(),
2534            source: format!("{INTENTS_DIR}/{id}.yaml"),
2535            title: id.into(),
2536            statement: "it becomes true".into(),
2537            invariants: vec![],
2538            satisfaction: criteria
2539                .iter()
2540                .map(|(cid, kind, r)| CriterionRecord {
2541                    id: (*cid).into(),
2542                    criterion: "observable".into(),
2543                    evidence: (*kind).into(),
2544                    reference: (*r).into(),
2545                })
2546                .collect(),
2547            milestones: milestones.iter().map(|m| m.to_string()).collect(),
2548            ..IntentRecord::default()
2549        }
2550    }
2551
2552    fn codes(i: &Intents) -> Vec<&str> {
2553        i.findings.iter().map(|f| f.code.as_str()).collect()
2554    }
2555
2556    const CASE: (&str, &str, &str) = ("case", "test", "test/cases/1_x.sh");
2557
2558    #[test]
2559    fn the_stage_follows_the_milestones_and_only_then_the_evidence() {
2560        let cases: &[(&[&str], IntentEvidenceState, IntentStage)] = &[
2561            (
2562                &["PLANNED"],
2563                IntentEvidenceState::Current,
2564                IntentStage::Planned,
2565            ),
2566            (
2567                &["BLOCKED"],
2568                IntentEvidenceState::Current,
2569                IntentStage::Planned,
2570            ),
2571            (
2572                &["ACTIVE"],
2573                IntentEvidenceState::Current,
2574                IntentStage::Executing,
2575            ),
2576            (
2577                &["VERIFY"],
2578                IntentEvidenceState::NotRun,
2579                IntentStage::Executing,
2580            ),
2581            (
2582                &["DONE", "PLANNED"],
2583                IntentEvidenceState::Current,
2584                IntentStage::Executing,
2585            ),
2586            (
2587                &["DONE"],
2588                IntentEvidenceState::NotRun,
2589                IntentStage::Verifying,
2590            ),
2591            (
2592                &["DONE"],
2593                IntentEvidenceState::Stale,
2594                IntentStage::Verifying,
2595            ),
2596            (
2597                &["DONE"],
2598                IntentEvidenceState::Failing,
2599                IntentStage::Verifying,
2600            ),
2601            (
2602                &["DONE"],
2603                IntentEvidenceState::Current,
2604                IntentStage::Satisfied,
2605            ),
2606            (
2607                &["DONE", "CANCELLED"],
2608                IntentEvidenceState::Current,
2609                IntentStage::Satisfied,
2610            ),
2611        ];
2612        for (statuses, evidence, want) in cases {
2613            let ms: Vec<PlanMilestone> = statuses
2614                .iter()
2615                .enumerate()
2616                .map(|(n, s)| milestone(&format!("m{n}"), s))
2617                .collect();
2618            let names: Vec<String> = ms.iter().map(|m| m.id.clone()).collect();
2619            let names: Vec<&str> = names.iter().map(String::as_str).collect();
2620            let p = plan(ms, vec![]);
2621            let ev = Table::new().with_test("suite:1_x", *evidence);
2622            let i = Intents::derive(vec![record("x", &names, &[CASE])], &p, &ev);
2623            assert_eq!(i.intents[0].stage, *want, "{statuses:?} with {evidence:?}");
2624            assert_eq!(i.failures(), 0, "{:?}", i.findings);
2625        }
2626    }
2627
2628    /// The stage still waits for the plan, and since ADR 0107 the evidence's own answer is no
2629    /// longer hidden behind it: with every criterion current and the milestone ACTIVE, the
2630    /// stage reads `executing` and the verdict reads `satisfied`. The stage expectation is
2631    /// unchanged; the verdict is what this test now adds.
2632    #[test]
2633    fn evidence_satisfies_nothing_while_a_milestone_is_open() {
2634        let p = plan(vec![milestone("m", "ACTIVE")], vec![]);
2635        let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2636        let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &ev);
2637        assert_eq!(i.intents[0].met, 1, "the criterion itself is met");
2638        assert_eq!(i.intents[0].stage, IntentStage::Executing);
2639        assert_eq!(i.intents[0].verdict.state, IntentVerdictState::Satisfied);
2640        assert!(i.intents[0].verdict.reasons.is_empty());
2641    }
2642
2643    #[test]
2644    fn the_verdict_is_unknown_when_only_a_kind_the_ledger_cannot_settle_is_unmet() {
2645        let p = plan(vec![milestone("m", "ACTIVE")], vec![]);
2646        let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2647        let i = Intents::derive(
2648            vec![record(
2649                "x",
2650                &["m"],
2651                &[
2652                    CASE,
2653                    ("ships", "command", "just verify"),
2654                    ("live", "deployment", "fly"),
2655                ],
2656            )],
2657            &p,
2658            &ev,
2659        );
2660        let v = &i.intents[0].verdict;
2661        assert_eq!(v.state, IntentVerdictState::Unknown);
2662        let held: Vec<(&str, &str, IntentEvidenceState)> = v
2663            .reasons
2664            .iter()
2665            .map(|r| (r.criterion.as_str(), r.evidence.as_str(), r.state))
2666            .collect();
2667        assert_eq!(
2668            held,
2669            [
2670                ("ships", "command", IntentEvidenceState::NotDerivable),
2671                ("live", "deployment", IntentEvidenceState::NotDerivable),
2672            ]
2673        );
2674        assert_eq!(
2675            i.intents[0].stage,
2676            IntentStage::Executing,
2677            "the stage is the plan's"
2678        );
2679    }
2680
2681    #[test]
2682    fn the_verdict_is_unsatisfied_when_a_test_the_ledger_can_settle_is_unmet() {
2683        // a DONE milestone neither implies the verdict nor is required for it
2684        for status in ["ACTIVE", "DONE"] {
2685            let p = plan(vec![milestone("m", status)], vec![]);
2686            for state in [
2687                IntentEvidenceState::NotRun,
2688                IntentEvidenceState::Stale,
2689                IntentEvidenceState::Failing,
2690            ] {
2691                let ev = Table::new().with_test("suite:1_x", state);
2692                let i = Intents::derive(
2693                    vec![record(
2694                        "x",
2695                        &["m"],
2696                        &[CASE, ("ships", "command", "just verify")],
2697                    )],
2698                    &p,
2699                    &ev,
2700                );
2701                let v = &i.intents[0].verdict;
2702                assert_eq!(
2703                    v.state,
2704                    IntentVerdictState::Unsatisfied,
2705                    "{status} {state:?}"
2706                );
2707                assert_eq!(v.reasons[0].criterion, "case");
2708                assert_eq!(v.reasons[0].state, state);
2709                assert_eq!(v.reasons.len(), 2, "every unmet criterion is named");
2710            }
2711        }
2712        // an unproven claim says no the same way
2713        let p = plan(vec![milestone("m", "DONE")], vec![]);
2714        let ev = Table::new().with_claim("c", IntentEvidenceState::Stale);
2715        let i = Intents::derive(vec![record("x", &["m"], &[("k", "claim", "c")])], &p, &ev);
2716        assert_eq!(i.intents[0].verdict.state, IntentVerdictState::Unsatisfied);
2717        assert_eq!(i.intents[0].stage, IntentStage::Verifying);
2718    }
2719
2720    #[test]
2721    fn the_verdict_of_an_intent_declaring_no_criterion_is_unknown() {
2722        let p = plan(vec![milestone("m", "DONE")], vec![]);
2723        let i = Intents::derive(vec![record("x", &["m"], &[])], &p, &Table::new());
2724        assert_eq!(i.intents[0].verdict.state, IntentVerdictState::Unknown);
2725        assert!(i.intents[0].verdict.reasons.is_empty());
2726        assert!(codes(&i).contains(&"intent_without_criterion"));
2727    }
2728
2729    #[test]
2730    fn every_verdict_and_evidence_state_is_printed_as_the_word_it_serialises_to() {
2731        for v in [
2732            IntentVerdictState::Satisfied,
2733            IntentVerdictState::Unsatisfied,
2734            IntentVerdictState::Unknown,
2735        ] {
2736            assert_eq!(
2737                serde_json::to_value(v).unwrap(),
2738                serde_json::Value::from(v.as_str())
2739            );
2740        }
2741        for s in [
2742            IntentEvidenceState::Current,
2743            IntentEvidenceState::Stale,
2744            IntentEvidenceState::Failing,
2745            IntentEvidenceState::NotRun,
2746            IntentEvidenceState::NotDerivable,
2747            IntentEvidenceState::Unresolved,
2748        ] {
2749            assert_eq!(
2750                serde_json::to_value(s).unwrap(),
2751                serde_json::Value::from(s.as_str())
2752            );
2753        }
2754    }
2755
2756    #[test]
2757    fn evidence_is_current_only_when_the_evidence_module_calls_it_current() {
2758        use IntentEvidenceState::{Current, Failing, NotRun, Stale};
2759        use TreeState::{Clean, Dirty, Unknown};
2760        for (state, tree, want) in [
2761            (ProofState::Proven, Clean, Current),
2762            (ProofState::InputsUnchanged, Clean, Current),
2763            // a pass on a tree that was not its commit: nothing later can be matched to it
2764            (ProofState::InputsUnchanged, Dirty, Stale),
2765            (ProofState::InputsUnchanged, Unknown, Stale),
2766            (ProofState::Stale, Clean, Stale),
2767            (ProofState::Failing, Clean, Failing),
2768            (ProofState::NotRun, Unknown, NotRun),
2769            (ProofState::Unrunnable, Unknown, NotRun),
2770            (ProofState::NoTest, Unknown, NotRun),
2771        ] {
2772            assert_eq!(
2773                IntentEvidenceState::judged(state, tree),
2774                want,
2775                "{state:?} on a {tree:?} tree"
2776            );
2777            assert_eq!(
2778                want == Current,
2779                evidence::current(state, tree, TreeState::Clean),
2780                "{state:?} on a {tree:?} tree: one answer to whether it is current"
2781            );
2782        }
2783    }
2784
2785    #[test]
2786    fn a_claim_criterion_is_met_only_by_current_evidence() {
2787        let p = plan(vec![milestone("m", "DONE")], vec![]);
2788        for state in [
2789            IntentEvidenceState::Current,
2790            IntentEvidenceState::Stale,
2791            IntentEvidenceState::Failing,
2792            IntentEvidenceState::NotRun,
2793        ] {
2794            let ev = Table::new().with_claim("c", state);
2795            let i = Intents::derive(vec![record("x", &["m"], &[("k", "claim", "c")])], &p, &ev);
2796            let c = &i.intents[0].satisfaction[0];
2797            assert_eq!(c.state, state);
2798            assert_eq!(c.met, state == IntentEvidenceState::Current, "{state:?}");
2799        }
2800    }
2801
2802    #[test]
2803    fn a_command_or_deployment_never_meets_a_criterion() {
2804        let p = plan(vec![milestone("m", "DONE")], vec![]);
2805        let i = Intents::derive(
2806            vec![record(
2807                "x",
2808                &["m"],
2809                &[("a", "command", "just verify"), ("b", "deployment", "fly")],
2810            )],
2811            &p,
2812            &Table::new(),
2813        );
2814        let v = &i.intents[0];
2815        assert!(v
2816            .satisfaction
2817            .iter()
2818            .all(|c| c.state == IntentEvidenceState::NotDerivable && !c.met));
2819        assert_eq!(v.stage, IntentStage::Verifying);
2820        assert_eq!(i.failures(), 0, "{:?}", i.findings);
2821    }
2822
2823    #[test]
2824    fn cancelled_and_superseded_are_what_the_record_says() {
2825        let p = plan(vec![milestone("m", "DONE")], vec![]);
2826        let mut a = record("a", &["m"], &[CASE]);
2827        a.cancelled = true;
2828        let mut b = record("b", &["m"], &[CASE]);
2829        b.superseded_by = "a".into();
2830        let i = Intents::derive(vec![a, b], &p, &Table::new());
2831        assert_eq!(i.intents[0].stage, IntentStage::Cancelled);
2832        assert_eq!(i.intents[1].stage, IntentStage::Superseded);
2833    }
2834
2835    /// A proposal names neither a milestone nor a criterion: declared, valid, two warnings.
2836    /// Naming one and not the other is half a plan, and each absence is a failure again.
2837    #[test]
2838    fn a_proposal_is_declared_and_warned_about_while_a_half_plan_fails() {
2839        let p = plan(vec![milestone("m", "READY")], vec![]);
2840        let i = Intents::derive(vec![record("x", &[], &[])], &p, &Table::new());
2841        assert_eq!(i.intents[0].stage, IntentStage::Declared);
2842        assert_eq!(i.failures(), 0, "{:?}", i.findings);
2843        for code in ["intent_without_milestone", "intent_without_criterion"] {
2844            let f = i.findings.iter().find(|f| f.code == code).unwrap();
2845            assert_eq!(f.level, WARN, "{code}");
2846            assert!(f.message.starts_with("a proposed intent"), "{}", f.message);
2847        }
2848        let half = Intents::derive(vec![record("y", &["m"], &[])], &p, &Table::new());
2849        let f = half
2850            .findings
2851            .iter()
2852            .find(|f| f.code == "intent_without_criterion")
2853            .unwrap();
2854        assert_eq!(f.level, FAIL);
2855    }
2856
2857    fn scratch(name: &str) -> PathBuf {
2858        let dir = std::env::temp_dir().join(format!("mj-propose-{name}-{}", std::process::id()));
2859        let _ = std::fs::remove_dir_all(&dir);
2860        dir
2861    }
2862
2863    /// The record reads back as what was given, whatever the statement holds, and its
2864    /// defaults are the statement's first line and that line's slug.
2865    #[test]
2866    fn a_proposal_is_written_whole_and_reads_back_as_given() {
2867        let root = scratch("whole");
2868        let statement = "The \"UI\": answers # fast\nand stays so";
2869        let p = propose(&root, statement, None, None, "2026-10-12").unwrap();
2870        assert_eq!(p.title, "The \"UI\": answers # fast");
2871        assert_eq!(p.id, "the-ui-answers-fast");
2872        assert_eq!(p.statement, statement);
2873        let text = std::fs::read_to_string(root.join(&p.path)).unwrap();
2874        let line = text.lines().find(|l| l.starts_with("statement: ")).unwrap();
2875        let back: String = serde_json::from_str(&line["statement: ".len()..]).unwrap();
2876        assert_eq!(back, statement);
2877        assert!(text.ends_with("created_at: 2026-10-12\nupdated_at: 2026-10-12\n"));
2878        // nothing is left beside the record
2879        let left: Vec<_> = std::fs::read_dir(root.join(INTENTS_DIR)).unwrap().collect();
2880        assert_eq!(left.len(), 1);
2881        // an explicit title and id are taken as given
2882        let q = propose(&root, "s", Some(" A title "), Some("named"), "2026-10-12").unwrap();
2883        assert_eq!((q.id.as_str(), q.title.as_str()), ("named", "A title"));
2884        std::fs::remove_dir_all(&root).unwrap();
2885    }
2886
2887    #[test]
2888    fn a_proposal_that_cannot_be_a_record_writes_nothing() {
2889        let root = scratch("refused");
2890        for (statement, title, id) in [
2891            ("  ", None, None),
2892            ("s", None, Some("Not A Slug")),
2893            ("s", Some("???"), None),
2894        ] {
2895            match propose(&root, statement, title, id, "2026-10-12") {
2896                Err(ProposeError::Invalid(m)) => assert!(!m.is_empty()),
2897                other => panic!("{statement:?} {title:?} {id:?} answered {other:?}"),
2898            }
2899        }
2900        let long = "x".repeat(PROPOSED_ID_MAX + 1);
2901        assert!(matches!(
2902            propose(&root, "s", None, Some(&long), "2026-10-12"),
2903            Err(ProposeError::Invalid(_))
2904        ));
2905        assert!(!root.exists(), "a refused proposal created something");
2906        // a title with no word boundary is cut at the bound
2907        assert_eq!(proposed_id(&long).len(), PROPOSED_ID_MAX);
2908        // a directory that cannot be made is a write failure, said with its path
2909        std::fs::create_dir_all(&root).unwrap();
2910        std::fs::write(root.join(".ai"), "a file where a directory must be").unwrap();
2911        match propose(&root, "s", None, None, "2026-10-12") {
2912            Err(e @ ProposeError::Write(_)) => assert!(e.to_string().contains(".ai"), "{e}"),
2913            other => panic!("an unwritable root answered {other:?}"),
2914        }
2915        std::fs::remove_dir_all(&root).unwrap();
2916    }
2917
2918    #[test]
2919    fn an_intent_with_no_milestone_is_declared_and_a_failure() {
2920        let p = plan(vec![], vec![]);
2921        let i = Intents::derive(vec![record("x", &[], &[CASE])], &p, &Table::new());
2922        assert_eq!(i.intents[0].stage, IntentStage::Declared);
2923        assert!(codes(&i).contains(&"intent_without_milestone"));
2924        assert!(i.failures() > 0);
2925    }
2926
2927    #[test]
2928    fn an_unknown_milestone_is_a_failure_and_holds_the_stage_at_declared() {
2929        let p = plan(vec![milestone("m", "DONE")], vec![]);
2930        let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2931        let i = Intents::derive(vec![record("x", &["m", "ghost"], &[CASE])], &p, &ev);
2932        assert_eq!(i.intents[0].stage, IntentStage::Declared);
2933        assert!(!i.intents[0].milestones[1].resolved);
2934        assert_eq!(codes(&i), ["unknown_milestone"]);
2935    }
2936
2937    #[test]
2938    fn a_criterion_without_a_ref_is_a_failure() {
2939        let p = plan(vec![milestone("m", "DONE")], vec![]);
2940        let i = Intents::derive(
2941            vec![record("x", &["m"], &[("c", "test", " ")])],
2942            &p,
2943            &Table::new(),
2944        );
2945        assert_eq!(codes(&i), ["criterion_without_ref"]);
2946        assert_eq!(
2947            i.intents[0].satisfaction[0].state,
2948            IntentEvidenceState::Unresolved
2949        );
2950    }
2951
2952    #[test]
2953    fn a_ref_that_does_not_resolve_is_a_failure_for_every_kind() {
2954        let p = plan(vec![milestone("m", "DONE")], vec![]);
2955        for (kind, r) in [
2956            ("test", "lib/not-a-test.sh"),
2957            ("test", "test/cases/2_absent.sh"),
2958            ("test", "suite:../x"),
2959            ("claim", "no-such-claim"),
2960            ("deployment", "nowhere"),
2961        ] {
2962            let i = Intents::derive(
2963                vec![record("x", &["m"], &[("c", kind, r)])],
2964                &p,
2965                &Table::new(),
2966            );
2967            assert_eq!(codes(&i), ["unresolved_evidence_ref"], "{kind} {r}");
2968            assert!(i.findings[0].message.contains("criterion `c`"));
2969        }
2970    }
2971
2972    #[test]
2973    fn a_milestone_serving_no_intent_is_a_warning_unless_it_is_retired() {
2974        let p = plan(
2975            vec![
2976                milestone("m", "DONE"),
2977                milestone("orphan", "ACTIVE"),
2978                milestone("gone", "CANCELLED"),
2979            ],
2980            vec![],
2981        );
2982        let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Current);
2983        let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &ev);
2984        assert_eq!(codes(&i), ["milestone_serves_no_intent"]);
2985        assert_eq!(i.findings[0].subject, "orphan");
2986        assert_eq!(i.findings[0].level, WARN);
2987        assert_eq!(i.failures(), 0);
2988        assert_eq!(i.warnings(), 1);
2989    }
2990
2991    #[test]
2992    fn the_remaining_findings_are_each_named() {
2993        let p = plan(vec![milestone("m", "DONE")], vec![]);
2994        let mut dup = record("dup", &["m"], &[CASE, CASE]);
2995        dup.governance = vec![
2996            "rule:project.alpha".into(),
2997            "rule:project.alpha@1".into(),
2998            "file:docs/INTENT.md".into(),
2999            "adr:adr-9999".into(),
3000        ];
3001        dup.superseded_by = "ghost".into();
3002        let mut moved = record("moved", &["m"], &[]);
3003        moved.source = format!("{INTENTS_DIR}/elsewhere.yaml");
3004        let i = Intents::derive(vec![dup, moved], &p, &Table::new());
3005        assert_eq!(
3006            codes(&i),
3007            [
3008                "duplicate_criterion",
3009                "unresolved_evidence_ref",
3010                "unresolved_evidence_ref",
3011                "unresolved_governance",
3012                "unknown_successor",
3013                "file_name_mismatch",
3014                "intent_without_criterion",
3015            ]
3016        );
3017        assert!(i.findings[3].message.contains("adr:adr-9999"));
3018        assert!(i.findings.iter().all(|f| f.reproduce == REPRODUCE));
3019    }
3020
3021    /// An issue under `milestone` serving each of `serves`.
3022    fn serving(id: &str, milestone: &str, scope: &[&str], serves: &[&str]) -> PlanIssue {
3023        PlanIssue {
3024            serves: serves.iter().map(|s| s.to_string()).collect(),
3025            ..issue(id, milestone, "READY", scope)
3026        }
3027    }
3028
3029    /// A critique of `intent` with `findings` as `(id, blocking, resolution)`.
3030    fn critique(intent: &str, findings: &[(&str, bool, &str)]) -> CritiqueRecord {
3031        let findings: Vec<serde_json::Value> = findings
3032            .iter()
3033            .map(|(id, blocking, state)| {
3034                serde_json::json!({"id": id, "class": "regression_risk", "subject": intent,
3035                    "finding": "f", "blocking": blocking,
3036                    "resolution": {"state": state, "issue": "I1", "because": "b"}})
3037            })
3038            .collect();
3039        CritiqueRecord::from_metadata(
3040            &format!(".ai/repo/project/critiques/{intent}.yaml"),
3041            &serde_json::json!({"intent": intent, "reviewed_at": "c0ffee",
3042                "reviewed_by": "the test", "findings": findings}),
3043        )
3044    }
3045
3046    fn causes(answer: &IntentPreflight) -> Vec<(&str, IntentPreflightCause)> {
3047        answer
3048            .refusals
3049            .iter()
3050            .map(|r| (r.issue.as_deref().unwrap_or(""), r.cause))
3051            .collect()
3052    }
3053
3054    #[test]
3055    fn preflight_follows_what_an_issue_serves_and_never_its_milestone_alone() {
3056        use IntentPreflightCause::*;
3057        let p = plan(
3058            vec![
3059                milestone("m", "ACTIVE"),
3060                milestone("n", "ACTIVE"),
3061                milestone("ops", "ACTIVE"),
3062            ],
3063            vec![
3064                serving("I1", "m", &["apps/cli/src"], &["x#case"]),
3065                serving("I2", "ops", &["lib"], &[]),
3066                serving("I3", "m", &["docs"], &[]),
3067                serving("I4", "m", &["share"], &["y#case"]),
3068                serving("I5", "m", &["share"], &["x#nope"]),
3069                serving("I6", "m", &["share"], &["ghost#case"]),
3070                serving("I7", "m", &["share"], &["not-a-reference"]),
3071                issue("I8", "m", "DONE", &["site"]),
3072            ],
3073        );
3074        let mut x = record("x", &["m"], &[CASE]);
3075        x.governance = vec!["rule:project.alpha".into()];
3076        let y = record("y", &["n"], &[CASE]);
3077        let i = Intents::derive(vec![x, y], &p, &Table::new());
3078        let reviewed = [critique("x", &[]), critique("y", &[])];
3079        let one = |id: &str| i.preflight(&p, &[], &reviewed, Some(id), &[]);
3080
3081        let served = one("I1");
3082        assert_eq!(served.verdict, IntentPreflightVerdict::Serves);
3083        assert_eq!(served.matches[0].intent, "x");
3084        assert_eq!(served.matches[0].criteria, ["case"]);
3085        assert_eq!(served.governance, ["rule:project.alpha"]);
3086        assert!(served.refusals.is_empty() && served.refusal.is_none());
3087
3088        // a milestone no intent names carries maintenance, exactly as validation allows it
3089        let maintenance = one("I2");
3090        assert_eq!(maintenance.verdict, IntentPreflightVerdict::Maintenance);
3091        assert!(maintenance.intents.is_empty() && maintenance.refusals.is_empty());
3092        assert_eq!(
3093            maintenance.issues[0].verdict,
3094            IntentPreflightVerdict::Maintenance
3095        );
3096
3097        // under the milestone of x, serving nothing: the milestone alone serves nothing
3098        for (id, cause) in [
3099            ("I3", IssueServesNothing),
3100            ("I4", ServesAnotherIntent),
3101            ("I5", ServesUnknownCriterion),
3102            ("I6", ServesUnknownCriterion),
3103            ("I7", ServesUnknownCriterion),
3104        ] {
3105            let answer = one(id);
3106            assert_eq!(answer.verdict, IntentPreflightVerdict::Refused, "{id}");
3107            assert_eq!(causes(&answer), [(id, cause)], "{id}");
3108            assert!(answer.refusal.unwrap().contains(id), "{id}");
3109            assert!(answer.matches.is_empty(), "{id} serves nothing it may");
3110            assert!(answer.intents.is_empty(), "{id}: no intent is served");
3111        }
3112        // a DONE issue under an intent milestone, serving nothing, is refused like any other
3113        assert_eq!(causes(&one("I8")), [("I8", IssueServesNothing)]);
3114
3115        let unknown = one("I404");
3116        assert_eq!(causes(&unknown), [("I404", UnknownIssue)]);
3117        assert!(unknown.refusal.unwrap().contains("I404"));
3118
3119        // a closed issue's scope claims nothing
3120        let closed = i.preflight(&p, &[], &reviewed, None, &["site/x.md".into()]);
3121        assert_eq!(causes(&closed), [("", NoIssueCoversPaths)]);
3122        assert!(closed.refusal.unwrap().contains("no open issue"));
3123    }
3124
3125    #[test]
3126    fn preflight_keeps_a_cancelled_issues_links_and_gathers_the_criteria_of_one_intent() {
3127        use IntentPreflightCause::*;
3128        let cancelled = |i: PlanIssue| PlanIssue {
3129            status: "CANCELLED".into(),
3130            ..i
3131        };
3132        let p = plan(
3133            vec![milestone("m", "ACTIVE")],
3134            vec![
3135                serving("I1", "m", &["lib"], &["x#case", "x#other"]),
3136                cancelled(serving("I2", "m", &["docs"], &["x#case"])),
3137                cancelled(serving("I3", "m", &["site"], &[])),
3138                serving("I4", "m", &["lib/x"], &["x#other"]),
3139            ],
3140        );
3141        let x = record("x", &["m"], &[CASE, ("other", "test", "test/cases/2_y.sh")]);
3142        let i = Intents::derive(vec![x], &p, &Table::new());
3143        let reviewed = [critique("x", &[])];
3144        let one = |id: &str| i.preflight(&p, &[], &reviewed, Some(id), &[]);
3145
3146        // two criteria of one intent are one match carrying both
3147        let both = one("I1");
3148        assert_eq!(both.verdict, IntentPreflightVerdict::Serves);
3149        assert_eq!(both.matches.len(), 1, "{:?}", both.matches);
3150        assert_eq!(both.matches[0].criteria, ["case", "other"]);
3151        assert_eq!(both.intents.len(), 1);
3152
3153        // a cancelled issue is not live, so coverage lists it under no criterion: what it
3154        // declared it served is still what it was for
3155        let gone = one("I2");
3156        assert_eq!(
3157            gone.verdict,
3158            IntentPreflightVerdict::Serves,
3159            "{:?}",
3160            gone.refusals
3161        );
3162        assert_eq!(gone.matches[0].criteria, ["case"]);
3163        // and a cancelled issue that served nothing under the milestone of x is refused
3164        assert_eq!(causes(&one("I3")), [("I3", IssueServesNothing)]);
3165
3166        // by path, two issues reach the same intent: it is held to once, with both criteria
3167        let by_path = i.preflight(&p, &[], &reviewed, None, &["lib/x/a.rs".into()]);
3168        assert_eq!(by_path.verdict, IntentPreflightVerdict::Serves);
3169        assert_eq!(by_path.issues.len(), 2);
3170        assert_eq!(by_path.intents.len(), 1);
3171        let held: Vec<&str> = by_path.intents[0]
3172            .criteria
3173            .iter()
3174            .map(|c| c.id.as_str())
3175            .collect();
3176        assert_eq!(held, ["case", "other"]);
3177    }
3178
3179    #[test]
3180    fn preflight_refuses_work_on_an_intent_whose_plan_was_not_reviewed_or_has_a_blocker_open() {
3181        use IntentPreflightCause::*;
3182        let p = plan(
3183            vec![milestone("m", "ACTIVE")],
3184            vec![serving("I1", "m", &["lib"], &["x#case"])],
3185        );
3186        let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &Table::new());
3187        let with = |critiques: &[CritiqueRecord]| i.preflight(&p, &[], critiques, Some("I1"), &[]);
3188
3189        let unreviewed = with(&[]);
3190        assert_eq!(causes(&unreviewed), [("I1", IntentNotCritiqued)]);
3191        // the intent is still carried, so the worker sees what it is held to
3192        assert_eq!(unreviewed.intents[0].id, "x");
3193        assert!(unreviewed.intents[0].critique.is_none());
3194        // a critique of another intent reviews nothing here
3195        assert_eq!(
3196            causes(&with(&[critique("other", &[])])),
3197            [("I1", IntentNotCritiqued)]
3198        );
3199
3200        let blocked = with(&[critique(
3201            "x",
3202            &[("open-one", true, "open"), ("advisory", false, "open")],
3203        )]);
3204        assert_eq!(causes(&blocked), [("I1", OpenBlockingFinding)]);
3205        assert!(blocked.refusal.unwrap().contains("open-one"));
3206        let held = blocked.intents[0].critique.as_ref().unwrap();
3207        let open: Vec<&str> = held.open_blocking.iter().map(|f| f.id.as_str()).collect();
3208        assert_eq!(
3209            open,
3210            ["open-one"],
3211            "a finding that blocks nothing is not listed"
3212        );
3213
3214        // planned or rejected, a blocking finding no longer holds the work
3215        let resolved = with(&[critique(
3216            "x",
3217            &[("planned", true, "planned"), ("rejected", true, "rejected")],
3218        )]);
3219        assert_eq!(resolved.verdict, IntentPreflightVerdict::Serves);
3220        let held = resolved.intents[0].critique.as_ref().unwrap();
3221        assert!(held.open_blocking.is_empty());
3222        assert_eq!(held.reviewed_at, "c0ffee");
3223    }
3224
3225    #[test]
3226    fn preflight_by_path_judges_every_issue_and_answers_the_worst() {
3227        use IntentPreflightVerdict::*;
3228        let p = plan(
3229            vec![milestone("m", "ACTIVE"), milestone("ops", "ACTIVE")],
3230            vec![
3231                serving("I1", "m", &["apps/cli"], &["x#case"]),
3232                serving("I2", "ops", &["apps/cli/src/ops"], &[]),
3233                serving("I3", "m", &["apps/cli/src/legacy"], &[]),
3234            ],
3235        );
3236        let i = Intents::derive(vec![record("x", &["m"], &[CASE])], &p, &Table::new());
3237        let reviewed = [critique("x", &[])];
3238        let at = |paths: &[&str]| {
3239            let paths: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
3240            i.preflight(&p, &[], &reviewed, None, &paths)
3241        };
3242        let verdicts = |a: &IntentPreflight| -> Vec<(String, IntentPreflightVerdict)> {
3243            a.issues
3244                .iter()
3245                .map(|x| (x.issue.clone(), x.verdict))
3246                .collect()
3247        };
3248
3249        assert_eq!(at(&["apps/cli/src/main.rs"]).verdict, Serves);
3250        // one served issue never vouches for another that serves nothing
3251        let mixed = at(&["apps/cli/src/legacy/old.rs"]);
3252        assert_eq!(mixed.verdict, Refused);
3253        assert_eq!(
3254            verdicts(&mixed),
3255            [("I1".to_string(), Serves), ("I3".to_string(), Refused)]
3256        );
3257        assert_eq!(
3258            causes(&mixed),
3259            [("I3", IntentPreflightCause::IssueServesNothing)]
3260        );
3261        // served work beside maintenance is held to the intent it serves
3262        let beside = at(&["apps/cli/src/ops/run.rs"]);
3263        assert_eq!(beside.verdict, Serves);
3264        assert_eq!(
3265            verdicts(&beside),
3266            [("I1".to_string(), Serves), ("I2".to_string(), Maintenance)]
3267        );
3268        assert_eq!(beside.intents.len(), 1);
3269    }
3270
3271    #[test]
3272    fn preflight_carries_only_the_served_intents_bounded_to_the_served_criteria() {
3273        let p = plan(
3274            vec![milestone("m", "ACTIVE"), milestone("elsewhere", "ACTIVE")],
3275            vec![
3276                serving("I1", "m", &["lib"], &["x#a"]),
3277                serving("I2", "elsewhere", &["share"], &["z#a"]),
3278            ],
3279        );
3280        let mut x = record(
3281            "x",
3282            &["m"],
3283            &[
3284                ("a", "test", "test/cases/1_x.sh"),
3285                ("b", "test", "test/cases/2_x.sh"),
3286            ],
3287        );
3288        x.invariants = vec!["nothing breaks".into()];
3289        x.non_goals = vec!["speed".into()];
3290        let z = record("z", &["elsewhere"], &[("a", "test", "test/cases/3_x.sh")]);
3291        let ev = Table::new().with_test("suite:1_x", IntentEvidenceState::Failing);
3292        let i = Intents::derive(vec![x, z], &p, &ev);
3293        let gap = GapRecord::from_metadata(
3294            ".ai/repo/project/gaps/x.yaml",
3295            &serde_json::json!({"intent": "x", "observed_at": "c0ffee",
3296                "conditions": [{"criterion": "a", "state": "missing"},
3297                               {"criterion": "b", "state": "unknown"}],
3298                "risks": ["the schema may refuse old records"]}),
3299        );
3300        let answer = i.preflight(
3301            &p,
3302            &[gap],
3303            &[critique("x", &[]), critique("z", &[])],
3304            Some("I1"),
3305            &[],
3306        );
3307        assert_eq!(answer.verdict, IntentPreflightVerdict::Serves);
3308        let ids: Vec<&str> = answer.intents.iter().map(|x| x.id.as_str()).collect();
3309        assert_eq!(
3310            ids,
3311            ["x"],
3312            "an intent the work does not serve is not carried"
3313        );
3314        let x = &answer.intents[0];
3315        assert_eq!(x.statement, "it becomes true");
3316        assert_eq!(x.invariants, ["nothing breaks"]);
3317        assert_eq!(x.non_goals, ["speed"]);
3318        let criteria: Vec<(&str, IntentEvidenceState)> = x
3319            .criteria
3320            .iter()
3321            .map(|c| (c.id.as_str(), c.state))
3322            .collect();
3323        assert_eq!(criteria, [("a", IntentEvidenceState::Failing)]);
3324        let gap = x.gap.as_ref().unwrap();
3325        let conditions: Vec<&str> = gap
3326            .conditions
3327            .iter()
3328            .map(|c| c.criterion.as_str())
3329            .collect();
3330        assert_eq!(conditions, ["a"]);
3331        assert_eq!(gap.risks, ["the schema may refuse old records"]);
3332    }
3333
3334    #[test]
3335    fn preflight_names_a_governance_entry_two_served_intents_share_once() {
3336        let p = plan(
3337            vec![milestone("m", "ACTIVE")],
3338            vec![serving("I1", "m", &["lib"], &["a#case", "b#case"])],
3339        );
3340        let mut a = record("a", &["m"], &[CASE]);
3341        a.governance = vec!["rule:project.alpha".into(), "file:docs/INTENT.md".into()];
3342        let mut b = record("b", &["m"], &[CASE]);
3343        b.governance = vec!["file:docs/INTENT.md".into(), "rule:project.alpha@1".into()];
3344        let i = Intents::derive(vec![a, b], &p, &Table::new());
3345        let reviewed = [critique("a", &[]), critique("b", &[])];
3346        let answer = i.preflight(&p, &[], &reviewed, Some("I1"), &[]);
3347        assert_eq!(answer.verdict, IntentPreflightVerdict::Serves);
3348        let served: Vec<&str> = answer.matches.iter().map(|m| m.intent.as_str()).collect();
3349        assert_eq!(served, ["a", "b"]);
3350        assert_eq!(
3351            answer.governance,
3352            [
3353                "rule:project.alpha",
3354                "file:docs/INTENT.md",
3355                "rule:project.alpha@1"
3356            ]
3357        );
3358    }
3359
3360    #[test]
3361    fn every_stage_is_printed_as_the_word_it_serialises_to() {
3362        for stage in [
3363            IntentStage::Declared,
3364            IntentStage::Planned,
3365            IntentStage::Executing,
3366            IntentStage::Verifying,
3367            IntentStage::Satisfied,
3368            IntentStage::Cancelled,
3369            IntentStage::Superseded,
3370        ] {
3371            assert_eq!(
3372                serde_json::to_value(stage).unwrap(),
3373                serde_json::Value::from(stage.as_str()),
3374                "{stage:?} is spelled one way by serde and another by as_str"
3375            );
3376        }
3377    }
3378
3379    #[test]
3380    fn a_named_test_is_a_suite_case_or_a_crate_test_and_no_other_runner() {
3381        assert_eq!(
3382            test_id("suite:1_x"),
3383            TestId::named(evidence::Runner::Suite, "1_x")
3384        );
3385        assert_eq!(
3386            test_id("crate:intent"),
3387            TestId::named(evidence::Runner::Crate, "intent")
3388        );
3389        assert_ne!(test_id("crate:intent"), test_id("suite:intent"));
3390        assert_eq!(test_id("pytest:intent"), None);
3391        assert_eq!(test_id("no-prefix-at-all"), None);
3392
3393        // and a criterion naming a crate test is met by that test's current run
3394        let p = plan(vec![milestone("m", "DONE")], vec![]);
3395        let ev = Table::new().with_test(
3396            &TestId::named(evidence::Runner::Crate, "intent")
3397                .unwrap()
3398                .as_string(),
3399            IntentEvidenceState::Current,
3400        );
3401        let i = Intents::derive(
3402            vec![record("x", &["m"], &[("c", "test", "crate:intent")])],
3403            &p,
3404            &ev,
3405        );
3406        assert_eq!(codes(&i), Vec::<&str>::new());
3407        assert_eq!(i.intents[0].stage, IntentStage::Satisfied);
3408        let i = Intents::derive(
3409            vec![record("x", &["m"], &[("c", "test", "pytest:intent")])],
3410            &p,
3411            &ev,
3412        );
3413        assert_eq!(codes(&i), ["unresolved_evidence_ref"]);
3414    }
3415
3416    #[test]
3417    fn governance_resolves_only_a_known_kind_naming_what_the_repository_holds() {
3418        let ev = Table::new()
3419            .with_claim("known", IntentEvidenceState::Current)
3420            .with_test("suite:1_x", IntentEvidenceState::Current);
3421        for (entry, resolves) in [
3422            ("rule:project.alpha", true),
3423            ("rule:project.beta", false),
3424            ("claim:known", true),
3425            ("claim:unknown", false),
3426            ("file:docs/INTENT.md", true),
3427            ("file:docs/OTHER.md", false),
3428            ("adr:adr-0001", false),
3429            ("no-kind-at-all", false),
3430            ("ticket:42", false),
3431        ] {
3432            assert_eq!(governance_resolves(entry, &ev), resolves, "{entry}");
3433        }
3434        let p = plan(vec![milestone("m", "DONE")], vec![]);
3435        let mut x = record("x", &["m"], &[CASE]);
3436        x.governance = vec![
3437            "claim:known".into(),
3438            "no-kind-at-all".into(),
3439            "ticket:42".into(),
3440        ];
3441        let i = Intents::derive(vec![x], &p, &ev);
3442        assert_eq!(
3443            codes(&i),
3444            ["unresolved_governance", "unresolved_governance"]
3445        );
3446        assert!(i.findings[0].message.contains("`no-kind-at-all`"));
3447        assert!(i.findings[1].message.contains("`ticket:42`"));
3448    }
3449    /// The binding (ADR 0111) over the same plan the preflight is judged over: every way
3450    /// work is named resolves or says which link is missing, and every preflight cause
3451    /// reaches the binding under its own word.
3452    #[test]
3453    fn the_binding_resolves_every_way_work_is_named() {
3454        use crate::intent_binding::{bind, BindingCause, BindingRequest, BindingStanding};
3455        use crate::policy::IntentPolicy;
3456
3457        let mut finished = serving("I4", "zm", &["zed"], &["z#case"]);
3458        finished.status = "DONE".into();
3459        let p = plan(
3460            vec![
3461                milestone("m", "ACTIVE"),
3462                milestone("n", "ACTIVE"),
3463                milestone("zm", "ACTIVE"),
3464                milestone("rm", "ACTIVE"),
3465                milestone("ops", "ACTIVE"),
3466            ],
3467            vec![
3468                serving("I1", "m", &["lib"], &["x#case"]),
3469                serving("I2", "ops", &["ops"], &[]),
3470                serving("I3", "n", &["lib"], &["y#case"]),
3471                finished,
3472                serving("I5", "m", &["five"], &[]),
3473                serving("I6", "m", &["six"], &["y#case"]),
3474                serving("I7", "m", &["seven"], &["ghost#case"]),
3475            ],
3476        );
3477        let mut retired = record("r", &["rm"], &[CASE]);
3478        retired.cancelled = true;
3479        let i = Intents::derive(
3480            vec![
3481                record("x", &["m"], &[CASE]),
3482                record("y", &["n"], &[CASE]),
3483                record("z", &["zm"], &[CASE]),
3484                retired,
3485            ],
3486            &p,
3487            &Table::new(),
3488        );
3489        let reviewed = [critique("x", &[]), critique("y", &[]), critique("z", &[])];
3490        let policy = IntentPolicy::default();
3491        let ask = |critiques: &[CritiqueRecord], r: BindingRequest| {
3492            bind(&i, &p, &[], critiques, &policy, r)
3493        };
3494        let by_issue = |id: &str| BindingRequest {
3495            issue: Some(id.into()),
3496            ..Default::default()
3497        };
3498        let by_intent = |id: &str| BindingRequest {
3499            intent: Some(id.into()),
3500            ..Default::default()
3501        };
3502        let by_path = |path: &str| BindingRequest {
3503            paths: vec![path.into()],
3504            ..Default::default()
3505        };
3506        let first = |b: &crate::intent_binding::IntentBinding| b.refusals[0].cause;
3507
3508        // an issue, the intent it serves, and both together are one binding
3509        let bound = ask(&reviewed, by_issue("I1"));
3510        assert_eq!(bound.standing, BindingStanding::Bound);
3511        assert_eq!(bound.standing.as_str(), "bound");
3512        assert!(!bound.standing.refuses());
3513        assert_eq!(bound.plan_revision.len(), 64);
3514        assert_eq!(bound.evidence_standing.len(), 64);
3515        let named = ask(&reviewed, by_intent("x"));
3516        assert_eq!(named.standing, BindingStanding::Bound);
3517        assert_eq!(named.plan_revision, bound.plan_revision);
3518        let both = ask(
3519            &reviewed,
3520            BindingRequest {
3521                intent: Some("x".into()),
3522                ..by_issue("I1")
3523            },
3524        );
3525        assert_eq!(both.standing, BindingStanding::Bound);
3526        // an issue beside an intent it does not serve is a contradiction
3527        let contradiction = ask(
3528            &reviewed,
3529            BindingRequest {
3530                intent: Some("y".into()),
3531                ..by_issue("I1")
3532            },
3533        );
3534        assert_eq!(first(&contradiction), BindingCause::IssueOutsideIntent);
3535        assert!(contradiction.standing.refuses());
3536        assert_eq!(contradiction.standing.as_str(), "refused");
3537
3538        // paths that reach two intents are ambiguous; paths under no intent are maintenance
3539        let ambiguous = ask(&reviewed, by_path("lib/a.rs"));
3540        assert_eq!(first(&ambiguous), BindingCause::AmbiguousIntent);
3541        assert!(ambiguous.refusal.unwrap().contains("x, y"));
3542        let maintenance = ask(&reviewed, by_path("ops/run.sh"));
3543        assert_eq!(maintenance.standing, BindingStanding::Maintenance);
3544        assert_eq!(maintenance.standing.as_str(), "maintenance");
3545        assert!(maintenance.plan_revision.is_empty() && maintenance.evidence_standing.is_empty());
3546        assert_eq!(BindingStanding::Exempt.as_str(), "exempt");
3547
3548        // an intent with nothing open to do, and one that was retired
3549        assert_eq!(
3550            first(&ask(&reviewed, by_intent("z"))),
3551            BindingCause::IntentHasNoOpenWork
3552        );
3553        let gone = ask(&reviewed, by_intent("r"));
3554        assert_eq!(first(&gone), BindingCause::IntentRetired);
3555        assert!(gone.refusal.unwrap().contains("cancelled"));
3556
3557        // a path the named issue's scope does not cover is said, and refuses nothing
3558        let noted = ask(
3559            &reviewed,
3560            BindingRequest {
3561                paths: vec!["docs/x.md".into(), "lib/a.rs".into()],
3562                ..by_issue("I1")
3563            },
3564        );
3565        assert_eq!(noted.standing, BindingStanding::Bound);
3566        assert_eq!(noted.notes, ["docs/x.md lie outside the scope of I1"]);
3567
3568        // each of the preflight's causes is the binding's cause, under the same word
3569        assert_eq!(
3570            first(&ask(&[], by_issue("I1"))),
3571            BindingCause::IntentNotCritiqued
3572        );
3573        let blocked = [critique("x", &[("F1", true, "open")])];
3574        assert_eq!(
3575            first(&ask(&blocked, by_issue("I1"))),
3576            BindingCause::OpenBlockingFinding
3577        );
3578        for (id, cause) in [
3579            ("I5", BindingCause::IssueServesNothing),
3580            ("I6", BindingCause::ServesAnotherIntent),
3581            ("I7", BindingCause::ServesUnknownCriterion),
3582        ] {
3583            assert_eq!(first(&ask(&reviewed, by_issue(id))), cause, "{id}");
3584        }
3585        for (from, to) in [
3586            (
3587                IntentPreflightCause::UnknownIssue,
3588                BindingCause::UnknownIssue,
3589            ),
3590            (
3591                IntentPreflightCause::NoIssueCoversPaths,
3592                BindingCause::NoIssueCoversPaths,
3593            ),
3594            (
3595                IntentPreflightCause::IssueServesNothing,
3596                BindingCause::IssueServesNothing,
3597            ),
3598            (
3599                IntentPreflightCause::ServesAnotherIntent,
3600                BindingCause::ServesAnotherIntent,
3601            ),
3602            (
3603                IntentPreflightCause::ServesUnknownCriterion,
3604                BindingCause::ServesUnknownCriterion,
3605            ),
3606            (
3607                IntentPreflightCause::IntentNotCritiqued,
3608                BindingCause::IntentNotCritiqued,
3609            ),
3610            (
3611                IntentPreflightCause::OpenBlockingFinding,
3612                BindingCause::OpenBlockingFinding,
3613            ),
3614        ] {
3615            assert_eq!(BindingCause::from(from), to);
3616            assert_eq!(
3617                serde_json::to_value(from).unwrap(),
3618                serde_json::to_value(to).unwrap(),
3619                "the word changed on the way"
3620            );
3621        }
3622    }
3623}