Skip to main content

majordomus_cli/environment/
preflight.rs

1//! The preflight: whether Majordomus is actually in force in this checkout, and what proves
2//! each part of that claim.
3//!
4//! The [`super::RepositoryEnvironment`] says what this checkout *is* — its branch, its
5//! toolchains, where a server published its address. It does not say what is *proven*: an
6//! address that accepts a connection is not a server of this version serving this checkout,
7//! a rule file on disk is not a rule anything enforces, and a recorded test run is not a run
8//! of the tree in front of you. Until this module, the entry banner drew a `✓` beside the
9//! Cockpit on the strength of a TCP connection while the server behind it was two versions
10//! old. The preflight is the value that refuses that.
11//!
12//! # One verdict per claim, and no verdict without evidence
13//!
14//! Every [`Check`] carries a [`Verdict`] and the [`Evidence`] behind it. The three verdicts
15//! that assert something is in force — [`Verdict::Verified`], [`Verdict::Active`] and
16//! [`Verdict::Fresh`] — cannot be constructed without evidence: [`Check::new`] turns such a
17//! claim into [`Verdict::Unknown`] and says why. That is the whole rule
18//! `project.entry-reports-only-evidence` enforces, and it is enforced in the type rather
19//! than in each renderer.
20//!
21//! # Two halves: observing and deriving
22//!
23//! [`observe`] reads — the lease, one loopback request, the session and task records, the
24//! evidence ledger, one `git log` of the deployment ref — and returns [`Observations`], plain
25//! data. [`derive()`] turns observations into a [`Preflight`] and reads nothing, so every
26//! verdict in this file is a test over a value rather than over a machine. The command line,
27//! the HTTP route, the MCP tool and resource, the Cockpit and the entry banner all render
28//! the value [`derive()`] returns.
29//!
30//! # What it never does
31//!
32//! It never builds the index on the entry path (the rule tally comes from the cache a full
33//! resolution wrote, and says when it was counted), never contacts anything but the loopback
34//! address a server of this checkout published, never runs a test, a build or a generator,
35//! and never writes outside `.ai/local/`.
36//!
37//! ```
38//! use majordomus_cli::environment::preflight::{derive, Observations, Verdict};
39//!
40//! // a checkout nobody has observed anything about
41//! let preflight = derive(&Observations::empty("demo", 0));
42//! // nothing is claimed in force, and nothing absent is dressed up as present
43//! assert!(preflight.sections.iter().flat_map(|s| &s.checks).all(|c| !c.verdict.proves()));
44//! assert_eq!(preflight.check("integration.server").unwrap().verdict, Verdict::Unknown);
45//! assert_eq!(preflight.check("verification.deployment").unwrap().verdict, Verdict::Unavailable);
46//! ```
47
48use std::collections::BTreeMap;
49use std::fmt::Write as _;
50use std::path::Path;
51use std::process::Command;
52use std::time::Duration;
53
54use schemars::JsonSchema;
55use serde::{Deserialize, Serialize};
56use serde_json::Value;
57
58use crate::capability::builtin::continuity::{self, Freshness, Thresholds};
59use crate::capability::builtin::server::{standing_of, ServerStanding};
60use crate::evidence::Ledger;
61use crate::lease::{self, LeaseFile};
62use crate::policy::LoadedPolicy;
63use crate::rules::{RuleState, RulesReport};
64
65use super::cache::{Cache, Entry};
66use super::{ProjectionState, RepositoryEnvironment, Resolution};
67
68/// The schema identity of [`Preflight`].
69pub const SCHEMA: &str = "majordomus/preflight";
70
71/// The version of [`SCHEMA`]; it rises when a reader of the old shape would misread the new.
72pub const SCHEMA_VERSION: u32 = 1;
73
74/// The ref the site deployment is read from: the remote-tracking copy of the branch
75/// `scripts/pages` publishes, whose every commit names the source commit it was built from
76/// (`source: <sha>`). A local ref, so reading it contacts nothing — and it is only as recent
77/// as the last fetch, which every verdict built on it says.
78pub const PAGES_REF: &str = "refs/remotes/origin/gh-pages";
79
80/// How long the one loopback request to a published server may take. Loopback answers in
81/// about a millisecond or is not there; anything slower is a machine under load, and a
82/// shell prompt is not the place to wait for it.
83pub const ANSWER_BUDGET: Duration = Duration::from_millis(250);
84
85/// How long the peer board may take when a person asked for the preflight. The board gathers
86/// every checkout's, which is tens of milliseconds on a quiet machine and more on a loaded one;
87/// entry never asks it, so this budget is spent only when somebody is waiting for the answer.
88pub const BOARD_BUDGET: Duration = Duration::from_secs(1);
89
90/// The path under the local half where a full resolution leaves the rule tally for the
91/// entry path to read. It lives in the environment cache file, as one more tier.
92pub const RULES_TIER: &str = "rules";
93
94/// What the repository can show about one claim.
95///
96/// Nine words, not a boolean, because the actions they call for differ: `stale` is re-run
97/// the proof, `unavailable` is nothing is there, `unknown` is nobody looked, and
98/// `not_applicable` is there is nothing to look for.
99///
100/// ```
101/// use majordomus_cli::environment::preflight::Verdict;
102/// assert!(Verdict::Verified.proves() && Verdict::Active.proves() && Verdict::Fresh.proves());
103/// assert!(!Verdict::Stale.proves() && !Verdict::NotApplicable.proves());
104/// assert_eq!(Verdict::NotApplicable.as_str(), "not_applicable");
105/// // the most urgent first: a failure outranks a stale proof, which outranks an absence
106/// assert!(Verdict::Failed.urgency() < Verdict::Stale.urgency());
107/// assert_eq!(Verdict::Verified.urgency(), None);
108/// ```
109#[derive(
110    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
111)]
112#[serde(rename_all = "snake_case")]
113#[schemars(rename = "PreflightVerdict")]
114pub enum Verdict {
115    /// Proven against this tree by a recorded result or a direct answer.
116    Verified,
117    /// In force now, observed directly, with nothing recorded to verify.
118    Active,
119    /// Judged recent enough to act on, against a declared threshold or the current commit.
120    Fresh,
121    /// Evidence exists, but it is about another commit, tree or moment.
122    Stale,
123    /// In force, but in a form that is not the one this executable would serve.
124    Degraded,
125    /// Nothing is there to observe.
126    Unavailable,
127    /// Observed, and it did not hold.
128    Failed,
129    /// Nobody looked, or the looking did not finish.
130    Unknown,
131    /// There is nothing here to judge.
132    NotApplicable,
133}
134
135impl Verdict {
136    /// The word as serialised and printed.
137    ///
138    /// ```
139    /// use majordomus_cli::environment::preflight::Verdict;
140    /// assert_eq!(Verdict::Degraded.as_str(), "degraded");
141    /// ```
142    pub fn as_str(self) -> &'static str {
143        match self {
144            Verdict::Verified => "verified",
145            Verdict::Active => "active",
146            Verdict::Fresh => "fresh",
147            Verdict::Stale => "stale",
148            Verdict::Degraded => "degraded",
149            Verdict::Unavailable => "unavailable",
150            Verdict::Failed => "failed",
151            Verdict::Unknown => "unknown",
152            Verdict::NotApplicable => "not_applicable",
153        }
154    }
155
156    /// Does this verdict assert that something is in force? Only these three need evidence
157    /// to be constructed, and only these three may be drawn as a success.
158    ///
159    /// ```
160    /// use majordomus_cli::environment::preflight::Verdict;
161    /// assert!(Verdict::Fresh.proves());
162    /// assert!(!Verdict::Unknown.proves());
163    /// ```
164    pub fn proves(self) -> bool {
165        matches!(self, Verdict::Verified | Verdict::Active | Verdict::Fresh)
166    }
167
168    /// How urgently a person should look, lowest first; `None` for a verdict that needs no
169    /// attention.
170    ///
171    /// ```
172    /// use majordomus_cli::environment::preflight::Verdict;
173    /// assert_eq!(Verdict::Failed.urgency(), Some(0));
174    /// assert_eq!(Verdict::NotApplicable.urgency(), None);
175    /// ```
176    pub fn urgency(self) -> Option<u8> {
177        match self {
178            Verdict::Failed => Some(0),
179            Verdict::Degraded => Some(1),
180            Verdict::Stale => Some(2),
181            Verdict::Unavailable => Some(3),
182            Verdict::Unknown => Some(4),
183            Verdict::Verified | Verdict::Active | Verdict::Fresh | Verdict::NotApplicable => None,
184        }
185    }
186
187    /// The status word the Cockpit's design system files this verdict under.
188    ///
189    /// ```
190    /// use majordomus_cli::environment::preflight::Verdict;
191    /// assert_eq!(Verdict::Verified.status(), "ok");
192    /// assert_eq!(Verdict::Failed.status(), "fail");
193    /// ```
194    pub fn status(self) -> &'static str {
195        match self {
196            Verdict::Verified | Verdict::Active | Verdict::Fresh => "ok",
197            Verdict::Stale | Verdict::Degraded | Verdict::Unknown => "warn",
198            Verdict::Failed => "fail",
199            Verdict::Unavailable | Verdict::NotApplicable => "info",
200        }
201    }
202}
203
204/// One thing a verdict rests on: where it was read and what was seen there.
205///
206/// ```
207/// use majordomus_cli::environment::preflight::Evidence;
208/// let e = Evidence::new(".ai/repo/evidence/ledger.json", "6 recorded runs");
209/// assert_eq!(e.source, ".ai/repo/evidence/ledger.json");
210/// ```
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
212#[schemars(rename = "PreflightEvidence")]
213pub struct Evidence {
214    /// The file, command, route or capability that was read.
215    pub source: String,
216    /// What it said, in one phrase.
217    pub observed: String,
218}
219
220impl Evidence {
221    /// Evidence read from `source`: the file, command or route that was read, and the phrase
222    /// saying what it held at the moment it was read.
223    ///
224    /// ```
225    /// use majordomus_cli::environment::preflight::Evidence;
226    /// assert_eq!(Evidence::new("GET /", "leaseholder").observed, "leaseholder");
227    /// ```
228    pub fn new(source: impl Into<String>, observed: impl Into<String>) -> Self {
229        Evidence {
230            source: source.into(),
231            observed: observed.into(),
232        }
233    }
234}
235
236/// One claim, its verdict, and what the verdict rests on.
237///
238/// ```
239/// use majordomus_cli::environment::preflight::{Check, Evidence, Verdict};
240///
241/// // a claim of something in force without evidence is not accepted as one
242/// let bare = Check::new("integration.mcp", "MCP", Verdict::Verified, "healthy", vec![]);
243/// assert_eq!(bare.verdict, Verdict::Unknown);
244/// assert!(bare.summary.contains("no evidence"));
245///
246/// let backed = Check::new(
247///     "integration.mcp", "MCP", Verdict::Verified, "served",
248///     vec![Evidence::new("GET /", "surface mcp ready")],
249/// );
250/// assert_eq!(backed.verdict, Verdict::Verified);
251/// ```
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
253#[schemars(rename = "PreflightCheck")]
254pub struct Check {
255    /// A stable dotted id, `<section>.<claim>`.
256    pub id: String,
257    /// The short name a person reads.
258    pub title: String,
259    /// What the repository can show.
260    pub verdict: Verdict,
261    /// The verdict in one sentence, with the numbers that decided it.
262    pub summary: String,
263    /// Everything the verdict rests on. Never empty for a verdict that proves something.
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub evidence: Vec<Evidence>,
266    /// The command that shows more, or that changes the verdict.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub next: Option<String>,
269}
270
271impl Check {
272    /// A check. A verdict that [`Verdict::proves`] something and arrives without evidence
273    /// becomes [`Verdict::Unknown`], with the summary saying so.
274    ///
275    /// ```
276    /// use majordomus_cli::environment::preflight::{Check, Verdict};
277    /// let c = Check::new("x.y", "Y", Verdict::Stale, "counted at another commit", vec![]);
278    /// assert_eq!(c.verdict, Verdict::Stale, "only a claim of force needs evidence");
279    /// ```
280    pub fn new(
281        id: impl Into<String>,
282        title: impl Into<String>,
283        verdict: Verdict,
284        summary: impl Into<String>,
285        evidence: Vec<Evidence>,
286    ) -> Self {
287        let summary = summary.into();
288        let (verdict, summary) = if verdict.proves() && evidence.is_empty() {
289            (
290                Verdict::Unknown,
291                format!(
292                    "claimed {} with no evidence, so not claimed: {summary}",
293                    verdict.as_str()
294                ),
295            )
296        } else {
297            (verdict, summary)
298        };
299        Check {
300            id: id.into(),
301            title: title.into(),
302            verdict,
303            summary,
304            evidence,
305            next: None,
306        }
307    }
308
309    /// The same check, naming the command that shows more.
310    ///
311    /// ```
312    /// use majordomus_cli::environment::preflight::{Check, Verdict};
313    /// let c = Check::new("x.y", "Y", Verdict::Unknown, "not probed", vec![])
314    ///     .next("majordomus serve status");
315    /// assert_eq!(c.next.as_deref(), Some("majordomus serve status"));
316    /// ```
317    pub fn next(mut self, command: impl Into<String>) -> Self {
318        self.next = Some(command.into());
319        self
320    }
321}
322
323/// A group of checks: repository, session, governance, integration, verification.
324///
325/// ```
326/// use majordomus_cli::environment::preflight::{derive, Observations, Section};
327/// let p = derive(&Observations::empty("demo", 0));
328/// let ids: Vec<&str> = p.sections.iter().map(|s| s.id.as_str()).collect();
329/// assert_eq!(ids, ["repository", "session", "governance", "integration", "verification"]);
330/// // every check in a section carries the section's id as its prefix
331/// let session: &Section = &p.sections[1];
332/// assert!(session.checks.iter().all(|c| c.id.starts_with("session.")));
333/// ```
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335#[schemars(rename = "PreflightSection")]
336pub struct Section {
337    /// A stable id, the prefix of every check in it.
338    pub id: String,
339    /// The heading a person reads.
340    pub title: String,
341    /// The checks, in the order they are rendered.
342    pub checks: Vec<Check>,
343}
344
345/// Whether Majordomus is in force in this checkout, claim by claim, with the evidence.
346///
347/// ```
348/// use majordomus_cli::environment::preflight::{derive, Observations, Preflight};
349/// let p = derive(&Observations::empty("demo", 0));
350/// assert_eq!(p.schema, Preflight::schema_id());
351/// assert_eq!(p.verdicts().len(), p.sections.iter().map(|s| s.checks.len()).sum::<usize>());
352/// ```
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
354pub struct Preflight {
355    /// `majordomus/preflight/v1`.
356    pub schema: String,
357    /// When the observations were taken, RFC 3339 in UTC.
358    pub generated_at: String,
359    /// How the environment behind it was resolved.
360    pub resolution: Resolution,
361    /// The repository's name.
362    pub repository: String,
363    /// The branch, when git named one.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub branch: Option<String>,
366    /// The commit HEAD names.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub head: Option<String>,
369    /// The groups of checks.
370    pub sections: Vec<Section>,
371    /// The ids of every check that needs a look, most urgent first.
372    pub attention: Vec<String>,
373}
374
375impl Preflight {
376    /// The schema identity a document of this shape carries.
377    ///
378    /// ```
379    /// use majordomus_cli::environment::preflight::Preflight;
380    /// assert_eq!(Preflight::schema_id(), "majordomus/preflight/v1");
381    /// ```
382    pub fn schema_id() -> String {
383        format!("{SCHEMA}/v{SCHEMA_VERSION}")
384    }
385
386    /// One check by its dotted id, or `None` for an id no section declares — which a caller
387    /// must not read as a check that passed.
388    ///
389    /// ```
390    /// use majordomus_cli::environment::preflight::{derive, Observations};
391    /// let p = derive(&Observations::empty("demo", 0));
392    /// assert!(p.check("session.episode").is_some());
393    /// assert!(p.check("nothing.here").is_none());
394    /// ```
395    pub fn check(&self, id: &str) -> Option<&Check> {
396        self.sections
397            .iter()
398            .flat_map(|s| &s.checks)
399            .find(|c| c.id == id)
400    }
401
402    /// Every check's verdict by id: the part two surfaces must agree on.
403    ///
404    /// ```
405    /// use majordomus_cli::environment::preflight::{derive, Observations, Verdict};
406    /// let v = derive(&Observations::empty("demo", 0)).verdicts();
407    /// assert_eq!(v["verification.coverage"], Verdict::Unavailable);
408    /// ```
409    pub fn verdicts(&self) -> BTreeMap<String, Verdict> {
410        self.sections
411            .iter()
412            .flat_map(|s| &s.checks)
413            .map(|c| (c.id.clone(), c.verdict))
414            .collect()
415    }
416}
417
418// ---------------------------------------------------------------- observations
419
420/// What git said about the work tree, taken from the snapshot's one `git status` rather than
421/// asked again.
422///
423/// ```
424/// use majordomus_cli::environment::preflight::{derive, GitObservation, Observations, Verdict};
425/// let mut o = Observations::empty("demo", 0);
426/// assert_eq!(derive(&o).check("repository.git").unwrap().verdict, Verdict::Unavailable);
427/// o.git = Some(GitObservation { branch: None, head: Some("a".repeat(40)), clean: false, changed: 3 });
428/// let git = derive(&o);
429/// let c = git.check("repository.git").unwrap();
430/// assert_eq!(c.verdict, Verdict::Active);
431/// assert!(c.summary.contains("detached") && c.summary.contains("3 path(s) changed"));
432/// ```
433#[derive(Debug, Clone, PartialEq, Eq, Default)]
434pub struct GitObservation {
435    /// The branch, when HEAD names one.
436    pub branch: Option<String>,
437    /// The commit HEAD names.
438    pub head: Option<String>,
439    /// Whether nothing is staged, modified, untracked or conflicted.
440    pub clean: bool,
441    /// Paths that are not clean.
442    pub changed: u32,
443}
444
445/// The open episode, read from `session-current.yaml`, with whether it is this checkout's.
446///
447/// ```
448/// use majordomus_cli::environment::preflight::{derive, EpisodeObservation, Observations, Verdict};
449/// let mut o = Observations::empty("demo", 0);
450/// o.episode = Some(EpisodeObservation { id: "s-1".into(), this_checkout: false, ..Default::default() });
451/// // an episode another checkout opened is not this work
452/// assert_eq!(derive(&o).check("session.episode").unwrap().verdict, Verdict::Degraded);
453/// ```
454#[derive(Debug, Clone, PartialEq, Eq, Default)]
455pub struct EpisodeObservation {
456    /// The episode id.
457    pub id: String,
458    /// When it opened.
459    pub started_at: String,
460    /// The provider that opened it, when one did.
461    pub provider: String,
462    /// The commit its briefing was written at.
463    pub start_head: String,
464    /// `clean` or `dirty` when its briefing was written.
465    pub start_working_tree: String,
466    /// Whether the record belongs to this checkout.
467    pub this_checkout: bool,
468}
469
470/// The task record, read from `current.yaml` through the continuity module's own reader.
471///
472/// ```
473/// use majordomus_cli::environment::preflight::{derive, Observations, TaskObservation, Verdict};
474/// let mut o = Observations::empty("demo", 0);
475/// o.task = Some(TaskObservation { id: "t-1".into(), outcome: "active".into(), ..Default::default() });
476/// assert_eq!(derive(&o).check("session.task").unwrap().verdict, Verdict::Active);
477/// o.task = Some(TaskObservation { id: "t-1".into(), outcome: "handed_over".into(), ..Default::default() });
478/// assert_eq!(derive(&o).check("session.task").unwrap().verdict, Verdict::NotApplicable);
479/// ```
480#[derive(Debug, Clone, PartialEq, Eq, Default)]
481pub struct TaskObservation {
482    /// The task id.
483    pub id: String,
484    /// What it is.
485    pub task: String,
486    /// `active` (or empty, for an older record) while it is in progress; the outcome once it
487    /// ended.
488    pub outcome: String,
489    /// When it started.
490    pub started_at: String,
491}
492
493/// The handover a resuming worker would be given, judged by continuity's own freshness rule.
494///
495/// ```
496/// use majordomus_cli::capability::builtin::continuity::Freshness;
497/// use majordomus_cli::environment::preflight::{derive, HandoverObservation, Observations, Verdict};
498/// let mut o = Observations::empty("demo", 0);
499/// o.handover = Some(HandoverObservation {
500///     path: "h.md".into(), freshness: Freshness::Aging, reason: "5h old".into(), divergence: "exact".into(),
501/// });
502/// assert_eq!(derive(&o).check("session.handover").unwrap().verdict, Verdict::Fresh);
503/// ```
504#[derive(Debug, Clone, PartialEq, Eq)]
505pub struct HandoverObservation {
506    /// Repository-relative path.
507    pub path: String,
508    /// How old it is, against the policy's thresholds.
509    pub freshness: Freshness,
510    /// Why, in one phrase.
511    pub reason: String,
512    /// Where its commit sits relative to HEAD.
513    pub divergence: String,
514}
515
516/// What the continuity commands last left in this checkout's `continuity.json`: the
517/// handovers other devices published that could be resumed here, as the last sync computed
518/// them, and how that sync went. Read from that one file — never from the store, never from
519/// a remote — so that entering a directory costs no git call and no network.
520///
521/// A checkout that publishes and has never synced is told to sync:
522///
523/// ```
524/// use majordomus_cli::environment::preflight::{derive, ContinuityObservation, Observations, Verdict};
525/// let mut o = Observations::empty("demo", 0);
526/// o.continuity = Some(ContinuityObservation { offers: Vec::new(), last_sync: None });
527/// let c = derive(&o).check("session.continuity").unwrap().clone();
528/// assert_eq!(c.verdict, Verdict::Unknown);
529/// assert!(c.summary.contains("never synced"));
530/// ```
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct ContinuityObservation {
533    /// Resumable handovers from other devices.
534    pub offers: Vec<crate::continuity::local::Offer>,
535    /// The last sync.
536    pub last_sync: Option<crate::continuity::local::SyncNote>,
537}
538
539/// Whether the policy parsed, was refused, or was never read by this call.
540///
541/// ```
542/// use majordomus_cli::environment::preflight::{derive, Observations, PolicyObservation, Verdict};
543/// let mut o = Observations::empty("demo", 0);
544/// o.policy = PolicyObservation::Refused("unknown key".into());
545/// assert_eq!(derive(&o).check("governance.policy").unwrap().verdict, Verdict::Failed);
546/// ```
547#[derive(Debug, Clone, PartialEq, Eq, Default)]
548pub enum PolicyObservation {
549    /// It parsed.
550    Parsed,
551    /// It did not; the reason.
552    Refused(String),
553    /// Nobody read it.
554    #[default]
555    NotRead,
556}
557
558/// The rule corpus, counted by [`crate::rules::report`] against a commit.
559///
560/// ```
561/// use majordomus_cli::environment::preflight::RulesTally;
562/// use majordomus_cli::evidence::Ledger;
563/// use majordomus_cli::synthetic::SyntheticRepository;
564/// let repo = SyntheticRepository::small().unwrap();
565/// let report = majordomus_cli::rules::report(&repo.index().unwrap(), &Ledger::empty());
566/// let tally = RulesTally::of(&report);
567/// assert_eq!(tally.rules, report.coverage.rules);
568/// assert_eq!(tally.proven, 0, "an empty ledger proves nothing");
569/// ```
570#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
571#[schemars(rename = "PreflightRulesTally")]
572pub struct RulesTally {
573    /// The commit it was counted at.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub head: Option<String>,
576    /// `clean`, `dirty` or `unknown` when it was counted.
577    pub working_tree: String,
578    /// Rules discovered.
579    pub rules: usize,
580    /// Of those, blocking.
581    pub blocking: usize,
582    /// Of those, the repository's own (`project.`); the rest are vendored.
583    pub project: usize,
584    /// Rules a CI gate runs.
585    pub gated: usize,
586    /// Rules in each state, by the state's printed word.
587    pub states: BTreeMap<String, usize>,
588    /// Rules proven against the tree they were counted at.
589    pub proven: usize,
590    /// Rules whose latest recorded run failed.
591    pub failing: usize,
592    /// Rules that declare a reader enforces them.
593    pub reviewed: usize,
594    /// Rules naming neither a validator nor a test.
595    pub unproven: usize,
596    /// Rules whose class their proof does not support.
597    pub findings: usize,
598}
599
600impl RulesTally {
601    /// The tally of one report: the counts entry needs, stamped with the commit and tree the
602    /// report was derived against, so a later reader can tell when it went stale.
603    ///
604    /// ```
605    /// use majordomus_cli::environment::preflight::RulesTally;
606    /// use majordomus_cli::rules::{RulesReport, RulesVerdict};
607    /// let empty = RulesReport { head: None, working_tree: "clean".into(),
608    ///     verdict: RulesVerdict::Unproven, rules: vec![], states: Default::default(),
609    ///     coverage: Default::default(), findings: vec![], satisfied: true };
610    /// assert_eq!(RulesTally::of(&empty).rules, 0);
611    /// ```
612    pub fn of(report: &RulesReport) -> Self {
613        let count = |s: RuleState| report.states.get(s.label()).copied().unwrap_or(0);
614        RulesTally {
615            head: report.head.clone(),
616            working_tree: report.working_tree.clone(),
617            rules: report.coverage.rules,
618            blocking: report.coverage.blocking,
619            project: report
620                .rules
621                .iter()
622                .filter(|r| r.rule.id.starts_with("project."))
623                .count(),
624            gated: report.coverage.gated,
625            states: report.states.clone(),
626            proven: count(RuleState::Proven),
627            failing: count(RuleState::Failing),
628            reviewed: count(RuleState::Reviewed),
629            unproven: count(RuleState::Unproven),
630            findings: report.findings.len(),
631        }
632    }
633}
634
635/// Where the rule tally came from: counted now, taken from the cache, or not there at all.
636///
637/// ```
638/// use majordomus_cli::environment::preflight::{derive, Observations, RulesObservation, RulesTally, Verdict};
639/// let mut o = Observations::empty("demo", 0);
640/// assert_eq!(derive(&o).check("governance.rules").unwrap().verdict, Verdict::Unknown);
641/// // a cached tally with no commit to compare against cannot be current
642/// o.rules = RulesObservation::Cached(RulesTally { rules: 4, ..Default::default() });
643/// assert_eq!(derive(&o).check("governance.rules").unwrap().verdict, Verdict::Stale);
644/// ```
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub enum RulesObservation {
647    /// Counted in this process.
648    Counted(RulesTally),
649    /// Taken from the cache a full resolution wrote.
650    Cached(RulesTally),
651    /// Neither.
652    Absent,
653}
654
655/// What the lease named and what the address it names answered, if it was asked.
656///
657/// ```
658/// use majordomus_cli::capability::builtin::server::ServerStanding;
659/// use majordomus_cli::environment::preflight::{derive, Observations, ServerObservation, Verdict};
660/// let mut o = Observations::empty("demo", 0);
661/// o.server = ServerObservation {
662///     standing: Some(ServerStanding::Outdated),
663///     reason: Some("the server is serving version 0.6.1".into()),
664///     ..Default::default()
665/// };
666/// assert_eq!(derive(&o).check("integration.server").unwrap().verdict, Verdict::Degraded);
667/// ```
668#[derive(Debug, Clone, PartialEq, Eq, Default)]
669pub struct ServerObservation {
670    /// Where it stands; `None` when there is a lease and nobody asked the address.
671    pub standing: Option<ServerStanding>,
672    /// Why, when it is not ready.
673    pub reason: Option<String>,
674    /// The address it published.
675    pub url: Option<String>,
676    /// Its process id.
677    pub pid: Option<u32>,
678    /// The version it serves.
679    pub version: Option<String>,
680    /// Every surface its index lists, with whether it is ready; empty when it did not answer.
681    pub surfaces: Vec<(String, String, bool)>,
682    /// Whether the answer came from this very process, which holds the lease.
683    pub this_process: bool,
684}
685
686/// The peer board's tally, when somebody asked it.
687///
688/// ```
689/// use majordomus_cli::environment::preflight::{derive, Observations, PeersObservation, Verdict};
690/// let mut o = Observations::empty("demo", 0);
691/// o.peers = Ok(PeersObservation { attached: 3, detached: 1, announced: 2 });
692/// let p = derive(&o);
693/// assert_eq!(p.check("integration.peers").unwrap().verdict, Verdict::Active);
694/// assert!(p.check("integration.peers").unwrap().summary.starts_with("3 attached"));
695/// ```
696#[derive(Debug, Clone, PartialEq, Eq, Default)]
697pub struct PeersObservation {
698    /// Peers attached now.
699    pub attached: usize,
700    /// Peers gone whose claims the board still shows.
701    pub detached: usize,
702    /// Attached peers that announced what they are doing.
703    pub announced: usize,
704}
705
706/// The evidence ledger, joined to the tree in front of you.
707///
708/// ```
709/// use majordomus_cli::environment::preflight::{derive, LedgerObservation, Observations, Verdict};
710/// let mut o = Observations::empty("demo", 0);
711/// o.ledger = LedgerObservation::Unreadable("version 9".into());
712/// assert_eq!(derive(&o).check("verification.tests").unwrap().verdict, Verdict::Failed);
713/// ```
714#[derive(Debug, Clone, PartialEq, Eq, Default)]
715pub enum LedgerObservation {
716    /// No ledger file.
717    #[default]
718    Absent,
719    /// A ledger this executable cannot read; the reason.
720    Unreadable(String),
721    /// The ledger, joined to HEAD.
722    Read {
723        /// Recorded executions.
724        total: usize,
725        /// Of those, made at HEAD on a clean tree, whose test still hashes as recorded.
726        current: usize,
727        /// Of the current ones, those that did not pass.
728        current_failing: usize,
729        /// The commit of the newest execution.
730        newest_commit: String,
731        /// When the newest execution was recorded.
732        newest_at: String,
733    },
734}
735
736/// Where `scripts/pages current` — the generation check the pre-commit hook runs — records its
737/// last verdict, relative to the repository root.
738pub const GENERATION_RECORD: &str = ".ai/local/state/generation/check.json";
739
740/// The last generation check `scripts/pages current` recorded, and the tree HEAD has now.
741///
742/// ```
743/// use majordomus_cli::environment::preflight::{derive, GenerationObservation, Observations, Verdict};
744/// let mut o = Observations::empty("demo", 0);
745/// o.generation = GenerationObservation::Unreadable("schema 9".into());
746/// assert_eq!(derive(&o).check("verification.docs").unwrap().verdict, Verdict::Unknown);
747/// ```
748#[derive(Debug, Clone, PartialEq, Eq, Default)]
749pub enum GenerationObservation {
750    /// No record: the check has not run in this checkout.
751    #[default]
752    Absent,
753    /// A record this executable cannot read; the reason.
754    Unreadable(String),
755    /// The record, and HEAD's tree when git could name it.
756    Read {
757        /// What the check recorded.
758        record: GenerationRecord,
759        /// `git rev-parse HEAD^{tree}` now.
760        head_tree: Option<String>,
761    },
762}
763
764/// One recorded generation check: the fields of [`GENERATION_RECORD`] this reads.
765///
766/// ```
767/// use majordomus_cli::environment::preflight::GenerationRecord;
768/// let r: GenerationRecord = serde_json::from_str(r#"{"schema": 1, "check": "scripts/pages current",
769///   "commit": "abc", "tree": "", "at": "2026-09-30T00:00:00Z", "outcome": "pass"}"#).unwrap();
770/// // an empty tree is a working tree that was not the index: the check proved no tree
771/// assert!(r.tree.is_empty());
772/// ```
773#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
774pub struct GenerationRecord {
775    /// The format; this reads 1.
776    pub schema: u32,
777    /// HEAD when the check ran; before a commit, that commit's parent.
778    #[serde(default)]
779    pub commit: String,
780    /// The tree the check measured (`git write-tree` of the index), or empty when the working
781    /// tree differed from the index and so no tree is what was measured.
782    #[serde(default)]
783    pub tree: String,
784    /// When it was recorded, RFC 3339.
785    pub at: String,
786    /// `pass`, `fail`, or `partial` when the registry half could not be checked.
787    pub outcome: String,
788}
789
790/// Where `scripts/rust-coverage` records the last measurement it made itself, relative to
791/// the repository root. Local state: a measurement belongs to the checkout that ran it.
792pub const COVERAGE_RECORD: &str = ".ai/local/state/coverage/rust.json";
793
794/// The last coverage measurement `scripts/rust-coverage` recorded, as it recorded it.
795///
796/// ```
797/// use majordomus_cli::environment::preflight::{derive, CoverageObservation, Observations, Verdict};
798/// let mut o = Observations::empty("demo", 0);
799/// o.coverage = CoverageObservation::Unreadable("schema 9".into());
800/// assert_eq!(derive(&o).check("verification.coverage").unwrap().verdict, Verdict::Unknown);
801/// ```
802#[derive(Debug, Clone, PartialEq, Eq, Default)]
803pub enum CoverageObservation {
804    /// No record: nothing in this checkout has measured coverage.
805    #[default]
806    Absent,
807    /// A record this executable cannot read; the reason.
808    Unreadable(String),
809    /// The record.
810    Read(CoverageRecord),
811}
812
813/// One recorded coverage measurement: the fields of [`COVERAGE_RECORD`] this reads.
814///
815/// ```
816/// use majordomus_cli::environment::preflight::CoverageRecord;
817/// let r: CoverageRecord = serde_json::from_str(r#"{"schema": 1, "commit": "abc",
818///   "working_tree": "clean", "at": "2026-09-30T00:00:00Z", "outcome": "pass",
819///   "crate": {"lines": {"covered": 9, "total": 10}, "functions": {"covered": 1, "total": 1},
820///             "regions": {"covered": 3, "total": 4}}}"#).unwrap();
821/// // a record written before the suite's exit status was kept reads as a passing suite
822/// assert_eq!(r.suite_exit, 0);
823/// assert_eq!(r.krate.lines.covered, 9);
824/// ```
825#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
826pub struct CoverageRecord {
827    /// The format; this reads 1.
828    pub schema: u32,
829    /// HEAD when the measurement began.
830    pub commit: String,
831    /// `clean` only when the tree was clean and HEAD unmoved from the run's start to its end.
832    pub working_tree: String,
833    /// When it was recorded, RFC 3339.
834    pub at: String,
835    /// `pass` or `fail` against the thresholds, or `report` when none was held.
836    pub outcome: String,
837    /// cargo's exit status for the instrumented suite; non-zero means a test failed.
838    #[serde(default)]
839    pub suite_exit: i32,
840    /// The crate, test code excluded.
841    #[serde(rename = "crate")]
842    pub krate: CoverageDimensions,
843}
844
845/// Covered over total for each of the three dimensions llvm-cov measures: lines, functions
846/// and regions, of the crate with its test code excluded.
847///
848/// ```
849/// use majordomus_cli::environment::preflight::CoverageDimensions;
850/// let d: CoverageDimensions = serde_json::from_str(r#"{"lines": {"covered": 9, "total": 10},
851///   "functions": {"covered": 1, "total": 2}, "regions": {"covered": 0, "total": 0}}"#).unwrap();
852/// assert_eq!((d.functions.covered, d.functions.total), (1, 2));
853/// ```
854#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
855pub struct CoverageDimensions {
856    /// Lines.
857    pub lines: CoverageFraction,
858    /// Functions.
859    pub functions: CoverageFraction,
860    /// Regions.
861    pub regions: CoverageFraction,
862}
863
864/// One dimension of a measurement: executed over executable.
865///
866/// ```
867/// use majordomus_cli::environment::preflight::CoverageFraction;
868/// let f: CoverageFraction = serde_json::from_str(r#"{"covered": 3, "total": 4}"#).unwrap();
869/// assert!(f.covered <= f.total);
870/// ```
871#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
872pub struct CoverageFraction {
873    /// Executed.
874    pub covered: u64,
875    /// Executable.
876    pub total: u64,
877}
878
879impl CoverageFraction {
880    fn percent(&self) -> f64 {
881        if self.total == 0 {
882            100.0
883        } else {
884            self.covered as f64 * 100.0 / self.total as f64
885        }
886    }
887}
888
889/// The newest commit of the deployment ref, and the source commit it says it was built from.
890///
891/// ```
892/// use majordomus_cli::environment::preflight::{derive, DeploymentObservation, Observations, Verdict};
893/// let mut o = Observations::empty("demo", 0);
894/// o.deployment = DeploymentObservation::Published { commit: "d".into(), at: "t".into(), source: None };
895/// // a deployment that does not name its source proves nothing about any commit
896/// assert_eq!(derive(&o).check("verification.deployment").unwrap().verdict, Verdict::Unknown);
897/// ```
898#[derive(Debug, Clone, PartialEq, Eq, Default)]
899pub enum DeploymentObservation {
900    /// The ref is not in this clone.
901    #[default]
902    NoRef,
903    /// The newest deployment commit, and the source commit it names.
904    Published {
905        /// The deployment commit.
906        commit: String,
907        /// When it was committed.
908        at: String,
909        /// The source commit it was built from, when its message names one.
910        source: Option<String>,
911    },
912}
913
914/// Everything [`derive()`] decides from. Plain data: a test builds one by hand.
915///
916/// ```
917/// use majordomus_cli::environment::preflight::{derive, GitObservation, Observations, Verdict};
918/// let mut o = Observations::empty("demo", 0);
919/// o.git = Some(GitObservation { branch: Some("main".into()), head: Some("a".repeat(40)), clean: true, changed: 0 });
920/// assert_eq!(derive(&o).check("repository.git").unwrap().verdict, Verdict::Active);
921/// ```
922#[derive(Debug, Clone, PartialEq, Eq)]
923pub struct Observations {
924    /// The repository's name.
925    pub repository: String,
926    /// When they were taken, RFC 3339.
927    pub generated_at: String,
928    /// The same instant, seconds since the epoch.
929    pub now: i64,
930    /// How the environment was resolved.
931    pub resolution: Resolution,
932    /// Version control.
933    pub git: Option<GitObservation>,
934    /// The open episode.
935    pub episode: Option<EpisodeObservation>,
936    /// The task record.
937    pub task: Option<TaskObservation>,
938    /// The handover a resuming worker would get.
939    pub handover: Option<HandoverObservation>,
940    /// What other devices published that could be resumed here; `None` when the
941    /// continuity commands never ran in this checkout.
942    pub continuity: Option<ContinuityObservation>,
943    /// The policy.
944    pub policy: PolicyObservation,
945    /// The rule tally.
946    pub rules: RulesObservation,
947    /// Architecture decisions the index holds, when counted.
948    pub adrs: Option<usize>,
949    /// The shared server.
950    pub server: ServerObservation,
951    /// The peer board; `None` with the reason when it was not asked.
952    pub peers: Result<PeersObservation, String>,
953    /// The evidence ledger.
954    pub ledger: LedgerObservation,
955    /// The last recorded coverage measurement.
956    pub coverage: CoverageObservation,
957    /// The last recorded generation check.
958    pub generation: GenerationObservation,
959    /// Provider projections against their templates.
960    pub projections: Vec<(String, ProjectionState)>,
961    /// The deployment ref.
962    pub deployment: DeploymentObservation,
963}
964
965impl Observations {
966    /// Observations of nothing at all: every reading absent.
967    ///
968    /// ```
969    /// use majordomus_cli::environment::preflight::Observations;
970    /// let o = Observations::empty("demo", 0);
971    /// assert!(o.git.is_none() && o.episode.is_none());
972    /// ```
973    pub fn empty(repository: &str, now: i64) -> Self {
974        Observations {
975            repository: repository.into(),
976            generated_at: "1970-01-01T00:00:00Z".into(),
977            now,
978            resolution: Resolution::Fast,
979            git: None,
980            episode: None,
981            task: None,
982            handover: None,
983            continuity: None,
984            policy: PolicyObservation::NotRead,
985            rules: RulesObservation::Absent,
986            adrs: None,
987            server: ServerObservation::default(),
988            peers: Err("not asked".into()),
989            ledger: LedgerObservation::Absent,
990            coverage: CoverageObservation::Absent,
991            generation: GenerationObservation::Absent,
992            projections: Vec::new(),
993            deployment: DeploymentObservation::NoRef,
994        }
995    }
996}
997
998// ---------------------------------------------------------------- deriving
999
1000fn short(commit: &str) -> &str {
1001    &commit[..12.min(commit.len())]
1002}
1003
1004/// The preflight these observations support. Reads nothing.
1005///
1006/// ```
1007/// use majordomus_cli::environment::preflight::{derive, DeploymentObservation, GitObservation, Observations, Verdict};
1008/// let head = "b".repeat(40);
1009/// let mut o = Observations::empty("demo", 0);
1010/// o.git = Some(GitObservation { branch: None, head: Some(head.clone()), clean: true, changed: 0 });
1011/// o.deployment = DeploymentObservation::Published { commit: "d".into(), at: "t".into(), source: Some(head) };
1012/// assert_eq!(derive(&o).check("verification.deployment").unwrap().verdict, Verdict::Verified);
1013/// ```
1014pub fn derive(o: &Observations) -> Preflight {
1015    let head = o.git.as_ref().and_then(|g| g.head.clone());
1016    let clean = o.git.as_ref().map(|g| g.clean);
1017    let sections = vec![
1018        Section {
1019            id: "repository".into(),
1020            title: "Repository".into(),
1021            checks: vec![git_check(o)],
1022        },
1023        Section {
1024            id: "session".into(),
1025            title: "Session".into(),
1026            checks: vec![
1027                episode_check(o),
1028                task_check(o),
1029                context_check(o, head.as_deref(), clean),
1030                handover_check(o),
1031                continuity_check(o),
1032            ],
1033        },
1034        Section {
1035            id: "governance".into(),
1036            title: "Governance".into(),
1037            checks: vec![
1038                policy_check(o),
1039                rules_check(o, head.as_deref()),
1040                adrs_check(o),
1041            ],
1042        },
1043        Section {
1044            id: "integration".into(),
1045            title: "Integration".into(),
1046            checks: vec![
1047                server_check(o),
1048                surface_check(o, "mcp", "integration.mcp", "MCP"),
1049                surface_check(o, "api", "integration.api", "API"),
1050                surface_check(o, "cockpit", "integration.cockpit", "Cockpit"),
1051                peers_check(o),
1052            ],
1053        },
1054        Section {
1055            id: "verification".into(),
1056            title: "Verification".into(),
1057            checks: vec![
1058                tests_check(o),
1059                coverage_check(o, head.as_deref(), clean),
1060                enforcement_check(o, head.as_deref(), clean),
1061                projections_check(o),
1062                docs_check(o, clean),
1063                deployment_check(o, head.as_deref()),
1064            ],
1065        },
1066    ];
1067    // Most urgent first, and within one urgency in the order the sections declare their
1068    // checks: one pass per level over a list already in canonical order, so no sort is
1069    // needed and none can disagree with the declaration order.
1070    let attention: Vec<String> = (0..=Verdict::Unknown.urgency().unwrap_or(0))
1071        .flat_map(|level| {
1072            sections
1073                .iter()
1074                .flat_map(|s| &s.checks)
1075                .filter(move |c| c.verdict.urgency() == Some(level))
1076                .map(|c| c.id.clone())
1077        })
1078        .collect();
1079    Preflight {
1080        schema: Preflight::schema_id(),
1081        generated_at: o.generated_at.clone(),
1082        resolution: o.resolution,
1083        repository: o.repository.clone(),
1084        branch: o.git.as_ref().and_then(|g| g.branch.clone()),
1085        head,
1086        sections,
1087        attention,
1088    }
1089}
1090
1091fn git_check(o: &Observations) -> Check {
1092    match &o.git {
1093        Some(g) => Check::new(
1094            "repository.git",
1095            "git",
1096            Verdict::Active,
1097            format!(
1098                "{} · {}",
1099                g.branch.as_deref().unwrap_or("detached"),
1100                if g.clean {
1101                    "clean".to_string()
1102                } else {
1103                    format!("{} path(s) changed", g.changed)
1104                }
1105            ),
1106            vec![Evidence::new(
1107                "git status --porcelain=v2 --branch",
1108                format!("HEAD {}", g.head.as_deref().map(short).unwrap_or("unborn")),
1109            )],
1110        ),
1111        None => Check::new(
1112            "repository.git",
1113            "git",
1114            Verdict::Unavailable,
1115            "git could not be asked about this checkout",
1116            vec![],
1117        ),
1118    }
1119}
1120
1121const SESSION_RECORD: &str = ".ai/local/state/session-current.yaml";
1122const TASK_RECORD: &str = ".ai/local/state/current.yaml";
1123
1124fn episode_check(o: &Observations) -> Check {
1125    match &o.episode {
1126        Some(e) if e.this_checkout => Check::new(
1127            "session.episode",
1128            "episode",
1129            Verdict::Active,
1130            format!(
1131                "{} opened {}{}",
1132                e.id,
1133                e.started_at,
1134                if e.provider.is_empty() {
1135                    String::new()
1136                } else {
1137                    format!(" by {}", e.provider)
1138                }
1139            ),
1140            vec![Evidence::new(
1141                SESSION_RECORD,
1142                format!("session_id {}", e.id),
1143            )],
1144        )
1145        .next("majordomus session"),
1146        Some(e) => Check::new(
1147            "session.episode",
1148            "episode",
1149            Verdict::Degraded,
1150            format!(
1151                "the open episode {} belongs to another checkout; nothing about it is this work",
1152                e.id
1153            ),
1154            vec![Evidence::new(
1155                SESSION_RECORD,
1156                "worktree names another checkout",
1157            )],
1158        ),
1159        None => Check::new(
1160            "session.episode",
1161            "episode",
1162            Verdict::Unavailable,
1163            "no episode is open; a provider's start event opens one",
1164            vec![],
1165        )
1166        .next("majordomus session open"),
1167    }
1168}
1169
1170/// Whether a task record is still in progress. `majordomus start` writes `outcome: active`
1171/// and `finish` or `handover` replaces it; a record written before that key existed has none.
1172fn in_progress(t: &TaskObservation) -> bool {
1173    t.outcome.is_empty() || t.outcome == "active"
1174}
1175
1176fn task_check(o: &Observations) -> Check {
1177    match &o.task {
1178        Some(t) if in_progress(t) => Check::new(
1179            "session.task",
1180            "task",
1181            Verdict::Active,
1182            format!("{}: {}", t.id, t.task),
1183            vec![Evidence::new(
1184                TASK_RECORD,
1185                format!("started {}, in progress", t.started_at),
1186            )],
1187        )
1188        .next("majordomus check"),
1189        Some(t) => Check::new(
1190            "session.task",
1191            "task",
1192            Verdict::NotApplicable,
1193            format!(
1194                "no task is in progress; the last, {}, ended {}",
1195                t.id, t.outcome
1196            ),
1197            vec![Evidence::new(TASK_RECORD, format!("outcome {}", t.outcome))],
1198        )
1199        .next("majordomus start \"<task>\" --scope <paths>"),
1200        None => Check::new(
1201            "session.task",
1202            "task",
1203            Verdict::NotApplicable,
1204            "no task has been started in this checkout",
1205            vec![],
1206        )
1207        .next("majordomus start \"<task>\" --scope <paths>"),
1208    }
1209}
1210
1211/// The briefing an episode was given is exact at the commit and tree it was written at. It
1212/// goes stale when HEAD moves, when the tree changes under it, or when a task starts after
1213/// it — the three things a briefing is about.
1214fn context_check(o: &Observations, head: Option<&str>, clean: Option<bool>) -> Check {
1215    let Some(e) = o.episode.as_ref().filter(|e| e.this_checkout) else {
1216        return Check::new(
1217            "session.context",
1218            "context",
1219            Verdict::Unknown,
1220            "no episode of this checkout, so no briefing to judge",
1221            vec![],
1222        );
1223    };
1224    let evidence = vec![Evidence::new(
1225        SESSION_RECORD,
1226        format!(
1227            "briefing written at {} ({})",
1228            short(&e.start_head),
1229            if e.start_working_tree.is_empty() {
1230                "tree unrecorded"
1231            } else {
1232                &e.start_working_tree
1233            }
1234        ),
1235    )];
1236    let Some(head) = head else {
1237        return Check::new(
1238            "session.context",
1239            "context",
1240            Verdict::Unknown,
1241            "git named no HEAD to compare the briefing with",
1242            evidence,
1243        );
1244    };
1245    let mut why: Vec<String> = Vec::new();
1246    if e.start_head.is_empty() {
1247        why.push("the episode recorded no commit".into());
1248    } else if e.start_head != head {
1249        why.push(format!(
1250            "HEAD moved from {} to {}",
1251            short(&e.start_head),
1252            short(head)
1253        ));
1254    }
1255    if e.start_working_tree == "clean" && clean == Some(false) {
1256        why.push("the tree changed since".into());
1257    }
1258    // A briefing written over a dirty tree cannot be shown exact later: the record keeps the
1259    // word `dirty`, not which changes, so an edit or a revert since is invisible. It is not
1260    // stale either — nothing is known to have moved — so it is reported as what it is.
1261    if why.is_empty() && e.start_working_tree != "clean" {
1262        return Check::new(
1263            "session.context",
1264            "context",
1265            Verdict::Unknown,
1266            format!(
1267                "the briefing was written over a {} tree at {}, so whether the tree changed since cannot be judged",
1268                if e.start_working_tree.is_empty() {
1269                    "an unrecorded"
1270                } else {
1271                    e.start_working_tree.as_str()
1272                },
1273                short(head)
1274            ),
1275            evidence,
1276        )
1277        .next("majordomus context");
1278    }
1279    if let Some(t) = &o.task {
1280        if in_progress(t) && !t.started_at.is_empty() && t.started_at > e.started_at {
1281            why.push(format!("task {} started after it", t.id));
1282        }
1283    }
1284    if why.is_empty() {
1285        Check::new(
1286            "session.context",
1287            "context",
1288            Verdict::Fresh,
1289            format!("the briefing is exact at {}", short(head)),
1290            evidence,
1291        )
1292    } else {
1293        Check::new(
1294            "session.context",
1295            "context",
1296            Verdict::Stale,
1297            format!("the briefing is stale: {}", why.join("; ")),
1298            evidence,
1299        )
1300        .next("majordomus context")
1301    }
1302}
1303
1304fn handover_check(o: &Observations) -> Check {
1305    let Some(h) = &o.handover else {
1306        return Check::new(
1307            "session.handover",
1308            "handover",
1309            Verdict::NotApplicable,
1310            "no handover is recorded for this branch",
1311            vec![],
1312        );
1313    };
1314    let verdict = match h.freshness {
1315        Freshness::Fresh | Freshness::Aging => Verdict::Fresh,
1316        Freshness::Stale | Freshness::Invalid => Verdict::Stale,
1317        Freshness::Unknown => Verdict::Unknown,
1318    };
1319    Check::new(
1320        "session.handover",
1321        "handover",
1322        verdict,
1323        format!(
1324            "{} and {}: {}",
1325            h.freshness.as_str(),
1326            h.divergence,
1327            h.reason
1328        ),
1329        vec![Evidence::new(
1330            &h.path,
1331            format!("session.freshness judged it {}", h.freshness.as_str()),
1332        )],
1333    )
1334    .next("majordomus handover --resolve")
1335}
1336
1337fn continuity_check(o: &Observations) -> Check {
1338    const FILE: &str = ".ai/local/state/continuity.json";
1339    let Some(c) = &o.continuity else {
1340        return Check::new(
1341            "session.continuity",
1342            "continuity",
1343            Verdict::NotApplicable,
1344            "no handover was published or synced from this checkout",
1345            vec![],
1346        );
1347    };
1348    if let Some(first) = c.offers.first() {
1349        let more = if c.offers.len() > 1 {
1350            format!(" (+{} more)", c.offers.len() - 1)
1351        } else {
1352            String::new()
1353        };
1354        let about: Vec<&str> = [first.branch.as_deref(), first.issue.as_deref()]
1355            .into_iter()
1356            .flatten()
1357            .collect();
1358        return Check::new(
1359            "session.continuity",
1360            "continuity",
1361            Verdict::Active,
1362            format!(
1363                "resumable handover from {} ({}){more}",
1364                first.device,
1365                about.join(", ")
1366            ),
1367            vec![Evidence::new(
1368                FILE,
1369                format!(
1370                    "record {} published {}",
1371                    &first.record[..12.min(first.record.len())],
1372                    first.published_at
1373                ),
1374            )],
1375        )
1376        .next("majordomus-cli continuity plan");
1377    }
1378    match &c.last_sync {
1379        Some(sync) if sync.outcome != "ok" => Check::new(
1380            "session.continuity",
1381            "continuity",
1382            Verdict::Degraded,
1383            format!(
1384                "the last sync with {} was {}; published handovers may be waiting there",
1385                sync.remote, sync.outcome
1386            ),
1387            vec![Evidence::new(
1388                FILE,
1389                format!("last_sync {} at {}", sync.outcome, sync.at),
1390            )],
1391        )
1392        .next("majordomus-cli continuity sync"),
1393        Some(sync) => Check::new(
1394            "session.continuity",
1395            "continuity",
1396            Verdict::Fresh,
1397            format!(
1398                "nothing waiting from another device as of the last sync ({})",
1399                sync.at
1400            ),
1401            vec![Evidence::new(FILE, format!("last_sync ok at {}", sync.at))],
1402        ),
1403        None => Check::new(
1404            "session.continuity",
1405            "continuity",
1406            Verdict::Unknown,
1407            "this checkout publishes handovers and has never synced",
1408            vec![],
1409        )
1410        .next("majordomus-cli continuity sync"),
1411    }
1412}
1413
1414fn policy_check(o: &Observations) -> Check {
1415    const POLICY: &str = ".ai/repo/policy.yaml";
1416    match &o.policy {
1417        PolicyObservation::Parsed => Check::new(
1418            "governance.policy",
1419            "policy",
1420            Verdict::Active,
1421            "the policy parses and every command reads it",
1422            vec![Evidence::new(POLICY, "parsed by LoadedPolicy")],
1423        ),
1424        PolicyObservation::Refused(reason) => Check::new(
1425            "governance.policy",
1426            "policy",
1427            Verdict::Failed,
1428            format!("the policy does not parse: {reason}"),
1429            vec![Evidence::new(POLICY, "refused")],
1430        )
1431        .next("majordomus doctor"),
1432        PolicyObservation::NotRead => Check::new(
1433            "governance.policy",
1434            "policy",
1435            Verdict::Unknown,
1436            "the policy was not read",
1437            vec![],
1438        ),
1439    }
1440}
1441
1442fn tally_of(o: &Observations) -> Option<(&RulesTally, &'static str)> {
1443    match &o.rules {
1444        RulesObservation::Counted(t) => Some((t, "rules.report over the index")),
1445        RulesObservation::Cached(t) => Some((
1446            t,
1447            ".ai/local/state/environment/snapshot.json (rules.report, cached)",
1448        )),
1449        RulesObservation::Absent => None,
1450    }
1451}
1452
1453fn rules_check(o: &Observations, head: Option<&str>) -> Check {
1454    let Some((t, source)) = tally_of(o) else {
1455        return Check::new(
1456            "governance.rules",
1457            "rules",
1458            Verdict::Unknown,
1459            "not counted: the rule corpus is counted from the index, which entry never builds",
1460            vec![],
1461        )
1462        .next("majordomus env preflight --full");
1463    };
1464    let summary = format!(
1465        "{} discovered ({} blocking; {} project, {} vendored) · {} run by a CI gate",
1466        t.rules,
1467        t.blocking,
1468        t.project,
1469        t.rules - t.project,
1470        t.gated
1471    );
1472    let evidence = vec![Evidence::new(
1473        source,
1474        format!(
1475            "counted at {}",
1476            t.head.as_deref().map(short).unwrap_or("an unknown commit")
1477        ),
1478    )];
1479    match (t.head.as_deref(), head) {
1480        (Some(counted), Some(now)) if counted == now => Check::new(
1481            "governance.rules",
1482            "rules",
1483            Verdict::Active,
1484            summary,
1485            evidence,
1486        ),
1487        _ => Check::new(
1488            "governance.rules",
1489            "rules",
1490            Verdict::Stale,
1491            format!("{summary}; counted at another commit"),
1492            evidence,
1493        )
1494        .next("majordomus env preflight --full"),
1495    }
1496}
1497
1498fn adrs_check(o: &Observations) -> Check {
1499    match o.adrs {
1500        Some(n) => Check::new(
1501            "governance.adrs",
1502            "ADRs",
1503            Verdict::Active,
1504            format!("{n} indexed; none is joined to the current task, so none is claimed relevant"),
1505            vec![Evidence::new(
1506                "the index, kind adr",
1507                format!("{n} object(s)"),
1508            )],
1509        )
1510        .next("majordomus adr list"),
1511        None => Check::new(
1512            "governance.adrs",
1513            "ADRs",
1514            Verdict::Unknown,
1515            "not counted: the index was not built and no cache holds a count",
1516            vec![],
1517        )
1518        .next("majordomus env status"),
1519    }
1520}
1521
1522fn server_check(o: &Observations) -> Check {
1523    let s = &o.server;
1524    // Named through the reader's own constant: the lease has one reader
1525    // (scripts/ci/lease-reader-check), and a second spelling of its path is where that starts.
1526    let lease = Evidence::new(
1527        format!("the shared server's lease ({})", lease::LEASE_PATH),
1528        match (s.pid, &s.url) {
1529            (Some(pid), Some(url)) => format!("pid {pid} published {url}"),
1530            (Some(pid), None) => format!("pid {pid}, no address yet"),
1531            _ => "no lease".into(),
1532        },
1533    );
1534    let reason = s.reason.clone().unwrap_or_default();
1535    match s.standing {
1536        Some(ServerStanding::Ready) => {
1537            let mut evidence = vec![lease];
1538            evidence.push(if s.this_process {
1539                Evidence::new("this process", "holds the checkout's lease")
1540            } else {
1541                Evidence::new(
1542                    "GET /",
1543                    "answers as this checkout's leaseholder, at this executable's version",
1544                )
1545            });
1546            Check::new(
1547                "integration.server",
1548                "server",
1549                Verdict::Verified,
1550                format!(
1551                    "version {} serves this checkout at {}",
1552                    s.version.as_deref().unwrap_or("?"),
1553                    s.url.as_deref().unwrap_or("?")
1554                ),
1555                evidence,
1556            )
1557        }
1558        Some(ServerStanding::Outdated) => Check::new(
1559            "integration.server",
1560            "server",
1561            Verdict::Degraded,
1562            reason,
1563            vec![lease, Evidence::new("GET /", "answers, from other code")],
1564        )
1565        .next("majordomus serve stop && majordomus serve ensure"),
1566        Some(ServerStanding::Stale) => Check::new(
1567            "integration.server",
1568            "server",
1569            Verdict::Failed,
1570            reason,
1571            vec![lease],
1572        )
1573        .next("majordomus serve ensure"),
1574        Some(ServerStanding::Starting) => Check::new(
1575            "integration.server",
1576            "server",
1577            Verdict::Unknown,
1578            "a server is binding and has not published an address yet",
1579            vec![lease],
1580        )
1581        .next("majordomus serve status"),
1582        Some(ServerStanding::Absent) => Check::new(
1583            "integration.server",
1584            "server",
1585            Verdict::Unavailable,
1586            "no server holds this checkout's lease",
1587            vec![],
1588        )
1589        .next("majordomus serve ensure"),
1590        None => Check::new(
1591            "integration.server",
1592            "server",
1593            Verdict::Unknown,
1594            "a lease names a server, and this reading did not ask it",
1595            vec![],
1596        )
1597        .next("majordomus serve status"),
1598    }
1599}
1600
1601fn surface_check(o: &Observations, surface: &str, id: &str, title: &str) -> Check {
1602    let s = &o.server;
1603    let listed = s.surfaces.iter().find(|(sid, _, _)| sid == surface);
1604    let at = |path: &str| {
1605        s.url
1606            .as_deref()
1607            .map(|u| super::services::join(u, path))
1608            .unwrap_or_else(|| path.to_string())
1609    };
1610    match (s.standing, listed) {
1611        (Some(ServerStanding::Ready), Some((_, path, true))) => Check::new(
1612            id,
1613            title,
1614            Verdict::Verified,
1615            format!("served at {}", at(path)),
1616            vec![Evidence::new(
1617                if s.this_process {
1618                    "this process"
1619                } else {
1620                    "GET /"
1621                },
1622                format!("surface {surface} ready"),
1623            )],
1624        ),
1625        (Some(ServerStanding::Outdated), Some((_, path, true))) => Check::new(
1626            id,
1627            title,
1628            Verdict::Degraded,
1629            format!(
1630                "served at {} by an outdated server: {}",
1631                at(path),
1632                s.reason.as_deref().unwrap_or("another version")
1633            ),
1634            vec![Evidence::new("GET /", format!("surface {surface} ready"))],
1635        ),
1636        (Some(ServerStanding::Ready | ServerStanding::Outdated), Some((_, _, false))) => {
1637            Check::new(
1638                id,
1639                title,
1640                Verdict::Failed,
1641                format!("the server answers and lists {surface} as not ready"),
1642                vec![Evidence::new(
1643                    "GET /",
1644                    format!("surface {surface} not ready"),
1645                )],
1646            )
1647            .next("majordomus serve status")
1648        }
1649        (Some(ServerStanding::Ready | ServerStanding::Outdated), None) => Check::new(
1650            id,
1651            title,
1652            Verdict::Failed,
1653            format!("the server answers and does not list {surface}"),
1654            vec![Evidence::new("GET /", format!("no surface {surface}"))],
1655        ),
1656        (Some(ServerStanding::Absent), _) => Check::new(
1657            id,
1658            title,
1659            Verdict::Unavailable,
1660            "nothing serves it: no server holds this checkout's lease",
1661            vec![],
1662        )
1663        .next("majordomus serve ensure"),
1664        (Some(ServerStanding::Stale), _) => Check::new(
1665            id,
1666            title,
1667            Verdict::Unavailable,
1668            "nothing serves it: the lease names a server that does not answer",
1669            vec![],
1670        )
1671        .next("majordomus serve ensure"),
1672        (Some(ServerStanding::Starting) | None, _) => Check::new(
1673            id,
1674            title,
1675            Verdict::Unknown,
1676            "the server behind it was not asked, or is still binding",
1677            vec![],
1678        ),
1679    }
1680}
1681
1682fn peers_check(o: &Observations) -> Check {
1683    match &o.peers {
1684        Ok(p) => {
1685            let summary = format!(
1686                "{} attached ({} announced) · {} gone with claims still shown",
1687                p.attached, p.announced, p.detached
1688            );
1689            let evidence = vec![Evidence::new("GET /api/v1/peers", "the board answered")];
1690            if o.server.standing == Some(ServerStanding::Outdated) {
1691                Check::new(
1692                    "integration.peers",
1693                    "peers",
1694                    Verdict::Degraded,
1695                    format!("{summary}, on an outdated server's board"),
1696                    evidence,
1697                )
1698            } else {
1699                Check::new(
1700                    "integration.peers",
1701                    "peers",
1702                    Verdict::Active,
1703                    summary,
1704                    evidence,
1705                )
1706            }
1707        }
1708        Err(reason) => Check::new(
1709            "integration.peers",
1710            "peers",
1711            if o.server.standing == Some(ServerStanding::Absent) {
1712                Verdict::Unavailable
1713            } else {
1714                Verdict::Unknown
1715            },
1716            reason.clone(),
1717            vec![],
1718        )
1719        .next("majordomus env preflight"),
1720    }
1721}
1722
1723fn tests_check(o: &Observations) -> Check {
1724    const LEDGER: &str = crate::evidence::ledger::LEDGER_PATH;
1725    match &o.ledger {
1726        LedgerObservation::Absent => Check::new(
1727            "verification.tests",
1728            "tests",
1729            Verdict::Unavailable,
1730            "no execution is recorded: the evidence ledger is absent",
1731            vec![],
1732        )
1733        .next("majordomus evidence record"),
1734        LedgerObservation::Unreadable(reason) => Check::new(
1735            "verification.tests",
1736            "tests",
1737            Verdict::Failed,
1738            format!("the evidence ledger cannot be read: {reason}"),
1739            vec![Evidence::new(LEDGER, "unreadable")],
1740        ),
1741        LedgerObservation::Read { total: 0, .. } => Check::new(
1742            "verification.tests",
1743            "tests",
1744            Verdict::Unavailable,
1745            "the evidence ledger records no execution",
1746            vec![Evidence::new(LEDGER, "0 executions")],
1747        )
1748        .next("majordomus evidence record"),
1749        LedgerObservation::Read {
1750            total,
1751            current,
1752            current_failing,
1753            newest_commit,
1754            newest_at,
1755        } => {
1756            let evidence = vec![Evidence::new(
1757                LEDGER,
1758                format!(
1759                    "{total} execution(s); newest at {} on {newest_at}",
1760                    short(newest_commit)
1761                ),
1762            )];
1763            if *current_failing > 0 {
1764                Check::new(
1765                    "verification.tests",
1766                    "tests",
1767                    Verdict::Failed,
1768                    format!(
1769                        "{current_failing} of the {current} run(s) recorded at HEAD did not pass"
1770                    ),
1771                    evidence,
1772                )
1773                .next("majordomus-cli evidence report")
1774            } else if current == total {
1775                Check::new(
1776                    "verification.tests",
1777                    "tests",
1778                    Verdict::Verified,
1779                    format!(
1780                        "all {total} recorded run(s) were made at HEAD on a clean tree and passed"
1781                    ),
1782                    evidence,
1783                )
1784            } else {
1785                Check::new(
1786                    "verification.tests",
1787                    "tests",
1788                    Verdict::Stale,
1789                    format!(
1790                        "{current} of {total} recorded run(s) measured this tree; the rest prove another commit, a changed tree or a changed test"
1791                    ),
1792                    evidence,
1793                )
1794                .next("majordomus-cli evidence report")
1795            }
1796        }
1797    }
1798}
1799
1800fn coverage_check(o: &Observations, head: Option<&str>, clean: Option<bool>) -> Check {
1801    let r = match &o.coverage {
1802        CoverageObservation::Absent => {
1803            return Check::new(
1804                "verification.coverage",
1805                "coverage",
1806                Verdict::Unavailable,
1807                "no coverage measurement is recorded in this checkout: scripts/rust-coverage records the one it makes",
1808                vec![],
1809            )
1810            .next("scripts/rust-coverage")
1811        }
1812        CoverageObservation::Unreadable(reason) => {
1813            return Check::new(
1814                "verification.coverage",
1815                "coverage",
1816                Verdict::Unknown,
1817                format!("the coverage record cannot be read: {reason}"),
1818                vec![Evidence::new(COVERAGE_RECORD, reason.clone())],
1819            )
1820            .next("scripts/rust-coverage")
1821        }
1822        CoverageObservation::Read(r) => r,
1823    };
1824    let c = &r.krate;
1825    let measured = format!(
1826        "lines {:.2}% ({}/{}) · functions {:.2}% · regions {:.2}%",
1827        c.lines.percent(),
1828        c.lines.covered,
1829        c.lines.total,
1830        c.functions.percent(),
1831        c.regions.percent()
1832    );
1833    let suite = if r.suite_exit != 0 {
1834        format!(" · the suite failed (cargo exit {})", r.suite_exit)
1835    } else {
1836        String::new()
1837    };
1838    let evidence = vec![Evidence::new(
1839        COVERAGE_RECORD,
1840        format!(
1841            "{} at {} on a {} tree: {}",
1842            short(&r.commit),
1843            r.at,
1844            r.working_tree,
1845            r.outcome
1846        ),
1847    )];
1848    // Currency first, as for the rule tally: a measurement of another commit, or of a tree
1849    // that was not clean for the whole run, says nothing about the code in front of you.
1850    let at_head = head.is_some_and(|h| h == r.commit);
1851    if !at_head || r.working_tree != "clean" || clean == Some(false) {
1852        return Check::new(
1853            "verification.coverage",
1854            "coverage",
1855            Verdict::Stale,
1856            format!(
1857                "{measured}; measured at {} on a {} tree, not this one",
1858                short(&r.commit),
1859                r.working_tree
1860            ),
1861            evidence,
1862        )
1863        .next("scripts/rust-coverage");
1864    }
1865    match r.outcome.as_str() {
1866        "pass" if r.suite_exit == 0 => Check::new(
1867            "verification.coverage",
1868            "coverage",
1869            Verdict::Verified,
1870            format!("{measured}; both thresholds hold at HEAD"),
1871            evidence,
1872        ),
1873        "report" => Check::new(
1874            "verification.coverage",
1875            "coverage",
1876            Verdict::Active,
1877            format!("{measured}{suite}; measured at HEAD, no threshold held"),
1878            evidence,
1879        )
1880        .next("scripts/rust-coverage"),
1881        "pass" | "fail" => Check::new(
1882            "verification.coverage",
1883            "coverage",
1884            Verdict::Failed,
1885            format!("{measured}{suite}; a threshold does not hold at HEAD"),
1886            evidence,
1887        )
1888        .next("scripts/rust-coverage"),
1889        other => Check::new(
1890            "verification.coverage",
1891            "coverage",
1892            Verdict::Unknown,
1893            format!("{measured}; the record's outcome '{other}' is not one this reads"),
1894            evidence,
1895        )
1896        .next("scripts/rust-coverage"),
1897    }
1898}
1899
1900fn enforcement_check(o: &Observations, head: Option<&str>, clean: Option<bool>) -> Check {
1901    let Some((t, source)) = tally_of(o) else {
1902        return Check::new(
1903            "verification.enforcement",
1904            "enforcement",
1905            Verdict::Unknown,
1906            "not derived: the rule proofs are joined over the index, which entry never builds",
1907            vec![],
1908        )
1909        .next("majordomus env preflight --full");
1910    };
1911    let owed = t.rules - t.reviewed - t.unproven;
1912    let summary = format!(
1913        "{} of {owed} rule(s) with an executable proof are proven against the tree · {} gated with no recorded verdict · {} finding(s)",
1914        t.proven,
1915        t.states.get(RuleState::Gated.label()).copied().unwrap_or(0),
1916        t.findings
1917    );
1918    let evidence = vec![Evidence::new(
1919        source,
1920        format!(
1921            "states {}",
1922            t.states
1923                .iter()
1924                .map(|(k, v)| format!("{k} {v}"))
1925                .collect::<Vec<_>>()
1926                .join(", ")
1927        ),
1928    )];
1929    let at_head = t.head.is_some() && t.head.as_deref() == head;
1930    // Currency first: a tally about another commit, or counted over a dirty tree (whose
1931    // changes the tally cannot name), says nothing about this tree — neither that a rule
1932    // fails here nor that every rule is proven.
1933    if !at_head || t.working_tree != "clean" || clean == Some(false) {
1934        let failing = if t.failing > 0 {
1935            format!("{} rule(s) were failing there · ", t.failing)
1936        } else {
1937            String::new()
1938        };
1939        Check::new(
1940            "verification.enforcement",
1941            "enforcement",
1942            Verdict::Stale,
1943            format!(
1944                "{failing}{summary}; derived at another commit, or over a tree that is not clean"
1945            ),
1946            evidence,
1947        )
1948        .next("majordomus env preflight --full")
1949    } else if t.failing > 0 {
1950        Check::new(
1951            "verification.enforcement",
1952            "enforcement",
1953            Verdict::Failed,
1954            format!("{} rule(s) failing · {summary}", t.failing),
1955            evidence,
1956        )
1957        .next("majordomus-cli rules report")
1958    } else if owed == 0 {
1959        // Zero of zero is not a proof: nothing owes one, so there is nothing to verify.
1960        Check::new(
1961            "verification.enforcement",
1962            "enforcement",
1963            Verdict::NotApplicable,
1964            format!("no rule owes an executable proof · {summary}"),
1965            evidence,
1966        )
1967    } else if t.proven == owed && t.findings == 0 {
1968        Check::new(
1969            "verification.enforcement",
1970            "enforcement",
1971            Verdict::Verified,
1972            summary,
1973            evidence,
1974        )
1975    } else {
1976        Check::new(
1977            "verification.enforcement",
1978            "enforcement",
1979            Verdict::Degraded,
1980            summary,
1981            evidence,
1982        )
1983        .next("majordomus-cli rules report")
1984    }
1985}
1986
1987fn projections_check(o: &Observations) -> Check {
1988    if o.projections.is_empty() {
1989        return Check::new(
1990            "verification.projections",
1991            "projections",
1992            Verdict::NotApplicable,
1993            "the policy declares no provider projection",
1994            vec![],
1995        );
1996    }
1997    let off: Vec<&str> = o
1998        .projections
1999        .iter()
2000        .filter(|(_, s)| matches!(s, ProjectionState::Stale | ProjectionState::Absent))
2001        .map(|(id, _)| id.as_str())
2002        .collect();
2003    let unknown = o
2004        .projections
2005        .iter()
2006        .filter(|(_, s)| *s == ProjectionState::Unknown)
2007        .count();
2008    let evidence = vec![Evidence::new(
2009        "policy projections[] rendered against their templates",
2010        format!("{} projection(s)", o.projections.len()),
2011    )];
2012    if !off.is_empty() {
2013        Check::new(
2014            "verification.projections",
2015            "projections",
2016            Verdict::Stale,
2017            format!("{} differ from their templates", off.join(", ")),
2018            evidence,
2019        )
2020        .next("majordomus generate")
2021    } else if unknown > 0 {
2022        Check::new(
2023            "verification.projections",
2024            "projections",
2025            Verdict::Unknown,
2026            format!(
2027                "{unknown} projection(s) could not be compared: the distribution was not found"
2028            ),
2029            vec![],
2030        )
2031    } else {
2032        Check::new(
2033            "verification.projections",
2034            "projections",
2035            Verdict::Verified,
2036            format!(
2037                "{} provider projection(s) match their templates",
2038                o.projections.len()
2039            ),
2040            evidence,
2041        )
2042    }
2043}
2044
2045fn docs_check(o: &Observations, clean: Option<bool>) -> Check {
2046    let (r, head_tree) = match &o.generation {
2047        GenerationObservation::Absent => {
2048            return Check::new(
2049                "verification.docs",
2050                "generated docs",
2051                Verdict::Unknown,
2052                "no generation check is recorded in this checkout: scripts/pages current records one, and the pre-commit hook runs it; entry does not run the generator",
2053                vec![],
2054            )
2055            .next("scripts/pages current")
2056        }
2057        GenerationObservation::Unreadable(reason) => {
2058            return Check::new(
2059                "verification.docs",
2060                "generated docs",
2061                Verdict::Unknown,
2062                format!("the generation record cannot be read: {reason}"),
2063                vec![Evidence::new(GENERATION_RECORD, reason.clone())],
2064            )
2065            .next("scripts/pages current")
2066        }
2067        GenerationObservation::Read { record, head_tree } => (record, head_tree.as_deref()),
2068    };
2069    let measured = if r.tree.is_empty() {
2070        "a working tree that was not its index".to_string()
2071    } else {
2072        format!("tree {}", short(&r.tree))
2073    };
2074    let evidence = vec![Evidence::new(
2075        GENERATION_RECORD,
2076        format!("{measured} at {}: {}", r.at, r.outcome),
2077    )];
2078    // Currency by tree, not commit: the hook checks the index before the commit exists, and
2079    // that index is exactly the tree the commit then has.
2080    let current = !r.tree.is_empty() && head_tree == Some(r.tree.as_str()) && clean != Some(false);
2081    if !current {
2082        return Check::new(
2083            "verification.docs",
2084            "generated docs",
2085            Verdict::Stale,
2086            format!(
2087                "the last generation check ({}) was of {measured}, not of HEAD's tree over a clean working tree",
2088                r.outcome
2089            ),
2090            evidence,
2091        )
2092        .next("scripts/pages current");
2093    }
2094    match r.outcome.as_str() {
2095        "pass" => Check::new(
2096            "verification.docs",
2097            "generated docs",
2098            Verdict::Verified,
2099            "every generated artifact was checked current against HEAD's tree: the site data by input hash and the registry projections by majordomus generate --check",
2100            evidence,
2101        ),
2102        "fail" => Check::new(
2103            "verification.docs",
2104            "generated docs",
2105            Verdict::Failed,
2106            "the generation check found stale derived data in HEAD's tree",
2107            evidence,
2108        )
2109        .next("scripts/derive"),
2110        "partial" => Check::new(
2111            "verification.docs",
2112            "generated docs",
2113            Verdict::Unknown,
2114            "the site data was checked current against HEAD's tree; the registry half was not, because the executable was not built",
2115            evidence,
2116        )
2117        .next("scripts/pages current"),
2118        other => Check::new(
2119            "verification.docs",
2120            "generated docs",
2121            Verdict::Unknown,
2122            format!("the record's outcome '{other}' is not one this reads"),
2123            evidence,
2124        )
2125        .next("scripts/pages current"),
2126    }
2127}
2128
2129fn deployment_check(o: &Observations, head: Option<&str>) -> Check {
2130    match &o.deployment {
2131        DeploymentObservation::NoRef => Check::new(
2132            "verification.deployment",
2133            "deployment",
2134            Verdict::Unavailable,
2135            format!("no {PAGES_REF} in this clone: nothing is known about a deployment"),
2136            vec![],
2137        )
2138        .next("git fetch origin gh-pages"),
2139        DeploymentObservation::Published {
2140            commit,
2141            at,
2142            source: None,
2143        } => Check::new(
2144            "verification.deployment",
2145            "deployment",
2146            Verdict::Unknown,
2147            format!(
2148                "the newest deployment commit {} names no source commit",
2149                short(commit)
2150            ),
2151            vec![Evidence::new(
2152                PAGES_REF,
2153                format!("{} at {at}", short(commit)),
2154            )],
2155        ),
2156        DeploymentObservation::Published {
2157            commit,
2158            at,
2159            source: Some(source),
2160        } => {
2161            let evidence = vec![Evidence::new(
2162                PAGES_REF,
2163                format!(
2164                    "{} at {at}: source {} (as last fetched)",
2165                    short(commit),
2166                    short(source)
2167                ),
2168            )];
2169            // A source written abbreviated names HEAD when it is a prefix of it; `source` is
2170            // at least seven hex characters (`observe_deployment` refuses anything shorter).
2171            if head.is_some_and(|h| h.starts_with(source.as_str())) {
2172                Check::new(
2173                    "verification.deployment",
2174                    "deployment",
2175                    Verdict::Verified,
2176                    format!(
2177                        "the site was built from HEAD {} and published {at}",
2178                        short(source)
2179                    ),
2180                    evidence,
2181                )
2182                .next("scripts/pages verify")
2183            } else {
2184                Check::new(
2185                    "verification.deployment",
2186                    "deployment",
2187                    Verdict::Stale,
2188                    format!(
2189                        "deployed from {} at {at}; HEAD is {}",
2190                        short(source),
2191                        head.map(short).unwrap_or("unknown")
2192                    ),
2193                    evidence,
2194                )
2195                .next("git fetch origin gh-pages")
2196            }
2197        }
2198    }
2199}
2200
2201// ---------------------------------------------------------------- observing
2202
2203/// What a reading may cost: whether it may ask the server, ask the peer board, and use the
2204/// checkout's cache for answers that spawn git.
2205///
2206/// ```
2207/// use majordomus_cli::environment::preflight::Probe;
2208/// let entry = Probe::entry();
2209/// assert!(entry.server && !entry.peers, "entry asks the server and not the board");
2210/// assert!(!Probe::sealed().server);
2211/// ```
2212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2213pub struct Probe {
2214    /// Ask the published server's address.
2215    pub server: bool,
2216    /// Ask the peer board, which gathers every checkout's and costs tens of milliseconds.
2217    pub peers: bool,
2218    /// Read and write the checkout's cache for answers that cost git processes. Never for a
2219    /// served request: a request with a side effect on the repository would be a defect.
2220    pub cache: bool,
2221}
2222
2223impl Probe {
2224    /// What entering a directory pays for: one loopback request, no board.
2225    ///
2226    /// ```
2227    /// use majordomus_cli::environment::preflight::Probe;
2228    /// assert!(Probe::entry().server);
2229    /// ```
2230    pub fn entry() -> Self {
2231        Probe {
2232            server: true,
2233            peers: false,
2234            cache: true,
2235        }
2236    }
2237
2238    /// What a person asking for the preflight pays for.
2239    ///
2240    /// ```
2241    /// use majordomus_cli::environment::preflight::Probe;
2242    /// assert!(Probe::asked().peers);
2243    /// ```
2244    pub fn asked() -> Self {
2245        Probe {
2246            server: true,
2247            peers: true,
2248            cache: true,
2249        }
2250    }
2251
2252    /// Nothing outside the process: what a test and a served request use.
2253    ///
2254    /// ```
2255    /// use majordomus_cli::environment::preflight::Probe;
2256    /// assert!(!Probe::sealed().peers);
2257    /// ```
2258    pub fn sealed() -> Self {
2259        Probe {
2260            server: false,
2261            peers: false,
2262            cache: false,
2263        }
2264    }
2265}
2266
2267/// Read everything a preflight is decided from.
2268///
2269/// `environment` is the snapshot already resolved for this call (its version control, layer
2270/// counts and provider projections are used as they are, never re-read); `policy` is the
2271/// policy the caller read, or why it could not; `rules` is a tally counted in this process,
2272/// or `None` to take the one a full resolution cached.
2273///
2274/// ```
2275/// use majordomus_cli::environment::preflight::{derive, observe, Probe, Verdict};
2276/// use majordomus_cli::environment::{resolve, EnvironmentQuery, Inputs};
2277/// use majordomus_cli::synthetic::SyntheticRepository;
2278///
2279/// let repo = SyntheticRepository::small().unwrap();
2280/// let repository = majordomus_cli::Repository::open(repo.root()).unwrap();
2281/// let env = resolve(
2282///     &Inputs { repository: &repository, share: None, index: None, registry: None, policy: None },
2283///     &EnvironmentQuery::fast().sealed(),
2284/// );
2285/// let p = derive(&observe(repo.root(), &env, Err("not read".into()), None, Probe::sealed()));
2286/// // no lease: nothing serves this checkout, and that needs no probe to know
2287/// assert_eq!(p.check("integration.server").unwrap().verdict, Verdict::Unavailable);
2288/// ```
2289pub fn observe(
2290    root: &Path,
2291    environment: &RepositoryEnvironment,
2292    policy: Result<&LoadedPolicy, String>,
2293    rules: Option<RulesTally>,
2294    probe: Probe,
2295) -> Observations {
2296    let local = environment.repository.local_path.as_str();
2297    let state = root.join(local).join("state");
2298    let now = std::time::SystemTime::now()
2299        .duration_since(std::time::UNIX_EPOCH)
2300        .map(|d| d.as_secs() as i64)
2301        .unwrap_or(0);
2302
2303    let git = environment.vcs.tree().map(|t| GitObservation {
2304        branch: t.branch.clone(),
2305        head: t.head.clone(),
2306        clean: t.clean,
2307        changed: t.dirty_files() + t.untracked,
2308    });
2309    let head = git.as_ref().and_then(|g| g.head.clone());
2310    let branch = git
2311        .as_ref()
2312        .and_then(|g| g.branch.clone())
2313        .unwrap_or_else(|| "DETACHED".into());
2314
2315    let episode = read_scalars(&state.join("session-current.yaml")).and_then(|f| {
2316        let id = f.get("session_id")?.clone();
2317        let worktree = f.get("worktree").cloned().unwrap_or_default();
2318        Some(EpisodeObservation {
2319            id,
2320            started_at: f.get("started_at").cloned().unwrap_or_default(),
2321            provider: f.get("provider").cloned().unwrap_or_default(),
2322            start_head: f.get("start_head").cloned().unwrap_or_default(),
2323            start_working_tree: f.get("start_working_tree").cloned().unwrap_or_default(),
2324            this_checkout: worktree.is_empty() || Path::new(&worktree) == root,
2325        })
2326    });
2327    let task = continuity::read_task(&state.join("current.yaml")).map(|t| TaskObservation {
2328        id: t.id,
2329        task: t.task,
2330        outcome: t.outcome,
2331        started_at: t.started_at,
2332    });
2333
2334    let (policy_observation, thresholds) = match &policy {
2335        Ok(p) => (
2336            PolicyObservation::Parsed,
2337            Thresholds {
2338                fresh_minutes: p.policy.session.freshness.fresh_minutes,
2339                stale_minutes: p.policy.session.freshness.stale_minutes,
2340            },
2341        ),
2342        Err(reason) if reason.is_empty() => (PolicyObservation::NotRead, Thresholds::default()),
2343        Err(reason) => (
2344            PolicyObservation::Refused(reason.clone()),
2345            Thresholds::default(),
2346        ),
2347    };
2348    let handover = continuity::resolve_record(
2349        root,
2350        &state.join("handovers"),
2351        &branch,
2352        head.as_deref(),
2353        thresholds,
2354        now,
2355    )
2356    .0
2357    .map(|r| HandoverObservation {
2358        path: r.path,
2359        freshness: r.freshness,
2360        reason: r.freshness_reason,
2361        divergence: r.divergence.as_str().to_string(),
2362    });
2363
2364    let rules = match rules {
2365        Some(t) => RulesObservation::Counted(t),
2366        None => match cached_rules(root, local) {
2367            Some(t) => RulesObservation::Cached(t),
2368            None => RulesObservation::Absent,
2369        },
2370    };
2371
2372    let server = observe_server(root, local, probe.server);
2373    let peers = if !probe.peers {
2374        Err("not asked on entry: the board gathers every checkout's and costs a round trip".into())
2375    } else {
2376        match (&server.standing, &server.url) {
2377            (Some(ServerStanding::Ready | ServerStanding::Outdated), Some(url)) => ask_peers(url),
2378            (Some(ServerStanding::Absent), _) => {
2379                Err("no server, so no board: the board lives in the shared server".into())
2380            }
2381            _ => Err("the server was not answering, so the board was not asked".into()),
2382        }
2383    };
2384
2385    Observations {
2386        repository: environment.repository.name.clone(),
2387        generated_at: crate::peers::rfc3339(std::time::SystemTime::now()),
2388        now,
2389        resolution: environment.resolution,
2390        git,
2391        episode,
2392        task,
2393        handover,
2394        continuity: crate::continuity::local::load(root)
2395            .ok()
2396            .filter(|s| {
2397                crate::continuity::local::local_path(root).is_file() || !s.offers.is_empty()
2398            })
2399            .map(|s| ContinuityObservation {
2400                offers: s.offers,
2401                last_sync: s.last_sync,
2402            }),
2403        policy: policy_observation,
2404        rules,
2405        adrs: environment.layer.kind("adr"),
2406        server,
2407        peers,
2408        ledger: observe_ledger(root, local, environment.vcs.tree(), probe.cache),
2409        coverage: observe_coverage(root),
2410        generation: observe_generation(root),
2411        projections: environment
2412            .providers
2413            .iter()
2414            .map(|p| (p.id.clone(), p.state))
2415            .collect(),
2416        deployment: observe_deployment(root),
2417    }
2418}
2419
2420/// The flat `key: value` lines of a small state record, with quotes removed. The records
2421/// this reads are written by the shell tool as scalars only.
2422fn read_scalars(path: &Path) -> Option<BTreeMap<String, String>> {
2423    let text = std::fs::read_to_string(path).ok()?;
2424    let mut out = BTreeMap::new();
2425    for line in text.lines() {
2426        if line.starts_with(' ') || line.starts_with('#') {
2427            continue;
2428        }
2429        if let Some((k, v)) = line.split_once(':') {
2430            let v = v.trim().trim_matches('"').trim_matches('\'');
2431            out.insert(k.trim().to_string(), v.to_string());
2432        }
2433    }
2434    Some(out)
2435}
2436
2437fn observe_server(root: &Path, local: &str, probe: bool) -> ServerObservation {
2438    // A served request is its own evidence: this process holds the lease, so the surfaces it
2439    // is serving the request from are the checkout's, at this executable's version.
2440    //
2441    // Holding the lease proves *which* process serves; it does not prove the code. A binary
2442    // rebuilt under a running server leaves it holding the lease with code no longer on disk,
2443    // so the standing is still `standing_of`'s — the one decision every other reader uses —
2444    // over this process's own document, with the answer already known to be yes.
2445    if let Some(doc) = lease::held().filter(|d| d.root.as_path() == root) {
2446        let (standing, reason) = standing_of(
2447            &LeaseFile::Document(doc.clone()),
2448            Duration::ZERO,
2449            |_| true,
2450            crate::VERSION,
2451        );
2452        return ServerObservation {
2453            standing: Some(standing),
2454            reason,
2455            url: doc.url.clone(),
2456            pid: Some(doc.pid),
2457            version: doc.version.clone(),
2458            surfaces: served_surfaces(),
2459            this_process: true,
2460        };
2461    }
2462    let path = lease::lease_file(root, local);
2463    let file = LeaseFile::read(&path);
2464    let doc = file.document().cloned();
2465    let mut observation = ServerObservation {
2466        url: doc.as_ref().and_then(|d| d.url.clone()),
2467        pid: doc.as_ref().map(|d| d.pid),
2468        version: doc.as_ref().and_then(|d| d.version.clone()),
2469        ..Default::default()
2470    };
2471    if matches!(file, LeaseFile::Absent) {
2472        observation.standing = Some(ServerStanding::Absent);
2473        return observation;
2474    }
2475    if !probe {
2476        return observation;
2477    }
2478    let reply = doc
2479        .as_ref()
2480        .and_then(|d| d.url.as_deref())
2481        .and_then(|url| lease::probe_reply(url, root, ANSWER_BUDGET));
2482    let (standing, reason) = standing_of(
2483        &file,
2484        lease::file_age(&path),
2485        |_| reply.is_some(),
2486        crate::VERSION,
2487    );
2488    observation.standing = Some(standing);
2489    observation.reason = reason;
2490    if let Some(body) = reply {
2491        observation.surfaces = body["surfaces"]
2492            .as_array()
2493            .map(|a| {
2494                a.iter()
2495                    .filter_map(|s| {
2496                        Some((
2497                            s["id"].as_str()?.to_string(),
2498                            s["path"].as_str().unwrap_or_default().to_string(),
2499                            s["ready"].as_bool().unwrap_or(false),
2500                        ))
2501                    })
2502                    .collect()
2503            })
2504            .unwrap_or_default();
2505    }
2506    observation
2507}
2508
2509/// The surfaces this process serves, from the constants that decide their routes. Only a
2510/// process holding the lease asks, and every one of them is mounted by the router it runs.
2511fn served_surfaces() -> Vec<(String, String, bool)> {
2512    [
2513        ("mcp", crate::http::mcp::PATH),
2514        (
2515            "api",
2516            crate::capability::HttpExposure::PREFIX.trim_end_matches('/'),
2517        ),
2518        ("cockpit", crate::cockpit::PREFIX),
2519    ]
2520    .into_iter()
2521    .map(|(id, path)| (id.to_string(), path.to_string(), true))
2522    .collect()
2523}
2524
2525fn ask_peers(url: &str) -> Result<PeersObservation, String> {
2526    // `checkouts=this`: this checkout's board only, which is the board a served preflight
2527    // reads from its own process. The default gathers every worktree's board over HTTP, which
2528    // would make the command line and the Cockpit count different peers for one state.
2529    let reply = crate::mcp::bridge::request(
2530        url,
2531        "GET",
2532        "/api/v1/peers?checkouts=this",
2533        &[],
2534        None,
2535        BOARD_BUDGET,
2536    )
2537    .map_err(|e| {
2538        format!(
2539            "the board did not answer within {} ms: {e}",
2540            BOARD_BUDGET.as_millis()
2541        )
2542    })?;
2543    if reply.status != 200 {
2544        return Err(format!("the board answered {}", reply.status));
2545    }
2546    let mut v: Value = serde_json::from_str(&reply.body).map_err(|e| e.to_string())?;
2547    let peers: Vec<crate::peers::Peer> =
2548        serde_json::from_value(v["peers"].take()).map_err(|e| e.to_string())?;
2549    Ok(PeersObservation::of(&peers))
2550}
2551
2552impl PeersObservation {
2553    /// The tally of a board's peers: the same count whether the board was asked over the
2554    /// loopback address or read in the process that holds it.
2555    ///
2556    /// ```
2557    /// use majordomus_cli::environment::preflight::PeersObservation;
2558    /// use majordomus_cli::peers::{PeerBoard, Transport};
2559    /// let board = PeerBoard::new();
2560    /// let a = board.attach(Transport::Http);
2561    /// board.announce(&a, "writing docs", vec!["docs".into()]);
2562    /// board.attach(Transport::Stdio);
2563    /// let t = PeersObservation::of(&board.list());
2564    /// assert_eq!((t.attached, t.announced, t.detached), (2, 1, 0));
2565    /// ```
2566    pub fn of(peers: &[crate::peers::Peer]) -> Self {
2567        PeersObservation {
2568            attached: peers.iter().filter(|p| p.attached).count(),
2569            detached: peers.iter().filter(|p| !p.attached).count(),
2570            announced: peers
2571                .iter()
2572                .filter(|p| p.attached && !p.claims.is_empty())
2573                .count(),
2574        }
2575    }
2576}
2577
2578fn observe_ledger(
2579    root: &Path,
2580    local: &str,
2581    tree: Option<&super::GitWorkingTree>,
2582    use_cache: bool,
2583) -> LedgerObservation {
2584    if !Ledger::present(root) {
2585        return LedgerObservation::Absent;
2586    }
2587    let ledger = match Ledger::load(root) {
2588        Ok(l) => l,
2589        Err(e) => return LedgerObservation::Unreadable(e.to_string()),
2590    };
2591    // What `evidence.report` calls proven, decided by its own comparison: the tree in front
2592    // of you is byte for byte the tree the run measured, the ledger's own row excluded —
2593    // recording dirties the tree and committing the record moves HEAD, so without that
2594    // exclusion no run could ever be current. One comparison per distinct recorded commit,
2595    // two git processes each — which is most of what entry would otherwise cost, so the
2596    // answers are cached under everything they depend on: HEAD, every path git reports
2597    // changed, and the ledger file.
2598    // Every changed path is stat'ed, not only named: an edit to a path already changed keeps
2599    // the set of names and moves the file's size or modification time.
2600    let fingerprint = tree.map(|t| {
2601        let mut paths = t.changed_paths.clone();
2602        paths.push(crate::evidence::ledger::LEDGER_PATH.to_string());
2603        super::cache::fingerprint_of(root, &paths, &[t.head.as_deref().unwrap_or("")])
2604    });
2605    let cache = use_cache.then(|| Cache::load(root, local));
2606    let cached = match (&cache, &fingerprint) {
2607        (Some(c), Some(f)) => c
2608            .tiers
2609            .ledger_trees
2610            .as_ref()
2611            .and_then(|e| e.fresh(f, None))
2612            .cloned(),
2613        _ => None,
2614    };
2615    let unchanged: BTreeMap<String, bool> = match cached {
2616        Some(answers) => answers,
2617        None => {
2618            let mut answers: BTreeMap<String, bool> = BTreeMap::new();
2619            for e in &ledger.executions {
2620                answers.entry(e.commit.clone()).or_insert_with(|| {
2621                    crate::evidence::changed_since(root, &e.commit).is_some_and(|changed| {
2622                        changed
2623                            .iter()
2624                            .all(|p| p == crate::evidence::ledger::LEDGER_PATH)
2625                    })
2626                });
2627            }
2628            if let (true, Some(f)) = (use_cache, fingerprint) {
2629                // Loaded again just before the write, with only this tier changed: the reading
2630                // above is minutes older than this under load, and writing it back whole would
2631                // put back a rule tally a `--full` run replaced meanwhile.
2632                let mut c = Cache::load(root, local);
2633                c.tiers.ledger_trees = Some(Cache::entry(f, answers.clone()));
2634                if let Err(e) = c.store(root, local) {
2635                    tracing::debug!(error = %e, "the ledger comparison could not be cached");
2636                }
2637            }
2638            answers
2639        }
2640    };
2641    let current: Vec<_> = ledger
2642        .executions
2643        .iter()
2644        .filter(|e| {
2645            e.working_tree == "clean"
2646                && unchanged.get(e.commit.as_str()) == Some(&true)
2647                && e.digest_matches(root) == Some(true)
2648        })
2649        .collect();
2650    let newest = ledger.executions.iter().max_by(|a, b| a.at.cmp(&b.at));
2651    LedgerObservation::Read {
2652        total: ledger.executions.len(),
2653        current: current.len(),
2654        current_failing: current.iter().filter(|e| !e.outcome.proves()).count(),
2655        newest_commit: newest.map(|e| e.commit.clone()).unwrap_or_default(),
2656        newest_at: newest.map(|e| e.at.clone()).unwrap_or_default(),
2657    }
2658}
2659
2660/// The coverage record `scripts/rust-coverage` left, read as it is: one small file, no git.
2661///
2662/// ```
2663/// use majordomus_cli::environment::preflight::{observe_coverage, CoverageObservation, COVERAGE_RECORD};
2664/// let dir = tempfile::tempdir().unwrap();
2665/// // nothing measured yet is absent, not an error
2666/// assert_eq!(observe_coverage(dir.path()), CoverageObservation::Absent);
2667/// let path = dir.path().join(COVERAGE_RECORD);
2668/// std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2669/// std::fs::write(&path, r#"{"schema": 9}"#).unwrap();
2670/// assert!(matches!(observe_coverage(dir.path()), CoverageObservation::Unreadable(_)));
2671/// ```
2672pub fn observe_coverage(root: &Path) -> CoverageObservation {
2673    let text = match std::fs::read_to_string(root.join(COVERAGE_RECORD)) {
2674        Ok(text) => text,
2675        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return CoverageObservation::Absent,
2676        Err(e) => return CoverageObservation::Unreadable(e.to_string()),
2677    };
2678    match serde_json::from_str::<CoverageRecord>(&text) {
2679        Ok(r) if r.schema == 1 => CoverageObservation::Read(r),
2680        Ok(r) => CoverageObservation::Unreadable(format!("schema {}, this reads 1", r.schema)),
2681        Err(e) => CoverageObservation::Unreadable(e.to_string()),
2682    }
2683}
2684
2685/// The generation record `scripts/pages current` left, and HEAD's tree to judge it against.
2686/// Git is asked only when there is a record to judge.
2687///
2688/// ```
2689/// use majordomus_cli::environment::preflight::{observe_generation, GenerationObservation, GENERATION_RECORD};
2690/// let dir = tempfile::tempdir().unwrap();
2691/// assert_eq!(observe_generation(dir.path()), GenerationObservation::Absent);
2692/// let path = dir.path().join(GENERATION_RECORD);
2693/// std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2694/// std::fs::write(&path, r#"{"schema": 9, "at": "t", "outcome": "pass"}"#).unwrap();
2695/// assert!(matches!(observe_generation(dir.path()), GenerationObservation::Unreadable(_)));
2696/// ```
2697pub fn observe_generation(root: &Path) -> GenerationObservation {
2698    let text = match std::fs::read_to_string(root.join(GENERATION_RECORD)) {
2699        Ok(text) => text,
2700        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return GenerationObservation::Absent,
2701        Err(e) => return GenerationObservation::Unreadable(e.to_string()),
2702    };
2703    let record = match serde_json::from_str::<GenerationRecord>(&text) {
2704        Ok(r) if r.schema == 1 => r,
2705        Ok(r) => {
2706            return GenerationObservation::Unreadable(format!("schema {}, this reads 1", r.schema))
2707        }
2708        Err(e) => return GenerationObservation::Unreadable(e.to_string()),
2709    };
2710    let head_tree = Command::new("git")
2711        .arg("-C")
2712        .arg(root)
2713        .args(["rev-parse", "--verify", "--quiet", "HEAD^{tree}"])
2714        .output()
2715        .ok()
2716        .filter(|out| out.status.success())
2717        .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
2718        .filter(|t| !t.is_empty());
2719    GenerationObservation::Read { record, head_tree }
2720}
2721
2722fn observe_deployment(root: &Path) -> DeploymentObservation {
2723    let out = Command::new("git")
2724        .arg("-C")
2725        .arg(root)
2726        .args(["log", "-1", "--format=%H%n%cI%n%B", PAGES_REF, "--"])
2727        .output();
2728    let Ok(out) = out else {
2729        return DeploymentObservation::NoRef;
2730    };
2731    if !out.status.success() {
2732        return DeploymentObservation::NoRef;
2733    }
2734    let text = String::from_utf8_lossy(&out.stdout);
2735    let mut lines = text.lines();
2736    let commit = lines.next().unwrap_or_default().to_string();
2737    let at = lines.next().unwrap_or_default().to_string();
2738    let source = lines
2739        .filter_map(|l| l.trim().strip_prefix("source:"))
2740        .map(|s| s.trim().to_string())
2741        .find(|s| s.len() >= 7 && s.chars().all(|c| c.is_ascii_hexdigit()));
2742    DeploymentObservation::Published { commit, at, source }
2743}
2744
2745/// The rule tally a full resolution left for the entry path, when there is one.
2746///
2747/// ```
2748/// use majordomus_cli::environment::preflight::cached_rules;
2749/// let dir = tempfile::tempdir().unwrap();
2750/// assert!(cached_rules(dir.path(), ".ai/local").is_none(), "a miss is not an error");
2751/// ```
2752pub fn cached_rules(root: &Path, local: &str) -> Option<RulesTally> {
2753    Cache::load(root, local)
2754        .tiers
2755        .rules
2756        .map(|entry| entry.value)
2757}
2758
2759/// Leave a rule tally for the entry path. Never fatal: a checkout that cannot be written
2760/// still gets a preflight, just one that says the rules were not counted.
2761///
2762/// ```
2763/// use majordomus_cli::environment::preflight::{cached_rules, store_rules, RulesTally};
2764/// let dir = tempfile::tempdir().unwrap();
2765/// store_rules(dir.path(), ".ai/local", &RulesTally { rules: 3, ..Default::default() });
2766/// assert_eq!(cached_rules(dir.path(), ".ai/local").unwrap().rules, 3);
2767/// ```
2768pub fn store_rules(root: &Path, local: &str, tally: &RulesTally) {
2769    let mut cache = Cache::load(root, local);
2770    cache.tiers.rules = Some(Entry {
2771        fingerprint: RULES_TIER.into(),
2772        written_at: super::cache::now_seconds(),
2773        value: tally.clone(),
2774    });
2775    if let Err(e) = cache.store(root, local) {
2776        tracing::debug!(error = %e, "the rule tally could not be cached");
2777    }
2778}
2779
2780// ---------------------------------------------------------------- rendering
2781
2782/// The mark a terminal draws beside a verdict. A success mark only for a verdict that
2783/// proves something, which is the one guarantee a person reading a banner needs.
2784///
2785/// ```
2786/// use majordomus_cli::environment::preflight::{mark, Verdict};
2787/// assert_eq!(mark(Verdict::Verified, true), "✓");
2788/// assert_eq!(mark(Verdict::Stale, true), "◐");
2789/// assert_eq!(mark(Verdict::Failed, false), "x");
2790/// ```
2791pub fn mark(verdict: Verdict, unicode: bool) -> &'static str {
2792    match (verdict, unicode) {
2793        (v, true) if v.proves() => "✓",
2794        (v, false) if v.proves() => "ok",
2795        (Verdict::Stale | Verdict::Degraded, true) => "◐",
2796        (Verdict::Stale | Verdict::Degraded, false) => "~",
2797        (Verdict::Failed, true) => "✗",
2798        (Verdict::Failed, false) => "x",
2799        (Verdict::Unavailable, true) => "○",
2800        (Verdict::Unavailable, false) => "o",
2801        (Verdict::NotApplicable, _) => "-",
2802        (_, _) => "?",
2803    }
2804}
2805
2806/// The short form an entry prints: who and where, one line of the claims that matter most,
2807/// and the most urgent thing to look at.
2808///
2809/// ```
2810/// use majordomus_cli::environment::preflight::{compact, derive, Observations};
2811/// let text = compact(&derive(&Observations::empty("demo", 0)), true);
2812/// assert!(text.starts_with("◆ demo"));
2813/// assert!(text.contains("server ?"), "{text}");
2814/// assert!(!text.contains('✓'), "nothing observed, nothing marked proven: {text}");
2815/// ```
2816pub fn compact(p: &Preflight, unicode: bool) -> String {
2817    let bullet = if unicode { "·" } else { "-" };
2818    let episode = compact_episode(p);
2819    let mut out = format!(
2820        "{} {} {bullet} {} {bullet} {episode}\n ",
2821        if unicode { "◆" } else { ">" },
2822        p.repository,
2823        p.branch.as_deref().unwrap_or("detached"),
2824    );
2825    out.push_str(&compact_checks(p, unicode));
2826    out
2827}
2828
2829/// Entry already drew the repository and branch. Keep its episode and checks without a
2830/// second repository heading; the standalone compact preflight retains its own heading.
2831pub(crate) fn entry_details(p: &Preflight, unicode: bool) -> String {
2832    format!("  {}\n {}", compact_episode(p), compact_checks(p, unicode))
2833}
2834
2835fn compact_episode(p: &Preflight) -> String {
2836    p.check("session.episode")
2837        .filter(|c| c.verdict.proves())
2838        .and_then(|c| c.evidence.first())
2839        .and_then(|e| e.observed.strip_prefix("session_id "))
2840        .map(|id| format!("episode {id}"))
2841        .unwrap_or_else(|| "no episode".into())
2842}
2843
2844fn compact_checks(p: &Preflight, unicode: bool) -> String {
2845    let bullet = if unicode { "·" } else { "-" };
2846    let mut out = String::new();
2847    let shown = [
2848        ("governance.rules", "rules"),
2849        ("session.context", "context"),
2850        ("integration.server", "server"),
2851        ("integration.mcp", "mcp"),
2852        ("integration.cockpit", "cockpit"),
2853        ("verification.tests", "tests"),
2854        ("verification.enforcement", "enforced"),
2855        ("verification.deployment", "deploy"),
2856    ];
2857    let parts: Vec<String> = shown
2858        .iter()
2859        .filter_map(|(id, label)| {
2860            p.check(id)
2861                .map(|c| format!("{label} {}", mark(c.verdict, unicode)))
2862        })
2863        .collect();
2864    let _ = write!(out, " {}", parts.join(&format!(" {bullet} ")));
2865    if let Some(first) = p.attention.first().and_then(|id| p.check(id)) {
2866        let _ = write!(
2867            out,
2868            "\n  {} {} {}: {}",
2869            mark(first.verdict, unicode),
2870            first.title,
2871            first.verdict.as_str(),
2872            first.summary
2873        );
2874        if p.attention.len() > 1 {
2875            let _ = write!(
2876                out,
2877                " (+{} more: majordomus env preflight)",
2878                p.attention.len() - 1
2879            );
2880        }
2881    }
2882    if let Some(c) = p
2883        .check("session.continuity")
2884        .filter(|c| c.verdict == Verdict::Active)
2885    {
2886        let _ = write!(
2887            out,
2888            "\n  {} {}: majordomus-cli continuity plan",
2889            if unicode { "↻" } else { "*" },
2890            c.summary
2891        );
2892    }
2893    out.push('\n');
2894    out
2895}
2896
2897/// The whole preflight, a section per heading and a line per claim, with its evidence.
2898///
2899/// ```
2900/// use majordomus_cli::environment::preflight::{full, derive, Observations};
2901/// let text = full(&derive(&Observations::empty("demo", 0)), true);
2902/// assert!(text.starts_with("MAJORDOMUS PREFLIGHT"));
2903/// assert!(text.contains("Verification"));
2904/// ```
2905pub fn full(p: &Preflight, unicode: bool) -> String {
2906    let mut out = format!(
2907        "MAJORDOMUS PREFLIGHT  {} {} {}\n",
2908        p.repository,
2909        p.branch.as_deref().unwrap_or("detached"),
2910        p.head.as_deref().map(short).unwrap_or("")
2911    );
2912    for section in &p.sections {
2913        let _ = write!(out, "\n{}\n", section.title);
2914        for c in &section.checks {
2915            let _ = writeln!(
2916                out,
2917                "  {:<13} {} {:<14} {}",
2918                c.title,
2919                mark(c.verdict, unicode),
2920                c.verdict.as_str(),
2921                c.summary
2922            );
2923            for e in &c.evidence {
2924                let _ = writeln!(
2925                    out,
2926                    "  {:<13}   {:<14} {}: {}",
2927                    "", "", e.source, e.observed
2928                );
2929            }
2930            if let Some(next) = &c.next {
2931                if c.verdict.urgency().is_some() {
2932                    let _ = writeln!(out, "  {:<13}   {:<14} next: {next}", "", "");
2933                }
2934            }
2935        }
2936    }
2937    if p.attention.is_empty() {
2938        out.push_str("\nNothing needs a look.\n");
2939    } else {
2940        let _ = writeln!(out, "\nNeeds a look: {}", p.attention.join(", "));
2941    }
2942    out
2943}
2944
2945#[cfg(test)]
2946mod tests {
2947    use super::*;
2948
2949    fn head() -> String {
2950        "c".repeat(40)
2951    }
2952
2953    fn at_head() -> Observations {
2954        let mut o = Observations::empty("demo", 0);
2955        o.git = Some(GitObservation {
2956            branch: Some("master".into()),
2957            head: Some(head()),
2958            clean: true,
2959            changed: 0,
2960        });
2961        o
2962    }
2963
2964    /// Entry draws the repository and the branch itself, so what it adds is the episode and
2965    /// the checks — the same two the standalone compact form carries under its own heading.
2966    #[test]
2967    fn the_entry_details_are_the_compact_form_without_its_heading() {
2968        let p = derive(&at_head());
2969        let full = compact(&p, true);
2970        assert!(full.starts_with("◆ demo · master · no episode"), "{full}");
2971        assert!(full.ends_with(&compact_checks(&p, true)), "{full}");
2972        let details = entry_details(&p, false);
2973        assert!(details.starts_with("  no episode\n "), "{details}");
2974        assert!(!details.contains("demo"), "a second heading: {details}");
2975        assert!(details.ends_with(&compact_checks(&p, false)), "{details}");
2976        assert_eq!(compact_episode(&p), "no episode");
2977
2978        // an episode this checkout opened is named by its id, in both forms
2979        let mut o = at_head();
2980        o.episode = Some(EpisodeObservation {
2981            id: "s-20261004-ab12".into(),
2982            this_checkout: true,
2983            ..Default::default()
2984        });
2985        let p = derive(&o);
2986        assert_eq!(compact_episode(&p), "episode s-20261004-ab12");
2987        assert!(compact(&p, false).contains("- episode s-20261004-ab12"));
2988        assert!(entry_details(&p, true).starts_with("  episode s-20261004-ab12\n"));
2989
2990        // one another checkout opened proves nothing here, so it is not named
2991        o.episode = Some(EpisodeObservation {
2992            id: "s-elsewhere".into(),
2993            this_checkout: false,
2994            ..Default::default()
2995        });
2996        assert_eq!(compact_episode(&derive(&o)), "no episode");
2997    }
2998
2999    fn offer(
3000        device: &str,
3001        branch: Option<&str>,
3002        issue: Option<&str>,
3003    ) -> crate::continuity::local::Offer {
3004        crate::continuity::local::Offer {
3005            record: "a".repeat(32),
3006            device: device.into(),
3007            branch: branch.map(str::to_string),
3008            published_at: "2026-10-03T12:00:00Z".into(),
3009            task: None,
3010            issue: issue.map(str::to_string),
3011        }
3012    }
3013
3014    fn sync_note(outcome: &str) -> crate::continuity::local::SyncNote {
3015        crate::continuity::local::SyncNote {
3016            remote: "origin".into(),
3017            at: "2026-10-03T12:00:00Z".into(),
3018            outcome: outcome.into(),
3019            detail: None,
3020        }
3021    }
3022
3023    fn continuity_of(c: Option<ContinuityObservation>) -> Check {
3024        let mut o = at_head();
3025        o.continuity = c;
3026        derive(&o).check("session.continuity").unwrap().clone()
3027    }
3028
3029    /// Every state `continuity.json` can be in reads as its own verdict, and only a handover
3030    /// waiting from another device asks for attention on the entry line.
3031    #[test]
3032    fn the_continuity_check_reads_every_state_of_the_local_file() {
3033        let c = continuity_of(None);
3034        assert_eq!(c.verdict, Verdict::NotApplicable);
3035
3036        let one = continuity_of(Some(ContinuityObservation {
3037            offers: vec![offer("macbook-pro", Some("feature/x"), Some("#1842"))],
3038            last_sync: None,
3039        }));
3040        assert_eq!(one.verdict, Verdict::Active);
3041        assert!(one.summary.contains("macbook-pro"), "{}", one.summary);
3042        assert!(one.summary.contains("feature/x, #1842"), "{}", one.summary);
3043        assert!(!one.summary.contains("more"), "{}", one.summary);
3044        assert_eq!(
3045            one.evidence[0].observed,
3046            "record aaaaaaaaaaaa published 2026-10-03T12:00:00Z"
3047        );
3048
3049        let two = continuity_of(Some(ContinuityObservation {
3050            offers: vec![
3051                offer("macbook-pro", None, None),
3052                offer("mac-mini", None, None),
3053            ],
3054            last_sync: Some(sync_note("ok")),
3055        }));
3056        assert_eq!(two.verdict, Verdict::Active);
3057        assert!(two.summary.ends_with("() (+1 more)"), "{}", two.summary);
3058
3059        let partial = continuity_of(Some(ContinuityObservation {
3060            offers: Vec::new(),
3061            last_sync: Some(sync_note("unreachable")),
3062        }));
3063        assert_eq!(partial.verdict, Verdict::Degraded);
3064        assert!(
3065            partial.summary.contains("origin was unreachable"),
3066            "{}",
3067            partial.summary
3068        );
3069
3070        let fresh = continuity_of(Some(ContinuityObservation {
3071            offers: Vec::new(),
3072            last_sync: Some(sync_note("ok")),
3073        }));
3074        assert_eq!(fresh.verdict, Verdict::Fresh);
3075        assert!(
3076            fresh.summary.contains("2026-10-03T12:00:00Z"),
3077            "{}",
3078            fresh.summary
3079        );
3080
3081        let never = continuity_of(Some(ContinuityObservation {
3082            offers: Vec::new(),
3083            last_sync: None,
3084        }));
3085        assert_eq!(never.verdict, Verdict::Unknown);
3086
3087        // the entry line points at the plan only when something waits
3088        let mut o = at_head();
3089        o.continuity = Some(ContinuityObservation {
3090            offers: vec![offer("macbook-pro", Some("feature/x"), None)],
3091            last_sync: None,
3092        });
3093        let p = derive(&o);
3094        let line = compact_checks(&p, true);
3095        assert!(
3096            line.contains("↻ resumable handover from macbook-pro"),
3097            "{line}"
3098        );
3099        assert!(
3100            compact_checks(&p, false).contains("\n  * resumable handover"),
3101            "{line}"
3102        );
3103        o.continuity = None;
3104        assert!(!compact_checks(&derive(&o), true).contains("continuity plan"));
3105    }
3106
3107    /// `observe` reads `continuity.json` when it exists and only then: a checkout the
3108    /// continuity commands never ran in has no continuity observation at all.
3109    #[test]
3110    fn observe_reads_the_continuity_file_only_where_one_was_written() {
3111        use crate::environment::{resolve, EnvironmentQuery, Inputs};
3112        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
3113        let repository = crate::Repository::open(repo.root()).unwrap();
3114        let env = resolve(
3115            &Inputs {
3116                repository: &repository,
3117                share: None,
3118                index: None,
3119                registry: None,
3120                policy: None,
3121            },
3122            &EnvironmentQuery::fast().sealed(),
3123        );
3124        let observed = |root: &Path| {
3125            observe(root, &env, Err("not read".into()), None, Probe::sealed()).continuity
3126        };
3127        assert_eq!(observed(repo.root()), None);
3128
3129        let state = crate::continuity::local::LocalState {
3130            offers: vec![offer("macbook-pro", Some("feature/x"), None)],
3131            last_sync: Some(sync_note("ok")),
3132            ..Default::default()
3133        };
3134        crate::continuity::local::save(repo.root(), &state).unwrap();
3135        let seen = observed(repo.root()).expect("the file is read");
3136        assert_eq!(seen.offers, state.offers);
3137        assert_eq!(seen.last_sync, state.last_sync);
3138    }
3139
3140    #[test]
3141    fn every_check_that_proves_something_names_its_evidence() {
3142        let mut o = at_head();
3143        o.policy = PolicyObservation::Parsed;
3144        o.adrs = Some(3);
3145        o.rules = RulesObservation::Counted(RulesTally {
3146            head: Some(head()),
3147            working_tree: "clean".into(),
3148            rules: 2,
3149            proven: 2,
3150            ..Default::default()
3151        });
3152        for c in derive(&o).sections.iter().flat_map(|s| &s.checks) {
3153            if c.verdict.proves() {
3154                assert!(!c.evidence.is_empty(), "{} proves with no evidence", c.id);
3155            }
3156        }
3157    }
3158
3159    #[test]
3160    fn attention_is_ordered_by_urgency() {
3161        let mut o = at_head();
3162        o.policy = PolicyObservation::Refused("bad".into());
3163        let p = derive(&o);
3164        assert_eq!(
3165            p.attention.first().map(String::as_str),
3166            Some("governance.policy")
3167        );
3168    }
3169
3170    #[test]
3171    fn a_handover_judged_stale_is_stale_whatever_its_divergence() {
3172        let mut o = at_head();
3173        o.handover = Some(HandoverObservation {
3174            path: "h.md".into(),
3175            freshness: Freshness::Stale,
3176            reason: "10d old".into(),
3177            divergence: "advanced".into(),
3178        });
3179        assert_eq!(
3180            derive(&o).check("session.handover").unwrap().verdict,
3181            Verdict::Stale
3182        );
3183    }
3184
3185    #[test]
3186    fn the_scalar_reader_takes_top_level_keys_only() {
3187        let dir = tempfile::tempdir().unwrap();
3188        let f = dir.path().join("s.yaml");
3189        std::fs::write(
3190            &f,
3191            "session_id: s-1\n# c\nnested:\n  inner: x\nowner: \"k\"\n",
3192        )
3193        .unwrap();
3194        let m = read_scalars(&f).unwrap();
3195        assert_eq!(m["session_id"], "s-1");
3196        assert_eq!(m["owner"], "k");
3197        assert!(!m.contains_key("inner"));
3198    }
3199}