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}