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