Skip to main content

zenkey_fleet/judge/
condition.rs

1//! Conditions and the watchdog (#227) — transitions, not states.
2//!
3//! Three shipped features each hard-coded their own predicate over the
4//! observation surface: `expect` (one window), `doctor --for` (five
5//! checks), `cutover` (silence). This module is the one **closed vocabulary**
6//! they were each a spelling of: [`Condition`], evaluated to three states,
7//! never two (RFC 09 §5.1 O4/O6) — `ok` / `firing` / **`unobservable`**. The
8//! third state is the reason this exists: an alerting tool that cannot say
9//! *"I could not tell"* is the one that pages at 3am for a dropped buffer. A
10//! drop under a completeness claim yields `unobservable`, never `ok`.
11//!
12//! The vocabulary is deliberately closed — no expressions, no templating, no
13//! rules engine. A new condition is a new variant, argued for the way a new
14//! doctor check id is.
15//!
16//! The semantic core is three tiny rules — [`judge_shortfall`],
17//! [`judge_excess`], [`judge_silence`] — shared with [`crate::judge::expect`], so
18//! the watchdog and the CI assertion cannot drift about what a drop means.
19//! Since RFC 13 (v1.24; the material was RFC 09 §5.1 pre-v1.24) the rules
20//! speak the four-pole [`Judgement`] core, and [`CondState`] is this
21//! module's serde-stable **wire projection** of it — see its mapping doc.
22//!
23//! [`run_watchdog`] is the continuous observer over the vocabulary:
24//! **foreground, explicitly launched, single-purpose, one process per
25//! invocation, no shared state** — not the hidden, auto-started,
26//! discovery-caching daemon the redesign ledger rejected
27//! (`docs/redesign-2026-07.md` §6.1). It emits [`Transition`]s: one per
28//! genuine state change, none per unchanged tick.
29
30use std::collections::BTreeMap;
31use std::time::Duration;
32
33use crate::{Error, Result};
34
35use crate::model::decode::SchemaStore;
36use crate::model::registry::SliceSet;
37use crate::report::{CheckId, DoctorReport};
38use crate::report::{CondState, Judgement, Transition, WatchdogSummary};
39
40/// The closed condition vocabulary (#227), over the existing observation
41/// surface. Each variant names what *firing* means; the drop rules are in
42/// the judge functions this module documents.
43#[derive(Debug, Clone, PartialEq)]
44pub enum Condition {
45    /// Samples on `selector` rode above `hz` over the evaluation window.
46    /// Firing is positive evidence, conclusive even under drops (a drop only
47    /// hides more); `ok` under drops is unobservable — the true rate is
48    /// higher than what was counted (O6).
49    RateAbove { selector: String, hz: f64 },
50    /// Samples on `selector` rode below `hz`. A shortfall under drops is
51    /// unobservable — the dropped samples could have filled it (O6); enough
52    /// observed is conclusive `ok` regardless.
53    RateBelow { selector: String, hz: f64 },
54    /// No sample matched `selector` for at least `for_s` seconds. Silence is
55    /// a completeness claim — it counts what did NOT happen — so it is
56    /// provable only over a drop-free span at least `for_s` long (O6), and
57    /// only once the observer has watched that long (O4).
58    SilentFor { selector: String, for_s: f64 },
59    /// An observed payload on `selector` did not reach [`crate::Verdict::Valid`]
60    /// (#159) — `Invalid` and `NotValidated` both count: asking for validity
61    /// and getting "unknowable" is not valid. Scoped to what was observed
62    /// and checked; the `ok` state claims "nothing checked failed", never
63    /// "nothing invalid rode" — the drop count rides in the evidence.
64    InvalidPayload { selector: String },
65    /// An observed sample on `selector` did not ride its registry-declared
66    /// QoS profile (RFC 04 §3). Same per-observed-sample scope as
67    /// [`Condition::InvalidPayload`]; samples with no declared profile are
68    /// unjudgeable and counted in the evidence, not the state.
69    QosMismatch { selector: String },
70    /// A doctor run reported at least one finding with this check id
71    /// (the stable [`crate::report::CheckId`] vocabulary). A failed doctor run is
72    /// unobservable for every doctor condition — never `ok`.
73    DoctorCheck { check: CheckId },
74    /// The origin holds no `alive` token on the liveliness roster
75    /// (RFC 04 §5). A roster that could not be asked is unobservable —
76    /// silence is not a verdict (RFC 05 §3.1).
77    OriginDown { origin: String },
78    /// The observer itself dropped samples this window (RFC 09 §5.1 O6) —
79    /// self-knowledge, so never unobservable.
80    Dropped,
81}
82
83/// The rule grammar, spelled once for the parse error and the docs.
84const VOCABULARY: &str = "rate-above <SEL> <HZ> | rate-below <SEL> <HZ> | \
85     silent-for <SEL> <SECS> | invalid-payload <SEL> | qos-mismatch <SEL> | \
86     doctor <CHECK-ID> | origin-down <ORIGIN> | dropped";
87
88impl Condition {
89    /// Parse one rule: whitespace-separated, kind first (Zenoh key
90    /// expressions cannot contain whitespace, so the split is unambiguous).
91    /// The vocabulary is closed; anything else is an error that spells it.
92    pub fn parse(rule: &str) -> Result<Condition> {
93        let hz = |s: &str, kind: &str| -> Result<f64> {
94            let v: f64 = s
95                .parse()
96                .map_err(|_| Error::unaskable(format!("{kind} {s:?}"), "is not a number"))?;
97            if !v.is_finite() || v < 0.0 {
98                return Err(Error::unaskable(
99                    kind.to_string(),
100                    "the threshold must be a finite non-negative number",
101                ));
102            }
103            Ok(v)
104        };
105        let tokens: Vec<&str> = rule.split_whitespace().collect();
106        Ok(match tokens.as_slice() {
107            ["rate-above", sel, n] => Condition::RateAbove {
108                selector: sel.to_string(),
109                hz: hz(n, "rate-above")?,
110            },
111            ["rate-below", sel, n] => Condition::RateBelow {
112                selector: sel.to_string(),
113                hz: hz(n, "rate-below")?,
114            },
115            ["silent-for", sel, n] => {
116                let for_s = hz(n, "silent-for")?;
117                if for_s <= 0.0 {
118                    return Err(Error::unaskable(
119                        "silent-for",
120                        "the span must be a positive number of seconds",
121                    ));
122                }
123                Condition::SilentFor {
124                    selector: sel.to_string(),
125                    for_s,
126                }
127            }
128            ["invalid-payload", sel] => Condition::InvalidPayload {
129                selector: sel.to_string(),
130            },
131            ["qos-mismatch", sel] => Condition::QosMismatch {
132                selector: sel.to_string(),
133            },
134            ["doctor", check] => {
135                let Some(check) = CheckId::parse(check) else {
136                    return Err(Error::unaskable(
137                        format!("doctor {check:?}"),
138                        format!(
139                            "is not a check id — the stable vocabulary is: {}",
140                            CheckId::ALL
141                                .iter()
142                                .map(|c| c.as_str())
143                                .collect::<Vec<_>>()
144                                .join(", ")
145                        ),
146                    ));
147                };
148                Condition::DoctorCheck { check }
149            }
150            ["origin-down", origin] => Condition::OriginDown {
151                origin: origin.to_string(),
152            },
153            ["dropped"] => Condition::Dropped,
154            _ => {
155                return Err(Error::unaskable(
156                    format!("{rule:?}"),
157                    format!(
158                        "is not a rule — the vocabulary is closed (no \
159                         expressions, no templating): {VOCABULARY}"
160                    ),
161                ));
162            }
163        })
164    }
165
166    /// The wire selector this condition observes, when it observes one.
167    pub fn selector(&self) -> Option<&str> {
168        match self {
169            Condition::RateAbove { selector, .. }
170            | Condition::RateBelow { selector, .. }
171            | Condition::SilentFor { selector, .. }
172            | Condition::InvalidPayload { selector }
173            | Condition::QosMismatch { selector } => Some(selector),
174            _ => None,
175        }
176    }
177
178    /// Judge one observation window. `None` for the conditions that are not
179    /// window-scoped ([`Condition::DoctorCheck`], [`Condition::OriginDown`]).
180    /// Judge this condition against everything one tick observed.
181    ///
182    /// **The single entry point**, and why `run_watchdog` has no `expect`s
183    /// left (#352). The three judges below each returned `None` for the
184    /// variants they do not own, which forced the caller to assert a
185    /// partition the compiler could not see — four times, every one
186    /// discharging the same claim. This match *is* the partition, and each
187    /// arm hands its judge exactly the evidence that judge needs, so none of
188    /// them has a `None` to return.
189    pub fn judge(&self, ev: &TickEvidence<'_>) -> Eval {
190        match self {
191            Condition::DoctorCheck { check } => judge_doctor_check(*check, ev.doctor),
192            Condition::OriginDown { origin } => judge_origin_down(origin, ev.roster),
193            _ => self.judge_window_total(ev.window),
194        }
195    }
196
197    pub fn judge_window(&self, w: &CondWindow) -> Option<Eval> {
198        let synth = if w.synthetic > 0 {
199            format!("; {} synthetic-marked (RFC 09 §5.3)", w.synthetic)
200        } else {
201            String::new()
202        };
203        let rate = if w.window_s > 0.0 {
204            w.samples as f64 / w.window_s
205        } else {
206            0.0
207        };
208        Some(match self {
209            Condition::RateAbove { hz, .. } => {
210                let state = CondState::from(judge_excess(rate > *hz, w.dropped));
211                let evidence = match state {
212                    CondState::Unobservable => format!(
213                        "{rate:.2} Hz observed but {} sample(s) dropped — the true rate \
214                         is at least that, not exactly that (O6){synth}",
215                        w.dropped
216                    ),
217                    _ => format!(
218                        "{} sample(s) in {:.1}s = {rate:.2} Hz against the {hz:.2} Hz \
219                         bound{synth}",
220                        w.samples, w.window_s
221                    ),
222                };
223                Eval { state, evidence }
224            }
225            Condition::RateBelow { hz, .. } => {
226                let state = CondState::from(judge_shortfall(rate < *hz, w.dropped));
227                let evidence = match state {
228                    CondState::Unobservable => format!(
229                        "{rate:.2} Hz observed with {} sample(s) dropped — the drops \
230                         could have carried the difference (O6){synth}",
231                        w.dropped
232                    ),
233                    _ => format!(
234                        "{} sample(s) in {:.1}s = {rate:.2} Hz against the {hz:.2} Hz \
235                         bound{synth}",
236                        w.samples, w.window_s
237                    ),
238                };
239                Eval { state, evidence }
240            }
241            Condition::SilentFor { for_s, .. } => {
242                let ev = SilenceEvidence {
243                    sample_within: w.last_sample_ago_s.map(|ago| ago < *for_s) == Some(true),
244                    span_observed: w.observed_s >= *for_s,
245                    drop_free: w.last_drop_ago_s.map(|ago| ago >= *for_s) != Some(false),
246                };
247                let SilenceEvidence { span_observed, .. } = ev;
248                let state = CondState::from(judge_silence(ev));
249                let evidence = match state {
250                    CondState::Ok => format!(
251                        "a sample rode {:.1}s ago, inside the {for_s:.1}s span{synth}",
252                        w.last_sample_ago_s.unwrap_or(0.0)
253                    ),
254                    CondState::Firing => {
255                        format!("no sample for {for_s:.1}s, on a drop-free observer{synth}")
256                    }
257                    CondState::Unobservable if !span_observed => format!(
258                        "watched only {:.1}s of a {for_s:.1}s silence claim — not asked \
259                         is not answered (O4){synth}",
260                        w.observed_s
261                    ),
262                    CondState::Unobservable => format!(
263                        "no sample seen, but the observer dropped inside the {for_s:.1}s \
264                         span — silence is unprovable (O6){synth}"
265                    ),
266                };
267                Eval { state, evidence }
268            }
269            Condition::InvalidPayload { .. } => Eval {
270                state: if w.invalid > 0 {
271                    CondState::Firing
272                } else {
273                    CondState::Ok
274                },
275                evidence: format!(
276                    "{} of {} checked sample(s) did not reach Valid ({} observed, \
277                     {} dropped{synth})",
278                    w.invalid, w.checked, w.samples, w.dropped
279                ),
280            },
281            Condition::QosMismatch { .. } => Eval {
282                state: if w.qos_mismatched > 0 {
283                    CondState::Firing
284                } else {
285                    CondState::Ok
286                },
287                evidence: format!(
288                    "{} of {} judged sample(s) did not ride their declared profile \
289                     ({} observed, {} with no declared profile to judge, \
290                     {} dropped{synth})",
291                    w.qos_mismatched,
292                    w.qos_judged,
293                    w.samples,
294                    w.samples.saturating_sub(w.qos_judged),
295                    w.dropped
296                ),
297            },
298            Condition::Dropped => Eval {
299                state: if w.dropped > 0 {
300                    CondState::Firing
301                } else {
302                    CondState::Ok
303                },
304                evidence: format!(
305                    "the observer dropped {} sample(s) in {:.1}s (O6){synth}",
306                    w.dropped, w.window_s
307                ),
308            },
309            Condition::DoctorCheck { .. } | Condition::OriginDown { .. } => return None,
310        })
311    }
312
313    /// [`judge_window`](Self::judge_window) for the variants that *have* a
314    /// window — total, because [`judge`](Self::judge) has already routed the
315    /// other two elsewhere.
316    fn judge_window_total(&self, w: &CondWindow) -> Eval {
317        debug_assert!(
318            !matches!(
319                self,
320                Condition::DoctorCheck { .. } | Condition::OriginDown { .. }
321            ),
322            "judge() routes these two to their own evidence"
323        );
324        self.judge_window(w).unwrap_or_else(|| Eval {
325            // Unreachable through `judge`; if some future variant reaches it,
326            // "I have no window for this" is the honest answer, not a panic
327            // in a watchdog that is supposed to keep running.
328            state: CondState::Unobservable,
329            evidence: "this rule is not judged against a sample window".into(),
330        })
331    }
332
333    /// Judge a roster ask. `None` unless this is [`Condition::OriginDown`].
334    /// `Err` is the ask failing, which is unobservable — silence is not a
335    /// verdict (RFC 05 §3.1).
336    pub fn judge_roster(
337        &self,
338        roster: Result<&BTreeMap<String, Vec<String>>, &str>,
339    ) -> Option<Eval> {
340        let Condition::OriginDown { origin } = self else {
341            return None;
342        };
343        Some(match roster {
344            Err(e) => Eval {
345                state: CondState::Unobservable,
346                evidence: format!("the roster could not be asked: {e}"),
347            },
348            Ok(r) => match r.get(origin) {
349                Some(producers) => Eval {
350                    state: CondState::Ok,
351                    evidence: format!(
352                        "{origin} holds an alive token ({} producer(s))",
353                        producers.len()
354                    ),
355                },
356                None => Eval {
357                    state: CondState::Firing,
358                    evidence: format!("{origin} holds no alive token (RFC 04 §5)"),
359                },
360            },
361        })
362    }
363
364    /// Judge a doctor run. `None` unless this is [`Condition::DoctorCheck`].
365    /// A failed run is unobservable for every doctor condition — never `ok`.
366    pub fn judge_doctor(&self, outcome: Result<&DoctorReport, &str>) -> Option<Eval> {
367        let Condition::DoctorCheck { check } = self else {
368            return None;
369        };
370        Some(match outcome {
371            Err(e) => Eval {
372                state: CondState::Unobservable,
373                evidence: format!("the doctor run failed: {e}"),
374            },
375            Ok(report) => {
376                let mut hits = report.findings.iter().filter(|f| f.check == *check);
377                match hits.next() {
378                    Some(first) => Eval {
379                        state: CondState::Firing,
380                        evidence: format!(
381                            "{} finding(s); first: {} — {}",
382                            1 + hits.count(),
383                            first.subject,
384                            first.evidence
385                        ),
386                    },
387                    None => Eval {
388                        state: CondState::Ok,
389                        evidence: format!("no {check} findings"),
390                    },
391                }
392            }
393        })
394    }
395}
396
397impl std::fmt::Display for Condition {
398    /// The canonical rule spelling — [`Condition::parse`] round-trips it,
399    /// and it is the `rule` field of every [`Transition`].
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        match self {
402            Condition::RateAbove { selector, hz } => write!(f, "rate-above {selector} {hz}"),
403            Condition::RateBelow { selector, hz } => write!(f, "rate-below {selector} {hz}"),
404            Condition::SilentFor { selector, for_s } => {
405                write!(f, "silent-for {selector} {for_s}")
406            }
407            Condition::InvalidPayload { selector } => write!(f, "invalid-payload {selector}"),
408            Condition::QosMismatch { selector } => write!(f, "qos-mismatch {selector}"),
409            Condition::DoctorCheck { check } => write!(f, "doctor {check}"),
410            Condition::OriginDown { origin } => write!(f, "origin-down {origin}"),
411            Condition::Dropped => write!(f, "dropped"),
412        }
413    }
414}
415
416// ─── the judgement rules (the vocabulary's semantic core) ───────────────────
417//
418// The three judges return the four-pole [`Judgement`] core (RFC 13, v1.24;
419// RFC 09 §5.1 pre-v1.24). None of them ever answers `NotAsked` — a judge is
420// only called when the question was put — but the pole exists in the currency
421// so a caller that *skipped* a judge can say so in the same vocabulary. The
422// watchdog projects each judgement onto [`CondState`] for the wire.
423
424/// The shortfall rule ([`Condition::RateBelow`]; `expect`'s count floor and
425/// rate floor): too little was seen. Enough seen is conclusively clean even
426/// under drops — a drop can only hide *more*. A shortfall with drops is
427/// unobservable: the dropped samples could have filled it (RFC 09 §5.1 O6).
428pub fn judge_shortfall(short: bool, dropped: u64) -> Judgement {
429    match (short, dropped) {
430        (false, _) => Judgement::NotEstablished {
431            reason: "enough was seen — a drop only hides more".into(),
432        },
433        (true, 0) => Judgement::Established,
434        (true, _) => Judgement::Unobservable {
435            reason: format!("{dropped} dropped sample(s) could have filled the shortfall (O6)"),
436        },
437    }
438}
439
440/// The excess rule ([`Condition::RateAbove`]; `expect`'s rate ceiling): too
441/// much was seen. An excess is positive evidence, conclusive under drops.
442/// "No excess" is a completeness claim — it counts what did NOT happen — so
443/// under drops it is unobservable, never clean (O6).
444pub fn judge_excess(over: bool, dropped: u64) -> Judgement {
445    match (over, dropped) {
446        (true, _) => Judgement::Established,
447        (false, 0) => Judgement::NotEstablished {
448            reason: "no excess was counted, on a clean observation".into(),
449        },
450        (false, _) => Judgement::Unobservable {
451            reason: format!(
452                "{dropped} sample(s) dropped — \"did not exceed\" is a completeness \
453                 claim (O6)"
454            ),
455        },
456    }
457}
458
459/// The silence rule ([`Condition::SilentFor`]; `expect --absent`): a sample
460/// inside the span conclusively breaks the silence; silence is provable only
461/// over a span the observer actually watched (O4) drop-free (O6) — otherwise
462/// unobservable, never clean.
463/// What one silence claim rests on — three facts that are all `bool` and all
464/// about the same span.
465///
466/// A struct rather than three positional parameters, because this feeds a
467/// *judgement* and a transposition of two identically-typed booleans returns
468/// a plausible wrong verdict with no compile error (#349).
469/// `judge_shortfall`/`judge_excess` keep their positional `(bool, u64)` —
470/// not transposable, so not a hazard.
471#[derive(Debug, Clone, Copy)]
472pub struct SilenceEvidence {
473    /// A sample rode inside the claimed span — the conclusive break.
474    pub sample_within: bool,
475    /// The observer actually watched the whole span (O4). A span it did not
476    /// watch is not a span it can call silent.
477    pub span_observed: bool,
478    /// The observer dropped nothing inside the span (O6). "Nothing arrived"
479    /// under drops is a completeness claim the observation cannot carry.
480    pub drop_free: bool,
481}
482
483pub fn judge_silence(ev: SilenceEvidence) -> Judgement {
484    let SilenceEvidence {
485        sample_within,
486        span_observed,
487        drop_free,
488    } = ev;
489    if sample_within {
490        Judgement::NotEstablished {
491            reason: "a sample rode inside the span".into(),
492        }
493    } else if span_observed && drop_free {
494        Judgement::Established
495    } else if !span_observed {
496        Judgement::Unobservable {
497            reason: "the observer has not watched the whole claimed span (O4)".into(),
498        }
499    } else {
500        Judgement::Unobservable {
501            reason: "the observer dropped inside the span — silence is unprovable (O6)".into(),
502        }
503    }
504}
505
506/// Everything one watchdog tick observed, in the three shapes the conditions
507/// are judged against.
508///
509/// `doctor` and `roster` are `Option` because a tick only runs those asks if
510/// some rule wants them — and "not run this tick" is *unobservable*, which is
511/// the honest reading and the one the caller used to assert away with
512/// `.expect("a doctor rule ran the doctor")` (#352).
513pub struct TickEvidence<'e> {
514    pub window: &'e CondWindow,
515    pub doctor: Option<Result<&'e DoctorReport, &'e str>>,
516    pub roster: Option<Result<&'e BTreeMap<String, Vec<String>>, &'e str>>,
517}
518
519/// Judge one doctor check against this tick's run — total, and total in the
520/// "did not run" direction too.
521pub fn judge_doctor_check(check: CheckId, outcome: Option<Result<&DoctorReport, &str>>) -> Eval {
522    let Some(outcome) = outcome else {
523        return Eval {
524            state: CondState::Unobservable,
525            evidence: "the doctor did not run this tick".into(),
526        };
527    };
528    Condition::DoctorCheck { check }
529        .judge_doctor(outcome)
530        .expect("a DoctorCheck is judged by the doctor")
531}
532
533/// Judge one origin against this tick's roster ask — likewise total.
534pub fn judge_origin_down(
535    origin: &str,
536    roster: Option<Result<&BTreeMap<String, Vec<String>>, &str>>,
537) -> Eval {
538    let Some(roster) = roster else {
539        return Eval {
540            state: CondState::Unobservable,
541            evidence: "the roster was not asked this tick".into(),
542        };
543    };
544    Condition::OriginDown {
545        origin: origin.to_string(),
546    }
547    .judge_roster(roster)
548    .expect("an OriginDown is judged by the roster")
549}
550
551// ─── observations and evaluations ───────────────────────────────────────────
552
553/// What one evaluation window observed on one condition's selector — the
554/// facts, separated from the judgement so the judgement is pure.
555///
556/// `CondWindow` and not `Window`: this type is re-exported at the crate root
557/// beside `BudgetWindow` and `RecordBounds`, and a bare `Window` there reads
558/// as *the* window of an engine that has several. Nothing serializes the
559/// name (the type carries no `Serialize`), so the rename is Rust-side only.
560#[derive(Debug, Clone, Copy, Default)]
561pub struct CondWindow {
562    /// The span this window judges, seconds.
563    pub window_s: f64,
564    /// How long the observer has been watching in total — a claim about a
565    /// span longer than this is unobservable (O4).
566    pub observed_s: f64,
567    /// Samples matching the selector within the window.
568    pub samples: u64,
569    /// Stream drops within the window — unattributable to any one selector,
570    /// so they taint every completeness claim (O6).
571    pub dropped: u64,
572    /// Seconds since the last matching sample; `None` = none seen since the
573    /// watch began.
574    pub last_sample_ago_s: Option<f64>,
575    /// Seconds since the last stream drop; `None` = the stream never dropped.
576    pub last_drop_ago_s: Option<f64>,
577    /// Samples whose payload did not reach `Valid`, among those checked.
578    pub invalid: u64,
579    /// Samples actually decode-checked (a budget bounds the cost).
580    pub checked: u64,
581    /// Samples that did not ride their declared QoS, among those judged.
582    pub qos_mismatched: u64,
583    /// Samples with a declared profile to judge against.
584    pub qos_judged: u64,
585    /// Samples carrying the RFC 09 §5.3 synthetic-traffic marker — generated
586    /// traffic judged as real would be a self-inflicted page, so every
587    /// evidence line carries the count.
588    pub synthetic: u64,
589}
590
591/// One evaluation: the three-valued state, and the evidence for it.
592#[derive(Debug, Clone, PartialEq)]
593pub struct Eval {
594    pub state: CondState,
595    pub evidence: String,
596}
597
598/// One rule's transition detector: feed evaluations in, get a [`Transition`]
599/// back **only** when the state genuinely changed. An unchanged tick returns
600/// `None` — transitions, not states.
601#[derive(Debug, Clone)]
602pub struct RuleState {
603    /// The condition itself, not its `Display`.
604    ///
605    /// It used to hold the rendered string and clone it into every
606    /// transition, with the two representations kept equal only by a
607    /// round-trip test — a second representation of a value that was
608    /// `Clone` and in scope (#352). The rendering happens where the
609    /// `Transition` is built, once, from the one source.
610    rule: Condition,
611    state: Option<CondState>,
612}
613
614impl RuleState {
615    pub fn new(rule: Condition) -> RuleState {
616        RuleState { rule, state: None }
617    }
618
619    /// The condition this state tracks.
620    pub fn rule(&self) -> &Condition {
621        &self.rule
622    }
623
624    /// The last observed state; `None` until the first evaluation.
625    pub fn state(&self) -> Option<CondState> {
626        self.state
627    }
628
629    /// Feed one evaluation. The first ever emits (from `null` — the baseline
630    /// is said once); after that only a genuine change does.
631    pub fn observe(&mut self, eval: Eval, at: impl Into<String>) -> Option<Transition> {
632        if self.state == Some(eval.state) {
633            return None;
634        }
635        let from = self.state;
636        self.state = Some(eval.state);
637        Some(Transition {
638            rule: self.rule.to_string(),
639            from,
640            to: eval.state,
641            at: at.into(),
642            evidence: eval.evidence,
643        })
644    }
645}
646
647/// Run-over-run delta over a doctor report: one [`RuleState`] per stable
648/// check id ([`CheckId`]), fed by `doctor --transitions`. The
649/// first run states the baseline (one transition per check id); every later run yields
650/// only genuine changes. A failed run flips every check to `unobservable` —
651/// a doctor that could not run has not said the fleet is healthy.
652#[derive(Debug, Clone)]
653pub struct DoctorWatch {
654    /// One state per check. A `Vec<(Condition, RuleState)>` until #352 — the
655    /// condition was in both halves of the pair.
656    checks: Vec<RuleState>,
657}
658
659impl DoctorWatch {
660    pub fn new() -> DoctorWatch {
661        DoctorWatch {
662            checks: CheckId::ALL
663                .iter()
664                .map(|id| RuleState::new(Condition::DoctorCheck { check: *id }))
665                .collect(),
666        }
667    }
668
669    /// Feed one doctor run (or its failure) and collect the transitions.
670    pub fn observe(&mut self, outcome: Result<&DoctorReport, &str>, at: &str) -> Vec<Transition> {
671        self.checks
672            .iter_mut()
673            .filter_map(|state| {
674                let Condition::DoctorCheck { check } = *state.rule() else {
675                    // Unconstructible: `new` builds only `DoctorCheck`s.
676                    return None;
677                };
678                let eval = judge_doctor_check(check, Some(outcome));
679                state.observe(eval, at)
680            })
681            .collect()
682    }
683}
684
685impl Default for DoctorWatch {
686    fn default() -> Self {
687        DoctorWatch::new()
688    }
689}
690
691// ─── the watchdog runner ────────────────────────────────────────────────────
692
693/// What a watchdog run watches, and for how long.
694#[derive(Debug, Clone)]
695pub struct WatchdogSpec {
696    /// The rules, evaluated every tick.
697    pub rules: Vec<Condition>,
698    /// Evaluation cadence. A tick that runs long (a doctor rule's fan-in)
699    /// slides rather than backlogs; windows are measured, not nominal.
700    pub tick: Duration,
701    /// Stop after this many ticks; `None` = run until the caller stops it.
702    pub ticks: Option<u64>,
703    /// Per-ask timeout for the roster and doctor conditions.
704    pub timeout: Duration,
705}
706
707/// How many decode attempts each key gets per tick under an
708/// `invalid-payload` rule — the same budget the doctor listen phase runs,
709/// for the same reason: a watchdog must not become a load test.
710const DECODE_BUDGET: u8 = 2;
711
712/// Watch the rules and emit one [`Transition`] per genuine change, none per
713/// unchanged tick. The subscriber set is declared before the first window
714/// opens (O4); every selector rule is judged per tick over the measured
715/// window, doctor and roster rules by one ask per tick each.
716pub async fn run_watchdog(
717    fleet: &crate::Fleet<'_>,
718    slices: Option<&SliceSet>,
719    store: &SchemaStore,
720    spec: &WatchdogSpec,
721    emit: &mut (dyn FnMut(&Transition) + Send),
722) -> Result<WatchdogSummary> {
723    use crate::{FleetEvent, StreamItem};
724
725    let (session, base) = (fleet.session(), fleet.base());
726
727    #[derive(Default, Clone, Copy)]
728    struct TickCounters {
729        samples: u64,
730        invalid: u64,
731        checked: u64,
732        qos_mismatched: u64,
733        qos_judged: u64,
734        synthetic: u64,
735    }
736
737    /// One rule's whole per-run state, together.
738    ///
739    /// This was four `Vec`s held in lockstep by index — `states`,
740    /// `keyexprs`, `counters`, `last_sample` — across a hundred and thirty
741    /// lines, with nothing structurally preventing them from disagreeing in
742    /// length, and a `counters.fill(default())` reset that could silently
743    /// miss one of them (#352).
744    struct RuleRuntime {
745        rule: Condition,
746        /// The rule's selector, compiled once for sample attribution.
747        keyexpr: Option<zenoh::key_expr::KeyExpr<'static>>,
748        counters: TickCounters,
749        last_sample: Option<tokio::time::Instant>,
750        state: RuleState,
751    }
752
753    // Compiled *before* the monitor exists, so the `?` has nothing to tear
754    // down (#336).
755    let mut rules: Vec<RuleRuntime> = spec
756        .rules
757        .iter()
758        .map(|rule| {
759            Ok(RuleRuntime {
760                rule: rule.clone(),
761                keyexpr: rule
762                    .selector()
763                    .map(|sel| {
764                        zenoh::key_expr::KeyExpr::try_from(sel.to_string())
765                            .map_err(|e| Error::unaskable_from(format!("{sel:?}"), e))
766                    })
767                    .transpose()?,
768                counters: TickCounters::default(),
769                last_sample: None,
770                state: RuleState::new(rule.clone()),
771            })
772        })
773        .collect::<Result<_>>()?;
774    let mut watched: Vec<String> = Vec::new();
775    for rule in &spec.rules {
776        if let Some(sel) = rule.selector()
777            && !watched.iter().any(|s| s == sel)
778        {
779            watched.push(sel.to_string());
780        }
781    }
782
783    let wants_doctor = spec
784        .rules
785        .iter()
786        .any(|r| matches!(r, Condition::DoctorCheck { .. }));
787    let wants_roster = spec
788        .rules
789        .iter()
790        .any(|r| matches!(r, Condition::OriginDown { .. }));
791    let wants_decode = spec
792        .rules
793        .iter()
794        .any(|r| matches!(r, Condition::InvalidPayload { .. }));
795
796    // Warmed before the first tick and sealed for the run (#337): a decode
797    // inside the drain loop must never become a `describe` GET, because
798    // nothing attends the broadcast while one is in flight and the tick's
799    // verdict is about the window that lost the samples. zenctl hands this
800    // store over cold. Each tick's sweep re-warms whatever is still
801    // unserved — from beside the drain, where waiting costs nothing.
802    if wants_decode {
803        crate::model::decode::prewarm(fleet, store, slices).await;
804    }
805    let _sealed = store.seal();
806
807    // Declared before the window opens — not-asked must never read as "no".
808    let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
809    let mut events = monitor.events();
810    let monitor = monitor.watching(&watched).await?;
811
812    let started = tokio::time::Instant::now();
813    let mut last_drop: Option<tokio::time::Instant> = None;
814    let mut dropped_tick: u64 = 0;
815    // Bounded (#107): the watchdog runs until stopped, so an unbounded
816    // per-key map here is a leak on any bus with churning keys. Evictions
817    // ride the summary (O6).
818    let mut facts_cache = crate::model::facts::FactsCache::default();
819    let mut decode_budget: BTreeMap<String, u8> = BTreeMap::new();
820
821    let mut summary = WatchdogSummary {
822        ticks: 0,
823        transitions: 0,
824        facts_evicted: 0,
825    };
826    let mut last_eval = started;
827    let mut closed = false;
828    loop {
829        let deadline = last_eval + spec.tick;
830        // The tick's bus work runs **beside** the drain, not after it (#338).
831        //
832        // A roster GET, a registry sweep, per-producer describes and state
833        // snapshots take seconds, and every one of them used to happen with
834        // the drain loop stopped — so the broadcast overflowed, and because
835        // `dropped_tick` was reset immediately afterwards, the loss was
836        // billed to the *following* window. In the one tool whose entire
837        // product is a per-window verdict.
838        //
839        // Now the sweep is a future the drain selects on: sampling never
840        // stops, and a sweep that outlives the tick period simply widens this
841        // window — `window_s` is measured from `last_eval`, never assumed —
842        // so the drops land in the tick that incurred them.
843        let sweep = async {
844            let doctor = if wants_doctor {
845                Some(
846                    crate::judge::doctor::run_doctor(
847                        fleet,
848                        slices,
849                        &crate::judge::doctor::DoctorSpec {
850                            deep: false,
851                            sample: None,
852                            timeout: spec.timeout,
853                            listen: None,
854                        },
855                    )
856                    .await
857                    .map_err(|e| e.to_string()),
858                )
859            } else {
860                None
861            };
862            let roster = if wants_roster {
863                Some(
864                    crate::bus::roster::roster(fleet, spec.timeout)
865                        .await
866                        .map_err(|e| e.to_string()),
867                )
868            } else {
869                None
870            };
871            // The schema warming rides here too (#337): still-unserved
872            // producers are re-asked at the store's own backoff, off the
873            // drain loop.
874            if wants_decode {
875                crate::model::decode::prewarm(fleet, store, slices).await;
876            }
877            (doctor, roster)
878        };
879        let mut sweep = std::pin::pin!(sweep);
880        let mut swept = None;
881        // One timer per tick, not one per drained sample (#346).
882        let tick_over = tokio::time::sleep_until(deadline);
883        tokio::pin!(tick_over);
884        while !closed {
885            let item = tokio::select! {
886                item = events.recv() => item,
887                // The tick cannot close before its own sweep has landed, and
888                // the drain keeps running until it does.
889                outcome = &mut sweep, if swept.is_none() => {
890                    swept = Some(outcome);
891                    continue;
892                }
893                () = &mut tick_over, if swept.is_some() => break,
894            };
895            match item {
896                Some(StreamItem::Event(FleetEvent::Sample(s))) => {
897                    let Ok(key) = zenoh::key_expr::KeyExpr::try_from(s.key.as_str()) else {
898                        continue;
899                    };
900                    let synthetic = s
901                        .attachment
902                        .as_ref()
903                        .is_some_and(|a| crate::judge::common::is_synthetic_marker(&a.to_bytes()));
904                    // Decode once per sample (budgeted per key per tick),
905                    // shared by every invalid-payload rule the key matches.
906                    let mut verdict: Option<crate::Verdict> = None;
907                    for rt in rules.iter_mut() {
908                        let Some(sel) = &rt.keyexpr else { continue };
909                        if !sel.intersects(&key) {
910                            continue;
911                        }
912                        rt.counters.samples += 1;
913                        if synthetic {
914                            rt.counters.synthetic += 1;
915                        }
916                        rt.last_sample = Some(tokio::time::Instant::now());
917                        match &rt.rule {
918                            Condition::InvalidPayload { .. } => {
919                                if verdict.is_none() {
920                                    let budget = decode_budget.entry(s.key.clone()).or_default();
921                                    if *budget < DECODE_BUDGET {
922                                        *budget += 1;
923                                        // An `invalid-payload` rule counts
924                                        // every not-`Valid` verdict the same
925                                        // way, so with no registry loaded
926                                        // `NoRegistry` (#246) changes no
927                                        // transition — only the reason the
928                                        // sample was not validated.
929                                        let d = crate::model::decode::decode_sample(
930                                            fleet,
931                                            store,
932                                            slices,
933                                            &s.key,
934                                            Some(&s.encoding),
935                                            &s.payload.to_bytes(),
936                                        )
937                                        .await;
938                                        verdict = Some(d.verdict);
939                                    }
940                                }
941                                if let Some(v) = &verdict {
942                                    rt.counters.checked += 1;
943                                    if !matches!(v, crate::Verdict::Valid) {
944                                        rt.counters.invalid += 1;
945                                    }
946                                }
947                            }
948                            Condition::QosMismatch { .. } => {
949                                facts_cache.ensure(base, &s.key, slices);
950                                let facts = facts_cache.get(&s.key).expect("just ensured this key");
951                                if let crate::model::facts::Registration::Registered(sf) =
952                                    &facts.registration
953                                    && let Some(profile) = sf.declared_qos()
954                                {
955                                    rt.counters.qos_judged += 1;
956                                    if !s.qos_matches(profile) {
957                                        rt.counters.qos_mismatched += 1;
958                                    }
959                                }
960                            }
961                            _ => {}
962                        }
963                    }
964                }
965                Some(StreamItem::Dropped(n)) => {
966                    dropped_tick += n;
967                    last_drop = Some(tokio::time::Instant::now());
968                }
969                Some(_) => {}
970                None => closed = true,
971            }
972        }
973
974        // Evaluate the tick over the measured window, then say only what
975        // changed. The sweep has already landed unless the stream closed
976        // under it — in which case there is nothing left to drain, and
977        // awaiting it here costs the tick nothing.
978        let (doctor_outcome, roster_outcome) = match swept {
979            Some(outcome) => outcome,
980            None => sweep.await,
981        };
982        let now = tokio::time::Instant::now();
983        let at = crate::tape::record::rfc3339_now();
984        for rt in rules.iter_mut() {
985            let window = CondWindow {
986                window_s: (now - last_eval).as_secs_f64(),
987                observed_s: (now - started).as_secs_f64(),
988                samples: rt.counters.samples,
989                dropped: dropped_tick,
990                last_sample_ago_s: rt.last_sample.map(|t| (now - t).as_secs_f64()),
991                last_drop_ago_s: last_drop.map(|t| (now - t).as_secs_f64()),
992                invalid: rt.counters.invalid,
993                checked: rt.counters.checked,
994                qos_mismatched: rt.counters.qos_mismatched,
995                qos_judged: rt.counters.qos_judged,
996                synthetic: rt.counters.synthetic,
997            };
998            let eval = rt.rule.judge(&TickEvidence {
999                window: &window,
1000                doctor: doctor_outcome
1001                    .as_ref()
1002                    .map(|o| o.as_ref().map_err(String::as_str)),
1003                roster: roster_outcome
1004                    .as_ref()
1005                    .map(|o| o.as_ref().map_err(String::as_str)),
1006            });
1007            if let Some(transition) = rt.state.observe(eval, &at) {
1008                summary.transitions += 1;
1009                emit(&transition);
1010            }
1011        }
1012        // One reset, over one collection — the four-`Vec` version had a
1013        // `counters.fill(..)` that could miss a sibling (#352).
1014        for rt in rules.iter_mut() {
1015            rt.counters = TickCounters::default();
1016        }
1017        dropped_tick = 0;
1018        decode_budget.clear();
1019        summary.ticks += 1;
1020        if closed || spec.ticks.is_some_and(|n| summary.ticks >= n) {
1021            break;
1022        }
1023        last_eval = now;
1024    }
1025    monitor.shutdown().await?;
1026    summary.facts_evicted = facts_cache.evicted();
1027    Ok(summary)
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033    use crate::report::{DoctorFinding, DoctorSeverity};
1034
1035    fn report_with(checks: &[CheckId]) -> DoctorReport {
1036        DoctorReport {
1037            findings: checks
1038                .iter()
1039                .map(|c| DoctorFinding {
1040                    severity: DoctorSeverity::Error,
1041                    check: *c,
1042                    subject: "s".into(),
1043                    evidence: "e".into(),
1044                    citation: None,
1045                })
1046                .collect(),
1047            synced: crate::report::Asked::NotAsked,
1048            introspect_answered: 0,
1049            live_producers: 0,
1050            describe_served: 0,
1051            describe_missing: 0,
1052            routers: 0,
1053            router_version: None,
1054            deep: false,
1055            observation: None,
1056        }
1057    }
1058
1059    /// Every variant's canonical spelling parses back to itself, and a rule
1060    /// outside the vocabulary is an error that names the vocabulary — closed
1061    /// means closed.
1062    #[test]
1063    fn the_vocabulary_round_trips_and_is_closed() {
1064        let rules = [
1065            "rate-above v1/*/telemetry/** 5",
1066            "rate-below v1/h-aaaaaaaaaaaa/state/p/health 0.5",
1067            "silent-for v1/*/events/** 30",
1068            "invalid-payload v1/*/state/**",
1069            "qos-mismatch v1/*/telemetry/**",
1070            "doctor slice-sync",
1071            "origin-down h-aaaaaaaaaaaa",
1072            "dropped",
1073        ];
1074        for rule in rules {
1075            let parsed = Condition::parse(rule).expect(rule);
1076            assert_eq!(parsed.to_string(), rule, "canonical spelling round-trips");
1077        }
1078        let err = Condition::parse("if rate > 5 then page").unwrap_err();
1079        assert!(err.to_string().contains("closed"), "{err}");
1080        assert!(err.to_string().contains("rate-above"), "{err}");
1081        // A doctor rule outside the stable check-id vocabulary is refused at
1082        // parse, naming the vocabulary.
1083        let err = Condition::parse("doctor no-such-check").unwrap_err();
1084        assert!(err.to_string().contains("slice-sync"), "{err}");
1085    }
1086
1087    /// The acceptance rule of #227: a drop under a completeness claim yields
1088    /// `unobservable`, **never** `ok` — across all three core judges, now
1089    /// spoken in the [`Judgement`] core and projected onto [`CondState`]
1090    /// (RFC 13, v1.24).
1091    #[test]
1092    fn a_drop_under_a_completeness_claim_is_unobservable_never_ok() {
1093        let wire = CondState::from;
1094        // Excess: the "did not exceed" side counts what did not happen.
1095        assert!(judge_excess(false, 1).is_unobservable());
1096        assert_eq!(wire(judge_excess(false, 0)), CondState::Ok);
1097        // …while firing is positive evidence, conclusive under drops.
1098        assert_eq!(judge_excess(true, 7), Judgement::Established);
1099        // Shortfall: the drops could have carried the difference.
1100        assert!(judge_shortfall(true, 1).is_unobservable());
1101        assert_eq!(judge_shortfall(true, 0), Judgement::Established);
1102        // …while "enough seen" is conclusive: a drop only hides more.
1103        assert_eq!(wire(judge_shortfall(false, 9)), CondState::Ok);
1104        // Silence: unprovable over a dropped or unwatched span. Named fields
1105        // rather than three bare `bool`s, which is the whole of #349 — read
1106        // the old spelling `judge_silence(false, true, false)` and say which
1107        // one was the drop.
1108        let silence = |sample_within, span_observed, drop_free| {
1109            judge_silence(SilenceEvidence {
1110                sample_within,
1111                span_observed,
1112                drop_free,
1113            })
1114        };
1115        assert!(silence(false, true, false).is_unobservable());
1116        assert!(silence(false, false, true).is_unobservable());
1117        assert_eq!(silence(false, true, true), Judgement::Established);
1118        assert_eq!(wire(silence(true, true, false)), CondState::Ok);
1119    }
1120
1121    /// The wire projection's documented mapping, polarity note included:
1122    /// `NotEstablished` (established-clean) is `ok`, `Established` (the
1123    /// condition holds) is `firing`, and **both** unestablished poles land
1124    /// on `unobservable` — the wire cannot say more (RFC 13, v1.24).
1125    #[test]
1126    fn cond_state_is_the_documented_projection_of_the_judgement_core() {
1127        assert_eq!(CondState::from(Judgement::Established), CondState::Firing);
1128        assert_eq!(
1129            CondState::from(Judgement::NotEstablished {
1130                reason: "clean".into()
1131            }),
1132            CondState::Ok
1133        );
1134        assert_eq!(
1135            CondState::from(Judgement::NotAsked),
1136            CondState::Unobservable
1137        );
1138        assert_eq!(
1139            CondState::from(Judgement::Unobservable {
1140                reason: "drops".into()
1141            }),
1142            CondState::Unobservable
1143        );
1144    }
1145
1146    /// The window judges apply those rules: `rate-above` firing survives
1147    /// drops, its ok does not; a young watch cannot claim silence.
1148    #[test]
1149    fn window_judgement_applies_the_drop_rules() {
1150        let rule = Condition::parse("rate-above k/** 1").unwrap();
1151        let base = CondWindow {
1152            window_s: 10.0,
1153            observed_s: 10.0,
1154            ..CondWindow::default()
1155        };
1156        let over = CondWindow {
1157            samples: 20,
1158            dropped: 5,
1159            ..base
1160        };
1161        assert_eq!(rule.judge_window(&over).unwrap().state, CondState::Firing);
1162        let under_dropped = CondWindow {
1163            samples: 2,
1164            dropped: 5,
1165            ..base
1166        };
1167        assert_eq!(
1168            rule.judge_window(&under_dropped).unwrap().state,
1169            CondState::Unobservable
1170        );
1171
1172        let rule = Condition::parse("silent-for k/** 30").unwrap();
1173        let young = CondWindow {
1174            window_s: 5.0,
1175            observed_s: 5.0,
1176            ..CondWindow::default()
1177        };
1178        let eval = rule.judge_window(&young).unwrap();
1179        assert_eq!(eval.state, CondState::Unobservable);
1180        assert!(eval.evidence.contains("watched only"), "{}", eval.evidence);
1181        let silent = CondWindow {
1182            window_s: 5.0,
1183            observed_s: 60.0,
1184            ..CondWindow::default()
1185        };
1186        assert_eq!(rule.judge_window(&silent).unwrap().state, CondState::Firing);
1187        let recently_dropped = CondWindow {
1188            last_drop_ago_s: Some(10.0),
1189            ..silent
1190        };
1191        assert_eq!(
1192            rule.judge_window(&recently_dropped).unwrap().state,
1193            CondState::Unobservable
1194        );
1195        let spoken = CondWindow {
1196            samples: 1,
1197            last_sample_ago_s: Some(3.0),
1198            ..silent
1199        };
1200        assert_eq!(rule.judge_window(&spoken).unwrap().state, CondState::Ok);
1201    }
1202
1203    /// The synthetic-traffic marker count (RFC 09 §5.3, the #162 rider)
1204    /// rides every window evidence line when present.
1205    #[test]
1206    fn synthetic_marked_samples_are_said_out_loud() {
1207        let rule = Condition::parse("rate-above k/** 0.1").unwrap();
1208        let w = CondWindow {
1209            window_s: 10.0,
1210            observed_s: 10.0,
1211            samples: 20,
1212            synthetic: 3,
1213            ..CondWindow::default()
1214        };
1215        let eval = rule.judge_window(&w).unwrap();
1216        assert!(
1217            eval.evidence.contains("3 synthetic-marked"),
1218            "{}",
1219            eval.evidence
1220        );
1221    }
1222
1223    /// The transition machine: the first evaluation states the baseline
1224    /// (from `null`), an unchanged tick emits nothing, a genuine change
1225    /// emits exactly one line.
1226    #[test]
1227    fn transitions_fire_once_per_genuine_change_and_never_per_tick() {
1228        let eval = |state| Eval {
1229            state,
1230            evidence: "e".into(),
1231        };
1232        // The condition itself, not its rendering — which is the point of
1233        // #352: the two can no longer disagree.
1234        let mut rs = RuleState::new(Condition::Dropped);
1235        let first = rs.observe(eval(CondState::Ok), "t0").expect("baseline");
1236        assert_eq!(first.rule, "dropped", "the transition renders its rule");
1237        assert_eq!(first.from, None, "the baseline comes from null (O4)");
1238        assert_eq!(first.to, CondState::Ok);
1239        assert!(rs.observe(eval(CondState::Ok), "t1").is_none());
1240        assert!(rs.observe(eval(CondState::Ok), "t2").is_none());
1241        let change = rs.observe(eval(CondState::Firing), "t3").expect("a change");
1242        assert_eq!(change.from, Some(CondState::Ok));
1243        assert_eq!(change.to, CondState::Firing);
1244        assert!(rs.observe(eval(CondState::Firing), "t4").is_none());
1245    }
1246
1247    /// The ndjson shape of a transition is a wire contract for scripts:
1248    /// `{"rule","from","to","at","evidence"}`, states snake_case, `from`
1249    /// null on the baseline.
1250    #[test]
1251    fn transition_json_shape_is_pinned() {
1252        let t = Transition {
1253            rule: "silent-for k/** 30".into(),
1254            from: None,
1255            to: CondState::Unobservable,
1256            at: "2026-08-22T00:00:00Z".into(),
1257            evidence: "watched only 5.0s of a 30.0s silence claim".into(),
1258        };
1259        assert_eq!(
1260            serde_json::to_value(&t).unwrap(),
1261            serde_json::json!({
1262                "rule": "silent-for k/** 30",
1263                "from": null,
1264                "to": "unobservable",
1265                "at": "2026-08-22T00:00:00Z",
1266                "evidence": "watched only 5.0s of a 30.0s silence claim",
1267            })
1268        );
1269        let t = Transition {
1270            from: Some(CondState::Ok),
1271            to: CondState::Firing,
1272            ..t
1273        };
1274        let json = serde_json::to_value(&t).unwrap();
1275        assert_eq!(json["from"], "ok");
1276        assert_eq!(json["to"], "firing");
1277    }
1278
1279    /// `doctor --transitions`'s delta: the first run is a full baseline (every
1280    /// stable check id, once), an identical second run says nothing, a new
1281    /// finding transitions exactly its check — and a failed run flips every
1282    /// check to unobservable, never ok.
1283    #[test]
1284    fn doctor_watch_reports_deltas_not_states() {
1285        let mut watch = DoctorWatch::new();
1286        let clean = report_with(&[]);
1287        let baseline = watch.observe(Ok(&clean), "t0");
1288        assert_eq!(baseline.len(), CheckId::ALL.len());
1289        assert!(baseline.iter().all(|t| t.from.is_none()));
1290        assert!(baseline.iter().all(|t| t.to == CondState::Ok));
1291
1292        assert!(
1293            watch.observe(Ok(&clean), "t1").is_empty(),
1294            "an unchanged run emits nothing"
1295        );
1296
1297        let drifted = report_with(&[CheckId::SchemaDrift, CheckId::SchemaDrift]);
1298        let changes = watch.observe(Ok(&drifted), "t2");
1299        assert_eq!(changes.len(), 1, "only the changed check transitions");
1300        assert_eq!(changes[0].rule, "doctor schema-drift");
1301        assert_eq!(changes[0].to, CondState::Firing);
1302        assert!(changes[0].evidence.contains("2 finding(s)"));
1303
1304        let failed = watch.observe(Err("session lost"), "t3");
1305        assert_eq!(
1306            failed.len(),
1307            CheckId::ALL.len(),
1308            "a failed run is unobservable for every check — never ok"
1309        );
1310        assert!(failed.iter().all(|t| t.to == CondState::Unobservable));
1311    }
1312}