1use 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
68pub const SCHEMA: &str = "majordomus/preflight";
70
71pub const SCHEMA_VERSION: u32 = 1;
73
74pub const PAGES_REF: &str = "refs/remotes/origin/gh-pages";
79
80pub const ANSWER_BUDGET: Duration = Duration::from_millis(250);
84
85pub const BOARD_BUDGET: Duration = Duration::from_secs(1);
89
90pub const RULES_TIER: &str = "rules";
93
94#[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 Verified,
117 Active,
119 Fresh,
121 Stale,
123 Degraded,
125 Unavailable,
127 Failed,
129 Unknown,
131 NotApplicable,
133}
134
135impl Verdict {
136 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 pub fn proves(self) -> bool {
165 matches!(self, Verdict::Verified | Verdict::Active | Verdict::Fresh)
166 }
167
168 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
212#[schemars(rename = "PreflightEvidence")]
213pub struct Evidence {
214 pub source: String,
216 pub observed: String,
218}
219
220impl Evidence {
221 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
253#[schemars(rename = "PreflightCheck")]
254pub struct Check {
255 pub id: String,
257 pub title: String,
259 pub verdict: Verdict,
261 pub summary: String,
263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub evidence: Vec<Evidence>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub next: Option<String>,
269}
270
271impl Check {
272 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 pub fn next(mut self, command: impl Into<String>) -> Self {
318 self.next = Some(command.into());
319 self
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335#[schemars(rename = "PreflightSection")]
336pub struct Section {
337 pub id: String,
339 pub title: String,
341 pub checks: Vec<Check>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
354pub struct Preflight {
355 pub schema: String,
357 pub generated_at: String,
359 pub resolution: Resolution,
361 pub repository: String,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub branch: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub head: Option<String>,
369 pub sections: Vec<Section>,
371 pub attention: Vec<String>,
373}
374
375impl Preflight {
376 pub fn schema_id() -> String {
383 format!("{SCHEMA}/v{SCHEMA_VERSION}")
384 }
385
386 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
434pub struct GitObservation {
435 pub branch: Option<String>,
437 pub head: Option<String>,
439 pub clean: bool,
441 pub changed: u32,
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Default)]
455pub struct EpisodeObservation {
456 pub id: String,
458 pub started_at: String,
460 pub provider: String,
462 pub start_head: String,
464 pub start_working_tree: String,
466 pub this_checkout: bool,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Default)]
481pub struct TaskObservation {
482 pub id: String,
484 pub task: String,
486 pub outcome: String,
489 pub started_at: String,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq)]
505pub struct HandoverObservation {
506 pub path: String,
508 pub freshness: Freshness,
510 pub reason: String,
512 pub divergence: String,
514}
515
516#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct ContinuityObservation {
533 pub offers: Vec<crate::continuity::local::Offer>,
535 pub last_sync: Option<crate::continuity::local::SyncNote>,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Default)]
548pub enum PolicyObservation {
549 Parsed,
551 Refused(String),
553 #[default]
555 NotRead,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
571#[schemars(rename = "PreflightRulesTally")]
572pub struct RulesTally {
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub head: Option<String>,
576 pub working_tree: String,
578 pub rules: usize,
580 pub blocking: usize,
582 pub project: usize,
584 pub gated: usize,
586 pub states: BTreeMap<String, usize>,
588 pub proven: usize,
590 pub failing: usize,
592 pub reviewed: usize,
594 pub unproven: usize,
596 pub findings: usize,
598}
599
600impl RulesTally {
601 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#[derive(Debug, Clone, PartialEq, Eq)]
646pub enum RulesObservation {
647 Counted(RulesTally),
649 Cached(RulesTally),
651 Absent,
653}
654
655#[derive(Debug, Clone, PartialEq, Eq, Default)]
669pub struct ServerObservation {
670 pub standing: Option<ServerStanding>,
672 pub reason: Option<String>,
674 pub url: Option<String>,
676 pub pid: Option<u32>,
678 pub version: Option<String>,
680 pub surfaces: Vec<(String, String, bool)>,
682 pub this_process: bool,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Default)]
697pub struct PeersObservation {
698 pub attached: usize,
700 pub detached: usize,
702 pub announced: usize,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, Default)]
715pub enum LedgerObservation {
716 #[default]
718 Absent,
719 Unreadable(String),
721 Read {
723 total: usize,
725 current: usize,
727 current_failing: usize,
729 newest_commit: String,
731 newest_at: String,
733 },
734}
735
736pub const GENERATION_RECORD: &str = ".ai/local/state/generation/check.json";
739
740#[derive(Debug, Clone, PartialEq, Eq, Default)]
749pub enum GenerationObservation {
750 #[default]
752 Absent,
753 Unreadable(String),
755 Read {
757 record: GenerationRecord,
759 head_tree: Option<String>,
761 },
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
774pub struct GenerationRecord {
775 pub schema: u32,
777 #[serde(default)]
779 pub commit: String,
780 #[serde(default)]
783 pub tree: String,
784 pub at: String,
786 pub outcome: String,
788}
789
790pub const COVERAGE_RECORD: &str = ".ai/local/state/coverage/rust.json";
793
794#[derive(Debug, Clone, PartialEq, Eq, Default)]
803pub enum CoverageObservation {
804 #[default]
806 Absent,
807 Unreadable(String),
809 Read(CoverageRecord),
811}
812
813#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
826pub struct CoverageRecord {
827 pub schema: u32,
829 pub commit: String,
831 pub working_tree: String,
833 pub at: String,
835 pub outcome: String,
837 #[serde(default)]
839 pub suite_exit: i32,
840 #[serde(rename = "crate")]
842 pub krate: CoverageDimensions,
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
855pub struct CoverageDimensions {
856 pub lines: CoverageFraction,
858 pub functions: CoverageFraction,
860 pub regions: CoverageFraction,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
872pub struct CoverageFraction {
873 pub covered: u64,
875 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
899pub enum DeploymentObservation {
900 #[default]
902 NoRef,
903 Published {
905 commit: String,
907 at: String,
909 source: Option<String>,
911 },
912}
913
914#[derive(Debug, Clone, PartialEq, Eq)]
923pub struct Observations {
924 pub repository: String,
926 pub generated_at: String,
928 pub now: i64,
930 pub resolution: Resolution,
932 pub git: Option<GitObservation>,
934 pub episode: Option<EpisodeObservation>,
936 pub task: Option<TaskObservation>,
938 pub handover: Option<HandoverObservation>,
940 pub continuity: Option<ContinuityObservation>,
943 pub policy: PolicyObservation,
945 pub rules: RulesObservation,
947 pub adrs: Option<usize>,
949 pub server: ServerObservation,
951 pub peers: Result<PeersObservation, String>,
953 pub ledger: LedgerObservation,
955 pub coverage: CoverageObservation,
957 pub generation: GenerationObservation,
959 pub projections: Vec<(String, ProjectionState)>,
961 pub deployment: DeploymentObservation,
963}
964
965impl Observations {
966 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
998fn short(commit: &str) -> &str {
1001 &commit[..12.min(commit.len())]
1002}
1003
1004pub 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 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
1170fn 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
1211fn 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2213pub struct Probe {
2214 pub server: bool,
2216 pub peers: bool,
2218 pub cache: bool,
2221}
2222
2223impl Probe {
2224 pub fn entry() -> Self {
2231 Probe {
2232 server: true,
2233 peers: false,
2234 cache: true,
2235 }
2236 }
2237
2238 pub fn asked() -> Self {
2245 Probe {
2246 server: true,
2247 peers: true,
2248 cache: true,
2249 }
2250 }
2251
2252 pub fn sealed() -> Self {
2259 Probe {
2260 server: false,
2261 peers: false,
2262 cache: false,
2263 }
2264 }
2265}
2266
2267pub 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
2420fn 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 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
2509fn 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 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 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 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 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
2660pub 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
2685pub 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
2745pub 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
2759pub 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
2780pub 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
2806pub 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
2829pub(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
2897pub 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 §ion.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 #[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 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 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 #[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 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 #[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}