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}