crates/estate-clock/src/report.rs
//! **What the last sync concluded**, kept where a control plane can read it.
//!
//! The defect that made this module necessary was not the clock. It was that
//! the clock's outcome existed only as a log line on a VGA console, so the
//! re-image that was supposed to fix it could only be judged by guessing. A
//! report is data: `gunnar-server` records one per attempt, `gunnar-grpc` serves
//! the boot one and the last one, and `gunnar control status` prints the last
//! one **without a credential** — because when the clock is wrong, a credential
//! is exactly what the server refuses.
//!
//! Process-global on purpose. There is one `CLOCK_REALTIME` per machine; a
//! report threaded through every service context would be several copies of a
//! single fact.
use std::sync::Mutex;
use crate::adjust::Action;
use crate::decide::{Decision, Poll};
/// **Why an attempt ran.** Three triggers, and only these (owner's ruling,
/// 2026-09-14) — the clock is not re-measured "constantly".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trigger {
/// Before any listener binds (the appliance) or the first sync of the
/// process (the front). Corrections are steps.
Boot,
/// The appliance refused a credential as stale: the refusal is itself the
/// evidence that its clock and the front's disagree. Rate-limited and
/// data-free — see [`crate::trigger`]. A running server: slew or step.
SkewRefusal,
/// The slow safety timer (`crate::trigger::SAFETY_INTERVAL`). A running
/// server: slew or step.
Timer,
}
impl Trigger {
/// Stable strings: they go on the wire.
pub fn as_str(self) -> &'static str {
match self {
Trigger::Boot => "boot",
Trigger::SkewRefusal => "skew-refusal",
Trigger::Timer => "timer",
}
}
}
/// The one-word result. Stable strings: they go on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
/// No source configured. Nothing was asked.
NotConfigured,
/// `off`, by name.
Off,
/// The configuration was refused (the detail says why).
Misconfigured,
/// The clock was stepped.
Stepped,
/// A slew was started.
Slewed,
/// The sources agree with this clock.
AlreadyRight,
/// Too few sources answered.
NoQuorum,
/// The sources could not be reconciled.
Disagree,
/// A correction was decided and the kernel refused it.
ApplyFailed,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Outcome::NotConfigured => "not-configured",
Outcome::Off => "off",
Outcome::Misconfigured => "misconfigured",
Outcome::Stepped => "stepped",
Outcome::Slewed => "slewed",
Outcome::AlreadyRight => "already-right",
Outcome::NoQuorum => "no-quorum",
Outcome::Disagree => "disagree",
Outcome::ApplyFailed => "apply-failed",
}
}
/// True when, after this attempt, the clock is known to agree with its
/// sources (or is being slewed onto them). What a front checks before it
/// signs time for anybody else.
pub fn vouches(self) -> bool {
matches!(
self,
Outcome::Stepped | Outcome::Slewed | Outcome::AlreadyRight
)
}
}
/// One configured source's part in an attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceReport {
pub source: String,
/// `source - local` when it answered.
pub offset_ms: Option<i64>,
/// Why it contributed nothing, when it did not.
pub error: Option<String>,
}
/// One attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub trigger: Trigger,
/// This machine's clock when the report was made (after any step).
pub at_unix_ms: i64,
pub outcome: Outcome,
/// The decided offset; 0 when there was none.
pub offset_ms: i64,
/// One sentence for a human.
pub detail: String,
pub sources: Vec<SourceReport>,
}
impl Report {
/// A report with no sources: not configured, off, or misconfigured.
pub fn without_sources(phase: Trigger, outcome: Outcome, detail: impl Into<String>) -> Report {
Report {
trigger: phase,
at_unix_ms: crate::now_unix_ms(),
outcome,
offset_ms: 0,
detail: detail.into(),
sources: Vec::new(),
}
}
/// The report for a poll and what was done about it. Pure apart from
/// reading the clock for `at_unix_ms`.
pub fn from_poll(
phase: Trigger,
poll: &Poll,
action: Action,
applied: Result<(), String>,
) -> Report {
let mut sources: Vec<SourceReport> = poll
.answers
.iter()
.map(|answer| match answer {
Ok(sample) => SourceReport {
source: sample.source.clone(),
offset_ms: Some(sample.offset_ms),
error: None,
},
Err((source, why)) => SourceReport {
source: source.clone(),
offset_ms: None,
error: Some(why.clone()),
},
})
.collect();
for (source, claimed_ms) in &poll.verdict.insane {
if let Some(row) = sources.iter_mut().find(|r| &r.source == source) {
row.error = Some(format!(
"claims {claimed_ms} ms since the epoch, outside the sanity window"
));
}
}
let (outcome, offset_ms, detail) = match (&poll.verdict.decision, action, applied) {
(Decision::Correct { offset_ms, agreed }, Action::Step(_), Ok(())) => (
Outcome::Stepped,
*offset_ms,
format!(
"clock stepped {:+} ms{} from {}",
offset_ms,
if phase != Trigger::Boot { " (live: beyond the slew ceiling)" } else { "" },
agreed.join(", ")
),
),
(Decision::Correct { offset_ms, agreed }, Action::Slew(_), Ok(())) => (
Outcome::Slewed,
*offset_ms,
format!("clock slewing {offset_ms:+} ms towards {}", agreed.join(", ")),
),
(Decision::Correct { offset_ms, .. }, _, Err(why)) => (
Outcome::ApplyFailed,
*offset_ms,
format!("a {offset_ms:+} ms correction was decided and could not be applied: {why}"),
),
(Decision::Correct { offset_ms, .. }, Action::Leave, Ok(())) => (
Outcome::AlreadyRight,
*offset_ms,
format!("{offset_ms:+} ms is below the correction threshold"),
),
(Decision::AlreadyRight { offset_ms, agreed }, _, _) => (
Outcome::AlreadyRight,
*offset_ms,
format!("clock agrees with {} ({offset_ms:+} ms)", agreed.join(", ")),
),
(Decision::NoQuorum { had, needed }, _, _) => (
Outcome::NoQuorum,
0,
format!(
"clock unchanged: {had} usable answer(s) from {} configured source(s), {needed} needed",
poll.answers.len()
),
),
(Decision::Disagree { spread_ms, .. }, _, _) => (
Outcome::Disagree,
0,
format!(
"clock unchanged: the sources disagree by {spread_ms} ms, and a disagreement is \
refused rather than averaged"
),
),
};
Report {
trigger: phase,
at_unix_ms: crate::now_unix_ms(),
outcome,
offset_ms,
detail,
sources,
}
}
/// One line: `periodic stepped -7199500 ms at 1789…: <detail>`.
pub fn summary(&self) -> String {
format!(
"{} {} {:+} ms at {} — {}",
self.trigger.as_str(),
self.outcome.as_str(),
self.offset_ms,
self.at_unix_ms,
self.detail
)
}
}
struct Store {
boot: Option<Report>,
last: Option<Report>,
}
static STORE: Mutex<Store> = Mutex::new(Store {
boot: None,
last: None,
});
/// Keep `report` as the last one — and as the boot one, when it is.
pub fn record(report: Report) {
let mut store = STORE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if report.trigger == Trigger::Boot {
store.boot = Some(report.clone());
}
store.last = Some(report);
}
/// The most recent attempt, if any has run in this process.
pub fn last() -> Option<Report> {
STORE.lock().unwrap_or_else(|p| p.into_inner()).last.clone()
}
/// The boot attempt, if it has run.
pub fn boot() -> Option<Report> {
STORE.lock().unwrap_or_else(|p| p.into_inner()).boot.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adjust::choose;
use crate::decide::Sample;
fn sample(source: &str, offset_ms: i64) -> Result<Sample, (String, String)> {
Ok(Sample {
source: source.into(),
source_unix_ms: 1_789_394_521_628,
offset_ms,
})
}
#[test]
fn an_unreachable_peer_changes_nothing_and_is_reported_by_name() {
let poll = Poll::over(vec![Err((
"peer://10.13.8.99:50123".into(),
"peer://10.13.8.99:50123 did not accept a connection: timed out".into(),
))]);
let action = choose(Trigger::Boot, &poll.verdict.decision);
assert_eq!(action, Action::Leave);
let report = Report::from_poll(Trigger::Boot, &poll, action, Ok(()));
assert_eq!(report.outcome, Outcome::NoQuorum);
assert!(!report.outcome.vouches());
assert_eq!(report.sources.len(), 1);
assert!(report.sources[0]
.error
.as_deref()
.unwrap()
.contains("did not accept"));
assert!(report.summary().contains("no-quorum"));
}
#[test]
fn a_boot_step_and_a_periodic_slew_are_reported_as_what_they_were() {
let poll = Poll::over(vec![sample("peer://10.13.8.99:50123", -7_199_500)]);
let report = Report::from_poll(Trigger::Boot, &poll, Action::Step(-7_199_500), Ok(()));
assert_eq!(
(report.outcome, report.offset_ms),
(Outcome::Stepped, -7_199_500)
);
assert!(report.outcome.vouches());
let poll = Poll::over(vec![sample("peer://10.13.8.99:50123", 3_000)]);
let report = Report::from_poll(Trigger::Timer, &poll, Action::Slew(3_000), Ok(()));
assert_eq!(report.outcome, Outcome::Slewed);
}
#[test]
fn a_correction_the_kernel_refused_is_reported_and_does_not_vouch() {
let poll = Poll::over(vec![sample("peer://10.13.8.99:50123", -7_199_500)]);
let report = Report::from_poll(
Trigger::Boot,
&poll,
Action::Step(-7_199_500),
Err("Operation not permitted".into()),
);
assert_eq!(report.outcome, Outcome::ApplyFailed);
assert!(!report.outcome.vouches());
assert!(report.detail.contains("Operation not permitted"));
}
#[test]
fn the_store_keeps_the_boot_report_after_later_ones() {
record(Report::without_sources(
Trigger::Boot,
Outcome::NotConfigured,
"boot",
));
record(Report::without_sources(
Trigger::Timer,
Outcome::Off,
"later",
));
assert_eq!(boot().unwrap().detail, "boot");
// `last` is shared with every other test that syncs in this binary, so
// only its presence is asserted; `boot` is recorded by this test alone.
assert!(last().is_some());
}
}