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 super::judgement::Judgement;
13use serde::{Deserialize, Serialize};
14
15/// Every check [`run_doctor`](crate::judge::doctor::run_doctor) can emit.
16///
17/// **Stable API**: scripts key on these through `--format json`, and the GUI
18/// keys deltas on them. New checks append; nothing renames one — which is
19/// exactly why this is an enum and no longer a `[&str; 21]` beside a
20/// `check: String`. The wire spelling is unchanged (kebab-case, one token per
21/// variant), and `check_ids_are_stable` still pins the list (#347).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum CheckId {
25    SliceParse,
26    SliceSync,
27    IntrospectCoverage,
28    AdminUnreachable,
29    RouterVersionSkew,
30    DescribeTotality,
31    SchemaDrift,
32    DescribeMissing,
33    StaleState,
34    UnstampedState,
35    StorageCoverage,
36    // The `--for` passive phase (#161) — traffic judged as it rides.
37    PayloadUndecodable,
38    PayloadInvalid,
39    QosObservedMismatch,
40    UnregisteredTraffic,
41    RateOverDeclared,
42    TimestampStampedElsewhere,
43    /// Key-population budgets (#221): declared `cardinality` vs the observed
44    /// expansion count, per origin. `{path...}` families are exempt and say so.
45    CardinalityOverDeclared,
46    // Field intelligence (#223): per-dotted-path judgement over the listen
47    // window — the failure modes per-sample validation cannot see.
48    FieldVanished,
49    FieldStuck,
50    FieldNew,
51    /// Declared versus observed (#422, RFC 08 §2 v1.32, RFC 13 §3): a
52    /// self-describing payload's tag disagrees with the subject's declared
53    /// `kind`, or a `counter` decreased within one origin's series with no
54    /// `alive` cycle in between.
55    KindMismatch,
56    /// A producer's declared `[budget]` (#391, RFC 08 §2 v1.32, RFC 13 §3)
57    /// against the `self_stats` on its health document (RFC 04 §1.2): the
58    /// resident set over `rss_mb`, or a named table over its bound. Asked
59    /// under `--deep`, because a health fetch costs the data plane.
60    BudgetExceeded,
61}
62
63impl CheckId {
64    /// Every check id, in the order the doctor reports them.
65    pub const ALL: [CheckId; 23] = [
66        CheckId::SliceParse,
67        CheckId::SliceSync,
68        CheckId::IntrospectCoverage,
69        CheckId::AdminUnreachable,
70        CheckId::RouterVersionSkew,
71        CheckId::DescribeTotality,
72        CheckId::SchemaDrift,
73        CheckId::DescribeMissing,
74        CheckId::StaleState,
75        CheckId::UnstampedState,
76        CheckId::StorageCoverage,
77        CheckId::PayloadUndecodable,
78        CheckId::PayloadInvalid,
79        CheckId::QosObservedMismatch,
80        CheckId::UnregisteredTraffic,
81        CheckId::RateOverDeclared,
82        CheckId::TimestampStampedElsewhere,
83        CheckId::CardinalityOverDeclared,
84        CheckId::FieldVanished,
85        CheckId::FieldStuck,
86        CheckId::FieldNew,
87        CheckId::KindMismatch,
88        CheckId::BudgetExceeded,
89    ];
90
91    /// The wire token, exactly as it serializes.
92    pub fn as_str(self) -> &'static str {
93        match self {
94            CheckId::SliceParse => "slice-parse",
95            CheckId::SliceSync => "slice-sync",
96            CheckId::IntrospectCoverage => "introspect-coverage",
97            CheckId::AdminUnreachable => "admin-unreachable",
98            CheckId::RouterVersionSkew => "router-version-skew",
99            CheckId::DescribeTotality => "describe-totality",
100            CheckId::SchemaDrift => "schema-drift",
101            CheckId::DescribeMissing => "describe-missing",
102            CheckId::StaleState => "stale-state",
103            CheckId::UnstampedState => "unstamped-state",
104            CheckId::StorageCoverage => "storage-coverage",
105            CheckId::PayloadUndecodable => "payload-undecodable",
106            CheckId::PayloadInvalid => "payload-invalid",
107            CheckId::QosObservedMismatch => "qos-observed-mismatch",
108            CheckId::UnregisteredTraffic => "unregistered-traffic",
109            CheckId::RateOverDeclared => "rate-over-declared",
110            CheckId::TimestampStampedElsewhere => "timestamp-stamped-elsewhere",
111            CheckId::CardinalityOverDeclared => "cardinality-over-declared",
112            CheckId::FieldVanished => "field-vanished",
113            CheckId::FieldStuck => "field-stuck",
114            CheckId::FieldNew => "field-new",
115            CheckId::KindMismatch => "kind-mismatch",
116            CheckId::BudgetExceeded => "budget-exceeded",
117        }
118    }
119
120    /// Read a check id a caller supplied — `doctor --transitions`, a
121    /// `check expect` condition, a script's filter.
122    pub fn parse(token: &str) -> Option<CheckId> {
123        CheckId::ALL.into_iter().find(|c| c.as_str() == token)
124    }
125}
126
127impl fmt::Display for CheckId {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133/// How bad a doctor finding is.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
135#[serde(rename_all = "snake_case")]
136pub enum DoctorSeverity {
137    /// A contract violation — the fleet disagrees with the RFCs or with
138    /// itself.
139    Error,
140    /// Suspicious but explainable — judgement is degraded, not wrong.
141    Warning,
142    /// Worth knowing; not a defect.
143    Info,
144}
145
146impl DoctorSeverity {
147    /// The wire token, exactly as it serializes.
148    pub fn as_str(self) -> &'static str {
149        match self {
150            DoctorSeverity::Error => "error",
151            DoctorSeverity::Warning => "warning",
152            DoctorSeverity::Info => "info",
153        }
154    }
155
156    /// Whether this severity reaches `floor` — `Error` reaches every floor,
157    /// `Info` only its own. The ladder a severity threshold reads (#510).
158    pub fn reaches(self, floor: DoctorSeverity) -> bool {
159        fn rank(s: DoctorSeverity) -> u8 {
160            match s {
161                DoctorSeverity::Error => 2,
162                DoctorSeverity::Warning => 1,
163                DoctorSeverity::Info => 0,
164            }
165        }
166        rank(self) >= rank(floor)
167    }
168}
169
170/// One machine-readable doctor finding (issue #46): what check fired, on
171/// what, with the evidence and the normative citation — the shape the GUI
172/// doctor panel renders as-is.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
174pub struct DoctorFinding {
175    pub severity: DoctorSeverity,
176    /// Which check fired. Serializes to the same kebab-case token it always
177    /// has; it is a type now so a typo is a compile error rather than a
178    /// finding nothing matches (#347).
179    pub check: CheckId,
180    /// What the finding is about (producer, key, or mesh-level subject).
181    pub subject: String,
182    /// The observed evidence, human-readable.
183    pub evidence: String,
184    /// The RFC section that makes this a finding (`None` when the check is
185    /// operational judgement rather than a normative clause).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub citation: Option<String>,
188}
189
190/// What one doctor run says relative to the previous one (#389): findings
191/// keyed on `(check, subject)`, so evidence and severity drift count as
192/// unchanged. Computed by [`crate::doctor_delta`]; rendered by the GUI
193/// panel and routed by a notifier's `doctor` rule.
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
195pub struct DoctorDelta {
196    /// Findings present now and absent from the previous run.
197    pub new: Vec<DoctorFinding>,
198    /// Findings present in the previous run and gone now.
199    pub fixed: Vec<DoctorFinding>,
200    /// Findings present in both runs.
201    pub unchanged: usize,
202}
203
204impl DoctorDelta {
205    /// Whether `f` is one of the new findings, by its key.
206    pub fn is_new(&self, f: &DoctorFinding) -> bool {
207        self.new
208            .iter()
209            .any(|n| n.check == f.check && n.subject == f.subject)
210    }
211}
212
213/// The full doctor run: findings plus the coverage summary that makes an
214/// empty findings list legible (what was checked, not just what was found —
215/// RFC 05 §3.1: silence needs attribution).
216#[derive(Debug, Clone, Serialize)]
217pub struct DoctorReport {
218    pub findings: Vec<DoctorFinding>,
219    /// Producer slices confirmed in sync with the local registry
220    /// (`origin/producer`).
221    ///
222    /// `NotAsked` = no local registry was given, so the served-vs-declared
223    /// diff **never ran** — which must not read like "ran, none in sync"
224    /// (RFC 09 §5.1 O4). `Asked(vec![])` = the diff ran and confirmed
225    /// nothing; the findings say why. The `Vec` used to skip-if-empty, which
226    /// conflated the two (review finding R1); the `Option` that fixed it is
227    /// now [`Asked`], wire-identically (#246 / P1).
228    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
229    pub synced: Asked<Vec<String>>,
230    /// Introspect replies received across the fleet — readable or not: an
231    /// unreadable one was received, and is a `slice-parse` finding, never
232    /// counted as silence (#491, RFC 13 §3 O4).
233    pub introspect_answered: usize,
234    /// Producers on the liveliness roster.
235    pub live_producers: usize,
236    /// Producers serving an RFC 08 §7 `describe`.
237    pub describe_served: usize,
238    /// Producers serving no `describe` (a SHOULD, not a MUST).
239    pub describe_missing: usize,
240    /// Routers that answered the admin sweep.
241    pub routers: usize,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub router_version: Option<String>,
244    /// Whether the `--deep` freshness/storage checks ran.
245    pub deep: bool,
246    /// The passive listening phase (`--for`, #161) — absent when it did
247    /// not run, so pre-#161 JSON consumers see an unchanged document.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub observation: Option<ObservationSummary>,
250    /// Set when the run **judged nothing** (#510), with the reason: no
251    /// producer on the liveliness roster, no router answering the admin
252    /// space, and nothing else that answered or rode — every check ran over
253    /// an empty scope, so its silence is not a clean bill (RFC 13 §1.2's
254    /// `Unobservable`; RFC 05 §3.1, silence needs attribution). A doctor
255    /// pointed at the wrong endpoint or base used to look exactly like a
256    /// healthy fleet. Absent whenever something was in scope, so an ordinary
257    /// document is unchanged.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub unobservable: Option<String>,
260}
261
262/// What `doctor --for` observed (#161) — the scope statement that keeps
263/// its findings honest (O5: `**` never crosses an `@`-chunk, so this section
264/// names exactly which selectors were watched), and the drop count that
265/// taints them (O6).
266#[derive(Debug, Clone, Serialize)]
267pub struct ObservationSummary {
268    pub window_s: f64,
269    /// The selectors actually watched — coverage is a statement, not a vibe.
270    pub scopes: Vec<String>,
271    pub samples: u64,
272    pub keys_seen: usize,
273    /// Samples the bounded observer missed; non-zero weakens every
274    /// listen-phase finding and the report says so.
275    pub dropped: u64,
276    /// Samples carrying the synthetic-traffic marker (RFC 09 §5.3, #162) —
277    /// generated traffic judged as real would be a self-inflicted finding.
278    pub synthetic_marked: u64,
279    /// Field-intelligence paths (#223) the bounded per-path table refused to
280    /// track — the O6 cost of that bound, absent when zero so pre-#223
281    /// consumers see an unchanged document.
282    #[serde(skip_serializing_if = "u64_is_zero")]
283    pub field_paths_dropped: u64,
284    /// Key projections the bounded facts cache (#107) retired during the
285    /// window — non-zero means `keys_seen`, the budget sweep and the field
286    /// context cover the retained keys only, and the report says what the
287    /// bound cost (RFC 09 §5.1 O6). Absent when zero, so earlier JSON
288    /// consumers see an unchanged document.
289    #[serde(skip_serializing_if = "u64_is_zero", default)]
290    pub facts_evicted: u64,
291}
292
293impl DoctorReport {
294    pub fn count(&self, severity: DoctorSeverity) -> usize {
295        self.findings
296            .iter()
297            .filter(|f| f.severity == severity)
298            .count()
299    }
300
301    /// The run as an RFC 13 §1.2 [`Judgement`], under an optional severity
302    /// threshold — what a frontend exits through (#510).
303    ///
304    /// The judged claim is *"the fleet has a finding at or above
305    /// `threshold`"*, so a hit is `Established` and none is
306    /// `NotEstablished`. With no threshold, findings are output rather than
307    /// verdicts and the run is `NotEstablished` whatever it found. A run that
308    /// [judged nothing](DoctorReport::unobservable) is `Unobservable` under
309    /// every threshold, `None` included: no threshold turns an empty scope
310    /// into a healthy fleet.
311    pub fn judgement(&self, threshold: Option<DoctorSeverity>) -> Judgement {
312        if let Some(reason) = &self.unobservable {
313            return Judgement::Unobservable {
314                reason: reason.clone(),
315            };
316        }
317        let Some(floor) = threshold else {
318            return Judgement::NotEstablished {
319                reason: "no severity threshold: findings are output, not verdicts".into(),
320            };
321        };
322        if self.findings.iter().any(|f| f.severity.reaches(floor)) {
323            Judgement::Established
324        } else {
325            Judgement::NotEstablished {
326                reason: format!("no finding at or above {}", floor.as_str()),
327            }
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::report::Asked;
336
337    /// The serialized DoctorReport is a wire contract: `zenctl doctor
338    /// --format json` scripts and the GUI panel both consume this exact
339    /// shape. Field renames/removals break users — this golden pin makes
340    /// that a deliberate act.
341    #[test]
342    fn doctor_report_json_shape_is_pinned() {
343        let report = DoctorReport {
344            findings: vec![DoctorFinding {
345                severity: DoctorSeverity::Error,
346                check: CheckId::SliceSync,
347                subject: "h-3fa9c2d41b7e/sysinfo".into(),
348                evidence: "registry version differs: served 1.0, local 2.0".into(),
349                citation: Some("RFC 08 §6".into()),
350            }],
351            // R1: `Option` since the report-honesty batch — `Some` serializes
352            // exactly as the old non-empty `Vec` did.
353            synced: Asked::Asked(vec!["h-3fa9c2d41b7e/other (registry 1.0)".into()]),
354            introspect_answered: 2,
355            live_producers: 3,
356            describe_served: 1,
357            describe_missing: 1,
358            routers: 1,
359            router_version: Some("1.9.0".into()),
360            deep: false,
361            observation: None,
362            unobservable: None,
363        };
364        let json = serde_json::to_value(&report).unwrap();
365        assert_eq!(
366            json,
367            serde_json::json!({
368                "findings": [{
369                    "severity": "error",
370                    "check": "slice-sync",
371                    "subject": "h-3fa9c2d41b7e/sysinfo",
372                    "evidence": "registry version differs: served 1.0, local 2.0",
373                    "citation": "RFC 08 §6",
374                }],
375                "synced": ["h-3fa9c2d41b7e/other (registry 1.0)"],
376                "introspect_answered": 2,
377                "live_producers": 3,
378                "describe_served": 1,
379                "describe_missing": 1,
380                "routers": 1,
381                "router_version": "1.9.0",
382                "deep": false,
383            }),
384            "without --for the document is byte-identical to pre-#161"
385        );
386        // R1 (report-honesty batch): `synced` is three-state. Absent = the
387        // served-vs-declared diff never ran (no registry, O4); `[]` = it ran
388        // and confirmed nothing; non-empty pins above. The wire change is
389        // deliberate: a no-registry run serialized nothing here before, and
390        // still does — only the ran-and-empty case gains a visible `[]`.
391        let unchecked = DoctorReport {
392            synced: Asked::NotAsked,
393            ..report.clone()
394        };
395        let json = serde_json::to_value(&unchecked).unwrap();
396        assert!(
397            !json.as_object().unwrap().contains_key("synced"),
398            "diff never ran: the key is absent, exactly as pre-R1 no-registry \
399             runs serialized"
400        );
401        let ran_empty = DoctorReport {
402            synced: Asked::Asked(vec![]),
403            ..report.clone()
404        };
405        let json = serde_json::to_value(&ran_empty).unwrap();
406        assert_eq!(
407            json["synced"],
408            serde_json::json!([]),
409            "ran and confirmed nothing is `[]`, not absence"
410        );
411        // With the listen phase, the observation section pins too. Note
412        // `field_paths_dropped` (#223) is absent at zero — appended, like
413        // #213/#221's additions, so pre-#223 consumers see an unchanged
414        // document.
415        let report = DoctorReport {
416            observation: Some(ObservationSummary {
417                window_s: 10.0,
418                scopes: vec!["v1/*/state/**".into()],
419                samples: 42,
420                keys_seen: 7,
421                dropped: 0,
422                synthetic_marked: 3,
423                field_paths_dropped: 0,
424                facts_evicted: 0,
425            }),
426            ..report
427        };
428        let json = serde_json::to_value(&report).unwrap();
429        assert_eq!(
430            json["observation"],
431            serde_json::json!({
432                "window_s": 10.0,
433                "scopes": ["v1/*/state/**"],
434                "samples": 42,
435                "keys_seen": 7,
436                "dropped": 0,
437                "synthetic_marked": 3,
438            })
439        );
440        // …and pins by name when the field table did drop (O6 is a wire
441        // fact, not only a table note).
442        let report = DoctorReport {
443            observation: Some(ObservationSummary {
444                field_paths_dropped: 2,
445                ..report.observation.unwrap()
446            }),
447            ..report
448        };
449        let json = serde_json::to_value(&report).unwrap();
450        assert_eq!(json["observation"]["field_paths_dropped"], 2);
451        // The facts-cache eviction count (#107) follows the same append
452        // rule: absent at zero, pinned by name when the bound cost keys.
453        assert!(
454            !json["observation"]
455                .as_object()
456                .unwrap()
457                .contains_key("facts_evicted")
458        );
459        let report = DoctorReport {
460            observation: Some(ObservationSummary {
461                facts_evicted: 5,
462                ..report.observation.unwrap()
463            }),
464            ..report
465        };
466        let json = serde_json::to_value(&report).unwrap();
467        assert_eq!(json["observation"]["facts_evicted"], 5);
468        // #510: a run that judged nothing says so, by name and with its
469        // reason — appended, absent otherwise, like every addition above.
470        assert!(!json.as_object().unwrap().contains_key("unobservable"));
471        let report = DoctorReport {
472            unobservable: Some("nothing in scope".into()),
473            ..report
474        };
475        let json = serde_json::to_value(&report).unwrap();
476        assert_eq!(json["unobservable"], "nothing in scope");
477    }
478
479    /// #510: the run's judgement under a threshold — a hit is the finding
480    /// (1), none is clean (0), no threshold is always clean, and a run that
481    /// judged nothing is unobservable (2) under every threshold, `None`
482    /// included.
483    #[test]
484    fn the_judgement_reads_the_threshold_and_the_empty_scope() {
485        use crate::report::judgement_exit_code;
486        let warning = DoctorFinding {
487            severity: DoctorSeverity::Warning,
488            check: CheckId::SchemaDrift,
489            subject: "Health".into(),
490            evidence: "agreement cannot be judged".into(),
491            citation: None,
492        };
493        let report = DoctorReport {
494            findings: vec![warning],
495            synced: Asked::NotAsked,
496            introspect_answered: 1,
497            live_producers: 1,
498            describe_served: 1,
499            describe_missing: 0,
500            routers: 0,
501            router_version: None,
502            deep: false,
503            observation: None,
504            unobservable: None,
505        };
506        let exit = |r: &DoctorReport, t| judgement_exit_code(&r.judgement(t));
507        assert_eq!(exit(&report, None), 0);
508        assert_eq!(exit(&report, Some(DoctorSeverity::Error)), 0);
509        assert_eq!(exit(&report, Some(DoctorSeverity::Warning)), 1);
510        assert_eq!(exit(&report, Some(DoctorSeverity::Info)), 1);
511
512        let empty = DoctorReport {
513            findings: vec![],
514            live_producers: 0,
515            introspect_answered: 0,
516            describe_served: 0,
517            unobservable: Some("nothing in scope".into()),
518            ..report
519        };
520        for t in [
521            None,
522            Some(DoctorSeverity::Error),
523            Some(DoctorSeverity::Warning),
524            Some(DoctorSeverity::Info),
525        ] {
526            assert_eq!(exit(&empty, t), 2, "{t:?}");
527        }
528        assert_eq!(
529            empty.judgement(None),
530            Judgement::Unobservable {
531                reason: "nothing in scope".into()
532            }
533        );
534    }
535}
536
537#[cfg(test)]
538mod check_id_tests {
539    use super::*;
540
541    /// The id vocabulary is API: additions append, nothing renames. If this
542    /// test fails you are renaming a shipped check id — don't.
543    ///
544    /// It asserts the *wire* spelling, not the variant names, which is the
545    /// half that is promised: #347 turned a `[&str; 21]` into an enum, and
546    /// this is what proves the turn cost nothing on the wire.
547    #[test]
548    fn check_ids_are_stable() {
549        assert_eq!(
550            CheckId::ALL.map(CheckId::as_str),
551            [
552                "slice-parse",
553                "slice-sync",
554                "introspect-coverage",
555                "admin-unreachable",
556                "router-version-skew",
557                "describe-totality",
558                "schema-drift",
559                "describe-missing",
560                "stale-state",
561                "unstamped-state",
562                "storage-coverage",
563                "payload-undecodable",
564                "payload-invalid",
565                "qos-observed-mismatch",
566                "unregistered-traffic",
567                "rate-over-declared",
568                "timestamp-stamped-elsewhere",
569                "cardinality-over-declared",
570                "field-vanished",
571                "field-stuck",
572                "field-new",
573                "kind-mismatch",
574                "budget-exceeded",
575            ]
576        );
577    }
578
579    /// `as_str`, serde and `parse` are one vocabulary, not three.
580    #[test]
581    fn every_check_id_round_trips_through_serde_and_parse() {
582        for id in CheckId::ALL {
583            let json = serde_json::to_string(&id).unwrap();
584            assert_eq!(json, format!("\"{}\"", id.as_str()));
585            assert_eq!(serde_json::from_str::<CheckId>(&json).unwrap(), id);
586            assert_eq!(CheckId::parse(id.as_str()), Some(id));
587        }
588        assert_eq!(CheckId::parse("slice-sinc"), None);
589    }
590}