Skip to main content

zenkey_fleet/report/
expect.rs

1//! The expectation plane: a CI-facing assertion over a window, its
2//! violations, and the verdict that follows (#160).
3
4use serde::Serialize;
5
6/// The `zenctl expect` verdict (#160). Three states, exit-coded 0/1/2: a CI
7/// assertion that cannot tell "condition not met" from "I could not observe
8/// properly" violates O4/O6 exactly where nobody reads logs carefully.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ExpectVerdict {
12    /// The expectation held within the window.
13    Met,
14    /// It did not, on a clean observation: conclusive positive evidence, or
15    /// a shortfall counted with zero drops.
16    NotMet,
17    /// The observation cannot carry the claim (drops under a completeness
18    /// claim, or a shortfall the dropped samples could have filled).
19    Impaired,
20}
21
22impl ExpectVerdict {
23    /// The [`Judgement`](crate::report::Judgement) mapping (RFC 13,
24    /// v1.24). The judged claim is the finding — "the expectation was
25    /// violated" — so `Met` is the established-**clean** pole:
26    ///
27    /// | verdict | judgement | exit (RFC 13 = this family's own contract) |
28    /// |---|---|---|
29    /// | `NotMet` | `Established` (finding) | 1 |
30    /// | `Met` | `NotEstablished` (clean) | 0 |
31    /// | `Impaired` | `Unobservable` | 2 |
32    pub fn to_judgement(self) -> crate::report::Judgement {
33        use crate::report::Judgement;
34        match self {
35            ExpectVerdict::NotMet => Judgement::Established,
36            ExpectVerdict::Met => Judgement::NotEstablished {
37                reason: "the expectation held within the window".into(),
38            },
39            ExpectVerdict::Impaired => Judgement::Unobservable {
40                reason: "the observation cannot carry the claim (RFC 09 §5.1 O6)".into(),
41            },
42        }
43    }
44}
45
46/// The inverse of [`ExpectVerdict::to_judgement`] — what lets `expect` fold
47/// a judge's answer straight into its verdict without hand-mapping. Both
48/// unestablished poles are `Impaired`: an assertion that was not (or could
49/// not be) observed is not met and not violated.
50impl From<crate::report::Judgement> for ExpectVerdict {
51    fn from(j: crate::report::Judgement) -> ExpectVerdict {
52        use crate::report::Judgement;
53        match j {
54            Judgement::Established => ExpectVerdict::NotMet,
55            Judgement::NotEstablished { .. } => ExpectVerdict::Met,
56            Judgement::NotAsked | Judgement::Unobservable { .. } => ExpectVerdict::Impaired,
57        }
58    }
59}
60
61/// The `zenctl expect` report (#160) — the window, what rode through it,
62/// and the judgement with its reasons spelled out.
63#[derive(Debug, Clone, Serialize)]
64pub struct ExpectReport {
65    pub selector: String,
66    /// The window actually observed (shorter than requested on early
67    /// success).
68    pub window_s: f64,
69    pub ended_early: bool,
70    pub samples: u64,
71    pub keys_seen: usize,
72    /// Samples the bounded observer missed (O6) — the reason `Impaired`
73    /// exists.
74    pub dropped: u64,
75    /// Present only when a rate bound was requested; measured over the full
76    /// requested window.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub rate_hz: Option<f64>,
79    /// Up to a cap of per-sample failures, verbatim.
80    #[serde(skip_serializing_if = "Vec::is_empty")]
81    pub violations: Vec<String>,
82    /// The exact total behind the capped examples.
83    pub violations_total: u64,
84    /// Why the verdict is not `Met`, one sentence per failed requirement.
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub unmet: Vec<String>,
87    pub verdict: ExpectVerdict,
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    /// Same contract as the doctor pin: `zenctl expect --format json` is
95    /// consumed by CI scripts, so the shape changes only deliberately (#160).
96    #[test]
97    fn expect_report_json_shape_is_pinned() {
98        let report = ExpectReport {
99            selector: "v1/*/state/sysinfo/health".into(),
100            window_s: 2.5,
101            ended_early: true,
102            samples: 3,
103            keys_seen: 2,
104            dropped: 0,
105            rate_hz: None,
106            violations: vec![],
107            violations_total: 0,
108            unmet: vec![],
109            verdict: ExpectVerdict::Met,
110        };
111        let json = serde_json::to_value(&report).unwrap();
112        assert_eq!(
113            json,
114            serde_json::json!({
115                "selector": "v1/*/state/sysinfo/health",
116                "window_s": 2.5,
117                "ended_early": true,
118                "samples": 3,
119                "keys_seen": 2,
120                "dropped": 0,
121                "violations_total": 0,
122                "verdict": "met",
123            })
124        );
125        // The optional fields appear when they carry facts — and `unmet`
126        // spells out every failed requirement.
127        let report = ExpectReport {
128            rate_hz: Some(0.5),
129            violations: vec!["k: invalid — /x: 3 is not a string".into()],
130            violations_total: 1,
131            unmet: vec!["1 sample(s) violated a per-sample requirement".into()],
132            verdict: ExpectVerdict::NotMet,
133            ended_early: false,
134            ..report
135        };
136        let json = serde_json::to_value(&report).unwrap();
137        assert_eq!(json["rate_hz"], 0.5);
138        assert_eq!(json["verdict"], "not_met");
139        assert_eq!(json["violations"][0], "k: invalid — /x: 3 is not a string");
140    }
141}