1use std::time::Duration;
12
13use crate::Result;
14use zenkey::grammar::with_base;
15use zenkey::{Declared, RegistrySlice};
16
17use crate::bus::query::{Answer, GetOpts, RepeatingRegistry, fleet_get, state_snapshot};
18use crate::judge::common::{FINDING_CAP, is_synthetic_marker};
19use crate::model::examples::Examples;
20use crate::report::{CheckId, DoctorFinding, DoctorReport, DoctorSeverity, DriftVerdict};
21
22pub use crate::judge::doctor_delta::doctor_delta;
25
26#[derive(Debug, Clone)]
28pub struct DoctorSpec {
29 pub deep: bool,
32 pub sample: Option<usize>,
36 pub timeout: Duration,
38 pub listen: Option<Duration>,
44}
45
46fn finding(
47 severity: DoctorSeverity,
48 check: CheckId,
49 subject: impl Into<String>,
50 evidence: impl Into<String>,
51 citation: Option<&str>,
52) -> DoctorFinding {
53 DoctorFinding {
54 severity,
55 check,
56 subject: subject.into(),
57 evidence: evidence.into(),
58 citation: citation.map(str::to_string),
59 }
60}
61
62pub async fn run_doctor(
75 fleet: &crate::Fleet<'_>,
76 locals: Option<&crate::model::registry::SliceSet>,
77 spec: &DoctorSpec,
78) -> Result<DoctorReport> {
79 let (session, base) = (fleet.session(), fleet.base());
80
81 let locals = locals.filter(|set| !set.slices().is_empty());
85 let roster = crate::bus::roster::roster(fleet, spec.timeout).await?;
86
87 let mut findings: Vec<DoctorFinding> = Vec::new();
88 let mut synced: Vec<String> = Vec::new();
89 let mut answered = 0usize;
90
91 for local in locals.iter().flat_map(|set| set.slices()) {
93 let key = crate::model::registry::rpc_key(base, local, "introspect")?;
94 let answers = fleet_get(fleet, &key, &GetOpts::new(spec.timeout)).await?;
95 for answer in &answers {
96 let Answer::Value(bytes) = &answer.answer else {
97 continue;
98 };
99 answered += 1;
100 let served_toml = bytes.to_bytes();
101 let served_toml = String::from_utf8_lossy(&served_toml);
102 let served = match zenkey::parse_slice(&served_toml) {
103 Ok(s) => s,
104 Err(e) => {
105 findings.push(finding(
106 DoctorSeverity::Error,
107 CheckId::SliceParse,
108 format!("{}/{}", answer.origin, local.name),
109 format!("served slice does not parse: {e}"),
110 Some("RFC 08 §6"),
111 ));
112 continue;
113 }
114 };
115 let diff = zenkey::slice::diff(&served, local);
116 if diff.is_empty() {
117 synced.push(format!(
118 "{}/{} (registry {})",
119 answer.origin, local.name, served.version
120 ));
121 } else {
122 for f in &diff {
123 findings.push(finding(
124 DoctorSeverity::Error,
125 CheckId::SliceSync,
126 format!("{}/{}", answer.origin, local.name),
127 f.summary(),
128 Some("RFC 08 §6"),
129 ));
130 }
131 }
132 }
133 }
134
135 let sweep = if locals.is_none() {
138 let repeating = RepeatingRegistry::declare(fleet, spec.timeout).await?;
139 let slices: Vec<RegistrySlice> = repeating
140 .fetch()
141 .await?
142 .into_iter()
143 .map(|(s, _)| s)
144 .collect();
145 repeating.undeclare().await?;
146 Some(slices)
147 } else {
148 None
149 };
150
151 if let Some(slices) = &sweep {
154 answered = slices.len();
155 }
156
157 let live: usize = roster.values().map(Vec::len).sum();
165 findings.extend(judge_introspect_coverage(
166 &roster,
167 locals.map(crate::model::registry::SliceSet::slices),
168 answered,
169 ));
170
171 let routers = crate::routers(session, spec.timeout)
173 .await
174 .unwrap_or_default();
175 let mut router_version = None;
176 if routers.is_empty() {
177 findings.push(finding(
178 DoctorSeverity::Info,
179 CheckId::AdminUnreachable,
180 "mesh",
181 "no routers answered @/*/router (peer-only mesh, or the admin space is \
182 disabled) — storage/version checks skipped",
183 None,
184 ));
185 } else {
186 let versions: std::collections::BTreeSet<&str> = routers
187 .iter()
188 .filter_map(|r| r.version.as_deref())
189 .collect();
190 if versions.len() > 1 {
191 findings.push(finding(
192 DoctorSeverity::Error,
193 CheckId::RouterVersionSkew,
194 "mesh",
195 format!("router version skew across the mesh: {versions:?}"),
196 None,
197 ));
198 } else {
199 router_version = versions.iter().next().map(|v| v.to_string());
200 }
201 }
202
203 let slice_set: std::borrow::Cow<'_, crate::model::registry::SliceSet> = match sweep {
207 Some(slices) => {
208 std::borrow::Cow::Owned(crate::model::registry::SliceSet::from_slices(slices))
209 }
210 None => match locals {
213 Some(set) => std::borrow::Cow::Borrowed(set),
214 None => std::borrow::Cow::Owned(crate::model::registry::SliceSet::default()),
215 },
216 };
217 let describes = crate::bus::describe::describe_sweep(fleet, &slice_set, spec.timeout).await?;
224 let described: Vec<(String, zenkey::schema::SchemaSet)> = describes.first_per_producer();
230 let undescribed = describes.undescribed.len();
231 for gap in crate::model::decode::totality_gaps(&described, &slice_set) {
234 findings.push(finding(
235 DoctorSeverity::Error,
236 CheckId::DescribeTotality,
237 gap.producer.clone(),
238 format!(
239 "describe is not total — missing: {}",
240 gap.missing.join(", ")
241 ),
242 Some("RFC 08 §7"),
243 ));
244 }
245 for drift in crate::model::decode::schema_drift(&describes.answers) {
246 let servers: Vec<String> = drift
247 .servers
248 .iter()
249 .map(|s| match s.hash.as_option() {
253 Some(h) => format!("{}@{} ({h})", s.producer, s.origin),
254 None => format!("{}@{} (no identity served)", s.producer, s.origin),
255 })
256 .collect();
257 let (severity, evidence) = match drift.verdict {
262 DriftVerdict::Disagree => (
263 DoctorSeverity::Error,
264 format!("served with different schemas by {}", servers.join(", ")),
265 ),
266 DriftVerdict::Unjudgeable => (
267 DoctorSeverity::Warning,
268 format!(
269 "agreement cannot be judged — {} served no schema identity: {} \
270 (RFC 09 §5.1 O4; the hash exists for exactly this, RFC 08 §7)",
271 drift
272 .servers
273 .iter()
274 .filter(|s| s.hash.is_not_asked())
275 .count(),
276 servers.join(", ")
277 ),
278 ),
279 };
280 findings.push(finding(
281 severity,
282 CheckId::SchemaDrift,
283 drift.type_name.clone(),
284 evidence,
285 Some("RFC 08 §7"),
286 ));
287 }
288 if undescribed > 0 {
289 findings.push(finding(
290 DoctorSeverity::Info,
291 CheckId::DescribeMissing,
292 "fleet",
293 format!(
294 "{undescribed} producer(s) serve no describe (a SHOULD; generic tools \
295 render their payloads structurally)"
296 ),
297 Some("RFC 08 §7"),
298 ));
299 }
300
301 if spec.deep {
303 let now = std::time::SystemTime::now();
304 let mut unstamped = 0usize;
305 for slice in slice_set.slices() {
306 for subject in &slice.subjects {
307 let (Some(ttl), true) = (subject.ttl_s, subject.class.is(&zenkey::Class::State))
308 else {
309 continue;
310 };
311 let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
312 continue;
313 };
314 let selector = match &slice.service_origin {
315 Some(origin) => with_base(
316 base,
317 format!("v1/{origin}/state/{}", pattern.selector_tail()),
318 ),
319 None => with_base(
320 base,
321 format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
322 ),
323 };
324 let samples = state_snapshot(session, &selector, spec.timeout, spec.sample).await?;
325 let (family_findings, family_unstamped) = judge_state_samples(&samples, ttl, now);
326 findings.extend(family_findings);
327 unstamped += family_unstamped;
328 }
329 }
330 for slice in slice_set.slices().iter().filter(|s| s.budget.is_some()) {
338 let selector = match &slice.service_origin {
339 Some(origin) => with_base(base, format!("v1/{origin}/state/health")),
340 None => with_base(base, format!("v1/*/state/{}/health", slice.name)),
341 };
342 let answers = fleet_get(fleet, &selector, &GetOpts::new(spec.timeout)).await?;
343 let read: Vec<(String, Option<crate::judge::self_stats::SelfStats>)> = answers
344 .iter()
345 .filter_map(|a| match &a.answer {
346 Answer::Value(bytes) => Some((
347 a.origin.clone(),
348 crate::model::decode::structural_value(&bytes.to_bytes())
349 .as_ref()
350 .and_then(crate::judge::self_stats::read_self_stats),
351 )),
352 Answer::Error { .. } => None,
353 })
354 .collect();
355 let asked = roster
356 .iter()
357 .filter(|(origin, producers)| match &slice.service_origin {
358 Some(service) => service.token() == origin.as_str(),
359 None => producers.iter().any(|p| {
360 zenkey::grammar::Producer::parse_chunk(p)
361 .map(|p| p.name() == slice.name)
362 .unwrap_or(p == &slice.name)
363 }),
364 })
365 .count();
366 findings.extend(crate::judge::self_stats::judge_self_stats(
367 slice, &read, asked,
368 ));
369 }
370 if unstamped > 0 {
371 findings.push(finding(
372 DoctorSeverity::Warning,
373 CheckId::UnstampedState,
374 "fleet",
375 format!(
376 "{unstamped} state sample(s) carry no HLC timestamp — the deployment \
377 lacks timestamping, which LWW requires; freshness is unjudgeable \
378 for them"
379 ),
380 Some("RFC 04 §4"),
381 ));
382 }
383 let storages = crate::storages(session, spec.timeout)
384 .await
385 .unwrap_or_default();
386 let coverage = crate::state_coverage(&slice_set, base, &storages);
387 let uncovered: Vec<&crate::CoverageRow> = coverage
388 .iter()
389 .filter(|r| r.coverage == crate::Coverage::Uncovered)
390 .collect();
391 if !uncovered.is_empty() {
392 findings.push(finding(
393 DoctorSeverity::Info,
394 CheckId::StorageCoverage,
395 "fleet",
396 format!(
397 "{} state famil(y|ies) have no storage coverage (volatile seeding \
398 may ride the advanced-pub/sub cache): {}",
399 uncovered.len(),
400 uncovered
401 .iter()
402 .map(|r| format!("{}/{}", r.producer, r.path))
403 .collect::<Vec<_>>()
404 .join(", ")
405 ),
406 Some("RFC 04 §3.5"),
407 ));
408 }
409 }
410
411 let observation = match spec.listen {
413 Some(window) => {
414 let store = crate::model::decode::SchemaStore::new(base, spec.timeout);
415 for (producer, set) in &described {
420 store.insert(producer, set.clone());
421 }
422 let _sealed = store.seal();
429 let (listen_findings, summary) =
430 observe_traffic(fleet, &slice_set, &store, &described, window).await?;
431 findings.extend(listen_findings);
432 Some(summary)
433 }
434 None => None,
435 };
436
437 Ok(DoctorReport {
438 findings,
439 synced: locals.is_some().then_some(synced).into(),
443 introspect_answered: answered,
444 live_producers: live,
445 describe_served: described.len(),
446 describe_missing: undescribed,
447 routers: routers.len(),
448 router_version,
449 deep: spec.deep,
450 observation,
451 })
452}
453
454const DECODE_BUDGET: u8 = 2;
457
458const SAME_FINDING: &str = "more key(s) with the same finding";
460
461fn emit_capped(
470 findings: &mut Vec<DoctorFinding>,
471 ex: Examples<DoctorFinding>,
472 check: CheckId,
473 tail: &str,
474) {
475 let more = ex.more(tail);
476
477 findings.extend(ex.into_vec());
478
479 if let Some(evidence) = more {
480 findings.push(finding(
481 DoctorSeverity::Info,
482 check,
483 "fleet",
484 evidence,
485 None,
486 ));
487 }
488}
489
490struct RateWindow {
493 cap: Option<u64>,
497 seen: u64,
499}
500
501async fn observe_traffic(
506 fleet: &crate::Fleet<'_>,
507 slices: &crate::model::registry::SliceSet,
508 store: &crate::model::decode::SchemaStore,
509 described: &[(String, zenkey::schema::SchemaSet)],
510 window: Duration,
511) -> Result<(Vec<DoctorFinding>, crate::report::ObservationSummary)> {
512 use std::collections::{BTreeMap, BTreeSet};
513
514 let (session, base) = (fleet.session(), fleet.base());
515
516 let scopes = crate::judge::common::data_plane_scopes(base, slices);
521
522 let mut liveliness = vec![
529 fleet.wire(zenkey::selector::all_liveliness(
530 zenkey::selector::Scope::fleet(),
531 )),
532 fleet.wire(zenkey::selector::service_alive(
533 &zenkey::ServiceOrigin::catalog(),
534 )),
535 ];
536 for slice in slices.slices() {
537 if let Some(origin) = slice.service_origin.as_ref().and_then(|o| o.known())
538 && *origin != zenkey::ServiceOrigin::catalog()
539 {
540 liveliness.push(fleet.wire(zenkey::selector::service_alive(origin)));
541 }
542 }
543 let monitor = crate::Monitor::start(
544 session,
545 crate::MonitorSpec {
546 liveliness,
547 ..Default::default()
548 },
549 )
550 .await?;
551 let mut events = monitor.events();
552 let monitor = monitor.watching(&scopes).await?;
555 let started = tokio::time::Instant::now();
556 let deadline = started + window;
557
558 let mut fields =
562 crate::judge::field::FieldObservation::new(crate::judge::field::DEFAULT_MAX_PATHS);
563 let mut samples: u64 = 0;
564 let mut dropped: u64 = 0;
565 let mut synthetic: u64 = 0;
566 let mut facts_cache = crate::model::facts::FactsCache::default();
569 let mut decode_budget: BTreeMap<String, u8> = BTreeMap::new();
570 let mut unregistered: BTreeMap<String, u64> = BTreeMap::new();
572 let mut qos_bad: BTreeMap<String, (String, u64, u64)> = BTreeMap::new();
573 let mut undecodable: BTreeMap<String, (String, u64)> = BTreeMap::new();
574 let mut invalid: BTreeMap<String, (String, u64)> = BTreeMap::new();
575 let mut event_counts: BTreeMap<(String, String), RateWindow> = BTreeMap::new();
578 let mut foreign_stampers: BTreeMap<String, u64> = BTreeMap::new();
580 let mut kinds = crate::judge::kind::KindObservation::new();
585 let mut alive_down: BTreeSet<(String, String)> = BTreeSet::new();
586
587 let window_over = tokio::time::sleep_until(deadline);
593 tokio::pin!(window_over);
594 loop {
595 let item = tokio::select! {
596 item = events.recv() => item,
597 () = &mut window_over => break,
598 };
599 match item {
600 Some(crate::StreamItem::Event(crate::FleetEvent::Sample(s))) => {
601 samples += 1;
602 if let Some(att) = &s.attachment
603 && is_synthetic_marker(&att.to_bytes())
604 {
605 synthetic += 1;
606 }
607 if let Some(crate::StampProvenance::Foreign { stamper }) = s.stamped_by {
612 *foreign_stampers.entry(stamper.to_string()).or_default() += 1;
613 }
614 let is_put = s.kind == zenoh::sample::SampleKind::Put;
622 let bytes = s.payload.to_bytes();
627 let mut doc = None;
631 if is_put {
632 if bytes.len() > crate::model::decode::OBSERVE_LIMIT {
633 fields.observe_unread(&s.key);
634 } else {
635 doc = crate::model::decode::structural_value(&bytes);
636 fields.observe(&s.key, started.elapsed().as_secs_f64(), doc.as_ref());
637 }
638 }
639 facts_cache.ensure(base, &s.key, Some(slices));
640 let facts = facts_cache.get(&s.key).expect("just ensured this key");
641 match &facts.registration {
642 crate::model::facts::Registration::Unregistered => {
643 *unregistered.entry(s.key.clone()).or_default() += 1;
644 }
645 crate::model::facts::Registration::Registered(sf) => {
646 if let (Some(profile), Some(declared)) = (sf.declared_qos(), &sf.qos) {
647 let entry = qos_bad
648 .entry(s.key.clone())
649 .or_insert_with(|| (declared.token().to_string(), 0, 0));
650 entry.2 += 1;
651 if !s.qos_matches(profile) {
652 entry.1 += 1;
653 }
654 }
655 if let (Some(rate), crate::model::facts::KeyShape::V1(v)) =
656 (&sf.rate, &facts.shape)
657 && v.class == "events"
658 {
659 let family = match &v.producer {
660 Some(p) => format!("{p}/{}", sf.path),
661 None => format!("{}/{}", v.origin, sf.path),
662 };
663 event_counts
669 .entry((family, rate.token()))
670 .or_insert_with(|| RateWindow {
671 cap: rate.cap_per_hour(),
672 seen: 0,
673 })
674 .seen += 1;
675 }
676 if let (Some(declared), crate::model::facts::KeyShape::V1(v)) =
680 (sf.kind.as_ref().and_then(|k| k.known()), &facts.shape)
681 && is_put
682 {
683 let producer = v
687 .producer
688 .clone()
689 .unwrap_or_else(|| v.origin.trim_start_matches('@').to_string());
690 kinds.observe(&s.key, &v.origin, &producer, *declared, doc.as_ref());
691 }
692 let budget = decode_budget.entry(s.key.clone()).or_default();
693 if is_put && *budget < DECODE_BUDGET {
694 *budget += 1;
695 let d = crate::model::decode::decode_sample(
703 fleet,
704 store,
705 Some(slices),
706 &s.key,
707 Some(&s.encoding),
708 &s.payload.to_bytes(),
709 )
710 .await;
711 match d.verdict {
712 crate::Verdict::NotValidated(
713 zenkey::schema::validate::NotValidated::Undecodable,
714 ) => {
715 let e = undecodable.entry(s.key.clone()).or_insert_with(|| {
716 (
717 d.decode_error
718 .unwrap_or_else(|| "does not decode".into()),
719 0,
720 )
721 });
722 e.1 += 1;
723 }
724 crate::Verdict::Invalid(errors) => {
725 let e = invalid
726 .entry(s.key.clone())
727 .or_insert_with(|| (errors.join("; "), 0));
728 e.1 += 1;
729 }
730 _ => {}
731 }
732 }
733 }
734 _ => {}
735 }
736 }
737 Some(crate::StreamItem::Dropped(n)) => dropped += n,
738 Some(crate::StreamItem::Event(crate::FleetEvent::NodeDown(key))) => {
739 if let Some(id) = crate::bus::roster::token_identity(base, &key) {
740 alive_down.insert(id);
741 }
742 }
743 Some(crate::StreamItem::Event(crate::FleetEvent::NodeUp(key))) => {
744 if let Some((origin, producer)) = crate::bus::roster::token_identity(base, &key)
748 && alive_down.remove(&(origin.clone(), producer.clone()))
749 {
750 kinds.alive_cycled(&origin, &producer);
751 }
752 }
753 Some(_) => continue,
754 None => break,
755 }
756 }
757 let keys_seen = facts_cache.len();
758 monitor.shutdown().await?;
759
760 let budgets =
764 crate::judge::budget::BudgetObservation::observe(base, slices, facts_cache.keys());
765
766 let window_s = window.as_secs_f64();
767 let mut findings = Vec::new();
768
769 let mut ex = Examples::new(FINDING_CAP);
770 for (key, (error, n)) in &undecodable {
771 ex.push_with(|| {
772 finding(
773 DoctorSeverity::Error,
774 CheckId::PayloadUndecodable,
775 key.clone(),
776 format!(
777 "payload does not decode as its declared type: {error} ({n} sample(s) tried)"
778 ),
779 Some("RFC 08 §7"),
780 )
781 });
782 }
783 emit_capped(&mut findings, ex, CheckId::PayloadUndecodable, SAME_FINDING);
784 let mut ex = Examples::new(FINDING_CAP);
785 for (key, (violations, n)) in &invalid {
786 ex.push_with(|| {
787 finding(
788 DoctorSeverity::Error,
789 CheckId::PayloadInvalid,
790 key.clone(),
791 format!("payload violates the served schema: {violations} ({n} sample(s) tried)"),
792 Some("RFC 08 §7"),
793 )
794 });
795 }
796 emit_capped(&mut findings, ex, CheckId::PayloadInvalid, SAME_FINDING);
797 findings.extend(judge_qos_observed(&qos_bad));
798 if !foreign_stampers.is_empty() {
799 let mut named: Vec<String> = foreign_stampers
800 .iter()
801 .map(|(zid, n)| format!("{zid} ({n} sample(s))"))
802 .collect();
803 named.sort();
804 findings.push(finding(
805 DoctorSeverity::Info,
806 CheckId::TimestampStampedElsewhere,
807 "fleet".to_string(),
808 format!(
809 "HLCs on this bus are stamped by {} node(s) that are not the publishing \
810 session — a deployment with router-side timestamping, which is legal and \
811 common. Latency measured from these stamps is stamper→observer, not \
812 publisher→observer: {}",
813 foreign_stampers.len(),
814 named.join(", ")
815 ),
816 Some("RFC 09 §5.1 O7"),
817 ));
818 }
819 let mut ex = Examples::new(FINDING_CAP);
820 for (key, n) in &unregistered {
821 ex.push_with(|| {
822 finding(
823 DoctorSeverity::Warning,
824 CheckId::UnregisteredTraffic,
825 key.clone(),
826 format!(
827 "{n} sample(s) on a subject the producer's slice does not declare — \
828 for a conforming producer, a subject that is not registered does not exist"
829 ),
830 Some("RFC 08 §2"),
831 )
832 });
833 }
834 emit_capped(
835 &mut findings,
836 ex,
837 CheckId::UnregisteredTraffic,
838 SAME_FINDING,
839 );
840 if window <= Duration::from_secs(3600) {
844 for ((family, rate), RateWindow { cap, seen: count }) in &event_counts {
845 let Some(cap) = cap else {
846 continue;
847 };
848 if count > cap {
849 findings.push(finding(
850 DoctorSeverity::Warning,
851 CheckId::RateOverDeclared,
852 family.clone(),
853 format!(
854 "{count} event(s) in {window_s:.0}s exceeds the declared \
855 `{rate}` cap ({cap}/h)"
856 ),
857 Some("RFC 04 §1.3"),
858 ));
859 }
860 }
861 }
862
863 findings.extend(judge_cardinality(slices, &budgets, window_s));
864 findings.extend(crate::judge::kind::judge_kind(&kinds, window_s));
865
866 let field_ctx = field_context_from(slices, described, &facts_cache);
870 findings.extend(crate::judge::field::judge_fields(
871 &fields, window_s, &field_ctx,
872 ));
873
874 Ok((
875 findings,
876 crate::report::ObservationSummary {
877 window_s,
878 scopes,
879 samples,
880 keys_seen,
881 dropped,
882 synthetic_marked: synthetic,
883 field_paths_dropped: fields.dropped_paths(),
886 facts_evicted: facts_cache.evicted(),
887 },
888 ))
889}
890
891fn field_context_from(
895 slices: &crate::model::registry::SliceSet,
896 described: &[(String, zenkey::schema::SchemaSet)],
897 facts: &crate::model::facts::FactsCache,
898) -> std::collections::BTreeMap<String, crate::judge::field::KeyFieldContext> {
899 use std::collections::BTreeMap;
900
901 let mut declared_cache: BTreeMap<(String, String), Option<crate::judge::field::DeclaredPaths>> =
902 BTreeMap::new();
903 let mut ctx = BTreeMap::new();
904 for (key, f) in facts.iter() {
905 let mut c = crate::judge::field::KeyFieldContext::default();
906 if let crate::model::facts::Registration::Registered(sf) = &f.registration {
907 c.ttl_s = sf.ttl_s;
908 c.type_name = Some(sf.type_name.clone());
909 if let Some(producer) = crate::judge::common::producer_of(f, Some(slices))
910 && !sf.type_name.is_empty()
911 {
912 let declared = declared_cache
913 .entry((producer.clone(), sf.type_name.clone()))
914 .or_insert_with(|| {
915 described
916 .iter()
917 .find(|(name, _)| *name == producer)
918 .and_then(|(_, set)| set.get(&sf.type_name))
919 .and_then(|schema| schema.json_document())
920 .and_then(crate::judge::field::DeclaredPaths::from_json_schema)
921 });
922 c.declared = declared.clone();
923 }
924 }
925 ctx.insert(key.to_string(), c);
926 }
927 ctx
928}
929
930fn judge_qos_observed(
940 qos_bad: &std::collections::BTreeMap<String, (String, u64, u64)>,
941) -> Vec<DoctorFinding> {
942 let mut findings = Vec::new();
943
944 let mut ex = Examples::new(FINDING_CAP);
945
946 for (key, (declared, bad, total)) in qos_bad.iter().filter(|(_, (_, bad, _))| *bad > 0) {
947 ex.push_with(|| {
948 finding(
949 DoctorSeverity::Warning,
950 CheckId::QosObservedMismatch,
951 key.clone(),
952 format!(
953 "{bad} of {total} sample(s) did not ride the declared {declared} — this \
954 is what actually rode: an interceptor MAY rewrite QoS, so it is a \
955 deviation, not proof of the publisher"
956 ),
957 Some("RFC 04 §3"),
958 )
959 });
960 }
961 emit_capped(
962 &mut findings,
963 ex,
964 CheckId::QosObservedMismatch,
965 SAME_FINDING,
966 );
967 findings
968}
969
970fn judge_introspect_coverage(
987 roster: &std::collections::BTreeMap<String, Vec<String>>,
988 locals: Option<&[RegistrySlice]>,
989 answered: usize,
990) -> Option<DoctorFinding> {
991 let live: usize = roster.values().map(Vec::len).sum();
992
993 let (in_scope, scope) = match locals {
994 None => (
995 live,
996 "scope: the whole roster (fleet-wide wildcard sweep)".to_string(),
997 ),
998 Some(locals) => {
999 let named = |origin: &str, producer: &str| {
1000 let base_name = zenkey::grammar::Producer::parse_chunk(producer)
1001 .map(|p| p.name().to_string())
1002 .unwrap_or_else(|_| producer.to_string());
1003 locals.iter().any(|l| {
1004 l.name == base_name
1005 || l.service_origin.as_ref().map(Declared::token) == Some(origin)
1006 })
1007 };
1008 let in_scope: usize = roster
1009 .iter()
1010 .map(|(origin, producers)| producers.iter().filter(|p| named(origin, p)).count())
1011 .sum();
1012 let mut names: Vec<&str> = locals.iter().map(|l| l.name.as_str()).collect();
1013 names.sort_unstable();
1014 names.dedup();
1015 let not_asked = live - in_scope;
1016 (
1017 in_scope,
1018 format!(
1019 "scope: the producer(s) the local registry names ({}); {} other \
1020 live producer(s) were not asked and are not counted (O4)",
1021 names.join(", "),
1022 not_asked
1023 ),
1024 )
1025 }
1026 };
1027 (answered < in_scope).then(|| {
1028 finding(
1029 DoctorSeverity::Error,
1030 CheckId::IntrospectCoverage,
1031 "fleet",
1032 format!(
1033 "{} of {} live producer(s) in scope did not answer introspect — \
1034 alive ⇒ callable, so this is a finding, not a boot race; {scope}",
1035 in_scope - answered,
1036 in_scope
1037 ),
1038 Some("RFC 04 §5"),
1039 )
1040 })
1041}
1042
1043fn judge_state_samples(
1048 samples: &[crate::StateSample],
1049 ttl: i64,
1050 now: std::time::SystemTime,
1051) -> (Vec<DoctorFinding>, usize) {
1052 let mut findings = Vec::new();
1053
1054 let mut unstamped = 0usize;
1055
1056 for sample in samples {
1057 match sample.timestamp {
1058 Some(ts) => {
1059 let stamped = ts.get_time().to_system_time();
1060 if let Ok(age) = now.duration_since(stamped)
1061 && age.as_secs() as i64 > ttl
1062 {
1063 findings.push(finding(
1064 DoctorSeverity::Error,
1065 CheckId::StaleState,
1066 sample.key.clone(),
1067 format!(
1068 "{}s old against ttl {ttl}s (refresh <= ttl/2)",
1069 age.as_secs()
1070 ),
1071 Some("RFC 04 §1.2"),
1072 ));
1073 }
1074 }
1075 None => unstamped += 1,
1076 }
1077 }
1078 (findings, unstamped)
1079}
1080
1081fn judge_cardinality(
1098 slices: &crate::model::registry::SliceSet,
1099 observed: &crate::judge::budget::BudgetObservation,
1100 window_s: f64,
1101) -> Vec<DoctorFinding> {
1102 let mut findings = Vec::new();
1103
1104 let mut over: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
1105
1106 for slice in slices.slices() {
1107 for s in &slice.subjects {
1108 if !s.path.contains('{') {
1109 continue; }
1111 if s.path.contains("...") {
1112 let seen: usize = observed
1113 .family(&slice.name, &s.path)
1114 .map(|origins| origins.values().map(|keys| keys.len()).sum())
1115 .unwrap_or(0);
1116 findings.push(finding(
1117 DoctorSeverity::Info,
1118 CheckId::CardinalityOverDeclared,
1119 format!("{}/{}", slice.name, s.path),
1120 format!(
1121 "exempt: rest-variable — a `{{var...}}` family is unbounded by \
1122 construction, so its declared cardinality is not a bound this \
1123 check can pass or fail; {seen} distinct key(s) observed in \
1124 {window_s:.0}s"
1125 ),
1126 Some("RFC 08 §6.1"),
1127 ));
1128 continue;
1129 }
1130 let Some(declared) = s.cardinality else {
1131 continue; };
1134 let Some(origins) = observed.family(&slice.name, &s.path) else {
1135 continue; };
1137 for (origin, keys) in origins {
1138 if keys.len() as i64 <= declared {
1139 continue; }
1141 let examples = Examples::collect(
1142 crate::judge::common::EXPANSION_CAP,
1143 keys.iter().map(String::as_str),
1144 );
1145 let subject = if origin.starts_with('@') {
1146 format!("{origin}/{}", s.path)
1147 } else {
1148 format!("{origin}/{}/{}", slice.name, s.path)
1149 };
1150 over.push_with(|| {
1151 finding(
1152 DoctorSeverity::Warning,
1153 CheckId::CardinalityOverDeclared,
1154 subject,
1155 format!(
1156 "{} distinct key(s) observed in {window_s:.0}s exceed the \
1157 declared cardinality {declared} — e.g. {}. A bounded window \
1158 observes a lower bound: the population is at least this",
1159 keys.len(),
1160 examples.as_slice().join(", ")
1161 ),
1162 Some("RFC 04 §1.2"),
1163 )
1164 });
1165 }
1166 }
1167 }
1168 emit_capped(
1169 &mut findings,
1170 over,
1171 CheckId::CardinalityOverDeclared,
1172 "more origin famil(y|ies) over their declared cardinality",
1173 );
1174 findings
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180
1181 const BOUNDED: &str = r#"
1182 [registry]
1183 version = "1.0"
1184 app = "t"
1185 convention = 1
1186 [producer]
1187 name = "sysinfo"
1188 [[subject]]
1189 path = "disk/{mount}/used"
1190 class = "telemetry"
1191 type = "Point"
1192 cardinality = 16
1193 "#;
1194
1195 #[test]
1199 fn cardinality_over_declared_fires_with_count_and_examples() {
1200 let slices = crate::model::registry::SliceSet::from_toml_for_tests(BOUNDED);
1201 let keys: Vec<String> = (0..40)
1202 .map(|i| format!("v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/m{i:02}/used"))
1203 .collect();
1204 let obs = crate::judge::budget::BudgetObservation::observe(
1205 "",
1206 &slices,
1207 keys.iter().map(String::as_str),
1208 );
1209 let findings = judge_cardinality(&slices, &obs, 10.0);
1210 assert_eq!(findings.len(), 1, "{findings:?}");
1211 let f = &findings[0];
1212 assert_eq!(f.check, CheckId::CardinalityOverDeclared);
1213 assert_eq!(f.severity, DoctorSeverity::Warning);
1214 assert_eq!(f.subject, "h-aaaaaaaaaaaa/sysinfo/disk/{mount}/used");
1215 assert!(f.evidence.contains("40 distinct key(s)"), "{}", f.evidence);
1216 assert!(f.evidence.contains("cardinality 16"), "{}", f.evidence);
1217 assert!(f.evidence.contains("10s"), "the window is stated");
1218 assert!(
1219 f.evidence.contains("disk/m00/used"),
1220 "examples are named: {}",
1221 f.evidence
1222 );
1223 }
1224
1225 #[test]
1229 fn cardinality_under_declared_is_not_a_finding() {
1230 let slices = crate::model::registry::SliceSet::from_toml_for_tests(BOUNDED);
1231 let keys = [
1232 "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/root/used",
1233 "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/var/used",
1234 "v1/h-bbbbbbbbbbbb/telemetry/sysinfo/disk/root/used",
1236 ];
1237 let obs = crate::judge::budget::BudgetObservation::observe("", &slices, keys);
1238 assert!(judge_cardinality(&slices, &obs, 5.0).is_empty());
1239 }
1240
1241 #[test]
1245 fn rest_variable_families_are_exempt_and_say_so() {
1246 let toml = r#"
1247 [registry]
1248 version = "1.0"
1249 app = "t"
1250 convention = 1
1251 [producer]
1252 name = "gnmi"
1253 [[subject]]
1254 path = "{device}/{path...}"
1255 class = "telemetry"
1256 type = "Point"
1257 cardinality = 2
1258 "#;
1259 let slices = crate::model::registry::SliceSet::from_toml_for_tests(toml);
1260 let keys: Vec<String> = (0..5)
1261 .map(|i| format!("v1/h-aaaaaaaaaaaa/telemetry/gnmi/sw1/if/eth{i}/rx"))
1262 .collect();
1263 let obs = crate::judge::budget::BudgetObservation::observe(
1264 "",
1265 &slices,
1266 keys.iter().map(String::as_str),
1267 );
1268 let findings = judge_cardinality(&slices, &obs, 5.0);
1269 assert_eq!(findings.len(), 1, "{findings:?}");
1270 let f = &findings[0];
1271 assert_eq!(f.severity, DoctorSeverity::Info, "an exemption, not a pass");
1272 assert!(
1273 f.evidence.starts_with("exempt: rest-variable"),
1274 "{}",
1275 f.evidence
1276 );
1277 assert!(f.evidence.contains("5 distinct key(s)"), "{}", f.evidence);
1278 let quiet = judge_cardinality(&slices, &Default::default(), 5.0);
1281 assert_eq!(quiet.len(), 1);
1282 assert!(quiet[0].evidence.starts_with("exempt: rest-variable"));
1283 }
1284
1285 #[test]
1292 fn qos_mismatch_cap_bounds_violators_not_map_entries() {
1293 let mut qos_bad: std::collections::BTreeMap<String, (String, u64, u64)> =
1294 std::collections::BTreeMap::new();
1295 for i in 0..30u32 {
1296 let bad = if i < 5 { 0 } else { 1 };
1298 qos_bad.insert(
1299 format!("v1/h-a/telemetry/x/k{i:02}"),
1300 ("tel".into(), bad, 10),
1301 );
1302 }
1303 let findings = judge_qos_observed(&qos_bad);
1304 let per_key: Vec<&DoctorFinding> = findings
1305 .iter()
1306 .filter(|f| f.severity == DoctorSeverity::Warning)
1307 .collect();
1308 assert_eq!(per_key.len(), FINDING_CAP, "the cap bounds the findings");
1309 assert!(
1310 per_key.iter().all(|f| f.evidence.starts_with("1 of 10")),
1311 "only violators become findings: {findings:#?}"
1312 );
1313 assert!(
1314 per_key.iter().any(|f| f.subject.ends_with("k24")),
1315 "violators past the first {FINDING_CAP} map entries (k20..k24) are \
1316 not dropped: {findings:#?}"
1317 );
1318 let note = findings
1319 .iter()
1320 .find(|f| f.severity == DoctorSeverity::Info)
1321 .expect("a remainder note");
1322 assert_eq!(
1323 note.evidence, "… and 5 more key(s) with the same finding",
1324 "the note counts violators (25 − 20), not map entries"
1325 );
1326
1327 let few: std::collections::BTreeMap<String, (String, u64, u64)> = qos_bad
1329 .iter()
1330 .take(10)
1331 .map(|(k, v)| (k.clone(), v.clone()))
1332 .collect();
1333 let findings = judge_qos_observed(&few);
1334 assert_eq!(findings.len(), 5, "{findings:#?}");
1335 assert!(
1336 findings
1337 .iter()
1338 .all(|f| f.severity == DoctorSeverity::Warning)
1339 );
1340 }
1341
1342 fn roster_of(entries: &[(&str, &[&str])]) -> std::collections::BTreeMap<String, Vec<String>> {
1343 entries
1344 .iter()
1345 .map(|(origin, producers)| {
1346 (
1347 origin.to_string(),
1348 producers.iter().map(|p| p.to_string()).collect(),
1349 )
1350 })
1351 .collect()
1352 }
1353
1354 fn slice_named(name: &str) -> RegistrySlice {
1355 zenkey::parse_slice(&format!(
1356 "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n\
1357 [producer]\nname = \"{name}\"\n"
1358 ))
1359 .expect("fixture slice parses")
1360 }
1361
1362 #[test]
1367 fn a_partial_registry_does_not_count_unasked_producers_against_coverage() {
1368 let roster = roster_of(&[("h-aaaaaaaaaaaa", &["sysinfo", "extra"])]);
1369 let locals = [slice_named("sysinfo")];
1370 assert_eq!(
1371 judge_introspect_coverage(&roster, Some(&locals), 1),
1372 None,
1373 "the un-asked producer is out of scope, not silent"
1374 );
1375 }
1376
1377 #[test]
1382 fn introspect_coverage_evidence_states_its_scope() {
1383 let roster = roster_of(&[
1384 ("h-aaaaaaaaaaaa", &["sysinfo", "extra"]),
1385 ("h-bbbbbbbbbbbb", &["sysinfo-2"]),
1386 ]);
1387 let locals = [slice_named("sysinfo")];
1388 let f = judge_introspect_coverage(&roster, Some(&locals), 1).expect("a finding");
1389 assert_eq!(f.check, CheckId::IntrospectCoverage);
1390 assert!(f.evidence.contains("1 of 2"), "{}", f.evidence);
1391 assert!(
1392 f.evidence.contains("the local registry names (sysinfo)"),
1393 "{}",
1394 f.evidence
1395 );
1396 assert!(
1397 f.evidence
1398 .contains("1 other live producer(s) were not asked"),
1399 "{}",
1400 f.evidence
1401 );
1402 }
1403
1404 #[test]
1407 fn a_service_slice_scopes_its_origin_into_coverage() {
1408 let roster = roster_of(&[("@catalog", &["catalog"]), ("h-aaaaaaaaaaaa", &["extra"])]);
1409 let locals = [zenkey::parse_slice(
1410 "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n\
1411 [service]\nname = \"catalog\"\norigin = \"@catalog\"\n",
1412 )
1413 .expect("service slice parses")];
1414 assert_eq!(judge_introspect_coverage(&roster, Some(&locals), 1), None);
1415 let f = judge_introspect_coverage(&roster, Some(&locals), 0).expect("a finding");
1416 assert!(f.evidence.contains("1 of 1"), "{}", f.evidence);
1417 }
1418
1419 #[test]
1421 fn the_wildcard_sweep_judges_the_whole_roster() {
1422 let roster = roster_of(&[("h-aaaaaaaaaaaa", &["sysinfo", "extra"])]);
1423 let f = judge_introspect_coverage(&roster, None, 1).expect("a finding");
1424 assert!(f.evidence.contains("1 of 2"), "{}", f.evidence);
1425 assert!(f.evidence.contains("whole roster"), "{}", f.evidence);
1426 assert_eq!(judge_introspect_coverage(&roster, None, 2), None);
1427 }
1428
1429 #[test]
1430 fn freshness_judgement_is_pure_and_ttl_bound() {
1431 let now = std::time::SystemTime::now();
1432 let fresh_ts = zenoh::time::Timestamp::new(
1433 zenoh::time::NTP64::from(now.duration_since(std::time::UNIX_EPOCH).unwrap()),
1434 zenoh::time::TimestampId::rand(),
1435 );
1436 let stale_ts = zenoh::time::Timestamp::new(
1437 zenoh::time::NTP64::from(
1438 now.duration_since(std::time::UNIX_EPOCH).unwrap() - Duration::from_secs(120),
1439 ),
1440 zenoh::time::TimestampId::rand(),
1441 );
1442 let samples = vec![
1443 crate::StateSample {
1444 key: "b/v1/h-aaaaaaaaaaaa/state/p/health".into(),
1445 timestamp: Some(fresh_ts),
1446 payload_len: 2,
1447 },
1448 crate::StateSample {
1449 key: "b/v1/h-bbbbbbbbbbbb/state/p/health".into(),
1450 timestamp: Some(stale_ts),
1451 payload_len: 2,
1452 },
1453 crate::StateSample {
1454 key: "b/v1/h-cccccccccccc/state/p/health".into(),
1455 timestamp: None,
1456 payload_len: 2,
1457 },
1458 ];
1459 let (findings, unstamped) = judge_state_samples(&samples, 30, now);
1460 assert_eq!(
1461 findings.len(),
1462 1,
1463 "only the stale stamped sample is a finding"
1464 );
1465 assert_eq!(findings[0].check, CheckId::StaleState);
1466 assert!(findings[0].subject.contains("h-bbbbbbbbbbbb"));
1467 assert_eq!(unstamped, 1, "the unstamped sample is counted, not judged");
1468 }
1469}