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}