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::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// The run-over-run comparison lives beside the doctor by name; it is
23// ungated (no session, no decode) and so has a file of its own.
24pub use crate::judge::doctor_delta::doctor_delta;
25
26/// What a doctor run should cost.
27#[derive(Debug, Clone)]
28pub struct DoctorSpec {
29    /// Run the deep checks too (per-family state snapshots for freshness,
30    /// storage-coverage join) — real query load, opt-in.
31    pub deep: bool,
32    /// At most this many state samples drained per family in the deep
33    /// checks (`--sample N`) — bounds the sweep's cost, not just its output.
34    /// `None` = unbounded.
35    pub sample: Option<usize>,
36    /// Per-query timeout.
37    pub timeout: Duration,
38    /// Listen passively to the data planes for this long after the GET
39    /// fan-in (`--for`, #161) and judge what rides: decode/validity,
40    /// declared-vs-observed QoS, unregistered traffic, over-rate events.
41    /// `None` = the phase does not run and the report carries no
42    /// observation section.
43    pub listen: Option<Duration>,
44}
45
46fn finding(
47    severity: DoctorSeverity,
48    check: CheckId,
49    subject: impl Into<String>,
50    evidence: impl Into<String>,
51    citation: Option<&str>,
52) -> DoctorFinding {
53    DoctorFinding {
54        severity,
55        check,
56        subject: subject.into(),
57        evidence: evidence.into(),
58        citation: citation.map(str::to_string),
59    }
60}
61
62/// Run every check against the live fleet and report typed findings.
63///
64/// `locals` is the caller's registry (loaded from `--registry` dirs or GUI
65/// settings). `None` means none was loaded: the served-vs-declared diff is
66/// skipped and only bus-derived checks run, and the report says so rather
67/// than reading in sync (O4 — "not asked" must not render as "clean").
68///
69/// `Option<&SliceSet>` and not `&[RegistrySlice]`: this is the engine's
70/// standing shape for "a registry, or honestly none" (`facts.rs` states it as
71/// policy), an empty slice could not tell the two apart, and the set arrives
72/// already indexed — doctor used to rebuild one from a clone of every slice
73/// halfway through the run.
74pub async fn run_doctor(
75    fleet: &crate::Fleet<'_>,
76    locals: Option<&crate::model::registry::SliceSet>,
77    spec: &DoctorSpec,
78) -> Result<DoctorReport> {
79    let (session, base) = (fleet.session(), fleet.base());
80
81    // A registry that declares nothing answers no question this run asks, so
82    // it takes the same path as none at all — normalised once, here, rather
83    // than at each of the four places that branch on it below.
84    let locals = locals.filter(|set| !set.slices().is_empty());
85    let roster = crate::bus::roster::roster(fleet, spec.timeout).await?;
86
87    let mut findings: Vec<DoctorFinding> = Vec::new();
88    let mut synced: Vec<String> = Vec::new();
89    let mut answered = 0usize;
90
91    // --- served-vs-declared diff (RFC 08 §6) --------------------------
92    for local in locals.iter().flat_map(|set| set.slices()) {
93        let key = crate::model::registry::rpc_key(base, local, "introspect")?;
94        let answers = fleet_get(fleet, &key, &GetOpts::new(spec.timeout)).await?;
95        for answer in &answers {
96            let Answer::Value(bytes) = &answer.answer else {
97                continue;
98            };
99            answered += 1;
100            let served_toml = bytes.to_bytes();
101            let served_toml = String::from_utf8_lossy(&served_toml);
102            let served = match zenkey::parse_slice(&served_toml) {
103                Ok(s) => s,
104                Err(e) => {
105                    findings.push(finding(
106                        DoctorSeverity::Error,
107                        CheckId::SliceParse,
108                        format!("{}/{}", answer.origin, local.name),
109                        format!("served slice does not parse: {e}"),
110                        Some("RFC 08 §6"),
111                    ));
112                    continue;
113                }
114            };
115            let diff = zenkey::slice::diff(&served, local);
116            if diff.is_empty() {
117                synced.push(format!(
118                    "{}/{} (registry {})",
119                    answer.origin, local.name, served.version
120                ));
121            } else {
122                for f in &diff {
123                    findings.push(finding(
124                        DoctorSeverity::Error,
125                        CheckId::SliceSync,
126                        format!("{}/{}", answer.origin, local.name),
127                        f.summary(),
128                        Some("RFC 08 §6"),
129                    ));
130                }
131            }
132        }
133    }
134
135    // One declared registry sweep (#37) serves both fallbacks below —
136    // doctor used to fan the identical wildcard GETs twice per run.
137    let sweep = if locals.is_none() {
138        let repeating = RepeatingRegistry::declare(fleet, spec.timeout).await?;
139        let slices: Vec<RegistrySlice> = repeating
140            .fetch()
141            .await?
142            .into_iter()
143            .map(|(s, _)| s)
144            .collect();
145        repeating.undeclare().await?;
146        Some(slices)
147    } else {
148        None
149    };
150
151    // With no local registry the only introspect coverage we can count is
152    // the fleet-wide wildcard.
153    if let Some(slices) = &sweep {
154        answered = slices.len();
155    }
156
157    // The roster is what makes silence legible (RFC 05 §3.1): a producer
158    // that holds an `alive` token but did not answer `introspect` is a bug,
159    // because producers MUST declare their @rpc queryables *before* their
160    // token — "alive ⇒ callable" (RFC 04 §5). Coverage is judged over the
161    // producers that were actually *asked*: with `--registry` covering a
162    // subset, a live producer outside the locals was never queried, and
163    // "not asked" must not render as "did not answer" (RFC 09 §5.1 O4).
164    let live: usize = roster.values().map(Vec::len).sum();
165    findings.extend(judge_introspect_coverage(
166        &roster,
167        locals.map(crate::model::registry::SliceSet::slices),
168        answered,
169    ));
170
171    // --- admin reachability ------------------------------------------
172    let routers = crate::routers(session, spec.timeout)
173        .await
174        .unwrap_or_default();
175    let mut router_version = None;
176    if routers.is_empty() {
177        findings.push(finding(
178            DoctorSeverity::Info,
179            CheckId::AdminUnreachable,
180            "mesh",
181            "no routers answered @/*/router (peer-only mesh, or the admin space is \
182             disabled) — storage/version checks skipped",
183            None,
184        ));
185    } else {
186        let versions: std::collections::BTreeSet<&str> = routers
187            .iter()
188            .filter_map(|r| r.version.as_deref())
189            .collect();
190        if versions.len() > 1 {
191            findings.push(finding(
192                DoctorSeverity::Error,
193                CheckId::RouterVersionSkew,
194                "mesh",
195                format!("router version skew across the mesh: {versions:?}"),
196                None,
197            ));
198        } else {
199            router_version = versions.iter().next().map(|v| v.to_string());
200        }
201    }
202
203    // --- schema conformance (RFC 08 §7) ------------------------------
204    // Which slices to judge: the locals when given, else what the fleet
205    // serves (the sweep above).
206    let slice_set: std::borrow::Cow<'_, crate::model::registry::SliceSet> = match sweep {
207        Some(slices) => {
208            std::borrow::Cow::Owned(crate::model::registry::SliceSet::from_slices(slices))
209        }
210        // The caller's set is already indexed; rebuilding it here reparsed
211        // every subject pattern to arrive at the set we were handed.
212        None => match locals {
213            Some(set) => std::borrow::Cow::Borrowed(set),
214            None => std::borrow::Cow::Owned(crate::model::registry::SliceSet::default()),
215        },
216    };
217    // One sweep, kept whole (#410): every answer attributed to the host that
218    // gave it, because `describe` fans in across every host running a
219    // producer and keeping one of them was how a schema disagreement came
220    // to name a producer and never a host (#398). The same helper serves
221    // `interface show --schema`, so the two no longer each hold a copy of
222    // "do these carriers agree".
223    let describes = crate::bus::describe::describe_sweep(fleet, &slice_set, spec.timeout).await?;
224    // One per producer, for the consumers whose question *is* the producer:
225    // totality, the listen phase's store, the served count, and the field
226    // table's declared-path join. Where several hosts answered this is the
227    // first of them — arrival order, which is not a fact about the fleet, and
228    // is why the drift check below reads the attributed list instead (#398).
229    let described: Vec<(String, zenkey::schema::SchemaSet)> = describes.first_per_producer();
230    let undescribed = describes.undescribed.len();
231    // Totality through the one engine implementation (`totality_gaps`) —
232    // doctor used to carry a parallel referenced-names path.
233    for gap in crate::model::decode::totality_gaps(&described, &slice_set) {
234        findings.push(finding(
235            DoctorSeverity::Error,
236            CheckId::DescribeTotality,
237            gap.producer.clone(),
238            format!(
239                "describe is not total — missing: {}",
240                gap.missing.join(", ")
241            ),
242            Some("RFC 08 §7"),
243        ));
244    }
245    for drift in crate::model::decode::schema_drift(&describes.answers) {
246        let servers: Vec<String> = drift
247            .servers
248            .iter()
249            // `producer@origin`, because a type with two identities and no
250            // host to go and look at is the finding you can do least with
251            // (#398).
252            .map(|s| match s.hash.as_option() {
253                Some(h) => format!("{}@{} ({h})", s.producer, s.origin),
254                None => format!("{}@{} (no identity served)", s.producer, s.origin),
255            })
256            .collect();
257        // The two verdicts are not the same finding. A disagreement is a
258        // defect; a producer that served no identity leaves the question
259        // *unanswered*, and calling that an error would be the mirror of the
260        // bug #370 fixed — reporting a verdict nobody's evidence supports.
261        let (severity, evidence) = match drift.verdict {
262            DriftVerdict::Disagree => (
263                DoctorSeverity::Error,
264                format!("served with different schemas by {}", servers.join(", ")),
265            ),
266            DriftVerdict::Unjudgeable => (
267                DoctorSeverity::Warning,
268                format!(
269                    "agreement cannot be judged — {} served no schema identity: {} \
270                     (RFC 09 §5.1 O4; the hash exists for exactly this, RFC 08 §7)",
271                    drift
272                        .servers
273                        .iter()
274                        .filter(|s| s.hash.is_not_asked())
275                        .count(),
276                    servers.join(", ")
277                ),
278            ),
279        };
280        findings.push(finding(
281            severity,
282            CheckId::SchemaDrift,
283            drift.type_name.clone(),
284            evidence,
285            Some("RFC 08 §7"),
286        ));
287    }
288    if undescribed > 0 {
289        findings.push(finding(
290            DoctorSeverity::Info,
291            CheckId::DescribeMissing,
292            "fleet",
293            format!(
294                "{undescribed} producer(s) serve no describe (a SHOULD; generic tools \
295                 render their payloads structurally)"
296            ),
297            Some("RFC 08 §7"),
298        ));
299    }
300
301    // --- deep: freshness + storage coverage --------------------------
302    if spec.deep {
303        let now = std::time::SystemTime::now();
304        let mut unstamped = 0usize;
305        for slice in slice_set.slices() {
306            for subject in &slice.subjects {
307                let (Some(ttl), true) = (subject.ttl_s, subject.class.is(&zenkey::Class::State))
308                else {
309                    continue;
310                };
311                let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
312                    continue;
313                };
314                let selector = match &slice.service_origin {
315                    Some(origin) => with_base(
316                        base,
317                        format!("v1/{origin}/state/{}", pattern.selector_tail()),
318                    ),
319                    None => with_base(
320                        base,
321                        format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
322                    ),
323                };
324                let samples = state_snapshot(session, &selector, spec.timeout, spec.sample).await?;
325                let (family_findings, family_unstamped) = judge_state_samples(&samples, ttl, now);
326                findings.extend(family_findings);
327                unstamped += family_unstamped;
328            }
329        }
330        // Declared `[budget]` versus the health document's `self_stats`
331        // (#391, RFC 08 §2 v1.32, RFC 04 §1.2). Under `--deep` because a
332        // health fetch costs the data plane, and RFC 13 §3 asks for that
333        // explicitly rather than folded into an ambient run (its frugality
334        // note); a slice without a budget is not asked, and issues no GET.
335        // `state_snapshot` carries no payload, so this is a `fleet_get` with
336        // the reply bytes read structurally — the listen phase's reading.
337        for slice in slice_set.slices().iter().filter(|s| s.budget.is_some()) {
338            let selector = match &slice.service_origin {
339                Some(origin) => with_base(base, format!("v1/{origin}/state/health")),
340                None => with_base(base, format!("v1/*/state/{}/health", slice.name)),
341            };
342            let answers = fleet_get(fleet, &selector, &GetOpts::new(spec.timeout)).await?;
343            let read: Vec<(String, Option<crate::judge::self_stats::SelfStats>)> = answers
344                .iter()
345                .filter_map(|a| match &a.answer {
346                    Answer::Value(bytes) => Some((
347                        a.origin.clone(),
348                        crate::model::decode::structural_value(&bytes.to_bytes())
349                            .as_ref()
350                            .and_then(crate::judge::self_stats::read_self_stats),
351                    )),
352                    Answer::Error { .. } => None,
353                })
354                .collect();
355            let asked = roster
356                .iter()
357                .filter(|(origin, producers)| match &slice.service_origin {
358                    Some(service) => service.token() == origin.as_str(),
359                    None => producers.iter().any(|p| {
360                        zenkey::grammar::Producer::parse_chunk(p)
361                            .map(|p| p.name() == slice.name)
362                            .unwrap_or(p == &slice.name)
363                    }),
364                })
365                .count();
366            findings.extend(crate::judge::self_stats::judge_self_stats(
367                slice, &read, asked,
368            ));
369        }
370        if unstamped > 0 {
371            findings.push(finding(
372                DoctorSeverity::Warning,
373                CheckId::UnstampedState,
374                "fleet",
375                format!(
376                    "{unstamped} state sample(s) carry no HLC timestamp — the deployment \
377                     lacks timestamping, which LWW requires; freshness is unjudgeable \
378                     for them"
379                ),
380                Some("RFC 04 §4"),
381            ));
382        }
383        let storages = crate::storages(session, spec.timeout)
384            .await
385            .unwrap_or_default();
386        let coverage = crate::state_coverage(&slice_set, base, &storages);
387        let uncovered: Vec<&crate::CoverageRow> = coverage
388            .iter()
389            .filter(|r| r.coverage == crate::Coverage::Uncovered)
390            .collect();
391        if !uncovered.is_empty() {
392            findings.push(finding(
393                DoctorSeverity::Info,
394                CheckId::StorageCoverage,
395                "fleet",
396                format!(
397                    "{} state famil(y|ies) have no storage coverage (volatile seeding \
398                     may ride the advanced-pub/sub cache): {}",
399                    uncovered.len(),
400                    uncovered
401                        .iter()
402                        .map(|r| format!("{}/{}", r.producer, r.path))
403                        .collect::<Vec<_>>()
404                        .join(", ")
405                ),
406                Some("RFC 04 §3.5"),
407            ));
408        }
409    }
410
411    // --- listen: judge what actually rides (#161) --------------------
412    let observation = match spec.listen {
413        Some(window) => {
414            let store = crate::model::decode::SchemaStore::new(base, spec.timeout);
415            // The GET phase above already asked every producer for its
416            // `describe` document. Hand those to the window's store rather
417            // than letting it re-ask the fleet, mid-window, for what this
418            // run is holding (RFC 08 §7; the store's frugality note).
419            for (producer, set) in &described {
420                store.insert(producer, set.clone());
421            }
422            // And sealed for the window (#337): the GET phase asked every
423            // producer the registry names, so a miss inside the window is a
424            // producer that served nothing — already counted as
425            // `describe_missing`. Left unsealed, that miss is a `describe`
426            // GET awaited inside the drain loop, re-asked every time its
427            // backoff expires, with nobody attending the broadcast.
428            let _sealed = store.seal();
429            let (listen_findings, summary) =
430                observe_traffic(fleet, &slice_set, &store, &described, window).await?;
431            findings.extend(listen_findings);
432            Some(summary)
433        }
434        None => None,
435    };
436
437    Ok(DoctorReport {
438        findings,
439        // `None` when no local registry was given: the served-vs-declared
440        // diff never ran, and the report must say so rather than looking
441        // like "ran, none in sync" (RFC 09 §5.1 O4, review finding R1).
442        synced: locals.is_some().then_some(synced).into(),
443        introspect_answered: answered,
444        live_producers: live,
445        describe_served: described.len(),
446        describe_missing: undescribed,
447        routers: routers.len(),
448        router_version,
449        deep: spec.deep,
450        observation,
451    })
452}
453
454/// How many decode attempts each key gets during the listen window — the
455/// budget that keeps a hot bus from turning the doctor into a load test.
456const DECODE_BUDGET: u8 = 2;
457
458/// The remainder wording every per-key listen check shares.
459const SAME_FINDING: &str = "more key(s) with the same finding";
460
461/// Spill a capped collector into `findings`, followed by the remainder note
462/// when the cap bit.
463///
464/// Filter and judge **into** the collector, never around it (deep-review D4):
465/// the `qos-observed-mismatch` cap used to bound the judged *keys*, so
466/// violators past the first [`FINDING_CAP`] of them vanished and the
467/// remainder note under-counted. [`Examples`] counts what it is offered, so
468/// the note cannot disagree with the population it summarises.
469fn emit_capped(
470    findings: &mut Vec<DoctorFinding>,
471    ex: Examples<DoctorFinding>,
472    check: CheckId,
473    tail: &str,
474) {
475    let more = ex.more(tail);
476
477    findings.extend(ex.into_vec());
478
479    if let Some(evidence) = more {
480        findings.push(finding(
481            DoctorSeverity::Info,
482            check,
483            "fleet",
484            evidence,
485            None,
486        ));
487    }
488}
489
490/// The declared events rate class as an hourly cap (RFC 04 §1.3):
491/// `rare` ≤ 1/h, `low` ≤ 1/min, `burst(n/h)` a declared cap.
492struct RateWindow {
493    /// The declared hourly cap, from [`RateClass::cap_per_hour`]. `None` is
494    /// a rate token this build cannot read — "cannot judge", never a
495    /// guessed budget (RFC 09 §5.1 O4).
496    cap: Option<u64>,
497    /// Samples seen on the family during the window.
498    seen: u64,
499}
500
501/// The passive listening phase: watch the data planes for `window`, judge
502/// each sample through the ladders that already exist — the Registration
503/// ladder, `qos_matches`, `decode_sample` — and aggregate per key so a hot
504/// key is one finding with a count, not a finding per sample.
505async fn observe_traffic(
506    fleet: &crate::Fleet<'_>,
507    slices: &crate::model::registry::SliceSet,
508    store: &crate::model::decode::SchemaStore,
509    described: &[(String, zenkey::schema::SchemaSet)],
510    window: Duration,
511) -> Result<(Vec<DoctorFinding>, crate::report::ObservationSummary)> {
512    use std::collections::{BTreeMap, BTreeSet};
513
514    let (session, base) = (fleet.session(), fleet.base());
515
516    // Scope statement (O5): the three data classes for host origins, plus
517    // each declared service origin's three — `*` never matches an `@` chunk
518    // (D4), so the service planes must be named to be seen. Shared with the
519    // `topic list --budget` observation (#221).
520    let scopes = crate::judge::common::data_plane_scopes(base, slices);
521
522    // The liveliness planes ride too (#422): a `counter` may reset across
523    // its producer's restart, and RFC 08 §2 makes the `alive` token cycling
524    // the one sanctioned reset — so the window has to see the cycle to
525    // excuse the drop. Two selectors, never one: `*` cannot reach a verbatim
526    // service origin (RFC 03 §4 D4), so `@catalog` and every service origin
527    // the slices declare are named. Zero payload by construction (RFC 04 §5).
528    let mut liveliness = vec![
529        fleet.wire(zenkey::selector::all_liveliness(
530            zenkey::selector::Scope::fleet(),
531        )),
532        fleet.wire(zenkey::selector::service_alive(
533            &zenkey::ServiceOrigin::catalog(),
534        )),
535    ];
536    for slice in slices.slices() {
537        if let Some(origin) = slice.service_origin.as_ref().and_then(|o| o.known())
538            && *origin != zenkey::ServiceOrigin::catalog()
539        {
540            liveliness.push(fleet.wire(zenkey::selector::service_alive(origin)));
541        }
542    }
543    let monitor = crate::Monitor::start(
544        session,
545        crate::MonitorSpec {
546            liveliness,
547            ..Default::default()
548        },
549    )
550    .await?;
551    let mut events = monitor.events();
552    // A scope that fails to declare tears the monitor down on the way out,
553    // rather than leaving a `**` subscriber to `Drop` (#336).
554    let monitor = monitor.watching(&scopes).await?;
555    let started = tokio::time::Instant::now();
556    let deadline = started + window;
557
558    // Field intelligence (#223): per-dotted-path stats over the structural
559    // value — sync and schema-free, so it rides every sample within the
560    // decode budget's reach and beyond.
561    let mut fields =
562        crate::judge::field::FieldObservation::new(crate::judge::field::DEFAULT_MAX_PATHS);
563    let mut samples: u64 = 0;
564    let mut dropped: u64 = 0;
565    let mut synthetic: u64 = 0;
566    // Bounded (#107): one projection per distinct key, LRU past the bound,
567    // evictions counted into the observation summary (O6).
568    let mut facts_cache = crate::model::facts::FactsCache::default();
569    let mut decode_budget: BTreeMap<String, u8> = BTreeMap::new();
570    // Per-key aggregates: key → count (+ what was wrong, first occurrence).
571    let mut unregistered: BTreeMap<String, u64> = BTreeMap::new();
572    let mut qos_bad: BTreeMap<String, (String, u64, u64)> = BTreeMap::new();
573    let mut undecodable: BTreeMap<String, (String, u64)> = BTreeMap::new();
574    let mut invalid: BTreeMap<String, (String, u64)> = BTreeMap::new();
575    // Per-family event counts: (family subject, declared rate) → what was
576    // seen and what was declared.
577    let mut event_counts: BTreeMap<(String, String), RateWindow> = BTreeMap::new();
578    // Stamping nodes that are not the publisher (#213): zid → samples.
579    let mut foreign_stampers: BTreeMap<String, u64> = BTreeMap::new();
580    // Declared versus observed `kind` (#422): per key, with the producers
581    // whose `alive` token is currently down — a `NodeUp` that follows one
582    // is a cycle; a lone `NodeUp` (history replaying the tokens that are
583    // simply alive at window start) is not.
584    let mut kinds = crate::judge::kind::KindObservation::new();
585    let mut alive_down: BTreeSet<(String, String)> = BTreeSet::new();
586
587    // One timer for the whole window, not one per iteration (#346).
588    // `sleep_until` builds a future and registers a timer each time it
589    // is evaluated, and a `select!` in a loop evaluates it on every
590    // pass — at 100k samples/s that is 100k registrations a second for
591    // a deadline that never moves.
592    let window_over = tokio::time::sleep_until(deadline);
593    tokio::pin!(window_over);
594    loop {
595        let item = tokio::select! {
596            item = events.recv() => item,
597            () = &mut window_over => break,
598        };
599        match item {
600            Some(crate::StreamItem::Event(crate::FleetEvent::Sample(s))) => {
601                samples += 1;
602                if let Some(att) = &s.attachment
603                    && is_synthetic_marker(&att.to_bytes())
604                {
605                    synthetic += 1;
606                }
607                // Who stamped it (#213). A router doing the timestamping is
608                // not a fault — it is a deployment choice — but it silently
609                // changes what every latency in this suite measures, so it is
610                // worth saying out loud once.
611                if let Some(crate::StampProvenance::Foreign { stamper }) = s.stamped_by {
612                    *foreign_stampers.entry(stamper.to_string()).or_default() += 1;
613                }
614                // A tombstone is a retirement, not a document (RFC 04 §1.2):
615                // a Delete carries no payload to read, decode, or validate,
616                // so the field and payload ladders skip it — judging the
617                // empty body as a value manufactures `payload-undecodable`
618                // out of a correct retirement (zensight#830). Everything
619                // that is a wire fact about the publisher — QoS axes,
620                // registration, stamping — still applies and stays judged.
621                let is_put = s.kind == zenoh::sample::SampleKind::Put;
622                // Same bound as `run_field`'s drain (#346): the parse is
623                // per sample by design, so the payload size is what has to be
624                // bounded, and the skip is counted rather than read as an
625                // absent document.
626                let bytes = s.payload.to_bytes();
627                // The structural document, read once for the field ladder
628                // and the kind judge alike; `None` for an oversized or
629                // undecodable body, which each of them counts as unread.
630                let mut doc = None;
631                if is_put {
632                    if bytes.len() > crate::model::decode::OBSERVE_LIMIT {
633                        fields.observe_unread(&s.key);
634                    } else {
635                        doc = crate::model::decode::structural_value(&bytes);
636                        fields.observe(&s.key, started.elapsed().as_secs_f64(), doc.as_ref());
637                    }
638                }
639                facts_cache.ensure(base, &s.key, Some(slices));
640                let facts = facts_cache.get(&s.key).expect("just ensured this key");
641                match &facts.registration {
642                    crate::model::facts::Registration::Unregistered => {
643                        *unregistered.entry(s.key.clone()).or_default() += 1;
644                    }
645                    crate::model::facts::Registration::Registered(sf) => {
646                        if let (Some(profile), Some(declared)) = (sf.declared_qos(), &sf.qos) {
647                            let entry = qos_bad
648                                .entry(s.key.clone())
649                                .or_insert_with(|| (declared.token().to_string(), 0, 0));
650                            entry.2 += 1;
651                            if !s.qos_matches(profile) {
652                                entry.1 += 1;
653                            }
654                        }
655                        if let (Some(rate), crate::model::facts::KeyShape::V1(v)) =
656                            (&sf.rate, &facts.shape)
657                            && v.class == "events"
658                        {
659                            let family = match &v.producer {
660                                Some(p) => format!("{p}/{}", sf.path),
661                                None => format!("{}/{}", v.origin, sf.path),
662                            };
663                            // The cap is taken from the typed `RateClass`
664                            // here, where it is in hand — this used to
665                            // stringify the token and re-parse it below
666                            // through a second copy of RFC 04 §1.3's
667                            // mapping (#350's sweep).
668                            event_counts
669                                .entry((family, rate.token()))
670                                .or_insert_with(|| RateWindow {
671                                    cap: rate.cap_per_hour(),
672                                    seen: 0,
673                                })
674                                .seen += 1;
675                        }
676                        // Declared `kind` (#422, RFC 08 §2): judged only where
677                        // the entry declares one this build knows — absent
678                        // or foreign is not asked (RFC 13 §3).
679                        if let (Some(declared), crate::model::facts::KeyShape::V1(v)) =
680                            (sf.kind.as_ref().and_then(|k| k.known()), &facts.shape)
681                            && is_put
682                        {
683                            // The producer half of the identity spelled the
684                            // way `token_identity` reads a liveliness token,
685                            // so a cycle lands on the keys it restarted.
686                            let producer = v
687                                .producer
688                                .clone()
689                                .unwrap_or_else(|| v.origin.trim_start_matches('@').to_string());
690                            kinds.observe(&s.key, &v.origin, &producer, *declared, doc.as_ref());
691                        }
692                        let budget = decode_budget.entry(s.key.clone()).or_default();
693                        if is_put && *budget < DECODE_BUDGET {
694                            *budget += 1;
695                            // `Some`: the doctor's slice set comes from its
696                            // own live introspect sweep, so the registry was
697                            // always asked here — `NoRegistry` (#246) cannot
698                            // arise, and like every not-validated reason
699                            // other than `Undecodable` it would fall through
700                            // the `_` arm below: not asked/not checkable is
701                            // never a finding (RFC 09 §5.1 O4).
702                            let d = crate::model::decode::decode_sample(
703                                fleet,
704                                store,
705                                Some(slices),
706                                &s.key,
707                                Some(&s.encoding),
708                                &s.payload.to_bytes(),
709                            )
710                            .await;
711                            match d.verdict {
712                                crate::Verdict::NotValidated(
713                                    zenkey::schema::validate::NotValidated::Undecodable,
714                                ) => {
715                                    let e = undecodable.entry(s.key.clone()).or_insert_with(|| {
716                                        (
717                                            d.decode_error
718                                                .unwrap_or_else(|| "does not decode".into()),
719                                            0,
720                                        )
721                                    });
722                                    e.1 += 1;
723                                }
724                                crate::Verdict::Invalid(errors) => {
725                                    let e = invalid
726                                        .entry(s.key.clone())
727                                        .or_insert_with(|| (errors.join("; "), 0));
728                                    e.1 += 1;
729                                }
730                                _ => {}
731                            }
732                        }
733                    }
734                    _ => {}
735                }
736            }
737            Some(crate::StreamItem::Dropped(n)) => dropped += n,
738            Some(crate::StreamItem::Event(crate::FleetEvent::NodeDown(key))) => {
739                if let Some(id) = crate::bus::roster::token_identity(base, &key) {
740                    alive_down.insert(id);
741                }
742            }
743            Some(crate::StreamItem::Event(crate::FleetEvent::NodeUp(key))) => {
744                // A cycle is down *then* up. History replays the tokens
745                // alive at window start as bare `NodeUp`s; those excuse
746                // nothing.
747                if let Some((origin, producer)) = crate::bus::roster::token_identity(base, &key)
748                    && alive_down.remove(&(origin.clone(), producer.clone()))
749                {
750                    kinds.alive_cycled(&origin, &producer);
751                }
752            }
753            Some(_) => continue,
754            None => break,
755        }
756    }
757    let keys_seen = facts_cache.len();
758    monitor.shutdown().await?;
759
760    // Key-population budgets (#221): the window's distinct keys, grouped
761    // into `{var}` families per origin, judged against each family's
762    // declared `cardinality`.
763    let budgets =
764        crate::judge::budget::BudgetObservation::observe(base, slices, facts_cache.keys());
765
766    let window_s = window.as_secs_f64();
767    let mut findings = Vec::new();
768
769    let mut ex = Examples::new(FINDING_CAP);
770    for (key, (error, n)) in &undecodable {
771        ex.push_with(|| {
772            finding(
773                DoctorSeverity::Error,
774                CheckId::PayloadUndecodable,
775                key.clone(),
776                format!(
777                    "payload does not decode as its declared type: {error} ({n} sample(s) tried)"
778                ),
779                Some("RFC 08 §7"),
780            )
781        });
782    }
783    emit_capped(&mut findings, ex, CheckId::PayloadUndecodable, SAME_FINDING);
784    let mut ex = Examples::new(FINDING_CAP);
785    for (key, (violations, n)) in &invalid {
786        ex.push_with(|| {
787            finding(
788                DoctorSeverity::Error,
789                CheckId::PayloadInvalid,
790                key.clone(),
791                format!("payload violates the served schema: {violations} ({n} sample(s) tried)"),
792                Some("RFC 08 §7"),
793            )
794        });
795    }
796    emit_capped(&mut findings, ex, CheckId::PayloadInvalid, SAME_FINDING);
797    findings.extend(judge_qos_observed(&qos_bad));
798    if !foreign_stampers.is_empty() {
799        let mut named: Vec<String> = foreign_stampers
800            .iter()
801            .map(|(zid, n)| format!("{zid} ({n} sample(s))"))
802            .collect();
803        named.sort();
804        findings.push(finding(
805            DoctorSeverity::Info,
806            CheckId::TimestampStampedElsewhere,
807            "fleet".to_string(),
808            format!(
809                "HLCs on this bus are stamped by {} node(s) that are not the publishing \
810                 session — a deployment with router-side timestamping, which is legal and \
811                 common. Latency measured from these stamps is stamper→observer, not \
812                 publisher→observer: {}",
813                foreign_stampers.len(),
814                named.join(", ")
815            ),
816            Some("RFC 09 §5.1 O7"),
817        ));
818    }
819    let mut ex = Examples::new(FINDING_CAP);
820    for (key, n) in &unregistered {
821        ex.push_with(|| {
822            finding(
823                DoctorSeverity::Warning,
824                CheckId::UnregisteredTraffic,
825                key.clone(),
826                format!(
827                    "{n} sample(s) on a subject the producer's slice does not declare — \
828                     for a conforming producer, a subject that is not registered does not exist"
829                ),
830                Some("RFC 08 §2"),
831            )
832        });
833    }
834    emit_capped(
835        &mut findings,
836        ex,
837        CheckId::UnregisteredTraffic,
838        SAME_FINDING,
839    );
840    // Over-rate only, and only when provable: within any window no longer
841    // than an hour, exceeding the hourly cap is conclusive. Absence or
842    // under-rate in a bounded window is never a finding (O1/O4).
843    if window <= Duration::from_secs(3600) {
844        for ((family, rate), RateWindow { cap, seen: count }) in &event_counts {
845            let Some(cap) = cap else {
846                continue;
847            };
848            if count > cap {
849                findings.push(finding(
850                    DoctorSeverity::Warning,
851                    CheckId::RateOverDeclared,
852                    family.clone(),
853                    format!(
854                        "{count} event(s) in {window_s:.0}s exceeds the declared \
855                         `{rate}` cap ({cap}/h)"
856                    ),
857                    Some("RFC 04 §1.3"),
858                ));
859            }
860        }
861    }
862
863    findings.extend(judge_cardinality(slices, &budgets, window_s));
864    findings.extend(crate::judge::kind::judge_kind(&kinds, window_s));
865
866    // Field intelligence (#223): the three field-granular checks, judged
867    // with what is known per key — declared `ttl_s`/type from the resolved
868    // facts, declared paths from the describe sets the GET phase gathered.
869    let field_ctx = field_context_from(slices, described, &facts_cache);
870    findings.extend(crate::judge::field::judge_fields(
871        &fields, window_s, &field_ctx,
872    ));
873
874    Ok((
875        findings,
876        crate::report::ObservationSummary {
877            window_s,
878            scopes,
879            samples,
880            keys_seen,
881            dropped,
882            synthetic_marked: synthetic,
883            // The per-path table is bounded like every other table here, and
884            // its cost is a wire fact (RFC 09 §5.1 O6).
885            field_paths_dropped: fields.dropped_paths(),
886            facts_evicted: facts_cache.evicted(),
887        },
888    ))
889}
890
891/// The per-key context the field judges need (#223), built from the listen
892/// phase's resolved facts and the already-gathered describe sets — pure, so
893/// the join is testable without a bus.
894fn field_context_from(
895    slices: &crate::model::registry::SliceSet,
896    described: &[(String, zenkey::schema::SchemaSet)],
897    facts: &crate::model::facts::FactsCache,
898) -> std::collections::BTreeMap<String, crate::judge::field::KeyFieldContext> {
899    use std::collections::BTreeMap;
900
901    let mut declared_cache: BTreeMap<(String, String), Option<crate::judge::field::DeclaredPaths>> =
902        BTreeMap::new();
903    let mut ctx = BTreeMap::new();
904    for (key, f) in facts.iter() {
905        let mut c = crate::judge::field::KeyFieldContext::default();
906        if let crate::model::facts::Registration::Registered(sf) = &f.registration {
907            c.ttl_s = sf.ttl_s;
908            c.type_name = Some(sf.type_name.clone());
909            if let Some(producer) = crate::judge::common::producer_of(f, Some(slices))
910                && !sf.type_name.is_empty()
911            {
912                let declared = declared_cache
913                    .entry((producer.clone(), sf.type_name.clone()))
914                    .or_insert_with(|| {
915                        described
916                            .iter()
917                            .find(|(name, _)| *name == producer)
918                            .and_then(|(_, set)| set.get(&sf.type_name))
919                            .and_then(|schema| schema.json_document())
920                            .and_then(crate::judge::field::DeclaredPaths::from_json_schema)
921                    });
922                c.declared = declared.clone();
923            }
924        }
925        ctx.insert(key.to_string(), c);
926    }
927    ctx
928}
929
930/// The `qos-observed-mismatch` findings from the listen window's per-key
931/// aggregates: `key → (declared profile, mismatched, judged)` — pure, so
932/// the cap arithmetic is testable without a bus.
933///
934/// Filter **then** cap, [`judge_cardinality`]'s pattern (deep-review D4):
935/// the map holds every judged key, most of them clean, so capping the map
936/// *entries* first silently dropped violators past the first
937/// [`FINDING_CAP`] keys and made the remainder note miscount. The cap
938/// bounds the findings; the filter decides what a finding is.
939fn judge_qos_observed(
940    qos_bad: &std::collections::BTreeMap<String, (String, u64, u64)>,
941) -> Vec<DoctorFinding> {
942    let mut findings = Vec::new();
943
944    let mut ex = Examples::new(FINDING_CAP);
945
946    for (key, (declared, bad, total)) in qos_bad.iter().filter(|(_, (_, bad, _))| *bad > 0) {
947        ex.push_with(|| {
948            finding(
949                DoctorSeverity::Warning,
950                CheckId::QosObservedMismatch,
951                key.clone(),
952                format!(
953                    "{bad} of {total} sample(s) did not ride the declared {declared} — this \
954                     is what actually rode: an interceptor MAY rewrite QoS, so it is a \
955                     deviation, not proof of the publisher"
956                ),
957                Some("RFC 04 §3"),
958            )
959        });
960    }
961    emit_capped(
962        &mut findings,
963        ex,
964        CheckId::QosObservedMismatch,
965        SAME_FINDING,
966    );
967    findings
968}
969
970/// Judge introspect coverage — "alive ⇒ callable" (RFC 04 §5) — against the
971/// producers that were actually asked. Pure, so the O4 boundary is testable
972/// without a bus.
973///
974/// `locals: Some` is the `--registry` run: only the producers the local
975/// slices name were queried, so only those count toward coverage — a live
976/// producer whose slice a *partial* registry does not carry was never asked,
977/// and counting it as "did not answer" would be a false finding (RFC 09
978/// §5.1 O4; deep-review D3). `None` is the wildcard sweep, where every
979/// roster producer was in the fan-in. Either way the evidence states the
980/// scope it checked.
981///
982/// Matching follows the roster's own conventions: an instance suffix shares
983/// its base slice (`sysinfo-2` → `sysinfo`, RFC 03 §1.5), and a service
984/// origin's token names the service as its producer (RFC 06 §5), matched by
985/// the slice's declared origin or name.
986fn judge_introspect_coverage(
987    roster: &std::collections::BTreeMap<String, Vec<String>>,
988    locals: Option<&[RegistrySlice]>,
989    answered: usize,
990) -> Option<DoctorFinding> {
991    let live: usize = roster.values().map(Vec::len).sum();
992
993    let (in_scope, scope) = match locals {
994        None => (
995            live,
996            "scope: the whole roster (fleet-wide wildcard sweep)".to_string(),
997        ),
998        Some(locals) => {
999            let named = |origin: &str, producer: &str| {
1000                let base_name = zenkey::grammar::Producer::parse_chunk(producer)
1001                    .map(|p| p.name().to_string())
1002                    .unwrap_or_else(|_| producer.to_string());
1003                locals.iter().any(|l| {
1004                    l.name == base_name
1005                        || l.service_origin.as_ref().map(Declared::token) == Some(origin)
1006                })
1007            };
1008            let in_scope: usize = roster
1009                .iter()
1010                .map(|(origin, producers)| producers.iter().filter(|p| named(origin, p)).count())
1011                .sum();
1012            let mut names: Vec<&str> = locals.iter().map(|l| l.name.as_str()).collect();
1013            names.sort_unstable();
1014            names.dedup();
1015            let not_asked = live - in_scope;
1016            (
1017                in_scope,
1018                format!(
1019                    "scope: the producer(s) the local registry names ({}); {} other \
1020                     live producer(s) were not asked and are not counted (O4)",
1021                    names.join(", "),
1022                    not_asked
1023                ),
1024            )
1025        }
1026    };
1027    (answered < in_scope).then(|| {
1028        finding(
1029            DoctorSeverity::Error,
1030            CheckId::IntrospectCoverage,
1031            "fleet",
1032            format!(
1033                "{} of {} live producer(s) in scope did not answer introspect — \
1034                 alive ⇒ callable, so this is a finding, not a boot race; {scope}",
1035                in_scope - answered,
1036                in_scope
1037            ),
1038            Some("RFC 04 §5"),
1039        )
1040    })
1041}
1042
1043/// Judge one state family's samples against its declared ttl — pure, so the
1044/// freshness math is testable without a bus. Returns the stale findings and
1045/// the count of unstamped samples (aggregated by the caller into the one
1046/// `unstamped-state` finding).
1047fn judge_state_samples(
1048    samples: &[crate::StateSample],
1049    ttl: i64,
1050    now: std::time::SystemTime,
1051) -> (Vec<DoctorFinding>, usize) {
1052    let mut findings = Vec::new();
1053
1054    let mut unstamped = 0usize;
1055
1056    for sample in samples {
1057        match sample.timestamp {
1058            Some(ts) => {
1059                let stamped = ts.get_time().to_system_time();
1060                if let Ok(age) = now.duration_since(stamped)
1061                    && age.as_secs() as i64 > ttl
1062                {
1063                    findings.push(finding(
1064                        DoctorSeverity::Error,
1065                        CheckId::StaleState,
1066                        sample.key.clone(),
1067                        format!(
1068                            "{}s old against ttl {ttl}s (refresh <= ttl/2)",
1069                            age.as_secs()
1070                        ),
1071                        Some("RFC 04 §1.2"),
1072                    ));
1073                }
1074            }
1075            None => unstamped += 1,
1076        }
1077    }
1078    (findings, unstamped)
1079}
1080
1081/// Judge every declared `{var}` family's key population against its declared
1082/// `cardinality` (#221) — pure, so the acceptance case (declared 16, 40
1083/// observed) is testable without a bus.
1084///
1085/// The honesty rules, verbatim from the issue:
1086///
1087/// - Observed **over** declared is a finding (RFC 04 §1.2's budget is a
1088///   MUST); observed **under** declared is **not** — an idle host declares
1089///   nothing wrong, and a bounded window proves a lower bound, never the
1090///   population (RFC 09 §5.1 O4/O6). The window rides in the evidence.
1091/// - `{path...}` rest-variable families are unbounded by construction and
1092///   are **exempt and say so** — "exempt: rest-variable", never a silent
1093///   skip and never a pass (the RFC 08 §6.1 v1.20 shape for subject checks).
1094/// - Judged **per origin**: RFC 04 §1 bounds cardinality per producer, so
1095///   one origin over the bound is conclusive and two origins' healthy
1096///   populations are never summed into a fake violation.
1097fn judge_cardinality(
1098    slices: &crate::model::registry::SliceSet,
1099    observed: &crate::judge::budget::BudgetObservation,
1100    window_s: f64,
1101) -> Vec<DoctorFinding> {
1102    let mut findings = Vec::new();
1103
1104    let mut over: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
1105
1106    for slice in slices.slices() {
1107        for s in &slice.subjects {
1108            if !s.path.contains('{') {
1109                continue; // a literal subject's population is 1 by construction
1110            }
1111            if s.path.contains("...") {
1112                let seen: usize = observed
1113                    .family(&slice.name, &s.path)
1114                    .map(|origins| origins.values().map(|keys| keys.len()).sum())
1115                    .unwrap_or(0);
1116                findings.push(finding(
1117                    DoctorSeverity::Info,
1118                    CheckId::CardinalityOverDeclared,
1119                    format!("{}/{}", slice.name, s.path),
1120                    format!(
1121                        "exempt: rest-variable — a `{{var...}}` family is unbounded by \
1122                         construction, so its declared cardinality is not a bound this \
1123                         check can pass or fail; {seen} distinct key(s) observed in \
1124                         {window_s:.0}s"
1125                    ),
1126                    Some("RFC 08 §6.1"),
1127                ));
1128                continue;
1129            }
1130            let Some(declared) = s.cardinality else {
1131                continue; // nothing declared, nothing to judge (the RFC 08 §5
1132                // lint that requires the field is the producer build's)
1133            };
1134            let Some(origins) = observed.family(&slice.name, &s.path) else {
1135                continue; // unobserved is not "within budget" — no verdict
1136            };
1137            for (origin, keys) in origins {
1138                if keys.len() as i64 <= declared {
1139                    continue; // under/at declared: not a finding (O4)
1140                }
1141                let examples = Examples::collect(
1142                    crate::judge::common::EXPANSION_CAP,
1143                    keys.iter().map(String::as_str),
1144                );
1145                let subject = if origin.starts_with('@') {
1146                    format!("{origin}/{}", s.path)
1147                } else {
1148                    format!("{origin}/{}/{}", slice.name, s.path)
1149                };
1150                over.push_with(|| {
1151                    finding(
1152                        DoctorSeverity::Warning,
1153                        CheckId::CardinalityOverDeclared,
1154                        subject,
1155                        format!(
1156                            "{} distinct key(s) observed in {window_s:.0}s exceed the \
1157                         declared cardinality {declared} — e.g. {}. A bounded window \
1158                         observes a lower bound: the population is at least this",
1159                            keys.len(),
1160                            examples.as_slice().join(", ")
1161                        ),
1162                        Some("RFC 04 §1.2"),
1163                    )
1164                });
1165            }
1166        }
1167    }
1168    emit_capped(
1169        &mut findings,
1170        over,
1171        CheckId::CardinalityOverDeclared,
1172        "more origin famil(y|ies) over their declared cardinality",
1173    );
1174    findings
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    const BOUNDED: &str = r#"
1182        [registry]
1183        version = "1.0"
1184        app = "t"
1185        convention = 1
1186        [producer]
1187        name = "sysinfo"
1188        [[subject]]
1189        path = "disk/{mount}/used"
1190        class = "telemetry"
1191        type = "Point"
1192        cardinality = 16
1193    "#;
1194
1195    /// The #221 acceptance case: declared 16, 40 observed expansions — the
1196    /// finding fires with the count, the declared bound, examples, and the
1197    /// window it rests on.
1198    #[test]
1199    fn cardinality_over_declared_fires_with_count_and_examples() {
1200        let slices = crate::model::registry::SliceSet::from_toml_for_tests(BOUNDED);
1201        let keys: Vec<String> = (0..40)
1202            .map(|i| format!("v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/m{i:02}/used"))
1203            .collect();
1204        let obs = crate::judge::budget::BudgetObservation::observe(
1205            "",
1206            &slices,
1207            keys.iter().map(String::as_str),
1208        );
1209        let findings = judge_cardinality(&slices, &obs, 10.0);
1210        assert_eq!(findings.len(), 1, "{findings:?}");
1211        let f = &findings[0];
1212        assert_eq!(f.check, CheckId::CardinalityOverDeclared);
1213        assert_eq!(f.severity, DoctorSeverity::Warning);
1214        assert_eq!(f.subject, "h-aaaaaaaaaaaa/sysinfo/disk/{mount}/used");
1215        assert!(f.evidence.contains("40 distinct key(s)"), "{}", f.evidence);
1216        assert!(f.evidence.contains("cardinality 16"), "{}", f.evidence);
1217        assert!(f.evidence.contains("10s"), "the window is stated");
1218        assert!(
1219            f.evidence.contains("disk/m00/used"),
1220            "examples are named: {}",
1221            f.evidence
1222        );
1223    }
1224
1225    /// Under (or at) the declared bound is **not** a finding: an idle host
1226    /// declares nothing wrong, and a bounded window proves a lower bound,
1227    /// never the population (O4/O6).
1228    #[test]
1229    fn cardinality_under_declared_is_not_a_finding() {
1230        let slices = crate::model::registry::SliceSet::from_toml_for_tests(BOUNDED);
1231        let keys = [
1232            "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/root/used",
1233            "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/disk/var/used",
1234            // Two origins at 15 each must never be summed into a fake 30 > 16.
1235            "v1/h-bbbbbbbbbbbb/telemetry/sysinfo/disk/root/used",
1236        ];
1237        let obs = crate::judge::budget::BudgetObservation::observe("", &slices, keys);
1238        assert!(judge_cardinality(&slices, &obs, 5.0).is_empty());
1239    }
1240
1241    /// The other #221 acceptance case: a `{path...}` family yields the
1242    /// exemption wording — "exempt: rest-variable" — and never a pass (nor an
1243    /// over-finding, however many members it grows).
1244    #[test]
1245    fn rest_variable_families_are_exempt_and_say_so() {
1246        let toml = r#"
1247            [registry]
1248            version = "1.0"
1249            app = "t"
1250            convention = 1
1251            [producer]
1252            name = "gnmi"
1253            [[subject]]
1254            path = "{device}/{path...}"
1255            class = "telemetry"
1256            type = "Point"
1257            cardinality = 2
1258        "#;
1259        let slices = crate::model::registry::SliceSet::from_toml_for_tests(toml);
1260        let keys: Vec<String> = (0..5)
1261            .map(|i| format!("v1/h-aaaaaaaaaaaa/telemetry/gnmi/sw1/if/eth{i}/rx"))
1262            .collect();
1263        let obs = crate::judge::budget::BudgetObservation::observe(
1264            "",
1265            &slices,
1266            keys.iter().map(String::as_str),
1267        );
1268        let findings = judge_cardinality(&slices, &obs, 5.0);
1269        assert_eq!(findings.len(), 1, "{findings:?}");
1270        let f = &findings[0];
1271        assert_eq!(f.severity, DoctorSeverity::Info, "an exemption, not a pass");
1272        assert!(
1273            f.evidence.starts_with("exempt: rest-variable"),
1274            "{}",
1275            f.evidence
1276        );
1277        assert!(f.evidence.contains("5 distinct key(s)"), "{}", f.evidence);
1278        // And with nothing observed the family still says so — exempt is a
1279        // property of the declaration, not of the traffic.
1280        let quiet = judge_cardinality(&slices, &Default::default(), 5.0);
1281        assert_eq!(quiet.len(), 1);
1282        assert!(quiet[0].evidence.starts_with("exempt: rest-variable"));
1283    }
1284
1285    /// Deep-review D4: the qos-observed-mismatch cap bounds *violators*, not
1286    /// map entries. 30 judged keys where the first 5 (in map order) are
1287    /// clean and the remaining 25 violate: every violator is counted — the
1288    /// first 20 as findings, the other 5 in a remainder note that counts
1289    /// correctly. Capping before filtering used to drop the violators past
1290    /// the first 20 map entries and miscount the note.
1291    #[test]
1292    fn qos_mismatch_cap_bounds_violators_not_map_entries() {
1293        let mut qos_bad: std::collections::BTreeMap<String, (String, u64, u64)> =
1294            std::collections::BTreeMap::new();
1295        for i in 0..30u32 {
1296            // k00..k04 sort first and are clean; k05..k29 are violators.
1297            let bad = if i < 5 { 0 } else { 1 };
1298            qos_bad.insert(
1299                format!("v1/h-a/telemetry/x/k{i:02}"),
1300                ("tel".into(), bad, 10),
1301            );
1302        }
1303        let findings = judge_qos_observed(&qos_bad);
1304        let per_key: Vec<&DoctorFinding> = findings
1305            .iter()
1306            .filter(|f| f.severity == DoctorSeverity::Warning)
1307            .collect();
1308        assert_eq!(per_key.len(), FINDING_CAP, "the cap bounds the findings");
1309        assert!(
1310            per_key.iter().all(|f| f.evidence.starts_with("1 of 10")),
1311            "only violators become findings: {findings:#?}"
1312        );
1313        assert!(
1314            per_key.iter().any(|f| f.subject.ends_with("k24")),
1315            "violators past the first {FINDING_CAP} map entries (k20..k24) are \
1316             not dropped: {findings:#?}"
1317        );
1318        let note = findings
1319            .iter()
1320            .find(|f| f.severity == DoctorSeverity::Info)
1321            .expect("a remainder note");
1322        assert_eq!(
1323            note.evidence, "… and 5 more key(s) with the same finding",
1324            "the note counts violators (25 − 20), not map entries"
1325        );
1326
1327        // At or under the cap: every violator is a finding, no note.
1328        let few: std::collections::BTreeMap<String, (String, u64, u64)> = qos_bad
1329            .iter()
1330            .take(10)
1331            .map(|(k, v)| (k.clone(), v.clone()))
1332            .collect();
1333        let findings = judge_qos_observed(&few);
1334        assert_eq!(findings.len(), 5, "{findings:#?}");
1335        assert!(
1336            findings
1337                .iter()
1338                .all(|f| f.severity == DoctorSeverity::Warning)
1339        );
1340    }
1341
1342    fn roster_of(entries: &[(&str, &[&str])]) -> std::collections::BTreeMap<String, Vec<String>> {
1343        entries
1344            .iter()
1345            .map(|(origin, producers)| {
1346                (
1347                    origin.to_string(),
1348                    producers.iter().map(|p| p.to_string()).collect(),
1349                )
1350            })
1351            .collect()
1352    }
1353
1354    fn slice_named(name: &str) -> RegistrySlice {
1355        zenkey::parse_slice(&format!(
1356            "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n\
1357             [producer]\nname = \"{name}\"\n"
1358        ))
1359        .expect("fixture slice parses")
1360    }
1361
1362    /// Deep-review D3: with `--registry` covering a subset of the fleet, a
1363    /// live producer the locals do not name was never asked — so it must not
1364    /// count as "did not answer" (O4). One local slice, answered by its one
1365    /// origin, beside an extra live producer: no finding.
1366    #[test]
1367    fn a_partial_registry_does_not_count_unasked_producers_against_coverage() {
1368        let roster = roster_of(&[("h-aaaaaaaaaaaa", &["sysinfo", "extra"])]);
1369        let locals = [slice_named("sysinfo")];
1370        assert_eq!(
1371            judge_introspect_coverage(&roster, Some(&locals), 1),
1372            None,
1373            "the un-asked producer is out of scope, not silent"
1374        );
1375    }
1376
1377    /// …and when an in-scope producer really did not answer, the finding
1378    /// fires and its evidence states the scope it checked — including that
1379    /// the out-of-scope producer was not counted. An instance suffix shares
1380    /// its base slice (RFC 03 §1.5), so `sysinfo-2` is in scope too.
1381    #[test]
1382    fn introspect_coverage_evidence_states_its_scope() {
1383        let roster = roster_of(&[
1384            ("h-aaaaaaaaaaaa", &["sysinfo", "extra"]),
1385            ("h-bbbbbbbbbbbb", &["sysinfo-2"]),
1386        ]);
1387        let locals = [slice_named("sysinfo")];
1388        let f = judge_introspect_coverage(&roster, Some(&locals), 1).expect("a finding");
1389        assert_eq!(f.check, CheckId::IntrospectCoverage);
1390        assert!(f.evidence.contains("1 of 2"), "{}", f.evidence);
1391        assert!(
1392            f.evidence.contains("the local registry names (sysinfo)"),
1393            "{}",
1394            f.evidence
1395        );
1396        assert!(
1397            f.evidence
1398                .contains("1 other live producer(s) were not asked"),
1399            "{}",
1400            f.evidence
1401        );
1402    }
1403
1404    /// A service origin's token names the service as its producer (RFC 06
1405    /// §5); a local slice matches it by declared origin.
1406    #[test]
1407    fn a_service_slice_scopes_its_origin_into_coverage() {
1408        let roster = roster_of(&[("@catalog", &["catalog"]), ("h-aaaaaaaaaaaa", &["extra"])]);
1409        let locals = [zenkey::parse_slice(
1410            "[registry]\nversion = \"1.0\"\napp = \"t\"\nconvention = 1\n\
1411             [service]\nname = \"catalog\"\norigin = \"@catalog\"\n",
1412        )
1413        .expect("service slice parses")];
1414        assert_eq!(judge_introspect_coverage(&roster, Some(&locals), 1), None);
1415        let f = judge_introspect_coverage(&roster, Some(&locals), 0).expect("a finding");
1416        assert!(f.evidence.contains("1 of 1"), "{}", f.evidence);
1417    }
1418
1419    /// The wildcard sweep keeps the whole roster in scope, and says so.
1420    #[test]
1421    fn the_wildcard_sweep_judges_the_whole_roster() {
1422        let roster = roster_of(&[("h-aaaaaaaaaaaa", &["sysinfo", "extra"])]);
1423        let f = judge_introspect_coverage(&roster, None, 1).expect("a finding");
1424        assert!(f.evidence.contains("1 of 2"), "{}", f.evidence);
1425        assert!(f.evidence.contains("whole roster"), "{}", f.evidence);
1426        assert_eq!(judge_introspect_coverage(&roster, None, 2), None);
1427    }
1428
1429    #[test]
1430    fn freshness_judgement_is_pure_and_ttl_bound() {
1431        let now = std::time::SystemTime::now();
1432        let fresh_ts = zenoh::time::Timestamp::new(
1433            zenoh::time::NTP64::from(now.duration_since(std::time::UNIX_EPOCH).unwrap()),
1434            zenoh::time::TimestampId::rand(),
1435        );
1436        let stale_ts = zenoh::time::Timestamp::new(
1437            zenoh::time::NTP64::from(
1438                now.duration_since(std::time::UNIX_EPOCH).unwrap() - Duration::from_secs(120),
1439            ),
1440            zenoh::time::TimestampId::rand(),
1441        );
1442        let samples = vec![
1443            crate::StateSample {
1444                key: "b/v1/h-aaaaaaaaaaaa/state/p/health".into(),
1445                timestamp: Some(fresh_ts),
1446                payload_len: 2,
1447            },
1448            crate::StateSample {
1449                key: "b/v1/h-bbbbbbbbbbbb/state/p/health".into(),
1450                timestamp: Some(stale_ts),
1451                payload_len: 2,
1452            },
1453            crate::StateSample {
1454                key: "b/v1/h-cccccccccccc/state/p/health".into(),
1455                timestamp: None,
1456                payload_len: 2,
1457            },
1458        ];
1459        let (findings, unstamped) = judge_state_samples(&samples, 30, now);
1460        assert_eq!(
1461            findings.len(),
1462            1,
1463            "only the stale stamped sample is a finding"
1464        );
1465        assert_eq!(findings[0].check, CheckId::StaleState);
1466        assert!(findings[0].subject.contains("h-bbbbbbbbbbbb"));
1467        assert_eq!(unstamped, 1, "the unstamped sample is counted, not judged");
1468    }
1469}