Skip to main content

koi_common/
diagnosis.rs

1//! The trust-doctor report (`diagnose()`) — ADR-020 §13.
2//!
3//! The category's defining failure is **silence** (silent expiry / downgrade /
4//! opaque failure / self-only diagnosis). So Koi's moat is **transparency of trust
5//! state**: a structured, queryable report whose every finding carries a *distinct*
6//! state, a cause, and an *exact, runnable* remedy — never one opaque error, and
7//! never a fake aggregate "success" over things it cannot actually verify (the
8//! mkcert-#182 honesty rule). The tool **fails loud**: it exits non-zero whenever
9//! anything is RED.
10//!
11//! Wire types only (serde + schema for `/v1/certmesh/diagnose` and the dashboard);
12//! the diagnosis *logic* lives in `koi-certmesh`, which reads identity/renewal/
13//! roster state.
14
15use serde::{Deserialize, Serialize};
16use utoipa::ToSchema;
17
18use crate::posture::Posture;
19
20/// The status of a single diagnosis check.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum CheckStatus {
24    /// Healthy.
25    Ok,
26    /// A warning — degraded but not failed (e.g. renewal due soon).
27    Warn,
28    /// A failure — something is broken now (e.g. cert expired / self revoked).
29    Red,
30    /// Not applicable in this posture (e.g. identity checks on an Open node).
31    NotApplicable,
32}
33
34/// The overall rollup of a [`TrustDiagnosis`].
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum DiagnosisStatus {
38    /// Every check is `Ok`/`NotApplicable`.
39    Healthy,
40    /// At least one `Warn`, no `Red`.
41    Degraded,
42    /// At least one `Red` — the tool exits non-zero.
43    Red,
44}
45
46/// One finding: a distinct state, a human-readable cause, and an exact remedy
47/// (ADR-020 §13 — `miette`-style actionable help; the remedy must be runnable, and
48/// runnable *remotely*).
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
50pub struct DiagnosisCheck {
51    /// Stable check id (e.g. `posture`, `renewal`, `self_revocation`).
52    pub name: String,
53    /// The check's status.
54    pub status: CheckStatus,
55    /// Human-readable state + cause.
56    pub detail: String,
57    /// The exact command (or action) that fixes it — present only when there is
58    /// something to do.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub remedy: Option<String>,
61}
62
63impl DiagnosisCheck {
64    fn new(name: &str, status: CheckStatus, detail: impl Into<String>) -> Self {
65        Self {
66            name: name.to_string(),
67            status,
68            detail: detail.into(),
69            remedy: None,
70        }
71    }
72
73    /// A healthy check.
74    pub fn ok(name: &str, detail: impl Into<String>) -> Self {
75        Self::new(name, CheckStatus::Ok, detail)
76    }
77
78    /// A check that does not apply in this posture.
79    pub fn not_applicable(name: &str, detail: impl Into<String>) -> Self {
80        Self::new(name, CheckStatus::NotApplicable, detail)
81    }
82
83    /// A warning.
84    pub fn warn(name: &str, detail: impl Into<String>) -> Self {
85        Self::new(name, CheckStatus::Warn, detail)
86    }
87
88    /// A failure.
89    pub fn red(name: &str, detail: impl Into<String>) -> Self {
90        Self::new(name, CheckStatus::Red, detail)
91    }
92
93    /// Attach the exact remediation command/action.
94    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
95        self.remedy = Some(remedy.into());
96        self
97    }
98}
99
100/// The trust-doctor's report (ADR-020 §13).
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
102pub struct TrustDiagnosis {
103    /// This node's posture at diagnosis time.
104    pub posture: Posture,
105    /// The rollup (the worst check wins).
106    pub overall: DiagnosisStatus,
107    /// Every check, in order.
108    pub checks: Vec<DiagnosisCheck>,
109}
110
111impl TrustDiagnosis {
112    /// Build a diagnosis from its checks, computing the rollup (worst wins): any
113    /// `Red` → `Red`, else any `Warn` → `Degraded`, else `Healthy`.
114    pub fn from_checks(posture: Posture, checks: Vec<DiagnosisCheck>) -> Self {
115        let overall = if checks.iter().any(|c| c.status == CheckStatus::Red) {
116            DiagnosisStatus::Red
117        } else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
118            DiagnosisStatus::Degraded
119        } else {
120            DiagnosisStatus::Healthy
121        };
122        Self {
123            posture,
124            overall,
125            checks,
126        }
127    }
128
129    /// Whether anything is RED — the tool must fail loud (exit non-zero) here.
130    pub fn is_red(&self) -> bool {
131        self.overall == DiagnosisStatus::Red
132    }
133
134    /// Process exit code: non-zero **only** when something is RED (warnings stay
135    /// loud in the output but do not fail the command — ADR-020 §13).
136    pub fn exit_code(&self) -> i32 {
137        if self.is_red() {
138            1
139        } else {
140            0
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn rollup_is_worst_check_wins() {
151        let healthy = TrustDiagnosis::from_checks(
152            Posture::OPEN,
153            vec![
154                DiagnosisCheck::ok("a", "fine"),
155                DiagnosisCheck::not_applicable("b", "n/a"),
156            ],
157        );
158        assert_eq!(healthy.overall, DiagnosisStatus::Healthy);
159        assert_eq!(healthy.exit_code(), 0);
160        assert!(!healthy.is_red());
161
162        let degraded = TrustDiagnosis::from_checks(
163            Posture::OPEN,
164            vec![
165                DiagnosisCheck::ok("a", "fine"),
166                DiagnosisCheck::warn("b", "soon"),
167            ],
168        );
169        assert_eq!(degraded.overall, DiagnosisStatus::Degraded);
170        assert_eq!(
171            degraded.exit_code(),
172            0,
173            "warnings are loud but not a failure"
174        );
175
176        let red = TrustDiagnosis::from_checks(
177            Posture::OPEN,
178            vec![
179                DiagnosisCheck::warn("a", "soon"),
180                DiagnosisCheck::red("b", "broken"),
181            ],
182        );
183        assert_eq!(red.overall, DiagnosisStatus::Red);
184        assert_eq!(red.exit_code(), 1, "RED must fail loud (non-zero)");
185        assert!(red.is_red());
186    }
187
188    #[test]
189    fn check_remedy_is_optional_and_omitted_when_absent() {
190        let c = DiagnosisCheck::ok("posture", "Authenticated");
191        let json = serde_json::to_value(&c).unwrap();
192        assert!(json.get("remedy").is_none(), "no remedy field when None");
193
194        let c =
195            DiagnosisCheck::red("renewal", "expired").with_remedy("koi certmesh join <endpoint>");
196        let json = serde_json::to_value(&c).unwrap();
197        assert_eq!(json["remedy"], "koi certmesh join <endpoint>");
198        assert_eq!(json["status"], "red");
199    }
200
201    #[test]
202    fn status_serializes_snake_case() {
203        assert_eq!(
204            serde_json::to_string(&CheckStatus::NotApplicable).unwrap(),
205            r#""not_applicable""#
206        );
207        assert_eq!(
208            serde_json::to_string(&DiagnosisStatus::Degraded).unwrap(),
209            r#""degraded""#
210        );
211    }
212
213    #[test]
214    fn diagnosis_round_trips() {
215        let d = TrustDiagnosis::from_checks(
216            Posture::new(true, false),
217            vec![DiagnosisCheck::ok("posture", "Authenticated")],
218        );
219        let json = serde_json::to_string(&d).unwrap();
220        let back: TrustDiagnosis = serde_json::from_str(&json).unwrap();
221        assert_eq!(d, back);
222    }
223}