Watch
1
0
Fork
You've already forked souveraine
0

sessiond: evidence-source health as device state

A source that reported and then went silent past 90s is DOWN, not quiet,
and says so as an error-security entry. Sources that never reported stay
unknown — a light sensor with no reporter is correctly silent forever.

Every forensic snapshot now carries sensors_degraded, so a decision taken
during an outage doesn't read like a healthy one. This is the 2026-07-25
SLPI case: hexagonrpcd-sdsp exited 0, every sensor dead for hours, nothing
in any log.

Health is not read from SensorProxy Has* — measured lying both ways.

Also: evidence_fresh meant "ever seen" and read true through the outage;
repeated readings no longer append to the trail, since reporters heartbeat.

55 sessiond tests.
This commit is contained in:
Fimeg 2026-07-26 08:30:07 -04:00
commit 862d1d7bba
2 changed files with 549 additions and 77 deletions

View file

@ -52,6 +52,24 @@ pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
/// a dead sensor looked exactly like a consumer of a quiet one.
pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
/// How long a source that was previously reporting may say nothing before the
/// machine calls it **down** rather than merely stale.
///
/// The two are different claims and the difference is the whole point.
/// `EVIDENCE_TTL` is about the *reading*: stop believing a 30-second-old "near".
/// This is about the *source*: proximity that sat at `far` all afternoon has an
/// unexpired flag of `false` and looks identical whether the sensor is healthy
/// and the phone is on a table, or the DSP took a CHRE fatal an hour ago. The
/// staleness rule cannot tell those apart, because nothing about a `false` flag
/// going unreported is anomalous.
///
/// It is longer than the TTL on purpose. A source is allowed to be quiet for a
/// while — that is normal. What is not normal is a source that was speaking and
/// then stopped for longer than any legitimate quiet period, which is what this
/// threshold names. Reporters keep this honest by re-reporting their last value
/// periodically, so silence means silence rather than "nothing changed".
pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90);
/// How long a blank waits for the compositor to acknowledge the lock it asked
/// for before going dark anyway.
///
@ -91,6 +109,8 @@ pub struct DeviceStatePolicy {
/// disease this machine exists to end. The field is settable so the
/// capability is real rather than implied.
pub unlocked_blank_after: Option<Duration>,
/// A source silent for this long, having previously reported, is down.
pub source_down_after: Duration,
}
impl Default for DeviceStatePolicy {
@ -103,6 +123,7 @@ impl Default for DeviceStatePolicy {
dim_warning: true,
lock_ack_budget: LOCK_ACK_BUDGET,
unlocked_blank_after: None,
source_down_after: SOURCE_DOWN_AFTER,
}
}
}
@ -144,6 +165,84 @@ pub struct EvidenceSeen {
pub touch: Option<Instant>,
}
/// Whether an evidence source is *there*, as opposed to what it last said.
///
/// Doctrine §9 says sensor readings are evidence, not fact. This is the
/// sentence underneath that one: the machine must also know whether it is
/// receiving evidence at all. `DEVICE-STATE-MACHINE.md` §0 states the
/// requirement — "'No evidence' and 'evidence says nothing is happening' must
/// not be the same state" — and until now they were.
///
/// Health is deliberately NOT read from `net.hadess.SensorProxy`'s
/// `HasProximity`/`HasAccelerometer` properties, which is the obvious place to
/// look and is wrong. Those lie in both directions, measured: on 2026-07-25 at
/// 12:19 the stack was dead and the proxy answered `HasProximity: true` for
/// hours (`PAF/slpi.md`), and in the 06:04 incident it answered `false` while
/// remoteproc had already recovered SLPI. A property that is wrong both ways is
/// not an authority. What the machine can actually trust is its own experience:
/// whether readings arrive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceHealth {
/// Nothing has ever been heard from this source. Not an error — a light
/// sensor with no reporter installed is silent, correctly, forever. Only a
/// source that spoke and then stopped has failed.
#[default]
Unknown,
/// Reporting within `source_down_after`.
Live,
/// Reported once and has now been silent past the threshold.
Down,
}
impl SourceHealth {
pub fn as_str(self) -> &'static str {
match self {
SourceHealth::Unknown => "unknown",
SourceHealth::Live => "live",
SourceHealth::Down => "down",
}
}
}
/// Per-source health, in the same shape as `EvidenceSeen`.
#[derive(Debug, Clone, Copy, Default)]
pub struct SourceHealthTable {
pub proximity: SourceHealth,
pub accel: SourceHealth,
pub light: SourceHealth,
pub touch: SourceHealth,
}
impl SourceHealthTable {
fn get_mut(&mut self, source: SensorSource) -> &mut SourceHealth {
match source {
SensorSource::Proximity => &mut self.proximity,
SensorSource::Accelerometer => &mut self.accel,
SensorSource::Light => &mut self.light,
SensorSource::Touch => &mut self.touch,
}
}
/// True when any source that once worked has gone silent. This is the
/// single question the rest of the system asks — a surface showing
/// "sensors degraded" does not need to know which one.
pub fn any_down(&self) -> bool {
[self.proximity, self.accel, self.light, self.touch]
.iter()
.any(|h| *h == SourceHealth::Down)
}
pub fn as_json(&self) -> serde_json::Value {
serde_json::json!({
"proximity": self.proximity.as_str(),
"accel": self.accel.as_str(),
"light": self.light.as_str(),
"touch": self.touch.as_str(),
})
}
}
// ── Forensic logging ─────────────────────────────────────────────────
// Every decision point emits a ForensicEntry capturing the full state
// at that moment. These are appended to a JSONL file alongside the
@ -229,6 +328,15 @@ pub struct StateSnapshot {
pub screen_lock_secure: bool,
pub idle_coordinator_state: String,
pub sleep_inhibitor_held: bool,
/// True when some evidence source that used to report has gone silent.
///
/// On the snapshot rather than only on the health entry because this is
/// what makes the trail diagnostic after the fact: every decision taken
/// during an outage is stamped with the fact that the machine was deciding
/// on absent evidence. Without it, a `panel-off` recorded during four dead
/// hours is indistinguishable from a healthy one, and the log answers
/// "what happened" but not "why was it wrong".
pub sensors_degraded: bool,
}
/// Forensic log — accumulates entries for post-hoc analysis.
@ -408,6 +516,9 @@ pub struct DeviceStateMachine {
idle_since: Option<Instant>,
/// Per-source freshness, for the staleness rule.
evidence_seen: EvidenceSeen,
/// Per-source health — whether evidence is arriving at all, which is a
/// different question from what it says. See `SourceHealth`.
pub source_health: SourceHealthTable,
/// True once a blank has been asked for, so we ask exactly once per wake
/// instead of every tick.
blank_requested: bool,
@ -485,6 +596,7 @@ impl DeviceStateMachine {
panel_on: true,
idle_since: None,
evidence_seen: EvidenceSeen::default(),
source_health: SourceHealthTable::default(),
blank_requested: false,
dimmed: false,
pending_blank: None,
@ -508,6 +620,7 @@ impl DeviceStateMachine {
/// point on the timeline instead of by sleeping.
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
self.expire_stale_evidence(now);
self.evaluate_source_health(now);
let mut actions = Vec::new();
@ -604,7 +717,11 @@ impl DeviceStateMachine {
if idle_for >= budget {
info!(
"[device-state] {}, idle {:.1}s of {}s — blanking",
if is_locked(self.state) { "locked" } else { "unlocked" },
if is_locked(self.state) {
"locked"
} else {
"unlocked"
},
idle_for.as_secs_f32(),
budget.as_secs()
);
@ -743,15 +860,88 @@ impl DeviceStateMachine {
}
}
/// Decide whether each evidence source is still there.
///
/// Separate from `expire_stale_evidence` because it answers a different
/// question, and because the staleness rule structurally cannot answer this
/// one: it only acts on a source whose flag is currently `true`. A
/// proximity sensor resting at `far` has a `false` flag, so it expires
/// nothing, reports nothing, and dies in complete silence. That is exactly
/// the 2026-07-25 outage — hours of dead sensors with nothing in any log.
fn evaluate_source_health(&mut self, now: Instant) {
let limit = self.policy.source_down_after;
let sources = [
(SensorSource::Proximity, self.evidence_seen.proximity),
(SensorSource::Accelerometer, self.evidence_seen.accel),
(SensorSource::Light, self.evidence_seen.light),
(SensorSource::Touch, self.evidence_seen.touch),
];
let mut newly_down: Vec<&str> = Vec::new();
for (source, seen) in sources {
let Some(t) = seen else { continue };
if now.saturating_duration_since(t) <= limit {
continue;
}
let health = self.source_health.get_mut(source);
if *health == SourceHealth::Down {
continue;
}
*health = SourceHealth::Down;
newly_down.push(source.as_str());
}
// One entry per outage edge, not per tick. A source stays Down until
// it speaks again, and a trail that repeated this every second would
// bury the transition that actually diagnoses anything.
for source in &newly_down {
warn!(
"[device-state] evidence source {source} has said nothing for {}s — treating it as DOWN, not quiet",
limit.as_secs()
);
self.record_error(
"sensor-health",
"source-down",
&format!(
"{source} reported before but has been silent for over {}s; its readings are absent, not negative",
limit.as_secs()
),
);
}
}
/// Stamp a source as freshly reported. Called alongside the reading.
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
let now = Instant::now();
self.mark_evidence_seen_at(source, Instant::now())
}
/// `mark_evidence_seen` with the clock passed in, so health transitions can
/// be tested on a timeline instead of by sleeping for 90 seconds.
pub fn mark_evidence_seen_at(&mut self, source: SensorSource, now: Instant) {
match source {
SensorSource::Proximity => self.evidence_seen.proximity = Some(now),
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
SensorSource::Light => self.evidence_seen.light = Some(now),
SensorSource::Touch => self.evidence_seen.touch = Some(now),
}
let health = self.source_health.get_mut(source);
let was = *health;
*health = SourceHealth::Live;
if was == SourceHealth::Down {
// Recovery is as diagnostic as the outage: the pair of entries
// bounds the window in which every sensor-driven rule was running
// on nothing, which is what makes the trail usable after the fact.
info!(
"[device-state] evidence source {} is reporting again",
source.as_str()
);
self.record_decision(
"source-recovered",
serde_json::json!({ "source": source.as_str() }),
"a source previously recorded as down has resumed reporting",
);
}
}
/// Real user input. Resets the blank budget, re-arms the rule, and undoes
@ -834,7 +1024,15 @@ impl DeviceStateMachine {
}
/// Build a forensic snapshot of the current state.
pub fn snapshot(&self, phase: &str, shell_alive: bool, screen_locked: bool, screen_lock_secure: bool, idle_state: &str, inhibitor_held: bool) -> StateSnapshot {
pub fn snapshot(
&self,
phase: &str,
shell_alive: bool,
screen_locked: bool,
screen_lock_secure: bool,
idle_state: &str,
inhibitor_held: bool,
) -> StateSnapshot {
StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
@ -849,6 +1047,7 @@ impl DeviceStateMachine {
screen_lock_secure,
idle_coordinator_state: idle_state.to_string(),
sleep_inhibitor_held: inhibitor_held,
sensors_degraded: self.source_health.any_down(),
}
}
@ -887,9 +1086,14 @@ impl DeviceStateMachine {
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
};
self.forensic.append(
ForensicEvent::Transition { from: prev, to: next, legal: false },
ForensicEvent::Transition {
from: prev,
to: next,
legal: false,
},
snapshot,
&format!("illegal transition {:?} → {:?} refused", prev, next),
);
@ -915,9 +1119,14 @@ impl DeviceStateMachine {
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
};
self.forensic.append(
ForensicEvent::Transition { from: prev, to: next, legal: true },
ForensicEvent::Transition {
from: prev,
to: next,
legal: true,
},
snapshot,
&format!("{:?} → {:?}", prev, next),
);
@ -997,49 +1206,72 @@ impl DeviceStateMachine {
let should_be_observed = self.state == DeviceState::Locked && proximity_near;
let confidence = evidence.confidence();
// A reading identical to the one already held is not a decision point.
//
// Reporters heartbeat now — they re-send their last value on a fixed
// interval so that silence means a dead source rather than a quiet one
// (see `SOURCE_DOWN_AFTER`). Those repeats must not each become a trail
// entry: proximity alone would add ~2,900 lines a day saying nothing
// changed, to a log that §0 already records as unbounded and on tmpfs.
// Freshness is still stamped by the caller either way, which is what
// the keepalive is actually for.
let unchanged = self.sensor_evidence.proximity_near == evidence.proximity_near
&& self.sensor_evidence.accel_moving == evidence.accel_moving
&& self.sensor_evidence.light_changing == evidence.light_changing
&& self.sensor_evidence.touch_active == evidence.touch_active;
self.sensor_evidence = evidence;
// Forensic: record the sensor input and its evaluation
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence,
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
};
self.forensic.append(
ForensicEvent::SensorInput {
// The reporting source, not a hardcoded Proximity. Accel,
// light and touch readings were all being written to the trail
// labelled as proximity, which makes post-hoc reconstruction —
// the entire point of the trail — lie about what was measured.
source,
value: match source {
SensorSource::Proximity => SensorValue::Near(proximity_near),
SensorSource::Accelerometer => {
SensorValue::Moving(self.sensor_evidence.accel_moving)
}
SensorSource::Light => {
SensorValue::Changing(self.sensor_evidence.light_changing)
}
SensorSource::Touch => SensorValue::Active(self.sensor_evidence.touch_active),
},
// Only the trail write is skipped, never the evaluation below. An
// identical reading can still change the answer: proximity held `near`
// across a lock means `should_be_observed` flips from false to true
// with no change in the evidence at all.
if !unchanged {
// Forensic: record the sensor input and its evaluation
let snapshot = StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: String::new(),
shell_alive: false,
proximity_near: self.sensor_evidence.proximity_near,
confidence,
},
snapshot,
&format!(
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
proximity_near, confidence, should_be_observed, was_observed
),
);
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked: false,
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
};
self.forensic.append(
ForensicEvent::SensorInput {
// The reporting source, not a hardcoded Proximity. Accel,
// light and touch readings were all being written to the trail
// labelled as proximity, which makes post-hoc reconstruction —
// the entire point of the trail — lie about what was measured.
source,
value: match source {
SensorSource::Proximity => SensorValue::Near(proximity_near),
SensorSource::Accelerometer => {
SensorValue::Moving(self.sensor_evidence.accel_moving)
}
SensorSource::Light => {
SensorValue::Changing(self.sensor_evidence.light_changing)
}
SensorSource::Touch => {
SensorValue::Active(self.sensor_evidence.touch_active)
}
},
confidence,
},
snapshot,
&format!(
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
proximity_near, confidence, should_be_observed, was_observed
),
);
}
if should_be_observed && !was_observed {
self.transition(DeviceState::Observed);
@ -1064,12 +1296,10 @@ impl DeviceStateMachine {
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
};
self.forensic.append(
ForensicEvent::Wake { trigger },
snapshot,
reason,
);
self.forensic
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
}
/// Record an error that affected device state.
@ -1088,6 +1318,7 @@ impl DeviceStateMachine {
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
};
self.forensic.append(
ForensicEvent::Error {
@ -1116,6 +1347,7 @@ impl DeviceStateMachine {
screen_lock_secure: false,
idle_coordinator_state: String::new(),
sleep_inhibitor_held: false,
sensors_degraded: self.source_health.any_down(),
};
self.forensic.append(
ForensicEvent::Decision {
@ -1141,12 +1373,28 @@ impl DeviceStateMachine {
"idle_secs": self.idle_secs(),
"dimmed": self.dimmed,
"blank_requested": self.blank_requested,
// Freshness means "reported within the TTL", not "has ever been
// heard from". The `is_some()` version read as fresh for the whole
// life of the daemon, because `expire_stale_evidence` only clears
// the timestamp for a source whose flag was set — so a sensor
// resting at `far` reported `fresh: true` indefinitely, including
// through the outage this field exists to make visible.
"evidence_fresh": {
"proximity": self.evidence_seen.proximity.is_some(),
"accel": self.evidence_seen.accel.is_some(),
"light": self.evidence_seen.light.is_some(),
"touch": self.evidence_seen.touch.is_some(),
"proximity": self.is_fresh(self.evidence_seen.proximity),
"accel": self.is_fresh(self.evidence_seen.accel),
"light": self.is_fresh(self.evidence_seen.light),
"touch": self.is_fresh(self.evidence_seen.touch),
},
// Health is the other axis: `fresh` says whether the reading may
// be believed, `health` says whether the source is there at all.
"sensor_health": self.source_health.as_json(),
"sensors_degraded": self.source_health.any_down(),
})
}
fn is_fresh(&self, seen: Option<Instant>) -> bool {
seen.is_some_and(|t| {
Instant::now().saturating_duration_since(t) <= self.policy.evidence_ttl
})
}
}
@ -1279,10 +1527,7 @@ mod tests {
// The budget expires: the machine wants the panel dark, but the
// session is unlocked, so the lock goes first — LOCK-DPMS §1.
assert_eq!(
sm.tick_at(t0 + Duration::from_secs(31)),
vec![Action::Lock]
);
assert_eq!(sm.tick_at(t0 + Duration::from_secs(31)), vec![Action::Lock]);
assert!(sm.pending_blank.is_some());
assert!(!sm.blank_requested, "the panel must not be dark yet");
}
@ -1411,9 +1656,7 @@ mod tests {
let tap = t0 + LOCK_BLANK_AFTER * 10;
sm.note_idle_start(tap);
let at_old_deadline = tap + LOCK_BLANK_AFTER - Duration::from_secs(1);
assert!(!sm
.tick_at(at_old_deadline)
.contains(&Action::Blank));
assert!(!sm.tick_at(at_old_deadline).contains(&Action::Blank));
// And it does eventually blank, one full budget after the tap.
assert_eq!(
@ -1779,13 +2022,17 @@ mod tests {
ForensicEvent::Transition { from, to, legal } => {
format!("transition {:?} → {:?} (legal={})", from, to, legal)
}
ForensicEvent::SensorInput { source, confidence, .. } => {
ForensicEvent::SensorInput {
source, confidence, ..
} => {
format!("sensor {:?} conf={:.2}", source, confidence)
}
ForensicEvent::Wake { trigger } => {
format!("wake {:?}", trigger)
}
ForensicEvent::Error { component, action, .. } => {
ForensicEvent::Error {
component, action, ..
} => {
format!("error [{}] {}", component, action)
}
ForensicEvent::Decision { decision, .. } => {
@ -1807,16 +2054,41 @@ mod tests {
println!("=== END FORENSIC LOG ===\n");
// Verify we captured the key events
assert!(entries.len() >= 12, "expected at least 12 forensic entries, got {}", entries.len());
assert!(
entries.len() >= 12,
"expected at least 12 forensic entries, got {}",
entries.len()
);
// Verify transitions were captured
let transitions: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Transition { .. })).collect();
assert!(transitions.len() >= 8, "expected at least 8 transitions, got {}", transitions.len());
let transitions: Vec<_> = entries
.iter()
.filter(|e| matches!(e.event, ForensicEvent::Transition { .. }))
.collect();
assert!(
transitions.len() >= 8,
"expected at least 8 transitions, got {}",
transitions.len()
);
// Verify sensor inputs were captured
let sensors: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
assert!(sensors.len() >= 2, "expected at least 2 sensor inputs, got {}", sensors.len());
let sensors: Vec<_> = entries
.iter()
.filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. }))
.collect();
assert!(
sensors.len() >= 2,
"expected at least 2 sensor inputs, got {}",
sensors.len()
);
// Verify wake events were captured
let wakes: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Wake { .. })).collect();
assert!(wakes.len() >= 2, "expected at least 2 wake events, got {}", wakes.len());
let wakes: Vec<_> = entries
.iter()
.filter(|e| matches!(e.event, ForensicEvent::Wake { .. }))
.collect();
assert!(
wakes.len() >= 2,
"expected at least 2 wake events, got {}",
wakes.len()
);
}
#[test]
@ -1825,11 +2097,18 @@ mod tests {
// Try an illegal transition: Active → DozeLight (must go through Locked)
assert!(!sm.transition(DeviceState::DozeLight));
let entries = sm.forensic.recent(10);
let refused: Vec<_> = entries.iter().filter(|e| match &e.event {
ForensicEvent::Transition { legal, .. } => !legal,
_ => false,
}).collect();
assert_eq!(refused.len(), 1, "expected 1 refused transition in forensic log");
let refused: Vec<_> = entries
.iter()
.filter(|e| match &e.event {
ForensicEvent::Transition { legal, .. } => !legal,
_ => false,
})
.collect();
assert_eq!(
refused.len(),
1,
"expected 1 refused transition in forensic log"
);
match &refused[0].event {
ForensicEvent::Transition { from, to, legal } => {
assert_eq!(*from, DeviceState::Active);
@ -1853,14 +2132,193 @@ mod tests {
touch_active: false,
});
let entries = sm.forensic.recent(10);
let sensor_entries: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
let sensor_entries: Vec<_> = entries
.iter()
.filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. }))
.collect();
assert!(!sensor_entries.is_empty());
// The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5)
match &sensor_entries[0].event {
ForensicEvent::SensorInput { confidence, .. } => {
assert!((confidence - 0.5).abs() < 0.01, "expected 0.5 confidence, got {}", confidence);
assert!(
(confidence - 0.5).abs() < 0.01,
"expected 0.5 confidence, got {}",
confidence
);
}
_ => unreachable!(),
}
}
// ── Evidence-source health ───────────────────────────────────────
// The distinction these cover is DEVICE-STATE-MACHINE.md §0's: "'No
// evidence' and 'evidence says nothing is happening' must not be the same
// state." The 2026-07-25 outage ran four hours with nothing in any log.
#[test]
fn a_source_that_never_reported_is_unknown_not_down() {
// A light sensor with no reporter installed is silent, correctly,
// forever. Calling that an outage would make the signal worthless.
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(60));
assert_eq!(sm.source_health.light, SourceHealth::Unknown);
assert_eq!(sm.source_health.proximity, SourceHealth::Unknown);
assert!(!sm.source_health.any_down());
}
#[test]
fn a_source_that_reported_and_went_silent_is_down_and_loud() {
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
// Still within the window: quiet is allowed.
sm.tick_at(t0 + SOURCE_DOWN_AFTER - Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Down);
assert!(sm.source_health.any_down());
// The outage is an error in the trail, not just a state field —
// otherwise nothing after the fact can find the window.
let errors: Vec<_> = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-down"))
.collect();
assert_eq!(errors.len(), 1, "expected exactly one source-down entry");
}
#[test]
fn a_down_source_is_recorded_once_not_every_tick() {
// A tick loop that re-logged this every second would bury the
// transition under 3600 identical lines an hour.
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
for i in 0..10 {
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1 + i));
}
let errors = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-down"))
.count();
assert_eq!(errors, 1);
}
#[test]
fn a_recovered_source_goes_live_and_bounds_the_outage() {
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Down);
sm.mark_evidence_seen_at(SensorSource::Proximity, t0 + Duration::from_secs(200));
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
assert!(!sm.source_health.any_down());
// The recovery entry is what closes the interval. Without it the trail
// says when the sensors died and never says when they came back.
let recovered = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Decision { decision, .. } if decision == "source-recovered"))
.count();
assert_eq!(recovered, 1);
}
#[test]
fn decisions_taken_during_an_outage_are_stamped_degraded() {
// The point of the whole feature: a panel-off recorded during four
// dead hours must not read like a healthy one.
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
sm.record_decision("panel-off", serde_json::json!({}), "test");
let entry = sm
.forensic
.recent(1)
.into_iter()
.next()
.expect("a decision was just recorded");
assert!(
entry.snapshot.sensors_degraded,
"a decision taken while a source is down must say so"
);
}
#[test]
fn freshness_expires_rather_than_meaning_ever_seen() {
// `evidence_fresh` used to be `is_some()`, which read true for the
// life of the daemon and stayed true straight through an outage.
let mut sm = DeviceStateMachine::new();
sm.mark_evidence_seen_at(
SensorSource::Proximity,
Instant::now() - (EVIDENCE_TTL + Duration::from_secs(5)),
);
let json = sm.to_ipc_json();
assert_eq!(
json["evidence_fresh"]["proximity"],
serde_json::json!(false)
);
sm.mark_evidence_seen(SensorSource::Proximity);
let json = sm.to_ipc_json();
assert_eq!(json["evidence_fresh"]["proximity"], serde_json::json!(true));
}
#[test]
fn a_repeated_reading_is_not_a_trail_entry() {
// Reporters heartbeat every 30s so silence is meaningful. If each
// repeat were logged, proximity alone would add ~2,900 lines a day to
// an unbounded tmpfs file, all of them saying nothing happened.
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
let near = SensorEvidence {
proximity_near: true,
..Default::default()
};
sm.update_sensors(near.clone());
let after_first = sm.forensic.recent(100).len();
for _ in 0..5 {
sm.update_sensors(near.clone());
}
assert_eq!(
sm.forensic.recent(100).len(),
after_first,
"identical readings must not each append to the trail"
);
}
#[test]
fn a_repeated_reading_still_reevaluates_observed() {
// The dedupe skips the trail write, never the decision. Proximity held
// `near` across a lock flips should_be_observed with no change in the
// evidence at all — an early return here would strand the machine.
let mut sm = DeviceStateMachine::new();
let near = SensorEvidence {
proximity_near: true,
..Default::default()
};
sm.update_sensors(near.clone());
assert_eq!(sm.state, DeviceState::Active);
sm.transition(DeviceState::Locked);
sm.update_sensors(near);
assert_eq!(
sm.state,
DeviceState::Observed,
"an unchanged reading must still be re-evaluated against the new state"
);
}
}

View file

@ -125,6 +125,20 @@ pub enum SensorSource {
Touch,
}
impl SensorSource {
/// The name this source is known by in the forensic trail and in logs.
/// Matches the `evidence_fresh` / `sensor_health` keys so a reader can
/// join them without a translation table.
pub fn as_str(self) -> &'static str {
match self {
SensorSource::Proximity => "proximity",
SensorSource::Accelerometer => "accel",
SensorSource::Light => "light",
SensorSource::Touch => "touch",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorValue {