Skip to main content

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}