Skip to main content

zenkey_fleet/report/
doctor.rs

1//! The doctor plane: conformance findings, and the observation that
2//! produced them.
3//!
4//! [`ObservationSummary`] is not decoration. A finding is only as good as
5//! the window it was found in, so the document carries what was watched, for
6//! how long, and — crucially — what was **dropped** (RFC 09 §5.1 O6): a
7//! clean report over a lossy window is not a clean fleet.
8
9use std::fmt;
10
11use super::asked::{Asked, u64_is_zero};
12use serde::{Deserialize, Serialize};
13
14/// Every check [`run_doctor`](crate::judge::doctor::run_doctor) can emit.
15///
16/// **Stable API**: scripts key on these through `--format json`, and the GUI
17/// keys deltas on them. New checks append; nothing renames one — which is
18/// exactly why this is an enum and no longer a `[&str; 21]` beside a
19/// `check: String`. The wire spelling is unchanged (kebab-case, one token per
20/// variant), and `check_ids_are_stable` still pins the list (#347).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum CheckId {
24    SliceParse,
25    SliceSync,
26    IntrospectCoverage,
27    AdminUnreachable,
28    RouterVersionSkew,
29    DescribeTotality,
30    SchemaDrift,
31    DescribeMissing,
32    StaleState,
33    UnstampedState,
34    StorageCoverage,
35    // The `--for` passive phase (#161) — traffic judged as it rides.
36    PayloadUndecodable,
37    PayloadInvalid,
38    QosObservedMismatch,
39    UnregisteredTraffic,
40    RateOverDeclared,
41    TimestampStampedElsewhere,
42    /// Key-population budgets (#221): declared `cardinality` vs the observed
43    /// expansion count, per origin. `{path...}` families are exempt and say so.
44    CardinalityOverDeclared,
45    // Field intelligence (#223): per-dotted-path judgement over the listen
46    // window — the failure modes per-sample validation cannot see.
47    FieldVanished,
48    FieldStuck,
49    FieldNew,
50}
51
52impl CheckId {
53    /// Every check id, in the order the doctor reports them.
54    pub const ALL: [CheckId; 21] = [
55        CheckId::SliceParse,
56        CheckId::SliceSync,
57        CheckId::IntrospectCoverage,
58        CheckId::AdminUnreachable,
59        CheckId::RouterVersionSkew,
60        CheckId::DescribeTotality,
61        CheckId::SchemaDrift,
62        CheckId::DescribeMissing,
63        CheckId::StaleState,
64        CheckId::UnstampedState,
65        CheckId::StorageCoverage,
66        CheckId::PayloadUndecodable,
67        CheckId::PayloadInvalid,
68        CheckId::QosObservedMismatch,
69        CheckId::UnregisteredTraffic,
70        CheckId::RateOverDeclared,
71        CheckId::TimestampStampedElsewhere,
72        CheckId::CardinalityOverDeclared,
73        CheckId::FieldVanished,
74        CheckId::FieldStuck,
75        CheckId::FieldNew,
76    ];
77
78    /// The wire token, exactly as it serializes.
79    pub fn as_str(self) -> &'static str {
80        match self {
81            CheckId::SliceParse => "slice-parse",
82            CheckId::SliceSync => "slice-sync",
83            CheckId::IntrospectCoverage => "introspect-coverage",
84            CheckId::AdminUnreachable => "admin-unreachable",
85            CheckId::RouterVersionSkew => "router-version-skew",
86            CheckId::DescribeTotality => "describe-totality",
87            CheckId::SchemaDrift => "schema-drift",
88            CheckId::DescribeMissing => "describe-missing",
89            CheckId::StaleState => "stale-state",
90            CheckId::UnstampedState => "unstamped-state",
91            CheckId::StorageCoverage => "storage-coverage",
92            CheckId::PayloadUndecodable => "payload-undecodable",
93            CheckId::PayloadInvalid => "payload-invalid",
94            CheckId::QosObservedMismatch => "qos-observed-mismatch",
95            CheckId::UnregisteredTraffic => "unregistered-traffic",
96            CheckId::RateOverDeclared => "rate-over-declared",
97            CheckId::TimestampStampedElsewhere => "timestamp-stamped-elsewhere",
98            CheckId::CardinalityOverDeclared => "cardinality-over-declared",
99            CheckId::FieldVanished => "field-vanished",
100            CheckId::FieldStuck => "field-stuck",
101            CheckId::FieldNew => "field-new",
102        }
103    }
104
105    /// Read a check id a caller supplied — `doctor --transitions`, a
106    /// `check expect` condition, a script's filter.
107    pub fn parse(token: &str) -> Option<CheckId> {
108        CheckId::ALL.into_iter().find(|c| c.as_str() == token)
109    }
110}
111
112impl fmt::Display for CheckId {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// How bad a doctor finding is.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "snake_case")]
121pub enum DoctorSeverity {
122    /// A contract violation — the fleet disagrees with the RFCs or with
123    /// itself.
124    Error,
125    /// Suspicious but explainable — judgement is degraded, not wrong.
126    Warning,
127    /// Worth knowing; not a defect.
128    Info,
129}
130
131/// One machine-readable doctor finding (issue #46): what check fired, on
132/// what, with the evidence and the normative citation — the shape the GUI
133/// doctor panel renders as-is.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct DoctorFinding {
136    pub severity: DoctorSeverity,
137    /// Which check fired. Serializes to the same kebab-case token it always
138    /// has; it is a type now so a typo is a compile error rather than a
139    /// finding nothing matches (#347).
140    pub check: CheckId,
141    /// What the finding is about (producer, key, or mesh-level subject).
142    pub subject: String,
143    /// The observed evidence, human-readable.
144    pub evidence: String,
145    /// The RFC section that makes this a finding (`None` when the check is
146    /// operational judgement rather than a normative clause).
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub citation: Option<String>,
149}
150
151/// The full doctor run: findings plus the coverage summary that makes an
152/// empty findings list legible (what was checked, not just what was found —
153/// RFC 05 §3.1: silence needs attribution).
154#[derive(Debug, Clone, Serialize)]
155pub struct DoctorReport {
156    pub findings: Vec<DoctorFinding>,
157    /// Producer slices confirmed in sync with the local registry
158    /// (`origin/producer`).
159    ///
160    /// `NotAsked` = no local registry was given, so the served-vs-declared
161    /// diff **never ran** — which must not read like "ran, none in sync"
162    /// (RFC 09 §5.1 O4). `Asked(vec![])` = the diff ran and confirmed
163    /// nothing; the findings say why. The `Vec` used to skip-if-empty, which
164    /// conflated the two (review finding R1); the `Option` that fixed it is
165    /// now [`Asked`], wire-identically (#246 / P1).
166    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
167    pub synced: Asked<Vec<String>>,
168    /// Introspect replies received across the fleet.
169    pub introspect_answered: usize,
170    /// Producers on the liveliness roster.
171    pub live_producers: usize,
172    /// Producers serving an RFC 08 §7 `describe`.
173    pub describe_served: usize,
174    /// Producers serving no `describe` (a SHOULD, not a MUST).
175    pub describe_missing: usize,
176    /// Routers that answered the admin sweep.
177    pub routers: usize,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub router_version: Option<String>,
180    /// Whether the `--deep` freshness/storage checks ran.
181    pub deep: bool,
182    /// The passive listening phase (`--for`, #161) — absent when it did
183    /// not run, so pre-#161 JSON consumers see an unchanged document.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub observation: Option<ObservationSummary>,
186}
187
188/// What `doctor --for` observed (#161) — the scope statement that keeps
189/// its findings honest (O5: `**` never crosses an `@`-chunk, so this section
190/// names exactly which selectors were watched), and the drop count that
191/// taints them (O6).
192#[derive(Debug, Clone, Serialize)]
193pub struct ObservationSummary {
194    pub window_s: f64,
195    /// The selectors actually watched — coverage is a statement, not a vibe.
196    pub scopes: Vec<String>,
197    pub samples: u64,
198    pub keys_seen: usize,
199    /// Samples the bounded observer missed; non-zero weakens every
200    /// listen-phase finding and the report says so.
201    pub dropped: u64,
202    /// Samples carrying the synthetic-traffic marker (RFC 09 §5.3, #162) —
203    /// generated traffic judged as real would be a self-inflicted finding.
204    pub synthetic_marked: u64,
205    /// Field-intelligence paths (#223) the bounded per-path table refused to
206    /// track — the O6 cost of that bound, absent when zero so pre-#223
207    /// consumers see an unchanged document.
208    #[serde(skip_serializing_if = "u64_is_zero")]
209    pub field_paths_dropped: u64,
210    /// Key projections the bounded facts cache (#107) retired during the
211    /// window — non-zero means `keys_seen`, the budget sweep and the field
212    /// context cover the retained keys only, and the report says what the
213    /// bound cost (RFC 09 §5.1 O6). Absent when zero, so earlier JSON
214    /// consumers see an unchanged document.
215    #[serde(skip_serializing_if = "u64_is_zero", default)]
216    pub facts_evicted: u64,
217}
218
219impl DoctorReport {
220    pub fn count(&self, severity: DoctorSeverity) -> usize {
221        self.findings
222            .iter()
223            .filter(|f| f.severity == severity)
224            .count()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::report::Asked;
232
233    /// The serialized DoctorReport is a wire contract: `zenctl doctor
234    /// --format json` scripts and the GUI panel both consume this exact
235    /// shape. Field renames/removals break users — this golden pin makes
236    /// that a deliberate act.
237    #[test]
238    fn doctor_report_json_shape_is_pinned() {
239        let report = DoctorReport {
240            findings: vec![DoctorFinding {
241                severity: DoctorSeverity::Error,
242                check: CheckId::SliceSync,
243                subject: "h-3fa9c2d41b7e/sysinfo".into(),
244                evidence: "registry version differs: served 1.0, local 2.0".into(),
245                citation: Some("RFC 08 §6".into()),
246            }],
247            // R1: `Option` since the report-honesty batch — `Some` serializes
248            // exactly as the old non-empty `Vec` did.
249            synced: Asked::Asked(vec!["h-3fa9c2d41b7e/other (registry 1.0)".into()]),
250            introspect_answered: 2,
251            live_producers: 3,
252            describe_served: 1,
253            describe_missing: 1,
254            routers: 1,
255            router_version: Some("1.9.0".into()),
256            deep: false,
257            observation: None,
258        };
259        let json = serde_json::to_value(&report).unwrap();
260        assert_eq!(
261            json,
262            serde_json::json!({
263                "findings": [{
264                    "severity": "error",
265                    "check": "slice-sync",
266                    "subject": "h-3fa9c2d41b7e/sysinfo",
267                    "evidence": "registry version differs: served 1.0, local 2.0",
268                    "citation": "RFC 08 §6",
269                }],
270                "synced": ["h-3fa9c2d41b7e/other (registry 1.0)"],
271                "introspect_answered": 2,
272                "live_producers": 3,
273                "describe_served": 1,
274                "describe_missing": 1,
275                "routers": 1,
276                "router_version": "1.9.0",
277                "deep": false,
278            }),
279            "without --for the document is byte-identical to pre-#161"
280        );
281        // R1 (report-honesty batch): `synced` is three-state. Absent = the
282        // served-vs-declared diff never ran (no registry, O4); `[]` = it ran
283        // and confirmed nothing; non-empty pins above. The wire change is
284        // deliberate: a no-registry run serialized nothing here before, and
285        // still does — only the ran-and-empty case gains a visible `[]`.
286        let unchecked = DoctorReport {
287            synced: Asked::NotAsked,
288            ..report.clone()
289        };
290        let json = serde_json::to_value(&unchecked).unwrap();
291        assert!(
292            !json.as_object().unwrap().contains_key("synced"),
293            "diff never ran: the key is absent, exactly as pre-R1 no-registry \
294             runs serialized"
295        );
296        let ran_empty = DoctorReport {
297            synced: Asked::Asked(vec![]),
298            ..report.clone()
299        };
300        let json = serde_json::to_value(&ran_empty).unwrap();
301        assert_eq!(
302            json["synced"],
303            serde_json::json!([]),
304            "ran and confirmed nothing is `[]`, not absence"
305        );
306        // With the listen phase, the observation section pins too. Note
307        // `field_paths_dropped` (#223) is absent at zero — appended, like
308        // #213/#221's additions, so pre-#223 consumers see an unchanged
309        // document.
310        let report = DoctorReport {
311            observation: Some(ObservationSummary {
312                window_s: 10.0,
313                scopes: vec!["v1/*/state/**".into()],
314                samples: 42,
315                keys_seen: 7,
316                dropped: 0,
317                synthetic_marked: 3,
318                field_paths_dropped: 0,
319                facts_evicted: 0,
320            }),
321            ..report
322        };
323        let json = serde_json::to_value(&report).unwrap();
324        assert_eq!(
325            json["observation"],
326            serde_json::json!({
327                "window_s": 10.0,
328                "scopes": ["v1/*/state/**"],
329                "samples": 42,
330                "keys_seen": 7,
331                "dropped": 0,
332                "synthetic_marked": 3,
333            })
334        );
335        // …and pins by name when the field table did drop (O6 is a wire
336        // fact, not only a table note).
337        let report = DoctorReport {
338            observation: Some(ObservationSummary {
339                field_paths_dropped: 2,
340                ..report.observation.unwrap()
341            }),
342            ..report
343        };
344        let json = serde_json::to_value(&report).unwrap();
345        assert_eq!(json["observation"]["field_paths_dropped"], 2);
346        // The facts-cache eviction count (#107) follows the same append
347        // rule: absent at zero, pinned by name when the bound cost keys.
348        assert!(
349            !json["observation"]
350                .as_object()
351                .unwrap()
352                .contains_key("facts_evicted")
353        );
354        let report = DoctorReport {
355            observation: Some(ObservationSummary {
356                facts_evicted: 5,
357                ..report.observation.unwrap()
358            }),
359            ..report
360        };
361        let json = serde_json::to_value(&report).unwrap();
362        assert_eq!(json["observation"]["facts_evicted"], 5);
363    }
364}
365
366#[cfg(test)]
367mod check_id_tests {
368    use super::*;
369
370    /// The id vocabulary is API: additions append, nothing renames. If this
371    /// test fails you are renaming a shipped check id — don't.
372    ///
373    /// It asserts the *wire* spelling, not the variant names, which is the
374    /// half that is promised: #347 turned a `[&str; 21]` into an enum, and
375    /// this is what proves the turn cost nothing on the wire.
376    #[test]
377    fn check_ids_are_stable() {
378        assert_eq!(
379            CheckId::ALL.map(CheckId::as_str),
380            [
381                "slice-parse",
382                "slice-sync",
383                "introspect-coverage",
384                "admin-unreachable",
385                "router-version-skew",
386                "describe-totality",
387                "schema-drift",
388                "describe-missing",
389                "stale-state",
390                "unstamped-state",
391                "storage-coverage",
392                "payload-undecodable",
393                "payload-invalid",
394                "qos-observed-mismatch",
395                "unregistered-traffic",
396                "rate-over-declared",
397                "timestamp-stamped-elsewhere",
398                "cardinality-over-declared",
399                "field-vanished",
400                "field-stuck",
401                "field-new",
402            ]
403        );
404    }
405
406    /// `as_str`, serde and `parse` are one vocabulary, not three.
407    #[test]
408    fn every_check_id_round_trips_through_serde_and_parse() {
409        for id in CheckId::ALL {
410            let json = serde_json::to_string(&id).unwrap();
411            assert_eq!(json, format!("\"{}\"", id.as_str()));
412            assert_eq!(serde_json::from_str::<CheckId>(&json).unwrap(), id);
413            assert_eq!(CheckId::parse(id.as_str()), Some(id));
414        }
415        assert_eq!(CheckId::parse("slice-sinc"), None);
416    }
417}