1use std::time::Duration;
12
13use crate::{Error, 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
22#[derive(Debug, Clone)]
24pub struct DoctorSpec {
25 pub deep: bool,
28 pub sample: Option<usize>,
32 pub timeout: Duration,
34 pub listen: Option<Duration>,
40}
41
42fn finding(
43 severity: DoctorSeverity,
44 check: CheckId,
45 subject: impl Into<String>,
46 evidence: impl Into<String>,
47 citation: Option<&str>,
48) -> DoctorFinding {
49 DoctorFinding {
50 severity,
51 check,
52 subject: subject.into(),
53 evidence: evidence.into(),
54 citation: citation.map(str::to_string),
55 }
56}
57
58fn rpc_key(base: &str, slice: &RegistrySlice, procedure: &str) -> Result<String> {
62 Ok(match &slice.service_origin {
63 Some(origin) => {
64 let o = origin.known().ok_or_else(|| {
70 Error::malformed(
71 format!("slice {}", slice.name),
72 format!("carries {:?} as a service origin", origin.token()),
73 )
74 })?;
75 with_base(base, zenkey::selector::service_rpc(o, &[procedure]))
76 }
77 None => with_base(base, zenkey::selector::fleet_rpc(&slice.name, &[procedure])),
78 })
79}
80
81pub async fn run_doctor(
94 fleet: &crate::Fleet<'_>,
95 locals: Option<&crate::model::registry::SliceSet>,
96 spec: &DoctorSpec,
97) -> Result<DoctorReport> {
98 let (session, base) = (fleet.session(), fleet.base());
99
100 let locals = locals.filter(|set| !set.slices().is_empty());
104 let roster = crate::bus::roster::roster(fleet, spec.timeout).await?;
105
106 let mut findings: Vec<DoctorFinding> = Vec::new();
107 let mut synced: Vec<String> = Vec::new();
108 let mut answered = 0usize;
109
110 for local in locals.iter().flat_map(|set| set.slices()) {
112 let key = rpc_key(base, local, "introspect")?;
113 let answers = fleet_get(fleet, &key, &GetOpts::new(spec.timeout)).await?;
114 for answer in &answers {
115 let Answer::Value(bytes) = &answer.answer else {
116 continue;
117 };
118 answered += 1;
119 let served_toml = bytes.to_bytes();
120 let served_toml = String::from_utf8_lossy(&served_toml);
121 let served = match zenkey::parse_slice(&served_toml) {
122 Ok(s) => s,
123 Err(e) => {
124 findings.push(finding(
125 DoctorSeverity::Error,
126 CheckId::SliceParse,
127 format!("{}/{}", answer.origin, local.name),
128 format!("served slice does not parse: {e}"),
129 Some("RFC 08 §6"),
130 ));
131 continue;
132 }
133 };
134 let diff = zenkey::slice::diff(&served, local);
135 if diff.is_empty() {
136 synced.push(format!(
137 "{}/{} (registry {})",
138 answer.origin, local.name, served.version
139 ));
140 } else {
141 for f in &diff {
142 findings.push(finding(
143 DoctorSeverity::Error,
144 CheckId::SliceSync,
145 format!("{}/{}", answer.origin, local.name),
146 f.summary(),
147 Some("RFC 08 §6"),
148 ));
149 }
150 }
151 }
152 }
153
154 let sweep = if locals.is_none() {
157 let repeating = RepeatingRegistry::declare(fleet, spec.timeout).await?;
158 let slices: Vec<RegistrySlice> = repeating
159 .fetch()
160 .await?
161 .into_iter()
162 .map(|(s, _)| s)
163 .collect();
164 repeating.undeclare().await?;
165 Some(slices)
166 } else {
167 None
168 };
169
170 if let Some(slices) = &sweep {
173 answered = slices.len();
174 }
175
176 let live: usize = roster.values().map(Vec::len).sum();
184 findings.extend(judge_introspect_coverage(
185 &roster,
186 locals.map(crate::model::registry::SliceSet::slices),
187 answered,
188 ));
189
190 let routers = crate::routers(session, spec.timeout)
192 .await
193 .unwrap_or_default();
194 let mut router_version = None;
195 if routers.is_empty() {
196 findings.push(finding(
197 DoctorSeverity::Info,
198 CheckId::AdminUnreachable,
199 "mesh",
200 "no routers answered @/*/router (peer-only mesh, or the admin space is \
201 disabled) — storage/version checks skipped",
202 None,
203 ));
204 } else {
205 let versions: std::collections::BTreeSet<&str> = routers
206 .iter()
207 .filter_map(|r| r.version.as_deref())
208 .collect();
209 if versions.len() > 1 {
210 findings.push(finding(
211 DoctorSeverity::Error,
212 CheckId::RouterVersionSkew,
213 "mesh",
214 format!("router version skew across the mesh: {versions:?}"),
215 None,
216 ));
217 } else {
218 router_version = versions.iter().next().map(|v| v.to_string());
219 }
220 }
221
222 let slice_set: std::borrow::Cow<'_, crate::model::registry::SliceSet> = match sweep {
226 Some(slices) => {
227 std::borrow::Cow::Owned(crate::model::registry::SliceSet::from_slices(slices))
228 }
229 None => match locals {
232 Some(set) => std::borrow::Cow::Borrowed(set),
233 None => std::borrow::Cow::Owned(crate::model::registry::SliceSet::default()),
234 },
235 };
236 let mut described: Vec<(String, zenkey::schema::SchemaSet)> = Vec::new();
242 let mut described_by_origin: Vec<crate::model::decode::DescribedSchema> = Vec::new();
247 let mut undescribed = 0usize;
248 let describe_opts = GetOpts::new(spec.timeout);
252 for slice in slice_set.slices() {
253 let key = rpc_key(base, slice, "describe")?;
254 let answers = fleet_get(fleet, &key, &describe_opts).await?;
255 let before = described_by_origin.len();
256 for a in answers {
257 let origin = a.origin;
258 let Answer::Value(bytes) = a.answer else {
259 continue;
260 };
261 let cow = bytes.to_bytes();
262 let Some(set) = std::str::from_utf8(&cow)
263 .ok()
264 .and_then(|t| zenkey::schema::SchemaSet::parse(t).ok())
265 else {
266 continue;
267 };
268 if described_by_origin.len() == before {
269 described.push((slice.name.clone(), set.clone()));
270 }
271 described_by_origin.push(crate::model::decode::DescribedSchema {
272 origin,
273 producer: slice.name.clone(),
274 set,
275 });
276 }
277 if described_by_origin.len() == before {
281 undescribed += 1;
282 }
283 }
284 for gap in crate::model::decode::totality_gaps(&described, &slice_set) {
287 findings.push(finding(
288 DoctorSeverity::Error,
289 CheckId::DescribeTotality,
290 gap.producer.clone(),
291 format!(
292 "describe is not total — missing: {}",
293 gap.missing.join(", ")
294 ),
295 Some("RFC 08 §7"),
296 ));
297 }
298 for drift in crate::model::decode::schema_drift(&described_by_origin) {
299 let servers: Vec<String> = drift
300 .servers
301 .iter()
302 .map(|s| match s.hash.as_option() {
306 Some(h) => format!("{}@{} ({h})", s.producer, s.origin),
307 None => format!("{}@{} (no identity served)", s.producer, s.origin),
308 })
309 .collect();
310 let (severity, evidence) = match drift.verdict {
315 DriftVerdict::Disagree => (
316 DoctorSeverity::Error,
317 format!("served with different schemas by {}", servers.join(", ")),
318 ),
319 DriftVerdict::Unjudgeable => (
320 DoctorSeverity::Warning,
321 format!(
322 "agreement cannot be judged — {} served no schema identity: {} \
323 (RFC 09 §5.1 O4; the hash exists for exactly this, RFC 08 §7)",
324 drift
325 .servers
326 .iter()
327 .filter(|s| s.hash.is_not_asked())
328 .count(),
329 servers.join(", ")
330 ),
331 ),
332 };
333 findings.push(finding(
334 severity,
335 CheckId::SchemaDrift,
336 drift.type_name.clone(),
337 evidence,
338 Some("RFC 08 §7"),
339 ));
340 }
341 if undescribed > 0 {
342 findings.push(finding(
343 DoctorSeverity::Info,
344 CheckId::DescribeMissing,
345 "fleet",
346 format!(
347 "{undescribed} producer(s) serve no describe (a SHOULD; generic tools \
348 render their payloads structurally)"
349 ),
350 Some("RFC 08 §7"),
351 ));
352 }
353
354 if spec.deep {
356 let now = std::time::SystemTime::now();
357 let mut unstamped = 0usize;
358 for slice in slice_set.slices() {
359 for subject in &slice.subjects {
360 let (Some(ttl), true) = (subject.ttl_s, subject.class.is(&zenkey::Class::State))
361 else {
362 continue;
363 };
364 let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
365 continue;
366 };
367 let selector = match &slice.service_origin {
368 Some(origin) => with_base(
369 base,
370 format!("v1/{origin}/state/{}", pattern.selector_tail()),
371 ),
372 None => with_base(
373 base,
374 format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
375 ),
376 };
377 let samples = state_snapshot(session, &selector, spec.timeout, spec.sample).await?;
378 let (family_findings, family_unstamped) = judge_state_samples(&samples, ttl, now);
379 findings.extend(family_findings);
380 unstamped += family_unstamped;
381 }
382 }
383 if unstamped > 0 {
384 findings.push(finding(
385 DoctorSeverity::Warning,
386 CheckId::UnstampedState,
387 "fleet",
388 format!(
389 "{unstamped} state sample(s) carry no HLC timestamp — the deployment \
390 lacks timestamping, which LWW requires; freshness is unjudgeable \
391 for them"
392 ),
393 Some("RFC 04 §4"),
394 ));
395 }
396 let storages = crate::storages(session, spec.timeout)
397 .await
398 .unwrap_or_default();
399 let coverage = crate::state_coverage(&slice_set, base, &storages);
400 let uncovered: Vec<&crate::CoverageRow> = coverage
401 .iter()
402 .filter(|r| r.coverage == crate::Coverage::Uncovered)
403 .collect();
404 if !uncovered.is_empty() {
405 findings.push(finding(
406 DoctorSeverity::Info,
407 CheckId::StorageCoverage,
408 "fleet",
409 format!(
410 "{} state famil(y|ies) have no storage coverage (volatile seeding \
411 may ride the advanced-pub/sub cache): {}",
412 uncovered.len(),
413 uncovered
414 .iter()
415 .map(|r| format!("{}/{}", r.producer, r.path))
416 .collect::<Vec<_>>()
417 .join(", ")
418 ),
419 Some("RFC 04 §3.5"),
420 ));
421 }
422 }
423
424 let observation = match spec.listen {
426 Some(window) => {
427 let store = crate::model::decode::SchemaStore::new(base, spec.timeout);
428 for (producer, set) in &described {
433 store.insert(producer, set.clone());
434 }
435 let _sealed = store.seal();
442 let (listen_findings, summary) =
443 observe_traffic(fleet, &slice_set, &store, &described, window).await?;
444 findings.extend(listen_findings);
445 Some(summary)
446 }
447 None => None,
448 };
449
450 Ok(DoctorReport {
451 findings,
452 synced: locals.is_some().then_some(synced).into(),
456 introspect_answered: answered,
457 live_producers: live,
458 describe_served: described.len(),
459 describe_missing: undescribed,
460 routers: routers.len(),
461 router_version,
462 deep: spec.deep,
463 observation,
464 })
465}
466
467const DECODE_BUDGET: u8 = 2;
470
471const SAME_FINDING: &str = "more key(s) with the same finding";
473
474fn emit_capped(
483 findings: &mut Vec<DoctorFinding>,
484 ex: Examples<DoctorFinding>,
485 check: CheckId,
486 tail: &str,
487) {
488 let more = ex.more(tail);
489
490 findings.extend(ex.into_vec());
491
492 if let Some(evidence) = more {
493 findings.push(finding(
494 DoctorSeverity::Info,
495 check,
496 "fleet",
497 evidence,
498 None,
499 ));
500 }
501}
502
503struct RateWindow {
506 cap: Option<u64>,
510 seen: u64,
512}
513
514async fn observe_traffic(
519 fleet: &crate::Fleet<'_>,
520 slices: &crate::model::registry::SliceSet,
521 store: &crate::model::decode::SchemaStore,
522 described: &[(String, zenkey::schema::SchemaSet)],
523 window: Duration,
524) -> Result<(Vec<DoctorFinding>, crate::report::ObservationSummary)> {
525 use std::collections::BTreeMap;
526
527 let (session, base) = (fleet.session(), fleet.base());
528
529 let scopes = crate::judge::common::data_plane_scopes(base, slices);
534
535 let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
536 let mut events = monitor.events();
537 let monitor = monitor.watching(&scopes).await?;
540 let started = tokio::time::Instant::now();
541 let deadline = started + window;
542
543 let mut fields =
547 crate::judge::field::FieldObservation::new(crate::judge::field::DEFAULT_MAX_PATHS);
548 let mut samples: u64 = 0;
549 let mut dropped: u64 = 0;
550 let mut synthetic: u64 = 0;
551 let mut facts_cache = crate::model::facts::FactsCache::default();
554 let mut decode_budget: BTreeMap<String, u8> = BTreeMap::new();
555 let mut unregistered: BTreeMap<String, u64> = BTreeMap::new();
557 let mut qos_bad: BTreeMap<String, (String, u64, u64)> = BTreeMap::new();
558 let mut undecodable: BTreeMap<String, (String, u64)> = BTreeMap::new();
559 let mut invalid: BTreeMap<String, (String, u64)> = BTreeMap::new();
560 let mut event_counts: BTreeMap<(String, String), RateWindow> = BTreeMap::new();
563 let mut foreign_stampers: BTreeMap<String, u64> = BTreeMap::new();
565
566 let window_over = tokio::time::sleep_until(deadline);
572 tokio::pin!(window_over);
573 loop {
574 let item = tokio::select! {
575 item = events.recv() => item,
576 () = &mut window_over => break,
577 };
578 match item {
579 Some(crate::StreamItem::Event(crate::FleetEvent::Sample(s))) => {
580 samples += 1;
581 if let Some(att) = &s.attachment
582 && is_synthetic_marker(&att.to_bytes())
583 {
584 synthetic += 1;
585 }
586 if let Some(crate::StampProvenance::Foreign { stamper }) = s.stamped_by {
591 *foreign_stampers.entry(stamper.to_string()).or_default() += 1;
592 }
593 let is_put = s.kind == zenoh::sample::SampleKind::Put;
601 let bytes = s.payload.to_bytes();
606 if is_put {
607 if bytes.len() > crate::model::decode::OBSERVE_LIMIT {
608 fields.observe_unread(&s.key);
609 } else {
610 let doc = crate::model::decode::structural_value(&bytes);
611 fields.observe(&s.key, started.elapsed().as_secs_f64(), doc.as_ref());
612 }
613 }
614 facts_cache.ensure(base, &s.key, Some(slices));
615 let facts = facts_cache.get(&s.key).expect("just ensured this key");
616 match &facts.registration {
617 crate::model::facts::Registration::Unregistered => {
618 *unregistered.entry(s.key.clone()).or_default() += 1;
619 }
620 crate::model::facts::Registration::Registered(sf) => {
621 if let (Some(profile), Some(declared)) = (sf.declared_qos(), &sf.qos) {
622 let entry = qos_bad
623 .entry(s.key.clone())
624 .or_insert_with(|| (declared.token().to_string(), 0, 0));
625 entry.2 += 1;
626 if !s.qos_matches(profile) {
627 entry.1 += 1;
628 }
629 }
630 if let (Some(rate), crate::model::facts::KeyShape::V1(v)) =
631 (&sf.rate, &facts.shape)
632 && v.class == "events"
633 {
634 let family = match &v.producer {
635 Some(p) => format!("{p}/{}", sf.path),
636 None => format!("{}/{}", v.origin, sf.path),
637 };
638 event_counts
644 .entry((family, rate.token()))
645 .or_insert_with(|| RateWindow {
646 cap: rate.cap_per_hour(),
647 seen: 0,
648 })
649 .seen += 1;
650 }
651 let budget = decode_budget.entry(s.key.clone()).or_default();
652 if is_put && *budget < DECODE_BUDGET {
653 *budget += 1;
654 let d = crate::model::decode::decode_sample(
662 fleet,
663 store,
664 Some(slices),
665 &s.key,
666 Some(&s.encoding),
667 &s.payload.to_bytes(),
668 )
669 .await;
670 match d.verdict {
671 crate::Verdict::NotValidated(
672 zenkey::schema::validate::NotValidated::Undecodable,
673 ) => {
674 let e = undecodable.entry(s.key.clone()).or_insert_with(|| {
675 (
676 d.decode_error
677 .unwrap_or_else(|| "does not decode".into()),
678 0,
679 )
680 });
681 e.1 += 1;
682 }
683 crate::Verdict::Invalid(errors) => {
684 let e = invalid
685 .entry(s.key.clone())
686 .or_insert_with(|| (errors.join("; "), 0));
687 e.1 += 1;
688 }
689 _ => {}
690 }
691 }
692 }
693 _ => {}
694 }
695 }
696 Some(crate::StreamItem::Dropped(n)) => dropped += n,
697 Some(_) => continue,
698 None => break,
699 }
700 }
701 let keys_seen = facts_cache.len();
702 monitor.shutdown().await?;
703
704 let budgets =
708 crate::judge::budget::BudgetObservation::observe(base, slices, facts_cache.keys());
709
710 let window_s = window.as_secs_f64();
711 let mut findings = Vec::new();
712
713 let mut ex = Examples::new(FINDING_CAP);
714 for (key, (error, n)) in &undecodable {
715 ex.push_with(|| {
716 finding(
717 DoctorSeverity::Error,
718 CheckId::PayloadUndecodable,
719 key.clone(),
720 format!(
721 "payload does not decode as its declared type: {error} ({n} sample(s) tried)"
722 ),
723 Some("RFC 08 §7"),
724 )
725 });
726 }
727 emit_capped(&mut findings, ex, CheckId::PayloadUndecodable, SAME_FINDING);
728 let mut ex = Examples::new(FINDING_CAP);
729 for (key, (violations, n)) in &invalid {
730 ex.push_with(|| {
731 finding(
732 DoctorSeverity::Error,
733 CheckId::PayloadInvalid,
734 key.clone(),
735 format!("payload violates the served schema: {violations} ({n} sample(s) tried)"),
736 Some("RFC 08 §7"),
737 )
738 });
739 }
740 emit_capped(&mut findings, ex, CheckId::PayloadInvalid, SAME_FINDING);
741 findings.extend(judge_qos_observed(&qos_bad));
742 if !foreign_stampers.is_empty() {
743 let mut named: Vec<String> = foreign_stampers
744 .iter()
745 .map(|(zid, n)| format!("{zid} ({n} sample(s))"))
746 .collect();
747 named.sort();
748 findings.push(finding(
749 DoctorSeverity::Info,
750 CheckId::TimestampStampedElsewhere,
751 "fleet".to_string(),
752 format!(
753 "HLCs on this bus are stamped by {} node(s) that are not the publishing \
754 session — a deployment with router-side timestamping, which is legal and \
755 common. Latency measured from these stamps is stamper→observer, not \
756 publisher→observer: {}",
757 foreign_stampers.len(),
758 named.join(", ")
759 ),
760 Some("RFC 09 §5.1 O7"),
761 ));
762 }
763 let mut ex = Examples::new(FINDING_CAP);
764 for (key, n) in &unregistered {
765 ex.push_with(|| {
766 finding(
767 DoctorSeverity::Warning,
768 CheckId::UnregisteredTraffic,
769 key.clone(),
770 format!(
771 "{n} sample(s) on a subject the producer's slice does not declare — \
772 for a conforming producer, a subject that is not registered does not exist"
773 ),
774 Some("RFC 08 §2"),
775 )
776 });
777 }
778 emit_capped(
779 &mut findings,
780 ex,
781 CheckId::UnregisteredTraffic,
782 SAME_FINDING,
783 );
784 if window <= Duration::from_secs(3600) {
788 for ((family, rate), RateWindow { cap, seen: count }) in &event_counts {
789 let Some(cap) = cap else {
790 continue;
791 };
792 if count > cap {
793 findings.push(finding(
794 DoctorSeverity::Warning,
795 CheckId::RateOverDeclared,
796 family.clone(),
797 format!(
798 "{count} event(s) in {window_s:.0}s exceeds the declared \
799 `{rate}` cap ({cap}/h)"
800 ),
801 Some("RFC 04 §1.3"),
802 ));
803 }
804 }
805 }
806
807 findings.extend(judge_cardinality(slices, &budgets, window_s));
808
809 let field_ctx = field_context_from(slices, described, &facts_cache);
813 findings.extend(crate::judge::field::judge_fields(
814 &fields, window_s, &field_ctx,
815 ));
816
817 Ok((
818 findings,
819 crate::report::ObservationSummary {
820 window_s,
821 scopes,
822 samples,
823 keys_seen,
824 dropped,
825 synthetic_marked: synthetic,
826 field_paths_dropped: fields.dropped_paths(),
829 facts_evicted: facts_cache.evicted(),
830 },
831 ))
832}
833
834fn field_context_from(
838 slices: &crate::model::registry::SliceSet,
839 described: &[(String, zenkey::schema::SchemaSet)],
840 facts: &crate::model::facts::FactsCache,
841) -> std::collections::BTreeMap<String, crate::judge::field::KeyFieldContext> {
842 use std::collections::BTreeMap;
843
844 let mut declared_cache: BTreeMap<(String, String), Option<crate::judge::field::DeclaredPaths>> =
845 BTreeMap::new();
846 let mut ctx = BTreeMap::new();
847 for (key, f) in facts.iter() {
848 let mut c = crate::judge::field::KeyFieldContext::default();
849 if let crate::model::facts::Registration::Registered(sf) = &f.registration {
850 c.ttl_s = sf.ttl_s;
851 c.type_name = Some(sf.type_name.clone());
852 if let Some(producer) = crate::judge::common::producer_of(f, Some(slices))
853 && !sf.type_name.is_empty()
854 {
855 let declared = declared_cache
856 .entry((producer.clone(), sf.type_name.clone()))
857 .or_insert_with(|| {
858 described
859 .iter()
860 .find(|(name, _)| *name == producer)
861 .and_then(|(_, set)| set.get(&sf.type_name))
862 .and_then(|schema| schema.json_document())
863 .and_then(crate::judge::field::DeclaredPaths::from_json_schema)
864 });
865 c.declared = declared.clone();
866 }
867 }
868 ctx.insert(key.to_string(), c);
869 }
870 ctx
871}
872
873fn judge_qos_observed(
883 qos_bad: &std::collections::BTreeMap<String, (String, u64, u64)>,
884) -> Vec<DoctorFinding> {
885 let mut findings = Vec::new();
886
887 let mut ex = Examples::new(FINDING_CAP);
888
889 for (key, (declared, bad, total)) in qos_bad.iter().filter(|(_, (_, bad, _))| *bad > 0) {
890 ex.push_with(|| {
891 finding(
892 DoctorSeverity::Warning,
893 CheckId::QosObservedMismatch,
894 key.clone(),
895 format!(
896 "{bad} of {total} sample(s) did not ride the declared {declared} — this \
897 is what actually rode: an interceptor MAY rewrite QoS, so it is a \
898 deviation, not proof of the publisher"
899 ),
900 Some("RFC 04 §3"),
901 )
902 });
903 }
904 emit_capped(
905 &mut findings,
906 ex,
907 CheckId::QosObservedMismatch,
908 SAME_FINDING,
909 );
910 findings
911}
912
913fn judge_introspect_coverage(
930 roster: &std::collections::BTreeMap<String, Vec<String>>,
931 locals: Option<&[RegistrySlice]>,
932 answered: usize,
933) -> Option<DoctorFinding> {
934 let live: usize = roster.values().map(Vec::len).sum();
935
936 let (in_scope, scope) = match locals {
937 None => (
938 live,
939 "scope: the whole roster (fleet-wide wildcard sweep)".to_string(),
940 ),
941 Some(locals) => {
942 let named = |origin: &str, producer: &str| {
943 let base_name = zenkey::grammar::Producer::parse_chunk(producer)
944 .map(|p| p.name().to_string())
945 .unwrap_or_else(|_| producer.to_string());
946 locals.iter().any(|l| {
947 l.name == base_name
948 || l.service_origin.as_ref().map(Declared::token) == Some(origin)
949 })
950 };
951 let in_scope: usize = roster
952 .iter()
953 .map(|(origin, producers)| producers.iter().filter(|p| named(origin, p)).count())
954 .sum();
955 let mut names: Vec<&str> = locals.iter().map(|l| l.name.as_str()).collect();
956 names.sort_unstable();
957 names.dedup();
958 let not_asked = live - in_scope;
959 (
960 in_scope,
961 format!(
962 "scope: the producer(s) the local registry names ({}); {} other \
963 live producer(s) were not asked and are not counted (O4)",
964 names.join(", "),
965 not_asked
966 ),
967 )
968 }
969 };
970 (answered < in_scope).then(|| {
971 finding(
972 DoctorSeverity::Error,
973 CheckId::IntrospectCoverage,
974 "fleet",
975 format!(
976 "{} of {} live producer(s) in scope did not answer introspect — \
977 alive ⇒ callable, so this is a finding, not a boot race; {scope}",
978 in_scope - answered,
979 in_scope
980 ),
981 Some("RFC 04 §5"),
982 )
983 })
984}
985
986fn judge_state_samples(
991 samples: &[crate::StateSample],
992 ttl: i64,
993 now: std::time::SystemTime,
994) -> (Vec<DoctorFinding>, usize) {
995 let mut findings = Vec::new();
996
997 let mut unstamped = 0usize;
998
999 for sample in samples {
1000 match sample.timestamp {
1001 Some(ts) => {
1002 let stamped = ts.get_time().to_system_time();
1003 if let Ok(age) = now.duration_since(stamped)
1004 && age.as_secs() as i64 > ttl
1005 {
1006 findings.push(finding(
1007 DoctorSeverity::Error,
1008 CheckId::StaleState,
1009 sample.key.clone(),
1010 format!(
1011 "{}s old against ttl {ttl}s (refresh <= ttl/2)",
1012 age.as_secs()
1013 ),
1014 Some("RFC 04 §1.2"),
1015 ));
1016 }
1017 }
1018 None => unstamped += 1,
1019 }
1020 }
1021 (findings, unstamped)
1022}
1023
1024fn judge_cardinality(
1041 slices: &crate::model::registry::SliceSet,
1042 observed: &crate::judge::budget::BudgetObservation,
1043 window_s: f64,
1044) -> Vec<DoctorFinding> {
1045 let mut findings = Vec::new();
1046
1047 let mut over: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
1048
1049 for slice in slices.slices() {
1050 for s in &slice.subjects {
1051 if !s.path.contains('{') {
1052 continue; }
1054 if s.path.contains("...") {
1055 let seen: usize = observed
1056 .family(&slice.name, &s.path)
1057 .map(|origins| origins.values().map(|keys| keys.len()).sum())
1058 .unwrap_or(0);
1059 findings.push(finding(
1060 DoctorSeverity::Info,
1061 CheckId::CardinalityOverDeclared,
1062 format!("{}/{}", slice.name, s.path),
1063 format!(
1064 "exempt: rest-variable — a `{{var...}}` family is unbounded by \
1065 construction, so its declared cardinality is not a bound this \
1066 check can pass or fail; {seen} distinct key(s) observed in \
1067 {window_s:.0}s"
1068 ),
1069 Some("RFC 08 §6.1"),
1070 ));
1071 continue;
1072 }
1073 let Some(declared) = s.cardinality else {
1074 continue; };
1077 let Some(origins) = observed.family(&slice.name, &s.path) else {
1078 continue; };
1080 for (origin, keys) in origins {
1081 if keys.len() as i64 <= declared {
1082 continue; }
1084 let examples = Examples::collect(
1085 crate::judge::common::EXPANSION_CAP,
1086 keys.iter().map(String::as_str),
1087 );
1088 let subject = if origin.starts_with('@') {
1089 format!("{origin}/{}", s.path)
1090 } else {
1091 format!("{origin}/{}/{}", slice.name, s.path)
1092 };
1093 over.push_with(|| {
1094 finding(
1095 DoctorSeverity::Warning,
1096 CheckId::CardinalityOverDeclared,
1097 subject,
1098 format!(
1099 "{} distinct key(s) observed in {window_s:.0}s exceed the \
1100 declared cardinality {declared} — e.g. {}. A bounded window \
1101 observes a lower bound: the population is at least this",
1102 keys.len(),
1103 examples.as_slice().join(", ")
1104 ),
1105 Some("RFC 04 §1.2"),
1106 )
1107 });
1108 }
1109 }
1110 }
1111 emit_capped(
1112 &mut findings,
1113 over,
1114 CheckId::CardinalityOverDeclared,
1115 "more origin famil(y|ies) over their declared cardinality",
1116 );
1117 findings
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::*;
1123
1124 const BOUNDED: &str = r#"
1125 [registry]
1126 version = "1.0"
1127 app = "t"
1128 convention = 1
1129 [producer]
1130 name = "sysinfo"
1131 [[subject]]
1132 path = "disk/{mount}/used"
1133 class = "telemetry"
1134 type = "Point"
1135 cardinality = 16
1136 "#;
1137
1138 #[test]
1142 fn cardinality_over_declared_fires_with_count_and_examples() {
1143 let slices = crate::model::registry::SliceSet::from_toml_for_tests(BOUNDED);
1144 let keys: Vec<String> = (0..40)
1145 .map(|i| format!("v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/m{i:02}/used"))
1146 .collect();
1147 let obs = crate::judge::budget::BudgetObservation::observe(
1148 "",
1149 &slices,
1150 keys.iter().map(String::as_str),
1151 );
1152 let findings = judge_cardinality(&slices, &obs, 10.0);
1153 assert_eq!(findings.len(), 1, "{findings:?}");
1154 let f = &findings[0];
1155 assert_eq!(f.check, CheckId::CardinalityOverDeclared);
1156 assert_eq!(f.severity, DoctorSeverity::Warning);
1157 assert_eq!(f.subject, "h-aaaaaaaaaaaa/sysinfo/disk/{mount}/used");
1158 assert!(f.evidence.contains("40 distinct key(s)"), "{}", f.evidence);
1159 assert!(f.evidence.contains("cardinality 16"), "{}", f.evidence);
1160 assert!(f.evidence.contains("10s"), "the window is stated");
1161 assert!(
1162 f.evidence.contains("disk/m00/used"),
1163 "examples are named: {}",
1164 f.evidence
1165 );
1166 }
1167
1168 #[test]
1172 fn cardinality_under_declared_is_not_a_finding() {
1173 let slices = crate::model::registry::SliceSet::from_toml_for_tests(BOUNDED);
1174 let keys = [
1175 "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/root/used",
1176 "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/var/used",
1177 "v1/h-bbbbbbbbbbbb/telemetry/sysinfo/disk/root/used",
1179 ];
1180 let obs = crate::judge::budget::BudgetObservation::observe("", &slices, keys);
1181 assert!(judge_cardinality(&slices, &obs, 5.0).is_empty());
1182 }
1183
1184 #[test]
1188 fn rest_variable_families_are_exempt_and_say_so() {
1189 let toml = r#"
1190 [registry]
1191 version = "1.0"
1192 app = "t"
1193 convention = 1
1194 [producer]
1195 name = "gnmi"
1196 [[subject]]
1197 path = "{device}/{path...}"
1198 class = "telemetry"
1199 type = "Point"
1200 cardinality = 2
1201 "#;
1202 let slices = crate::model::registry::SliceSet::from_toml_for_tests(toml);
1203 let keys: Vec<String> = (0..5)
1204 .map(|i| format!("v1/h-aaaaaaaaaaaa/telemetry/gnmi/sw1/if/eth{i}/rx"))
1205 .collect();
1206 let obs = crate::judge::budget::BudgetObservation::observe(
1207 "",
1208 &slices,
1209 keys.iter().map(String::as_str),
1210 );
1211 let findings = judge_cardinality(&slices, &obs, 5.0);
1212 assert_eq!(findings.len(), 1, "{findings:?}");
1213 let f = &findings[0];
1214 assert_eq!(f.severity, DoctorSeverity::Info, "an exemption, not a pass");
1215 assert!(
1216 f.evidence.starts_with("exempt: rest-variable"),
1217 "{}",
1218 f.evidence
1219 );
1220 assert!(f.evidence.contains("5 distinct key(s)"), "{}", f.evidence);
1221 let quiet = judge_cardinality(&slices, &Default::default(), 5.0);
1224 assert_eq!(quiet.len(), 1);
1225 assert!(quiet[0].evidence.starts_with("exempt: rest-variable"));
1226 }
1227
1228 #[test]
1235 fn qos_mismatch_cap_bounds_violators_not_map_entries() {
1236 let mut qos_bad: std::collections::BTreeMap<String, (String, u64, u64)> =
1237 std::collections::BTreeMap::new();
1238 for i in 0..30u32 {
1239 let bad = if i < 5 { 0 } else { 1 };
1241 qos_bad.insert(
1242 format!("v1/h-a/telemetry/x/k{i:02}"),
1243 ("tel".into(), bad, 10),
1244 );
1245 }
1246 let findings = judge_qos_observed(&qos_bad);
1247 let per_key: Vec<&DoctorFinding> = findings
1248 .iter()
1249 .filter(|f| f.severity == DoctorSeverity::Warning)
1250 .collect();
1251 assert_eq!(per_key.len(), FINDING_CAP, "the cap bounds the findings");
1252 assert!(
1253 per_key.iter().all(|f| f.evidence.starts_with("1 of 10")),
1254 "only violators become findings: {findings:#?}"
1255 );
1256 assert!(
1257 per_key.iter().any(|f| f.subject.ends_with("k24")),
1258 "violators past the first {FINDING_CAP} map entries (k20..k24) are \
1259 not dropped: {findings:#?}"
1260 );
1261 let note = findings
1262 .iter()
1263 .find(|f| f.severity == DoctorSeverity::Info)
1264 .expect("a remainder note");
1265 assert_eq!(
1266 note.evidence, "… and 5 more key(s) with the same finding",
1267 "the note counts violators (25 − 20), not map entries"
1268 );
1269
1270 let few: std::collections::BTreeMap<String, (String, u64, u64)> = qos_bad
1272 .iter()
1273 .take(10)
1274 .map(|(k, v)| (k.clone(), v.clone()))
1275 .collect();
1276 let findings = judge_qos_observed(&few);
1277 assert_eq!(findings.len(), 5, "{findings:#?}");
1278 assert!(
1279 findings
1280 .iter()
1281 .all(|f| f.severity == DoctorSeverity::Warning)
1282 );
1283 }
1284
1285 fn roster_of(entries: &[(&str, &[&str])]) -> std::collections::BTreeMap<String, Vec<String>> {
1286 entries
1287 .iter()
1288 .map(|(origin, producers)| {
1289 (
1290 origin.to_string(),
1291 producers.iter().map(|p| p.to_string()).collect(),
1292 )
1293 })
1294 .collect()
1295 }
1296
1297 fn slice_named(name: &str) -> RegistrySlice {
1298 zenkey::parse_slice(&format!(
1299 "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n\
1300 [producer]\nname = \"{name}\"\n"
1301 ))
1302 .expect("fixture slice parses")
1303 }
1304
1305 #[test]
1310 fn a_partial_registry_does_not_count_unasked_producers_against_coverage() {
1311 let roster = roster_of(&[("h-aaaaaaaaaaaa", &["sysinfo", "extra"])]);
1312 let locals = [slice_named("sysinfo")];
1313 assert_eq!(
1314 judge_introspect_coverage(&roster, Some(&locals), 1),
1315 None,
1316 "the un-asked producer is out of scope, not silent"
1317 );
1318 }
1319
1320 #[test]
1325 fn introspect_coverage_evidence_states_its_scope() {
1326 let roster = roster_of(&[
1327 ("h-aaaaaaaaaaaa", &["sysinfo", "extra"]),
1328 ("h-bbbbbbbbbbbb", &["sysinfo-2"]),
1329 ]);
1330 let locals = [slice_named("sysinfo")];
1331 let f = judge_introspect_coverage(&roster, Some(&locals), 1).expect("a finding");
1332 assert_eq!(f.check, CheckId::IntrospectCoverage);
1333 assert!(f.evidence.contains("1 of 2"), "{}", f.evidence);
1334 assert!(
1335 f.evidence.contains("the local registry names (sysinfo)"),
1336 "{}",
1337 f.evidence
1338 );
1339 assert!(
1340 f.evidence
1341 .contains("1 other live producer(s) were not asked"),
1342 "{}",
1343 f.evidence
1344 );
1345 }
1346
1347 #[test]
1350 fn a_service_slice_scopes_its_origin_into_coverage() {
1351 let roster = roster_of(&[("@catalog", &["catalog"]), ("h-aaaaaaaaaaaa", &["extra"])]);
1352 let locals = [zenkey::parse_slice(
1353 "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n\
1354 [service]\nname = \"catalog\"\norigin = \"@catalog\"\n",
1355 )
1356 .expect("service slice parses")];
1357 assert_eq!(judge_introspect_coverage(&roster, Some(&locals), 1), None);
1358 let f = judge_introspect_coverage(&roster, Some(&locals), 0).expect("a finding");
1359 assert!(f.evidence.contains("1 of 1"), "{}", f.evidence);
1360 }
1361
1362 #[test]
1364 fn the_wildcard_sweep_judges_the_whole_roster() {
1365 let roster = roster_of(&[("h-aaaaaaaaaaaa", &["sysinfo", "extra"])]);
1366 let f = judge_introspect_coverage(&roster, None, 1).expect("a finding");
1367 assert!(f.evidence.contains("1 of 2"), "{}", f.evidence);
1368 assert!(f.evidence.contains("whole roster"), "{}", f.evidence);
1369 assert_eq!(judge_introspect_coverage(&roster, None, 2), None);
1370 }
1371
1372 #[test]
1373 fn freshness_judgement_is_pure_and_ttl_bound() {
1374 let now = std::time::SystemTime::now();
1375 let fresh_ts = zenoh::time::Timestamp::new(
1376 zenoh::time::NTP64::from(now.duration_since(std::time::UNIX_EPOCH).unwrap()),
1377 zenoh::time::TimestampId::rand(),
1378 );
1379 let stale_ts = zenoh::time::Timestamp::new(
1380 zenoh::time::NTP64::from(
1381 now.duration_since(std::time::UNIX_EPOCH).unwrap() - Duration::from_secs(120),
1382 ),
1383 zenoh::time::TimestampId::rand(),
1384 );
1385 let samples = vec![
1386 crate::StateSample {
1387 key: "b/v1/h-aaaaaaaaaaaa/state/p/health".into(),
1388 timestamp: Some(fresh_ts),
1389 payload_len: 2,
1390 },
1391 crate::StateSample {
1392 key: "b/v1/h-bbbbbbbbbbbb/state/p/health".into(),
1393 timestamp: Some(stale_ts),
1394 payload_len: 2,
1395 },
1396 crate::StateSample {
1397 key: "b/v1/h-cccccccccccc/state/p/health".into(),
1398 timestamp: None,
1399 payload_len: 2,
1400 },
1401 ];
1402 let (findings, unstamped) = judge_state_samples(&samples, 30, now);
1403 assert_eq!(
1404 findings.len(),
1405 1,
1406 "only the stale stamped sample is a finding"
1407 );
1408 assert_eq!(findings[0].check, CheckId::StaleState);
1409 assert!(findings[0].subject.contains("h-bbbbbbbbbbbb"));
1410 assert_eq!(unstamped, 1, "the unstamped sample is counted, not judged");
1411 }
1412}