zenkey_fleet/report/why.rs
1//! The `why` plane (#214): the ladder a silence is explained by.
2//!
3//! One [`Rung`] per question, each carrying a [`RungAnswer`] — which is the
4//! judgement core itself, so "not asked" and "asked and unobservable" survive
5//! all the way onto the wire instead of collapsing into a missing field.
6//! [`WhyVerdict`] is the surface naming, and it is the documented case of
7//! inverted polarity: `Explained` is the *finding*, and its CLI exits 0.
8
9use serde::{Deserialize, Serialize};
10
11use crate::judge::why::is_cause;
12
13/// Every rung the `why` ladder can put, in ladder order.
14///
15/// The same promise as [`CheckId`](crate::report::CheckId), for the same
16/// reason: scripts key on these ids and the GUI renders them, so new rungs
17/// append and nothing renames one. It carries its own question, because the
18/// question is *serialized beside the id* — they are one fact, and keeping
19/// them apart is what let a `match` on the id go stale silently (#347).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum RungId {
23 ScopeReach,
24 KeyParse,
25 RegistryDeclared,
26 OriginAlive,
27 PublisherDeclared,
28 StorageCoverage,
29 StoredValue,
30 SampleFreshness,
31 AdminAnswered,
32 WireHeard,
33}
34
35impl RungId {
36 /// The ladder, in order. The `why` report emits exactly this, once each —
37 /// a property that used to rest on a `debug_assert_eq!`, which only ran
38 /// in debug builds.
39 pub const ALL: [RungId; 10] = [
40 RungId::ScopeReach,
41 RungId::KeyParse,
42 RungId::RegistryDeclared,
43 RungId::OriginAlive,
44 RungId::PublisherDeclared,
45 RungId::StorageCoverage,
46 RungId::StoredValue,
47 RungId::SampleFreshness,
48 RungId::AdminAnswered,
49 RungId::WireHeard,
50 ];
51
52 /// The wire token, exactly as it serializes.
53 pub fn as_str(self) -> &'static str {
54 match self {
55 RungId::ScopeReach => "scope-reach",
56 RungId::KeyParse => "key-parse",
57 RungId::RegistryDeclared => "registry-declared",
58 RungId::OriginAlive => "origin-alive",
59 RungId::PublisherDeclared => "publisher-declared",
60 RungId::StorageCoverage => "storage-coverage",
61 RungId::StoredValue => "stored-value",
62 RungId::SampleFreshness => "sample-freshness",
63 RungId::AdminAnswered => "admin-answered",
64 RungId::WireHeard => "wire-heard",
65 }
66 }
67
68 /// The question this rung puts, as prose — carried on the wire beside the
69 /// id, which is why it lives on the type rather than in a `match` the
70 /// compiler could not check.
71 pub fn question(self) -> &'static str {
72 match self {
73 RungId::ScopeReach => "does a `**` explorer scope reach this key?",
74 RungId::KeyParse => "does it parse as a v1 key under the base?",
75 RungId::RegistryDeclared => "does a loaded registry slice declare it?",
76 RungId::OriginAlive => "is the origin on the liveliness roster?",
77 RungId::PublisherDeclared => "did any session declare a matching publisher?",
78 RungId::StorageCoverage => "is a storage configured to capture it?",
79 RungId::StoredValue => "does a stored value answer a bounded GET?",
80 RungId::SampleFreshness => "is the last known sample within its declared ttl?",
81 RungId::AdminAnswered => "is the admin space answering at all?",
82 RungId::WireHeard => "did the key speak during a listen window?",
83 }
84 }
85
86 /// Whether a **not-established** answer on this rung explains the
87 /// silence.
88 ///
89 /// Policy, not rendering: both explorers and any script keying on the
90 /// ndjson must agree on what exit 0 meant. The five that do are the ones
91 /// whose failure *is* the reason nothing arrives. The five that do not,
92 /// and why: `publisher-declared` because publishers declare lazily
93 /// (RFC 08 §6.1), `storage-coverage` because uncovered volatile state is
94 /// a legitimate deployment (RFC 04 §3.5), `stored-value` and
95 /// `wire-heard` because an unanswered bounded ask is the very silence
96 /// under investigation, and `admin-answered` because an absent admin
97 /// space impairs the observation rather than explaining the key.
98 pub fn is_cause_when_unestablished(self) -> bool {
99 matches!(
100 self,
101 RungId::ScopeReach
102 | RungId::KeyParse
103 | RungId::RegistryDeclared
104 | RungId::OriginAlive
105 | RungId::SampleFreshness
106 )
107 }
108
109 /// Read a rung id a caller supplied.
110 pub fn parse(token: &str) -> Option<RungId> {
111 RungId::ALL.into_iter().find(|r| r.as_str() == token)
112 }
113}
114
115impl std::fmt::Display for RungId {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.write_str(self.as_str())
118 }
119}
120
121/// One rung's answer — the [`Judgement`](crate::report::Judgement) core
122/// (RFC 13, v1.24; RFC 09 §5.1 pre-v1.24), carried directly: since v1.24 the
123/// ladder's three shipped states *are* three of the core's four poles, and
124/// this alias is the fold. The serde tags are byte-identical to what #214
125/// shipped (`established` / `not_established` + `reason` / `not_asked`).
126///
127/// A rung's judgement is over **its own question** (the rung's fact), not
128/// over "is there a finding?" — which of its poles constitutes a finding is
129/// per-rung policy, and [`is_cause`](crate::judge::why::is_cause) is where that policy lives. The rungs
130/// currently never answer [`Unobservable`](crate::report::Judgement::Unobservable): an observation the
131/// ladder could not obtain degrades the rung to `NotAsked` and rides
132/// [`WhyReport::impairments`] instead.
133///
134/// A rung whose input was not fetched says
135/// [`NotAsked`](crate::report::Judgement::NotAsked), never
136/// `NotEstablished` (RFC 09 §5.1 O4).
137pub type RungAnswer = crate::report::Judgement;
138
139/// One rung of the ladder.
140#[derive(Debug, Clone, Serialize)]
141pub struct Rung {
142 /// Which rung this is — stable, script-keyable.
143 pub id: RungId,
144 /// The question this rung puts, as prose. Serialized (a reader should not
145 /// need this crate to know what was asked) and derived from `id`, so the
146 /// two can no longer disagree.
147 pub question: &'static str,
148 #[serde(flatten)]
149 pub answer: RungAnswer,
150 /// What the answer rests on, one fact per line.
151 #[serde(skip_serializing_if = "Vec::is_empty")]
152 pub evidence: Vec<String>,
153}
154
155/// The report's overall reading — what the CLI exits with (see the module
156/// doc's exit table).
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum WhyVerdict {
160 /// An explanation of the silence was established (exit 0).
161 Explained,
162 /// No cause, and everything checked looks healthy (exit 1).
163 Healthy,
164 /// No cause, and the observation was impaired: an input this ladder
165 /// wanted could not be obtained, so "healthy" cannot be claimed (exit 2).
166 Impaired,
167}
168
169impl WhyVerdict {
170 /// The [`Judgement`](crate::report::Judgement) mapping (RFC 13,
171 /// v1.24), and it is **THE inverted one — read this before wiring exit
172 /// codes**: `Explained` is *established-finding* (`Established`), because
173 /// the thing `why` establishes is a cause — a finding about the fleet —
174 /// even though this family's own historical CLI contract exits **0** for
175 /// it (the module doc's table). The RFC 13 exit projection
176 /// ([`crate::report::judgement_exit_code`]) therefore gives `why`'s
177 /// three verdicts 1 / 0 / 2 in this order — the flip between the two
178 /// contracts is carried **here, at the mapping**, never special-cased by
179 /// a consumer downstream.
180 ///
181 /// | verdict | judgement | RFC 13 exit | historical `zenctl why` exit |
182 /// |---|---|---|---|
183 /// | `Explained` | `Established` (finding) | 1 | 0 |
184 /// | `Healthy` | `NotEstablished` (clean) | 0 | 1 |
185 /// | `Impaired` | `Unobservable` | 2 | 2 |
186 pub fn to_judgement(self) -> crate::report::Judgement {
187 use crate::report::Judgement;
188 match self {
189 WhyVerdict::Explained => Judgement::Established,
190 WhyVerdict::Healthy => Judgement::NotEstablished {
191 reason: "no cause established, and everything checked looks healthy".into(),
192 },
193 WhyVerdict::Impaired => Judgement::Unobservable {
194 reason: "an input the ladder wanted could not be obtained — \"healthy\" \
195 cannot be claimed over questions it could not ask"
196 .into(),
197 },
198 }
199 }
200}
201
202/// The inverse of [`WhyVerdict::to_judgement`], same (inverted) polarity:
203/// an established finding is `Explained`, established-clean is `Healthy`,
204/// and both unestablished poles fold to `Impaired` — a ladder nobody asked
205/// is exactly a ladder that cannot claim health.
206impl From<crate::report::Judgement> for WhyVerdict {
207 fn from(j: crate::report::Judgement) -> WhyVerdict {
208 use crate::report::Judgement;
209 match j {
210 Judgement::Established => WhyVerdict::Explained,
211 Judgement::NotEstablished { .. } => WhyVerdict::Healthy,
212 Judgement::NotAsked | Judgement::Unobservable { .. } => WhyVerdict::Impaired,
213 }
214 }
215}
216
217/// The ladder, assembled. One rung per [`RungId`] entry, in order, always —
218/// a rung is never omitted, it degrades to `NotAsked`.
219#[derive(Debug, Clone, Serialize)]
220pub struct WhyReport {
221 /// The key (or selector) as asked, verbatim.
222 pub key: String,
223 /// The base the ladder judged under. Empty is the bus-root deployment.
224 pub base: String,
225 pub rungs: Vec<Rung>,
226 pub verdict: WhyVerdict,
227 /// Inputs the ladder wanted and could not obtain — what makes a
228 /// no-cause run [`WhyVerdict::Impaired`] rather than healthy.
229 #[serde(skip_serializing_if = "Vec::is_empty")]
230 pub impairments: Vec<String>,
231 /// The listen window that ran, seconds. Absent = not listened — which
232 /// the `wire-heard` rung states rather than hiding (O4).
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub listened_s: Option<f64>,
235}
236
237impl WhyReport {
238 /// The rung ids whose answers established a cause — what exit 0 rests on.
239 pub fn causes(&self) -> Vec<RungId> {
240 self.rungs
241 .iter()
242 .filter(|r| is_cause(r.id, &r.answer))
243 .map(|r| r.id)
244 .collect()
245 }
246}
247
248#[cfg(test)]
249mod rung_id_tests {
250 use super::*;
251
252 /// The id vocabulary is API: additions append, nothing renames. If this
253 /// test fails you are renaming a shipped rung id — don't (the
254 /// [`CheckId`](crate::report::CheckId) discipline, applied here).
255 #[test]
256 fn rung_ids_are_stable() {
257 assert_eq!(
258 RungId::ALL.map(RungId::as_str),
259 [
260 "scope-reach",
261 "key-parse",
262 "registry-declared",
263 "origin-alive",
264 "publisher-declared",
265 "storage-coverage",
266 "stored-value",
267 "sample-freshness",
268 "admin-answered",
269 "wire-heard",
270 ]
271 );
272 }
273
274 /// Every rung carries a question, and it is the type's — so a new rung
275 /// cannot ship with the wrong prose, which is what a `match` on a
276 /// `&'static str` allowed until #347 (its fallback was `unreachable!`).
277 #[test]
278 fn every_rung_id_has_a_question_and_round_trips() {
279 for id in RungId::ALL {
280 assert!(id.question().ends_with('?'), "{id}: {}", id.question());
281 let json = serde_json::to_string(&id).unwrap();
282 assert_eq!(json, format!("\"{}\"", id.as_str()));
283 assert_eq!(serde_json::from_str::<RungId>(&json).unwrap(), id);
284 assert_eq!(RungId::parse(id.as_str()), Some(id));
285 }
286 assert_eq!(RungId::parse("wire-herd"), None);
287 }
288
289 /// The cause poles, asserted as a set rather than by walking a report:
290 /// this is the policy exit 0 rests on (RFC 13 §1.2).
291 #[test]
292 fn exactly_five_rungs_explain_a_silence() {
293 let causes: Vec<&str> = RungId::ALL
294 .into_iter()
295 .filter(|r| r.is_cause_when_unestablished())
296 .map(RungId::as_str)
297 .collect();
298 assert_eq!(
299 causes,
300 [
301 "scope-reach",
302 "key-parse",
303 "registry-declared",
304 "origin-alive",
305 "sample-freshness"
306 ]
307 );
308 }
309}