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