Skip to main content

zenkey_fleet/judge/
doctor.rs

1//! The doctor checks as engine functions (#55): every finding both frontends
2//! render comes from here — `zenctl doctor` orchestrates and renders, the
3//! zengui doctor panel calls the same [`run_doctor`] and renders the same
4//! [`DoctorReport`]. A check that lives in one frontend is a check the other
5//! frontend's user never sees (RFC 08 §6.1's argument, applied to ourselves).
6//!
7//! Check ids are **stable API**: scripts key on them (`--format json`), the
8//! GUI keys deltas on them. New checks add ids; nothing renames one. The full
9//! set is pinned in [`crate::report::CheckId`].
10
11use 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/// What a doctor run should cost.
23#[derive(Debug, Clone)]
24pub struct DoctorSpec {
25    /// Run the deep checks too (per-family state snapshots for freshness,
26    /// storage-coverage join) — real query load, opt-in.
27    pub deep: bool,
28    /// At most this many state samples drained per family in the deep
29    /// checks (`--sample N`) — bounds the sweep's cost, not just its output.
30    /// `None` = unbounded.
31    pub sample: Option<usize>,
32    /// Per-query timeout.
33    pub timeout: Duration,
34    /// Listen passively to the data planes for this long after the GET
35    /// fan-in (`--for`, #161) and judge what rides: decode/validity,
36    /// declared-vs-observed QoS, unregistered traffic, over-rate events.
37    /// `None` = the phase does not run and the report carries no
38    /// observation section.
39    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
58/// The introspect key for a slice — a service origin's verbatim `@` chunk is
59/// structurally unmatchable by a fleet selector's `*` (property D4), so it
60/// takes its own key. That is the grammar working, not an exception to it.
61fn rpc_key(base: &str, slice: &RegistrySlice, procedure: &str) -> Result<String> {
62    Ok(match &slice.service_origin {
63        Some(origin) => {
64            // The slice already validated it on parse — `Other` here means the
65            // chunk is not a legal verbatim origin, which is the same finding
66            // the hand-rolled `ServiceOrigin::new` used to report.
67            // A *served* slice said this, so it is the peer that is
68            // malformed — not the caller, and not the fabric.
69            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
81/// Run every check against the live fleet and report typed findings.
82///
83/// `locals` is the caller's registry (loaded from `--registry` dirs or GUI
84/// settings). `None` means none was loaded: the served-vs-declared diff is
85/// skipped and only bus-derived checks run, and the report says so rather
86/// than reading in sync (O4 — "not asked" must not render as "clean").
87///
88/// `Option<&SliceSet>` and not `&[RegistrySlice]`: this is the engine's
89/// standing shape for "a registry, or honestly none" (`facts.rs` states it as
90/// policy), an empty slice could not tell the two apart, and the set arrives
91/// already indexed — doctor used to rebuild one from a clone of every slice
92/// halfway through the run.
93pub 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    // A registry that declares nothing answers no question this run asks, so
101    // it takes the same path as none at all — normalised once, here, rather
102    // than at each of the four places that branch on it below.
103    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    // --- served-vs-declared diff (RFC 08 §6) --------------------------
111    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    // One declared registry sweep (#37) serves both fallbacks below —
155    // doctor used to fan the identical wildcard GETs twice per run.
156    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    // With no local registry the only introspect coverage we can count is
171    // the fleet-wide wildcard.
172    if let Some(slices) = &sweep {
173        answered = slices.len();
174    }
175
176    // The roster is what makes silence legible (RFC 05 §3.1): a producer
177    // that holds an `alive` token but did not answer `introspect` is a bug,
178    // because producers MUST declare their @rpc queryables *before* their
179    // token — "alive ⇒ callable" (RFC 04 §5). Coverage is judged over the
180    // producers that were actually *asked*: with `--registry` covering a
181    // subset, a live producer outside the locals was never queried, and
182    // "not asked" must not render as "did not answer" (RFC 09 §5.1 O4).
183    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    // --- admin reachability ------------------------------------------
191    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    // --- schema conformance (RFC 08 §7) ------------------------------
223    // Which slices to judge: the locals when given, else what the fleet
224    // serves (the sweep above).
225    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        // The caller's set is already indexed; rebuilding it here reparsed
230        // every subject pattern to arrive at the set we were handed.
231        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    // One per producer, for the consumers whose question *is* the producer:
237    // totality, the listen phase's store, the served count, and the field
238    // table's declared-path join. Where several hosts answered this is the
239    // first of them — arrival order, which is not a fact about the fleet, and
240    // is why the drift check below reads the attributed list instead (#398).
241    let mut described: Vec<(String, zenkey::schema::SchemaSet)> = Vec::new();
242    // Every answer, attributed. `describe` is `@rpc/*/describe` — a wildcard
243    // origin — so this fans in across every host running the producer, and
244    // keeping one of them was how a schema disagreement came to name a
245    // producer and never a host (#398).
246    let mut described_by_origin: Vec<crate::model::decode::DescribedSchema> = Vec::new();
247    let mut undescribed = 0usize;
248    // One `GetOpts` for the sweep rather than one per producer: the elision
249    // ledger is per-options, so a fresh one per slice could never accumulate
250    // the fan-out's cost.
251    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        // Nobody parseable answered for this producer. A producer where one
278        // host answered and another did not is *described* — the SHOULD is
279        // met — and the gap between them is the drift check's business.
280        if described_by_origin.len() == before {
281            undescribed += 1;
282        }
283    }
284    // Totality through the one engine implementation (`totality_gaps`) —
285    // doctor used to carry a parallel referenced-names path.
286    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            // `producer@origin`, because a type with two identities and no
303            // host to go and look at is the finding you can do least with
304            // (#398).
305            .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        // The two verdicts are not the same finding. A disagreement is a
311        // defect; a producer that served no identity leaves the question
312        // *unanswered*, and calling that an error would be the mirror of the
313        // bug #370 fixed — reporting a verdict nobody's evidence supports.
314        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    // --- deep: freshness + storage coverage --------------------------
355    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    // --- listen: judge what actually rides (#161) --------------------
425    let observation = match spec.listen {
426        Some(window) => {
427            let store = crate::model::decode::SchemaStore::new(base, spec.timeout);
428            // The GET phase above already asked every producer for its
429            // `describe` document. Hand those to the window's store rather
430            // than letting it re-ask the fleet, mid-window, for what this
431            // run is holding (RFC 08 §7; the store's frugality note).
432            for (producer, set) in &described {
433                store.insert(producer, set.clone());
434            }
435            // And sealed for the window (#337): the GET phase asked every
436            // producer the registry names, so a miss inside the window is a
437            // producer that served nothing — already counted as
438            // `describe_missing`. Left unsealed, that miss is a `describe`
439            // GET awaited inside the drain loop, re-asked every time its
440            // backoff expires, with nobody attending the broadcast.
441            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        // `None` when no local registry was given: the served-vs-declared
453        // diff never ran, and the report must say so rather than looking
454        // like "ran, none in sync" (RFC 09 §5.1 O4, review finding R1).
455        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
467/// How many decode attempts each key gets during the listen window — the
468/// budget that keeps a hot bus from turning the doctor into a load test.
469const DECODE_BUDGET: u8 = 2;
470
471/// The remainder wording every per-key listen check shares.
472const SAME_FINDING: &str = "more key(s) with the same finding";
473
474/// Spill a capped collector into `findings`, followed by the remainder note
475/// when the cap bit.
476///
477/// Filter and judge **into** the collector, never around it (deep-review D4):
478/// the `qos-observed-mismatch` cap used to bound the judged *keys*, so
479/// violators past the first [`FINDING_CAP`] of them vanished and the
480/// remainder note under-counted. [`Examples`] counts what it is offered, so
481/// the note cannot disagree with the population it summarises.
482fn 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
503/// The declared events rate class as an hourly cap (RFC 04 §1.3):
504/// `rare` ≤ 1/h, `low` ≤ 1/min, `burst(n/h)` a declared cap.
505struct RateWindow {
506    /// The declared hourly cap, from [`RateClass::cap_per_hour`]. `None` is
507    /// a rate token this build cannot read — "cannot judge", never a
508    /// guessed budget (RFC 09 §5.1 O4).
509    cap: Option<u64>,
510    /// Samples seen on the family during the window.
511    seen: u64,
512}
513
514/// The passive listening phase: watch the data planes for `window`, judge
515/// each sample through the ladders that already exist — the Registration
516/// ladder, `qos_matches`, `decode_sample` — and aggregate per key so a hot
517/// key is one finding with a count, not a finding per sample.
518async 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    // Scope statement (O5): the three data classes for host origins, plus
530    // each declared service origin's three — `*` never matches an `@` chunk
531    // (D4), so the service planes must be named to be seen. Shared with the
532    // `topic list --budget` observation (#221).
533    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    // A scope that fails to declare tears the monitor down on the way out,
538    // rather than leaving a `**` subscriber to `Drop` (#336).
539    let monitor = monitor.watching(&scopes).await?;
540    let started = tokio::time::Instant::now();
541    let deadline = started + window;
542
543    // Field intelligence (#223): per-dotted-path stats over the structural
544    // value — sync and schema-free, so it rides every sample within the
545    // decode budget's reach and beyond.
546    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    // Bounded (#107): one projection per distinct key, LRU past the bound,
552    // evictions counted into the observation summary (O6).
553    let mut facts_cache = crate::model::facts::FactsCache::default();
554    let mut decode_budget: BTreeMap<String, u8> = BTreeMap::new();
555    // Per-key aggregates: key → count (+ what was wrong, first occurrence).
556    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    // Per-family event counts: (family subject, declared rate) → what was
561    // seen and what was declared.
562    let mut event_counts: BTreeMap<(String, String), RateWindow> = BTreeMap::new();
563    // Stamping nodes that are not the publisher (#213): zid → samples.
564    let mut foreign_stampers: BTreeMap<String, u64> = BTreeMap::new();
565
566    // One timer for the whole window, not one per iteration (#346).
567    // `sleep_until` builds a future and registers a timer each time it
568    // is evaluated, and a `select!` in a loop evaluates it on every
569    // pass — at 100k samples/s that is 100k registrations a second for
570    // a deadline that never moves.
571    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                // Who stamped it (#213). A router doing the timestamping is
587                // not a fault — it is a deployment choice — but it silently
588                // changes what every latency in this suite measures, so it is
589                // worth saying out loud once.
590                if let Some(crate::StampProvenance::Foreign { stamper }) = s.stamped_by {
591                    *foreign_stampers.entry(stamper.to_string()).or_default() += 1;
592                }
593                // A tombstone is a retirement, not a document (RFC 04 §1.2):
594                // a Delete carries no payload to read, decode, or validate,
595                // so the field and payload ladders skip it — judging the
596                // empty body as a value manufactures `payload-undecodable`
597                // out of a correct retirement (zensight#830). Everything
598                // that is a wire fact about the publisher — QoS axes,
599                // registration, stamping — still applies and stays judged.
600                let is_put = s.kind == zenoh::sample::SampleKind::Put;
601                // Same bound as `run_field`'s drain (#346): the parse is
602                // per sample by design, so the payload size is what has to be
603                // bounded, and the skip is counted rather than read as an
604                // absent document.
605                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                            // The cap is taken from the typed `RateClass`
639                            // here, where it is in hand — this used to
640                            // stringify the token and re-parse it below
641                            // through a second copy of RFC 04 §1.3's
642                            // mapping (#350's sweep).
643                            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                            // `Some`: the doctor's slice set comes from its
655                            // own live introspect sweep, so the registry was
656                            // always asked here — `NoRegistry` (#246) cannot
657                            // arise, and like every not-validated reason
658                            // other than `Undecodable` it would fall through
659                            // the `_` arm below: not asked/not checkable is
660                            // never a finding (RFC 09 §5.1 O4).
661                            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    // Key-population budgets (#221): the window's distinct keys, grouped
705    // into `{var}` families per origin, judged against each family's
706    // declared `cardinality`.
707    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    // Over-rate only, and only when provable: within any window no longer
785    // than an hour, exceeding the hourly cap is conclusive. Absence or
786    // under-rate in a bounded window is never a finding (O1/O4).
787    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    // Field intelligence (#223): the three field-granular checks, judged
810    // with what is known per key — declared `ttl_s`/type from the resolved
811    // facts, declared paths from the describe sets the GET phase gathered.
812    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            // The per-path table is bounded like every other table here, and
827            // its cost is a wire fact (RFC 09 §5.1 O6).
828            field_paths_dropped: fields.dropped_paths(),
829            facts_evicted: facts_cache.evicted(),
830        },
831    ))
832}
833
834/// The per-key context the field judges need (#223), built from the listen
835/// phase's resolved facts and the already-gathered describe sets — pure, so
836/// the join is testable without a bus.
837fn 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
873/// The `qos-observed-mismatch` findings from the listen window's per-key
874/// aggregates: `key → (declared profile, mismatched, judged)` — pure, so
875/// the cap arithmetic is testable without a bus.
876///
877/// Filter **then** cap, [`judge_cardinality`]'s pattern (deep-review D4):
878/// the map holds every judged key, most of them clean, so capping the map
879/// *entries* first silently dropped violators past the first
880/// [`FINDING_CAP`] keys and made the remainder note miscount. The cap
881/// bounds the findings; the filter decides what a finding is.
882fn 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
913/// Judge introspect coverage — "alive ⇒ callable" (RFC 04 §5) — against the
914/// producers that were actually asked. Pure, so the O4 boundary is testable
915/// without a bus.
916///
917/// `locals: Some` is the `--registry` run: only the producers the local
918/// slices name were queried, so only those count toward coverage — a live
919/// producer whose slice a *partial* registry does not carry was never asked,
920/// and counting it as "did not answer" would be a false finding (RFC 09
921/// §5.1 O4; deep-review D3). `None` is the wildcard sweep, where every
922/// roster producer was in the fan-in. Either way the evidence states the
923/// scope it checked.
924///
925/// Matching follows the roster's own conventions: an instance suffix shares
926/// its base slice (`sysinfo-2` → `sysinfo`, RFC 03 §1.5), and a service
927/// origin's token names the service as its producer (RFC 06 §5), matched by
928/// the slice's declared origin or name.
929fn 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
986/// Judge one state family's samples against its declared ttl — pure, so the
987/// freshness math is testable without a bus. Returns the stale findings and
988/// the count of unstamped samples (aggregated by the caller into the one
989/// `unstamped-state` finding).
990fn 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
1024/// Judge every declared `{var}` family's key population against its declared
1025/// `cardinality` (#221) — pure, so the acceptance case (declared 16, 40
1026/// observed) is testable without a bus.
1027///
1028/// The honesty rules, verbatim from the issue:
1029///
1030/// - Observed **over** declared is a finding (RFC 04 §1.2's budget is a
1031///   MUST); observed **under** declared is **not** — an idle host declares
1032///   nothing wrong, and a bounded window proves a lower bound, never the
1033///   population (RFC 09 §5.1 O4/O6). The window rides in the evidence.
1034/// - `{path...}` rest-variable families are unbounded by construction and
1035///   are **exempt and say so** — "exempt: rest-variable", never a silent
1036///   skip and never a pass (the RFC 08 §6.1 v1.20 shape for subject checks).
1037/// - Judged **per origin**: RFC 04 §1 bounds cardinality per producer, so
1038///   one origin over the bound is conclusive and two origins' healthy
1039///   populations are never summed into a fake violation.
1040fn 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; // a literal subject's population is 1 by construction
1053            }
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; // nothing declared, nothing to judge (the RFC 08 §5
1075                // lint that requires the field is the producer build's)
1076            };
1077            let Some(origins) = observed.family(&slice.name, &s.path) else {
1078                continue; // unobserved is not "within budget" — no verdict
1079            };
1080            for (origin, keys) in origins {
1081                if keys.len() as i64 <= declared {
1082                    continue; // under/at declared: not a finding (O4)
1083                }
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    /// The #221 acceptance case: declared 16, 40 observed expansions — the
1139    /// finding fires with the count, the declared bound, examples, and the
1140    /// window it rests on.
1141    #[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    /// Under (or at) the declared bound is **not** a finding: an idle host
1169    /// declares nothing wrong, and a bounded window proves a lower bound,
1170    /// never the population (O4/O6).
1171    #[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            // Two origins at 15 each must never be summed into a fake 30 > 16.
1178            "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    /// The other #221 acceptance case: a `{path...}` family yields the
1185    /// exemption wording — "exempt: rest-variable" — and never a pass (nor an
1186    /// over-finding, however many members it grows).
1187    #[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        // And with nothing observed the family still says so — exempt is a
1222        // property of the declaration, not of the traffic.
1223        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    /// Deep-review D4: the qos-observed-mismatch cap bounds *violators*, not
1229    /// map entries. 30 judged keys where the first 5 (in map order) are
1230    /// clean and the remaining 25 violate: every violator is counted — the
1231    /// first 20 as findings, the other 5 in a remainder note that counts
1232    /// correctly. Capping before filtering used to drop the violators past
1233    /// the first 20 map entries and miscount the note.
1234    #[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            // k00..k04 sort first and are clean; k05..k29 are violators.
1240            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        // At or under the cap: every violator is a finding, no note.
1271        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    /// Deep-review D3: with `--registry` covering a subset of the fleet, a
1306    /// live producer the locals do not name was never asked — so it must not
1307    /// count as "did not answer" (O4). One local slice, answered by its one
1308    /// origin, beside an extra live producer: no finding.
1309    #[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    /// …and when an in-scope producer really did not answer, the finding
1321    /// fires and its evidence states the scope it checked — including that
1322    /// the out-of-scope producer was not counted. An instance suffix shares
1323    /// its base slice (RFC 03 §1.5), so `sysinfo-2` is in scope too.
1324    #[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    /// A service origin's token names the service as its producer (RFC 06
1348    /// §5); a local slice matches it by declared origin.
1349    #[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    /// The wildcard sweep keeps the whole roster in scope, and says so.
1363    #[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}