Skip to main content

zenkey_fleet/judge/
why.rs

1//! Why is this key silent — the non-verdict, itemised (issue #214).
2//!
3//! Every tool in this space answers "is it publishing?" with a spinner. This
4//! suite deliberately refuses to answer at all — *silence is never a verdict*
5//! (RFC 05 §3.1) — and that refusal is correct, and it is also where the user
6//! is abandoned: the tool knows why the question is unanswerable and never
7//! says so. `why` turns the refusal into a product: a rung ladder over facts
8//! the engine already holds, where every rung answers `Established`,
9//! `NotEstablished` *with its reason*, or `NotAsked` — because "not asked" is
10//! not "answered no" (RFC 09 §5.1 O4), and a ladder that prints `No` where it
11//! means `NotAsked` becomes the exact thing it was built to replace.
12//!
13//! ## The rungs
14//!
15//! Ten rungs, in the order a fact weakens the ones below it. The id
16//! vocabulary is **stable API** in the [`crate::report::CheckId`] tradition:
17//! scripts key on ids, the GUI will key deltas on them, additions append and
18//! nothing renames. The full set is [`crate::report::RungId::ALL`].
19//!
20//! | id | question | source |
21//! |---|---|---|
22//! | `scope-reach` | does a `**` explorer scope reach this key? | key algebra (RFC 09 §5.1 O5; RFC 03 §4 D2/D4) |
23//! | `key-parse` | does it parse as a v1 key under the base? | [`crate::model::facts::describe_key`] (O2) |
24//! | `registry-declared` | does a loaded slice declare the subject? | [`SliceSet`] refinement (RFC 08 §2) |
25//! | `origin-alive` | is the origin on the liveliness roster? | [`crate::bus::roster::roster()`] (RFC 04 §5) |
26//! | `publisher-declared` | did any session declare a matching publisher? | [`crate::declared_entities`] — and see below |
27//! | `storage-coverage` | is a storage configured to capture it? | [`crate::storages`] (RFC 09 §2) |
28//! | `stored-value` | does a stored value answer a bounded GET? | [`crate::bus::query::fetch_stored`] (RFC 04 §3.2) |
29//! | `sample-freshness` | is the last known sample within its declared ttl? | declared `ttl_s` × the fetched stamp (RFC 04 §1.2) |
30//! | `admin-answered` | is the admin space answering at all? | [`crate::topology`]`.answered` |
31//! | `wire-heard` | did the key speak during a listen window? | a bounded [`crate::Monitor`] window, opt-in |
32//!
33//! The `publisher-declared` rung carries the one wording that must never
34//! drift: RFC 08 §6.1 (v1.20) records that publishers are declared **lazily,
35//! on the first publication** — so "no publisher declared" for a producer
36//! that is declared and alive is *not* evidence of a bug, and this rung says
37//! "declared, alive, never published" rather than letting the absence read
38//! as one.
39//!
40//! ## Cost discipline (RFC 09 §5.1, the v1.18 frugality note)
41//!
42//! The default run costs the control plane only: the liveliness sweep, the
43//! admin sweeps, and one bounded GET on the asked key (storages and the
44//! `@adv` cache answer GETs; no subscriber is declared). Anything that costs
45//! the data plane is the explicit opt-in: [`WhySpec::listen`] opens one
46//! bounded subscription on the asked key, and without it the `wire-heard`
47//! rung reads `NotAsked` — never "silent". A fan-out sweep per invocation
48//! would breach the frugality note, and there is none.
49//!
50//! ## Verdict and exit codes
51//!
52//! [`WhyVerdict`] is the report's overall reading, and the CLI exits with it:
53//!
54//! * **`Explained`** (exit 0) — an explanation was established: a *cause
55//!   rung* (`scope-reach`, `key-parse`, `registry-declared`, `origin-alive`,
56//!   `sample-freshness`) answered `NotEstablished`, or `wire-heard` answered
57//!   `Established` (the key is speaking — the question dissolves).
58//! * **`Healthy`** (exit 1) — no cause was established and everything that
59//!   was checked looks healthy. "Declared, alive, never published" lands
60//!   here on purpose: lazy publisher declaration is not a bug (RFC 08 §6.1).
61//! * **`Impaired`** (exit 2) — no cause was established *and* an input this
62//!   ladder wanted could not be obtained (no admin space answered, the
63//!   roster sweep failed, no registry could be loaded, the value GET did not
64//!   run). The ladder cannot claim "healthy" over questions it could not
65//!   ask. Benign `NotAsked` rungs — no `--listen` window requested, a
66//!   verbatim-plane key with no registry surface, no ttl declared, no sample
67//!   in hand to age — do not impair.
68//!
69//! Lives in the engine so both explorers ask one implementation, and both
70//! now do: `zenctl why`, and the Inspector's Why section (#214,
71//! `zengui/src/view/why.rs`), which renders the same ladder on demand. The
72//! tree-node action this comment also promised is the one part that did not
73//! land — the entry point is the command palette (#400).
74
75use std::collections::BTreeMap;
76use std::time::Duration;
77
78use crate::Result;
79use zenoh::Session;
80use zenoh::key_expr::keyexpr;
81
82use crate::judge::common::EVIDENCE_CAP;
83use crate::model::examples::Examples;
84use crate::model::facts::{KeyShape, OriginKind, Registration, describe_key};
85use crate::model::registry::SliceSet;
86use crate::report::{DeclaredEntities, EntityKind, StorageInfo};
87use crate::report::{Rung, RungAnswer, RungId, ValueSource, WhyReport, WhyVerdict};
88
89/// Whether one rung's answer counts as an established explanation.
90///
91/// Public policy, not a rendering choice: both explorers and any script
92/// keying on the ndjson must agree on what exit 0 meant. Which rungs qualify
93/// is [`RungId::is_cause_when_unestablished`] — the vocabulary owns its own
94/// policy, so the list cannot drift from the enum (#347).
95pub fn is_cause(id: RungId, answer: &RungAnswer) -> bool {
96    match answer {
97        RungAnswer::Established => id == RungId::WireHeard,
98        RungAnswer::NotEstablished { .. } => id.is_cause_when_unestablished(),
99        // Neither unestablished pole is ever a cause: an unput or uncarried
100        // question explains nothing (RFC 13, v1.24).
101        RungAnswer::NotAsked | RungAnswer::Unobservable { .. } => false,
102    }
103}
104
105/// A stored value as the ladder judges it — [`crate::FetchedValue`] with the
106/// aging already done, so the ladder stays pure and testable without a clock.
107#[derive(Debug, Clone)]
108pub struct StoredValue {
109    /// The concrete key the value arrived on.
110    pub key: String,
111    pub source: ValueSource,
112    pub payload_len: usize,
113    /// Seconds since the sample's HLC stamp; `None` = unstamped, which is
114    /// "unjudgeable", never "fresh" (RFC 04 §4).
115    pub age_s: Option<i64>,
116}
117
118/// The stored-value lookup's outcome, as input to the ladder.
119#[derive(Debug, Clone)]
120pub enum StoredLookup {
121    Found(StoredValue),
122    /// Every listed ask ran and none answered — silence, with exactly what
123    /// was asked (RFC 05 §3.1).
124    Silent {
125        attempted: Vec<&'static str>,
126    },
127}
128
129/// One bounded listen window's outcome (`--for`).
130#[derive(Debug, Clone, Copy)]
131pub struct WireWatch {
132    pub window_s: f64,
133    pub samples: u64,
134    /// Samples the bounded observer missed while behind — reported, so a
135    /// silence claim covers only what was seen (RFC 09 §5.1 O6).
136    pub dropped: u64,
137}
138
139/// Everything the ladder judges, each ingredient honest about whether it was
140/// obtained. `None` always means *not fetched* — the rung it feeds answers
141/// `NotAsked`, never `NotEstablished`.
142pub struct WhyInputs<'a> {
143    pub base: &'a str,
144    /// The key or selector as asked (params tolerated; stripped for algebra).
145    pub key: &'a str,
146    /// `None` = no registry was loaded (distinguishable from a loaded set
147    /// that covers nothing — the [`crate::model::facts`] rule).
148    pub slices: Option<&'a SliceSet>,
149    /// `None` = the liveliness sweep was not made or failed.
150    pub roster: Option<&'a BTreeMap<String, Vec<String>>>,
151    /// Outer `None` = the sweep was not made or failed; `Some(None)` = made,
152    /// and **no admin space answered** (`adminspace.enabled` defaults off) —
153    /// unknown, never zero (RFC 09 §5.1 O4).
154    pub entities: Option<Option<&'a DeclaredEntities>>,
155    /// `topology().answered` — how many admin root docs replied. `None` =
156    /// the sweep was not made or failed.
157    pub admin_answered: Option<usize>,
158    /// `None` = the storage sweep was not made (or the admin space that
159    /// would answer it did not).
160    pub storages: Option<&'a [StorageInfo]>,
161    /// `None` = the bounded value GET did not run.
162    pub stored: Option<&'a StoredLookup>,
163    /// `None` = no listen window was requested (the default; O4 says so).
164    pub wire: Option<&'a WireWatch>,
165}
166
167/// Assemble the ladder from what was (and was not) fetched. Pure — every
168/// judgement over bus data is testable without a bus.
169pub fn ladder(inputs: &WhyInputs<'_>) -> WhyReport {
170    let mut rungs: Vec<Rung> = Vec::with_capacity(RungId::ALL.len());
171    let mut impairments: Vec<String> = Vec::new();
172    // The selector-parameter tail (`?k=v`) rides GETs but is not key algebra.
173    let key = inputs.key.split('?').next().unwrap_or_default();
174    let desc = describe_key(inputs.base, key, inputs.slices);
175    let v1 = match &desc.facts.shape {
176        KeyShape::V1(f) => Some(f.as_ref()),
177        _ => None,
178    };
179
180    // ── scope-reach (RFC 09 §5.1 O5; RFC 03 §4 D2/D4) ──────────────────
181    let scope = zenkey::grammar::with_base(inputs.base, "v1/**");
182    let (answer, evidence) = match keyexpr::new(key) {
183        Err(e) => (
184            RungAnswer::NotEstablished {
185                reason: format!(
186                    "not a valid key expression ({e}) — nothing on a Zenoh bus \
187                     can carry it"
188                ),
189            },
190            vec![],
191        ),
192        Ok(ke) => {
193            let reaches = keyexpr::new(scope.as_str()).is_ok_and(|s| s.intersects(ke));
194            if reaches {
195                (
196                    RungAnswer::Established,
197                    vec![format!("the `{scope}` explorer scope intersects this key")],
198                )
199            } else {
200                let mut evidence = vec![format!(
201                    "a watcher scoped `{scope}` will never see this key"
202                )];
203                if key.split('/').any(|c| c.starts_with('@')) {
204                    evidence.push(
205                        "`**` never crosses an `@` chunk and `*` never matches a \
206                         verbatim origin (RFC 03 §4 D2/D4) — verbatim planes and \
207                         service origins must be named to be seen"
208                            .into(),
209                    );
210                }
211                (
212                    RungAnswer::NotEstablished {
213                        reason: "the wildcard explorer scope cannot reach this key \
214                                 (RFC 09 §5.1 O5)"
215                            .into(),
216                    },
217                    evidence,
218                )
219            }
220        }
221    };
222    rungs.push(rung(RungId::ScopeReach, answer, evidence));
223
224    // ── key-parse (RFC 09 §5.1 O2) ─────────────────────────────────────
225    let (answer, evidence) = match &desc.facts.shape {
226        KeyShape::V1(f) => (
227            RungAnswer::Established,
228            vec![format!(
229                "origin {} ({}), class {}{}{}",
230                f.origin,
231                match f.origin_kind {
232                    OriginKind::Host => "host",
233                    OriginKind::Service => "service",
234                },
235                f.class,
236                match &f.producer {
237                    Some(p) => format!(", producer {p}"),
238                    None => String::new(),
239                },
240                if f.subject.is_empty() {
241                    String::new()
242                } else {
243                    format!(", subject {}", f.subject.join("/"))
244                },
245            )],
246        ),
247        KeyShape::NotUnderBase => (
248            RungAnswer::NotEstablished {
249                reason: format!(
250                    "does not sit under the configured base {:?} (RFC 03 §1.1) — \
251                     another deployment's key, and its base is not guessed \
252                     (RFC 09 §5.1 O3)",
253                    inputs.base
254                ),
255            },
256            vec![],
257        ),
258        KeyShape::Unparsed { reason } => (
259            RungAnswer::NotEstablished {
260                reason: format!(
261                    "not a v1 key: {reason} — a fact, not an error (RFC 09 §5.1 \
262                     O1); everything below can only weaken"
263                ),
264            },
265            vec![],
266        ),
267    };
268    rungs.push(rung(RungId::KeyParse, answer, evidence));
269
270    // ── registry-declared (RFC 08 §2) ──────────────────────────────────
271    let mut declared = false;
272    let mut declared_ttl: Option<i64> = None;
273    let (answer, evidence) = match &desc.facts.registration {
274        Registration::Unknown => {
275            impairments
276                .push("no registry was loaded — the declaration rung could not be asked".into());
277            (
278                RungAnswer::NotAsked,
279                vec![
280                    "no registry loaded — not asked is not answered no (RFC 09 §5.1 \
281                     O4); pass --registry <dir> or ask a fleet that answers \
282                     introspect"
283                        .into(),
284                ],
285            )
286        }
287        Registration::NotApplicable => (
288            RungAnswer::NotAsked,
289            vec![if v1.is_some() {
290                "a verbatim plane carries no [[subject]] declarations (RFC 03 §1.4) \
291                 — there is no registry surface to consult"
292                    .into()
293            } else {
294                "a key that does not parse has no registry surface to consult".into()
295            }],
296        ),
297        Registration::NoSliceForProducer => (
298            RungAnswer::NotEstablished {
299                reason: "no loaded slice declares this producer — nothing conforming \
300                         claims to publish here (RFC 08 §2)"
301                    .into(),
302            },
303            vec![],
304        ),
305        Registration::Unregistered => (
306            RungAnswer::NotEstablished {
307                reason: "the producer's slice does not declare this subject — for a \
308                         conforming producer, a subject that is not registered does \
309                         not exist (RFC 08 §2)"
310                    .into(),
311            },
312            vec![],
313        ),
314        Registration::Registered(sf) => {
315            declared = true;
316            declared_ttl = sf.ttl_s;
317            let mut evidence = vec![format!(
318                "declared as {} ({}){}",
319                sf.path,
320                sf.type_name,
321                sf.qos
322                    .as_ref()
323                    .map(|q| format!(", qos {}", q.token()))
324                    .unwrap_or_default(),
325            )];
326            if let Some(ttl) = sf.ttl_s {
327                evidence.push(format!("declares ttl_s = {ttl} (refresh <= ttl/2)"));
328            }
329            (RungAnswer::Established, evidence)
330        }
331    };
332    rungs.push(rung(RungId::RegistryDeclared, answer, evidence));
333
334    // ── origin-alive (RFC 04 §5) ───────────────────────────────────────
335    let mut alive = false;
336    let (answer, evidence) = match (v1, inputs.roster) {
337        (None, _) => (
338            RungAnswer::NotAsked,
339            vec!["the key names no origin this ladder can look for".into()],
340        ),
341        (Some(_), None) => {
342            impairments.push("the liveliness roster could not be swept".into());
343            (
344                RungAnswer::NotAsked,
345                vec!["the liveliness roster was not obtained".into()],
346            )
347        }
348        (Some(f), Some(roster)) => match roster.get(&f.origin) {
349            None => (
350                RungAnswer::NotEstablished {
351                    reason: format!(
352                        "{} holds no liveliness token — offline or unenrolled; its \
353                         silence is expected, and unattributable beyond that \
354                         (RFC 05 §3.1)",
355                        f.origin
356                    ),
357                },
358                vec![],
359            ),
360            Some(producers) => {
361                let wanted = f.producer.as_deref();
362                let holds = match wanted {
363                    // A service origin's token has no producer chunk — the
364                    // service is the producer (RFC 06 §5).
365                    None => true,
366                    Some(name) => producers.iter().any(|chunk| {
367                        zenkey::grammar::Producer::parse_chunk(chunk)
368                            .map(|p| {
369                                p.name() == name
370                                    && (f.instance.is_none() || p.instance() == f.instance)
371                            })
372                            .unwrap_or(chunk == name)
373                    }),
374                };
375                if holds {
376                    alive = true;
377                    (
378                        RungAnswer::Established,
379                        vec![format!(
380                            "{} is on the roster with producer(s): {}",
381                            f.origin,
382                            producers.join(", ")
383                        )],
384                    )
385                } else {
386                    (
387                        RungAnswer::NotEstablished {
388                            reason: format!(
389                                "{} is up, but producer {:?} holds no liveliness \
390                                 token there — not running, or unenrolled \
391                                 (RFC 04 §5)",
392                                f.origin,
393                                wanted.unwrap_or_default()
394                            ),
395                        },
396                        vec![format!("token(s) held: {}", producers.join(", "))],
397                    )
398                }
399            }
400        },
401    };
402    rungs.push(rung(RungId::OriginAlive, answer, evidence));
403
404    // ── publisher-declared (RFC 08 §6.1) ───────────────────────────────
405    let (answer, evidence) = match inputs.entities {
406        None => {
407            impairments.push("the declared-entity sweep was not made — publishers unknown".into());
408            (
409                RungAnswer::NotAsked,
410                vec!["the admin declared-entity sweep was not made".into()],
411            )
412        }
413        Some(None) => {
414            impairments
415                .push("no admin space answered — declared publishers are unknown, not zero".into());
416            (
417                RungAnswer::NotAsked,
418                vec![
419                    "no admin space answered the sweep (`adminspace.enabled` \
420                     defaults off; a pure peer mesh has none) — declared publishers \
421                     are unknown, not zero (RFC 09 §5.1 O4)"
422                        .into(),
423                ],
424            )
425        }
426        Some(Some(entities)) => {
427            let matches: Vec<&crate::report::DeclaredEntity> = keyexpr::new(key)
428                .ok()
429                .map(|ke| {
430                    entities
431                        .entities
432                        .iter()
433                        .filter(|e| e.kind == EntityKind::Publisher)
434                        .filter(|e| {
435                            keyexpr::new(e.keyexpr.as_str()).is_ok_and(|d| d.intersects(ke))
436                        })
437                        .collect()
438                })
439                .unwrap_or_default();
440            if matches.is_empty() {
441                // The rung that must never read as a bug: publishers are
442                // declared lazily, on the first publication (RFC 08 §6.1
443                // v1.20), so absence here is the *expected* state of a key
444                // nothing has published yet.
445                let reason = if declared && alive {
446                    "declared, alive, never published — publishers declare lazily \
447                     (RFC 08 §6.1): no publisher declaration exists until the \
448                     first publication, so this is not evidence of a bug"
449                        .to_string()
450                } else {
451                    "no session declares a publisher intersecting this key — \
452                     publishers declare lazily on first publication (RFC 08 §6.1), \
453                     so this is not evidence of a bug"
454                        .to_string()
455                };
456                (RungAnswer::NotEstablished { reason }, vec![])
457            } else {
458                let mut evidence = Examples::new(EVIDENCE_CAP);
459                for e in &matches {
460                    evidence.push_with(|| {
461                        format!("publisher {} declared by session {}", e.keyexpr, e.node_zid)
462                    });
463                }
464                (RungAnswer::Established, evidence.into_lines("more"))
465            }
466        }
467    };
468    rungs.push(rung(RungId::PublisherDeclared, answer, evidence));
469
470    // ── storage-coverage (RFC 09 §2 / RFC 04 §3.5) ─────────────────────
471    let (answer, evidence) = match inputs.storages {
472        None => (
473            RungAnswer::NotAsked,
474            vec![
475                "the storage sweep was not made (no admin space to answer it) — \
476                 coverage unknown, not uncovered (RFC 09 §5.1 O4)"
477                    .into(),
478            ],
479        ),
480        Some(storages) => {
481            let judged: Vec<(String, bool)> = keyexpr::new(key)
482                .ok()
483                .map(|ke| {
484                    storages
485                        .iter()
486                        .filter_map(|s| {
487                            let expr = s.key_expr.as_deref()?;
488                            let ske = keyexpr::new(expr).ok()?;
489                            if ske.includes(ke) {
490                                Some((format!("{}@{} ({expr})", s.name, s.zid), true))
491                            } else if ske.intersects(ke) {
492                                Some((format!("{}@{} ({expr})", s.name, s.zid), false))
493                            } else {
494                                None
495                            }
496                        })
497                        .collect()
498                })
499                .unwrap_or_default();
500            if judged.is_empty() {
501                (
502                    RungAnswer::NotEstablished {
503                        reason: "no configured storage captures this key — a GET \
504                                 cannot return a past sample from storage; \
505                                 legitimate for volatile state seeded from \
506                                 publisher caches (RFC 04 §3.5)"
507                            .into(),
508                    },
509                    vec![format!(
510                        "{} storage(s) configured, none match",
511                        storages.len()
512                    )],
513                )
514            } else {
515                let mut evidence = Examples::new(EVIDENCE_CAP);
516                for (name, full) in &judged {
517                    evidence.push_with(|| {
518                        format!(
519                            "storage {name} {}",
520                            if *full {
521                                "captures every key this expression names"
522                            } else {
523                                "overlaps it partially"
524                            }
525                        )
526                    });
527                }
528                (RungAnswer::Established, evidence.into_vec())
529            }
530        }
531    };
532    rungs.push(rung(RungId::StorageCoverage, answer, evidence));
533
534    // ── stored-value (RFC 04 §3.2) ─────────────────────────────────────
535    let mut stored_age: Option<i64> = None;
536    let mut stored_unstamped = false;
537    let (answer, evidence) = match inputs.stored {
538        None => {
539            impairments.push("the bounded value GET did not run".into());
540            (
541                RungAnswer::NotAsked,
542                vec!["the bounded value GET was not made".into()],
543            )
544        }
545        Some(StoredLookup::Found(v)) => {
546            match v.age_s {
547                Some(age) => stored_age = Some(age),
548                None => stored_unstamped = true,
549            }
550            (
551                RungAnswer::Established,
552                vec![format!(
553                    "{} answered on {}: {} byte(s), {}",
554                    match v.source {
555                        ValueSource::Storage => "a storage (or queryable)",
556                        ValueSource::Cache => "the publisher's @adv cache",
557                        ValueSource::Window => "a live sample in the window",
558                    },
559                    v.key,
560                    v.payload_len,
561                    match v.age_s {
562                        Some(age) => format!("stamped {age}s ago"),
563                        None => "unstamped (no HLC — RFC 04 §4)".to_string(),
564                    }
565                )],
566            )
567        }
568        Some(StoredLookup::Silent { attempted }) => (
569            RungAnswer::NotEstablished {
570                reason: format!(
571                    "none of {} returned a value — which is silence, not proof no \
572                     value exists (RFC 05 §3.1)",
573                    attempted.join(", ")
574                ),
575            },
576            vec![],
577        ),
578    };
579    rungs.push(rung(RungId::StoredValue, answer, evidence));
580
581    // ── sample-freshness (RFC 04 §1.2) ─────────────────────────────────
582    let (answer, evidence) = match (declared_ttl, stored_age) {
583        (None, _) => (
584            RungAnswer::NotAsked,
585            vec![if declared {
586                "the declared subject carries no ttl_s — freshness has no bound to \
587                 be judged against"
588                    .into()
589            } else {
590                "no declared ttl to judge against (the subject did not refine \
591                 against a loaded registry)"
592                    .into()
593            }],
594        ),
595        (Some(_), None) => (
596            RungAnswer::NotAsked,
597            vec![if stored_unstamped {
598                "the fetched sample carries no HLC timestamp — its age is \
599                 unjudgeable, which is not the same as fresh (RFC 04 §4)"
600                    .into()
601            } else {
602                "no sample in hand to age — the stored-value rung found none".into()
603            }],
604        ),
605        (Some(ttl), Some(age)) => {
606            if age > ttl {
607                (
608                    RungAnswer::NotEstablished {
609                        reason: format!(
610                            "the last known sample is {age}s old against ttl_s {ttl} \
611                             (refresh <= ttl/2) — the producer stopped refreshing \
612                             (RFC 04 §1.2)"
613                        ),
614                    },
615                    vec![],
616                )
617            } else {
618                (
619                    RungAnswer::Established,
620                    vec![format!("{age}s old against ttl_s {ttl} — within its ttl")],
621                )
622            }
623        }
624    };
625    rungs.push(rung(RungId::SampleFreshness, answer, evidence));
626
627    // ── admin-answered ─────────────────────────────────────────────────
628    let (answer, evidence) = match inputs.admin_answered {
629        None => {
630            impairments.push("the admin topology sweep was not made".into());
631            (
632                RungAnswer::NotAsked,
633                vec!["the admin topology sweep was not made".into()],
634            )
635        }
636        Some(0) => {
637            impairments.push(
638                "no admin root document answered @/*/* — the entity and storage \
639                 rungs could not be asked"
640                    .into(),
641            );
642            (
643                RungAnswer::NotEstablished {
644                    reason: "no admin root document answered @/*/* — a peer-only \
645                             mesh, or the admin space is disabled; a reading about \
646                             reachability, never an empty mesh"
647                        .into(),
648                },
649                vec![],
650            )
651        }
652        Some(n) => (
653            RungAnswer::Established,
654            vec![format!("{n} admin root document(s) answered @/*/*")],
655        ),
656    };
657    rungs.push(rung(RungId::AdminAnswered, answer, evidence));
658
659    // ── wire-heard (opt-in; RFC 09 §5.1 frugality) ─────────────────────
660    let (answer, evidence) = match inputs.wire {
661        None => (
662            RungAnswer::NotAsked,
663            vec![
664                "not listened — the data plane costs one deliberate action \
665                 (RFC 09 §5.1, v1.18 frugality); pass --for <SECS> to watch the \
666                 wire"
667                    .into(),
668            ],
669        ),
670        Some(w) => {
671            let mut evidence = Vec::new();
672            if w.dropped > 0 {
673                evidence.push(format!(
674                    "{} sample(s) dropped while behind — the claim covers only what \
675                     was seen (RFC 09 §5.1 O6)",
676                    w.dropped
677                ));
678            }
679            if w.samples > 0 {
680                evidence.insert(
681                    0,
682                    format!(
683                        "{} sample(s) in {:.0}s — the key is speaking; the question \
684                         dissolves",
685                        w.samples, w.window_s
686                    ),
687                );
688                (RungAnswer::Established, evidence)
689            } else {
690                (
691                    RungAnswer::NotEstablished {
692                        reason: format!(
693                            "nothing heard in {:.0}s — a bounded window bounds only \
694                             itself, and its silence is not a verdict (RFC 05 §3.1)",
695                            w.window_s
696                        ),
697                    },
698                    evidence,
699                )
700            }
701        }
702    };
703    rungs.push(rung(RungId::WireHeard, answer, evidence));
704
705    // One rung per id, in order, always. This was a `debug_assert_eq!` —
706    // which is to say it did not run in the builds anyone ships (#347).
707    assert_eq!(
708        rungs.iter().map(|r| r.id).collect::<Vec<_>>(),
709        RungId::ALL,
710        "one rung per id, in order, always"
711    );
712
713    let explained = rungs.iter().any(|r| is_cause(r.id, &r.answer));
714    let verdict = if explained {
715        WhyVerdict::Explained
716    } else if impairments.is_empty() {
717        WhyVerdict::Healthy
718    } else {
719        WhyVerdict::Impaired
720    };
721    WhyReport {
722        key: inputs.key.to_string(),
723        base: inputs.base.to_string(),
724        rungs,
725        verdict,
726        impairments,
727        listened_s: inputs.wire.map(|w| w.window_s),
728    }
729}
730
731fn rung(id: RungId, answer: RungAnswer, evidence: Vec<String>) -> Rung {
732    let question = id.question();
733    Rung {
734        id,
735        question,
736        answer,
737        evidence,
738    }
739}
740
741/// What a `why` run should cost.
742#[derive(Debug, Clone, Copy)]
743pub struct WhySpec {
744    /// Per-sweep / per-GET timeout.
745    pub timeout: Duration,
746    /// Listen passively on the asked key for this long — the one rung that
747    /// costs the data plane. `None` = the `wire-heard` rung reads `NotAsked`.
748    pub listen: Option<Duration>,
749}
750
751/// Gather the inputs off the live bus and assemble the ladder.
752///
753/// The default run is control-plane only (see the module doc): one
754/// liveliness sweep, the admin sweeps ([`crate::topology`],
755/// [`crate::declared_entities`], [`crate::storages`] — the last only when an
756/// admin space answered, so an empty vec cannot masquerade as "no storages
757/// configured"), and one bounded [`crate::bus::query::fetch_stored`] on the asked
758/// key. Every ingredient that fails to arrive degrades its rung to
759/// `NotAsked` and is recorded as an impairment — never as a `No`.
760///
761/// `slices` is the caller's registry (bus-swept or `--registry` dirs); `None`
762/// means none was loaded, and the declaration rung says so (O4).
763pub async fn run_why(
764    fleet: &crate::Fleet<'_>,
765    key: &str,
766    slices: Option<&SliceSet>,
767    spec: &WhySpec,
768) -> Result<WhyReport> {
769    let (session, base) = (fleet.session(), fleet.base());
770
771    let key_part = key.split('?').next().unwrap_or_default();
772
773    let roster = crate::bus::roster::roster(fleet, spec.timeout).await.ok();
774
775    let admin_answered = crate::topology(session, spec.timeout)
776        .await
777        .ok()
778        .map(|t| t.answered);
779    let entities = crate::declared_entities(session, spec.timeout).await.ok();
780    let storages = match admin_answered {
781        Some(n) if n > 0 => crate::storages(session, spec.timeout).await.ok(),
782        // Zero (or no) admin answers: an empty storage list would be
783        // "unknown" wearing "none configured"'s clothes — leave it unasked.
784        _ => None,
785    };
786
787    let stored = match crate::bus::query::fetch_stored(session, key_part, spec.timeout).await {
788        Ok(Some(v)) => {
789            let age_s = v.timestamp.and_then(|t| {
790                std::time::SystemTime::now()
791                    .duration_since(t.get_time().to_system_time())
792                    .ok()
793                    .map(|d| d.as_secs() as i64)
794            });
795            Some(StoredLookup::Found(StoredValue {
796                key: v.key,
797                source: v.source,
798                payload_len: v.payload.len(),
799                age_s,
800            }))
801        }
802        Ok(None) => Some(StoredLookup::Silent {
803            attempted: vec!["get", "@adv cache"],
804        }),
805        Err(_) => None,
806    };
807
808    let wire = match spec.listen {
809        None => None,
810        Some(window) => Some(listen_window(session, key_part, window).await?),
811    };
812
813    Ok(ladder(&WhyInputs {
814        base,
815        key,
816        slices,
817        roster: roster.as_ref(),
818        entities: entities.as_ref().map(|o| o.as_ref()),
819        admin_answered,
820        storages: storages.as_deref(),
821        stored: stored.as_ref(),
822        wire: wire.as_ref(),
823    }))
824}
825
826/// One bounded subscription on the asked key, through the [`crate::Monitor`]
827/// so a bus that outruns the observer surfaces as `dropped` rather than as a
828/// quieter bus (RFC 09 §5.1 O6). The subscriber is released when the window
829/// closes, provably — nothing stays subscribed (issue #85's contract).
830async fn listen_window(session: &Session, key: &str, window: Duration) -> Result<WireWatch> {
831    let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
832    let mut events = monitor.events();
833    let monitor = monitor.watching([key]).await?;
834    let deadline = tokio::time::Instant::now() + window;
835    let (mut samples, mut dropped) = (0u64, 0u64);
836    // One timer for the whole window, not one per iteration (#346).
837    // `sleep_until` builds a future and registers a timer each time it
838    // is evaluated, and a `select!` in a loop evaluates it on every
839    // pass — at 100k samples/s that is 100k registrations a second for
840    // a deadline that never moves.
841    let window_over = tokio::time::sleep_until(deadline);
842    tokio::pin!(window_over);
843    loop {
844        let item = tokio::select! {
845            item = events.recv() => item,
846            () = &mut window_over => break,
847        };
848        match item {
849            Some(crate::StreamItem::Event(crate::FleetEvent::Sample(_))) => samples += 1,
850            Some(crate::StreamItem::Dropped(n)) => dropped += n,
851            Some(_) => continue,
852            None => break,
853        }
854    }
855    monitor.shutdown().await?;
856    Ok(WireWatch {
857        window_s: window.as_secs_f64(),
858        samples,
859        dropped,
860    })
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    const KEY: &str = "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/root/used";
868
869    const SLICE: &str = r#"
870        [registry]
871        version = "1.0"
872        app = "t"
873        convention = 1
874        [producer]
875        name = "sysinfo"
876        [[subject]]
877        path = "disk/{mount}/used"
878        class = "telemetry"
879        type = "Point"
880        [[subject]]
881        path = "health"
882        class = "state"
883        type = "Health"
884        ttl_s = 30
885    "#;
886
887    fn slices() -> SliceSet {
888        SliceSet::from_toml_for_tests(SLICE)
889    }
890
891    fn nothing_fetched(key: &str) -> WhyInputs<'_> {
892        WhyInputs {
893            base: "",
894            key,
895            slices: None,
896            roster: None,
897            entities: None,
898            admin_answered: None,
899            storages: None,
900            stored: None,
901            wire: None,
902        }
903    }
904
905    fn get(report: &WhyReport, id: RungId) -> Rung {
906        report
907            .rungs
908            .iter()
909            .find(|r| r.id == id)
910            .unwrap_or_else(|| panic!("rung {id} missing"))
911            .clone()
912    }
913
914    /// The acceptance rule: every rung whose input was not fetched says
915    /// `NotAsked`, never `NotEstablished` — and a run that could ask nothing
916    /// is `Impaired`, because it cannot claim "healthy" over questions it
917    /// never put (RFC 09 §5.1 O4).
918    #[test]
919    fn unfetched_inputs_answer_not_asked_never_no() {
920        let report = ladder(&nothing_fetched(KEY));
921        assert_eq!(
922            report.rungs.iter().map(|r| r.id).collect::<Vec<_>>(),
923            RungId::ALL,
924            "one rung per id, in order, always"
925        );
926        for id in [
927            RungId::RegistryDeclared,
928            RungId::OriginAlive,
929            RungId::PublisherDeclared,
930            RungId::StorageCoverage,
931            RungId::StoredValue,
932            RungId::SampleFreshness,
933            RungId::AdminAnswered,
934            RungId::WireHeard,
935        ] {
936            assert_eq!(
937                get(&report, id).answer,
938                RungAnswer::NotAsked,
939                "{id} must say NotAsked when its input was not fetched"
940            );
941        }
942        // The two pure rungs always have their input — the key itself.
943        assert_eq!(
944            get(&report, RungId::ScopeReach).answer,
945            RungAnswer::Established
946        );
947        assert_eq!(
948            get(&report, RungId::KeyParse).answer,
949            RungAnswer::Established
950        );
951        assert_eq!(report.verdict, WhyVerdict::Impaired);
952        assert!(!report.impairments.is_empty());
953    }
954
955    /// The #214 acceptance fixture: a producer that is declared and alive but
956    /// has never published the subject yields the lazy-publisher-declaration
957    /// wording (RFC 08 §6.1) — and the verdict is `Healthy`, because "never
958    /// published" is not a bug and must not read as one.
959    #[test]
960    fn alive_but_never_published_yields_the_lazy_declaration_wording() {
961        let slices = slices();
962        let mut roster = std::collections::BTreeMap::new();
963        roster.insert("h-aaaaaaaaaaaa".to_string(), vec!["sysinfo".to_string()]);
964        // The admin space answered, and it holds no publisher for this key
965        // (only an unrelated subscriber) — the lazily-undeclared state.
966        let entities = DeclaredEntities {
967            entities: vec![crate::report::DeclaredEntity {
968                kind: EntityKind::Subscriber,
969                keyexpr: "v1/**".into(),
970                node_zid: "z1".into(),
971                sources: serde_json::Value::Null,
972            }],
973        };
974        let storages = [StorageInfo {
975            zid: "z1".into(),
976            name: "latest".into(),
977            key_expr: Some("v1/*/telemetry/**".into()),
978            strip_prefix: None,
979            volume: None,
980            raw: serde_json::Value::Null,
981        }];
982        let stored = StoredLookup::Silent {
983            attempted: vec!["get", "@adv cache"],
984        };
985        let report = ladder(&WhyInputs {
986            base: "",
987            key: KEY,
988            slices: Some(&slices),
989            roster: Some(&roster),
990            entities: Some(Some(&entities)),
991            admin_answered: Some(1),
992            storages: Some(&storages),
993            stored: Some(&stored),
994            wire: None,
995        });
996
997        let publisher = get(&report, RungId::PublisherDeclared);
998        match &publisher.answer {
999            RungAnswer::NotEstablished { reason } => {
1000                assert!(
1001                    reason.contains("declared, alive, never published"),
1002                    "the wording is the acceptance: {reason}"
1003                );
1004                assert!(reason.contains("publishers declare lazily"), "{reason}");
1005                assert!(reason.contains("RFC 08 §6.1"), "{reason}");
1006                assert!(reason.contains("not evidence of a bug"), "{reason}");
1007            }
1008            other => panic!("expected NotEstablished with the lazy wording, got {other:?}"),
1009        }
1010        assert_eq!(
1011            report.verdict,
1012            WhyVerdict::Healthy,
1013            "never-published is not a cause: exit 1, everything checked is healthy"
1014        );
1015        assert!(report.causes().is_empty());
1016        assert!(report.impairments.is_empty(), "{:?}", report.impairments);
1017    }
1018
1019    /// A subject the loaded slice does not declare is an established
1020    /// explanation (RFC 08 §2) — exit 0.
1021    #[test]
1022    fn an_unregistered_subject_is_an_established_cause() {
1023        let slices = slices();
1024        let mut inputs = nothing_fetched("v1/h-aaaaaaaaaaaa/telemetry/sysinfo/nonesuch");
1025        inputs.slices = Some(&slices);
1026        let report = ladder(&inputs);
1027        assert!(matches!(
1028            get(&report, RungId::RegistryDeclared).answer,
1029            RungAnswer::NotEstablished { .. }
1030        ));
1031        assert_eq!(report.verdict, WhyVerdict::Explained);
1032        assert_eq!(report.causes(), [RungId::RegistryDeclared]);
1033    }
1034
1035    /// An origin with no liveliness token is an established explanation —
1036    /// and so is a producer missing from an otherwise-live origin.
1037    #[test]
1038    fn a_missing_liveliness_token_is_an_established_cause() {
1039        let roster = std::collections::BTreeMap::new();
1040        let mut inputs = nothing_fetched(KEY);
1041        inputs.roster = Some(&roster);
1042        let report = ladder(&inputs);
1043        match get(&report, RungId::OriginAlive).answer {
1044            RungAnswer::NotEstablished { ref reason } => {
1045                assert!(reason.contains("no liveliness token"), "{reason}")
1046            }
1047            other => panic!("expected NotEstablished, got {other:?}"),
1048        }
1049        assert_eq!(report.verdict, WhyVerdict::Explained);
1050
1051        let mut roster = std::collections::BTreeMap::new();
1052        roster.insert("h-aaaaaaaaaaaa".to_string(), vec!["other".to_string()]);
1053        let mut inputs = nothing_fetched(KEY);
1054        inputs.roster = Some(&roster);
1055        let report = ladder(&inputs);
1056        match get(&report, RungId::OriginAlive).answer {
1057            RungAnswer::NotEstablished { ref reason } => {
1058                assert!(
1059                    reason.contains("holds no liveliness token there"),
1060                    "{reason}"
1061                )
1062            }
1063            other => panic!("expected NotEstablished, got {other:?}"),
1064        }
1065    }
1066
1067    /// A verbatim-plane key cannot be reached by the `**` scope (D2), and the
1068    /// rung says so with the citation — an established explanation.
1069    #[test]
1070    fn a_verbatim_plane_key_is_out_of_scope_and_says_why() {
1071        let report = ladder(&nothing_fetched(
1072            "v1/h-aaaaaaaaaaaa/@rpc/sysinfo/introspect",
1073        ));
1074        let scope = get(&report, RungId::ScopeReach);
1075        assert!(matches!(scope.answer, RungAnswer::NotEstablished { .. }));
1076        assert!(
1077            scope.evidence.iter().any(|e| e.contains("RFC 03 §4 D2/D4")),
1078            "{:?}",
1079            scope.evidence
1080        );
1081        // And the registry rung is NotAsked (a plane has no [[subject]]
1082        // surface), never "unregistered".
1083        assert_eq!(
1084            get(&report, RungId::RegistryDeclared).answer,
1085            RungAnswer::NotAsked
1086        );
1087        assert_eq!(report.verdict, WhyVerdict::Explained);
1088    }
1089
1090    /// A stamped sample older than its declared ttl is an established
1091    /// explanation (RFC 04 §1.2); one within it is healthy evidence.
1092    #[test]
1093    fn a_sample_past_its_ttl_is_an_established_cause() {
1094        let slices = slices();
1095        let stale = StoredLookup::Found(StoredValue {
1096            key: "v1/h-aaaaaaaaaaaa/state/sysinfo/health".into(),
1097            source: ValueSource::Storage,
1098            payload_len: 2,
1099            age_s: Some(120),
1100        });
1101        let mut inputs = nothing_fetched("v1/h-aaaaaaaaaaaa/state/sysinfo/health");
1102        inputs.slices = Some(&slices);
1103        inputs.stored = Some(&stale);
1104        let report = ladder(&inputs);
1105        match get(&report, RungId::SampleFreshness).answer {
1106            RungAnswer::NotEstablished { ref reason } => {
1107                assert!(reason.contains("120s old against ttl_s 30"), "{reason}");
1108            }
1109            other => panic!("expected NotEstablished, got {other:?}"),
1110        }
1111        assert_eq!(report.verdict, WhyVerdict::Explained);
1112
1113        let fresh = StoredLookup::Found(StoredValue {
1114            age_s: Some(10),
1115            ..match stale {
1116                StoredLookup::Found(v) => v,
1117                _ => unreachable!(),
1118            }
1119        });
1120        let mut inputs = nothing_fetched("v1/h-aaaaaaaaaaaa/state/sysinfo/health");
1121        inputs.slices = Some(&slices);
1122        inputs.stored = Some(&fresh);
1123        let report = ladder(&inputs);
1124        assert_eq!(
1125            get(&report, RungId::SampleFreshness).answer,
1126            RungAnswer::Established
1127        );
1128    }
1129
1130    /// An unstamped sample's age is unjudgeable — `NotAsked`, never "fresh"
1131    /// and never "stale" (RFC 04 §4).
1132    #[test]
1133    fn an_unstamped_sample_leaves_freshness_unasked() {
1134        let slices = slices();
1135        let unstamped = StoredLookup::Found(StoredValue {
1136            key: "v1/h-aaaaaaaaaaaa/state/sysinfo/health".into(),
1137            source: ValueSource::Cache,
1138            payload_len: 2,
1139            age_s: None,
1140        });
1141        let mut inputs = nothing_fetched("v1/h-aaaaaaaaaaaa/state/sysinfo/health");
1142        inputs.slices = Some(&slices);
1143        inputs.stored = Some(&unstamped);
1144        let report = ladder(&inputs);
1145        let rung = get(&report, RungId::SampleFreshness);
1146        assert_eq!(rung.answer, RungAnswer::NotAsked);
1147        assert!(
1148            rung.evidence.iter().any(|e| e.contains("no HLC timestamp")),
1149            "{:?}",
1150            rung.evidence
1151        );
1152    }
1153
1154    /// A listen window that hears the key dissolves the question — an
1155    /// established explanation; one that hears nothing is a bounded
1156    /// observation, not a verdict.
1157    #[test]
1158    fn a_speaking_key_dissolves_the_question() {
1159        let heard = WireWatch {
1160            window_s: 5.0,
1161            samples: 12,
1162            dropped: 0,
1163        };
1164        let mut inputs = nothing_fetched(KEY);
1165        inputs.wire = Some(&heard);
1166        let report = ladder(&inputs);
1167        assert_eq!(
1168            get(&report, RungId::WireHeard).answer,
1169            RungAnswer::Established
1170        );
1171        assert_eq!(report.verdict, WhyVerdict::Explained);
1172        assert_eq!(report.causes(), [RungId::WireHeard]);
1173
1174        let silent = WireWatch {
1175            window_s: 5.0,
1176            samples: 0,
1177            dropped: 3,
1178        };
1179        let mut inputs = nothing_fetched(KEY);
1180        inputs.wire = Some(&silent);
1181        let report = ladder(&inputs);
1182        let rung = get(&report, RungId::WireHeard);
1183        match rung.answer {
1184            RungAnswer::NotEstablished { ref reason } => {
1185                assert!(reason.contains("not a verdict"), "{reason}")
1186            }
1187            other => panic!("expected NotEstablished, got {other:?}"),
1188        }
1189        assert!(
1190            rung.evidence
1191                .iter()
1192                .any(|e| e.contains("3 sample(s) dropped")),
1193            "the O6 ledger rides the evidence: {:?}",
1194            rung.evidence
1195        );
1196        assert!(
1197            !report.causes().contains(&RungId::WireHeard),
1198            "a silent bounded window is never a cause"
1199        );
1200    }
1201
1202    /// A key under another deployment's base is an established explanation —
1203    /// and the base is not guessed (O3).
1204    #[test]
1205    fn another_deployments_key_is_an_established_cause() {
1206        let mut inputs = nothing_fetched("other/v1/h-aaaaaaaaaaaa/state/sysinfo/health");
1207        inputs.base = "zs";
1208        let report = ladder(&inputs);
1209        match get(&report, RungId::KeyParse).answer {
1210            RungAnswer::NotEstablished { ref reason } => {
1211                assert!(
1212                    reason.contains("does not sit under the configured base"),
1213                    "{reason}"
1214                );
1215            }
1216            other => panic!("expected NotEstablished, got {other:?}"),
1217        }
1218        assert_eq!(report.verdict, WhyVerdict::Explained);
1219    }
1220}