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