//! N-of-M status-transition debounce, shared by the health and ssh-banner loops. //! //! The probe loops insert a health snapshot every tick (the raw history), but a //! *transition*, the thing that pages an operator and opens an incident, must //! not fire on the first differing check: one transient timeout would flip //! `Operational -> Unreachable`, email an alert, and open an incident that //! resolves on the next probe (fuzz-2026-07-06: alert fatigue + incident noise). //! //! [`TransitionGate`] only reports a transition once a new status has been //! observed for `confirmations` consecutive checks, and it is **seeded from the //! last stored status on startup**, so a restart of a persistently-degraded //! target does not re-fire its alert. use pom::types::HealthStatus; /// Tracks the confirmed status and a pending candidate change. #[derive(Debug, Clone)] pub(crate) struct TransitionGate { /// The status we have committed to (alerted/incident-tracked on). confirmed: Option, /// A differing status observed but not yet confirmed: (candidate, run length). pending: Option<(HealthStatus, u32)>, /// Consecutive matching observations required to confirm a change. confirmations: u32, } impl TransitionGate { /// Seed from the last stored status (`None` if the target has no history). /// `confirmations` is clamped to at least 1 (1 = alert on first difference). pub(crate) fn seeded(confirmed: Option, confirmations: u32) -> Self { Self { confirmed, pending: None, confirmations: confirmations.max(1), } } /// Feed one observed status. Returns `Some((from, to))` only when a change is /// *confirmed*: observed for `confirmations` consecutive checks. Returns /// `None` while a change is still pending, when the status is unchanged, or on /// the very first observation of a target with no prior confirmed status. pub(crate) fn observe( &mut self, observed: HealthStatus, ) -> Option<(HealthStatus, HealthStatus)> { match self.confirmed { // No prior status (fresh target with no history): adopt silently; the // old code likewise needs a `previous` row before it alerts. None => { self.confirmed = Some(observed); self.pending = None; None } // Back to (or still at) the confirmed status: any pending change was a // blip; drop it. Some(cur) if observed == cur => { self.pending = None; None } // Differs from confirmed: accumulate consecutive agreement. Some(cur) => { let count = match self.pending { Some((p, c)) if p == observed => c + 1, _ => 1, }; if count >= self.confirmations { self.pending = None; self.confirmed = Some(observed); Some((cur, observed)) } else { self.pending = Some((observed, count)); None } } } } } #[cfg(test)] mod tests { use super::*; use pom::types::HealthStatus::*; #[test] fn confirmations_1_fires_on_first_difference() { let mut g = TransitionGate::seeded(Some(Operational), 1); assert_eq!(g.observe(Error), Some((Operational, Error))); assert_eq!(g.observe(Error), None); // now confirmed } #[test] fn single_blip_is_absorbed_at_confirmations_2() { let mut g = TransitionGate::seeded(Some(Operational), 2); // One differing check → pending, no transition. assert_eq!(g.observe(Error), None); // Back to operational → the blip is dropped. assert_eq!(g.observe(Operational), None); // A later genuine outage still needs two in a row. assert_eq!(g.observe(Error), None); assert_eq!(g.observe(Error), Some((Operational, Error))); } #[test] fn flapping_candidate_resets_the_run() { let mut g = TransitionGate::seeded(Some(Operational), 3); assert_eq!(g.observe(Error), None); // Error x1 assert_eq!(g.observe(Unreachable), None); // candidate changed → Unreachable x1 assert_eq!(g.observe(Unreachable), None); // Unreachable x2 assert_eq!(g.observe(Unreachable), Some((Operational, Unreachable))); // x3 confirms } #[test] fn seeded_status_does_not_refire_on_restart() { // Restart: last stored status was Error. Observing Error again must NOT // produce a transition (that was the re-fire-on-restart bug). let mut g = TransitionGate::seeded(Some(Error), 2); assert_eq!(g.observe(Error), None); assert_eq!(g.observe(Error), None); } #[test] fn first_ever_observation_is_silent() { let mut g = TransitionGate::seeded(None, 2); assert_eq!(g.observe(Operational), None); // Now Operational is confirmed; a change debounces normally. assert_eq!(g.observe(Error), None); assert_eq!(g.observe(Error), Some((Operational, Error))); } }