1use 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
89pub 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 RungAnswer::NotAsked | RungAnswer::Unobservable { .. } => false,
102 }
103}
104
105#[derive(Debug, Clone)]
108pub struct StoredValue {
109 pub key: String,
111 pub source: ValueSource,
112 pub payload_len: usize,
113 pub age_s: Option<i64>,
116}
117
118#[derive(Debug, Clone)]
120pub enum StoredLookup {
121 Found(StoredValue),
122 Silent {
125 attempted: Vec<&'static str>,
126 },
127}
128
129#[derive(Debug, Clone, Copy)]
131pub struct WireWatch {
132 pub window_s: f64,
133 pub samples: u64,
134 pub dropped: u64,
137}
138
139pub struct WhyInputs<'a> {
143 pub base: &'a str,
144 pub key: &'a str,
146 pub slices: Option<&'a SliceSet>,
149 pub roster: Option<&'a BTreeMap<String, Vec<String>>>,
151 pub entities: Option<Option<&'a DeclaredEntities>>,
155 pub admin_answered: Option<usize>,
158 pub storages: Option<&'a [StorageInfo]>,
161 pub stored: Option<&'a StoredLookup>,
163 pub wire: Option<&'a WireWatch>,
165}
166
167pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy)]
743pub struct WhySpec {
744 pub timeout: Duration,
746 pub listen: Option<Duration>,
749}
750
751pub 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 _ => 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
826async 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 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 #[test]
919 fn unfetched_inputs_answer_not_asked_never_no() {
920 let report = ladder(¬hing_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 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 #[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 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 #[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 #[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 #[test]
1070 fn a_verbatim_plane_key_is_out_of_scope_and_says_why() {
1071 let report = ladder(¬hing_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 assert_eq!(
1084 get(&report, RungId::RegistryDeclared).answer,
1085 RungAnswer::NotAsked
1086 );
1087 assert_eq!(report.verdict, WhyVerdict::Explained);
1088 }
1089
1090 #[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 #[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 #[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 #[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}