1use 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
88pub 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 RungAnswer::NotAsked | RungAnswer::Unobservable { .. } => false,
101 }
102}
103
104#[derive(Debug, Clone)]
107pub struct StoredValue {
108 pub key: String,
110 pub source: ValueSource,
111 pub payload_len: usize,
112 pub age_s: Option<i64>,
115}
116
117#[derive(Debug, Clone)]
119pub enum StoredLookup {
120 Found(StoredValue),
121 Silent {
124 attempted: Vec<&'static str>,
125 },
126}
127
128#[derive(Debug, Clone, Copy)]
130pub struct WireWatch {
131 pub window_s: f64,
132 pub samples: u64,
133 pub dropped: u64,
136}
137
138pub struct WhyInputs<'a> {
142 pub base: &'a str,
143 pub key: &'a str,
145 pub slices: Option<&'a SliceSet>,
148 pub roster: Option<&'a BTreeMap<String, Vec<String>>>,
150 pub entities: Option<Option<&'a DeclaredEntities>>,
154 pub admin_answered: Option<usize>,
157 pub storages: Option<&'a [StorageInfo]>,
160 pub stored: Option<&'a StoredLookup>,
162 pub wire: Option<&'a WireWatch>,
164}
165
166pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy)]
742pub struct WhySpec {
743 pub timeout: Duration,
745 pub listen: Option<Duration>,
748}
749
750pub 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 _ => 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
825async 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 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 #[test]
918 fn unfetched_inputs_answer_not_asked_never_no() {
919 let report = ladder(¬hing_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 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 #[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 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 #[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 #[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 #[test]
1069 fn a_verbatim_plane_key_is_out_of_scope_and_says_why() {
1070 let report = ladder(¬hing_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 assert_eq!(
1083 get(&report, RungId::RegistryDeclared).answer,
1084 RungAnswer::NotAsked
1085 );
1086 assert_eq!(report.verdict, WhyVerdict::Explained);
1087 }
1088
1089 #[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 #[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 #[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 #[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}