Watch
1
0
Fork
You've already forked souveraine
0
souveraine/rescue/archdev-unversioned-copy-2026-08-09/files/src/sessiond/device_state.rs

5264 lines
222 KiB
Rust
Raw Normal View History

//! Unified device state machine — the single authority for device power state.
//!
//! Every actor that changes device power (idle timers, proximity sensor,
//! sleep signals, DPMS) routes through this machine. The machine owns the
//! state; actors are inputs, not authorities.
//!
//! The state graph:
//! Active → Dimmed → Locked → {Observed, DozeLight, DozeDeep} → Suspending → Asleep
//! Any locked state → Active (on PAM auth)
//! Any pre-sleep state → Suspending (on PrepareForSleep)
//!
//! Doctrine: SESSION-AUTHORITY-DOCTRINE §9 ("sensor readings are evidence,
//! not fact") and §11 ("the session authority and the binary authority are
//! the same authority"). TASK-08 and TASK-15 define the tiers.
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, PowerVerb, SensorSource, SensorValue,
TouchGesture, UsbMode,
};
/// How long a locked, lit panel waits for input before it blanks.
///
/// Android blanks the lock screen in about this long; Sailfish's mce keeps a
/// separate blank-from-lockscreen timeout for exactly this case. We had
/// neither: the only backstop was hypridle's 600 s screen-off timer, shared
/// with the desktop case, so glancing at the clock lit the panel for ten
/// minutes.
pub const LOCK_BLANK_AFTER: Duration = Duration::from_secs(15);
/// Same, when evidence says the device is in a hand rather than on a table.
pub const LOCK_BLANK_AFTER_HELD: Duration = Duration::from_secs(25);
/// How long the dimmed warning lasts before the panel goes dark.
///
/// Dimming first is the pre-warning: the screen visibly fades and a tap
/// inside this window cancels the blank. The mechanism already existed and
/// was orphaned — hypridle once carried a dim listener
/// (`brightnessctl -s set 10`, `on-resume = blueline-undim`) which is no
/// longer in the live config, while `blueline-undim` is still installed.
pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
/// Evidence older than this is stale, and stale evidence is *unknown* —
/// neither "user present" nor "user absent".
///
/// This exists because of a measured failure: on 2026-07-25 the SLPI took a
/// CHRE fatal, `blueline-hexagonrpcd-sdsp` exited with status 0 so systemd's
/// `Restart=on-failure` never fired, and every sensor was dead for hours
/// while iio-sensor-proxy still answered `HasProximity: true`. A consumer of
/// 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 after daemon start an *expected* source may stay silent before the
/// machine calls it `Absent`.
///
/// Deliberately much longer than `SOURCE_DOWN_AFTER`. sessiond starts before
/// the session does — that is the whole point of it, it takes the lock before
/// quickshell exists — so its reporters legitimately arrive late. This window
/// has to cover session startup on a cold boot without covering a reporter
/// that is never coming, and it is a policy field so it can be moved off the
/// trail rather than argued about here.
pub const SOURCE_EXPECTED_WITHIN: Duration = Duration::from_secs(300);
/// Which sources a reporter is expected to serve on this device.
///
/// Exactly what `souveraine-sensord` reports (`SOURCES` in its `main`):
/// proximity, light, accelerometer. `Touch` is deliberately absent — nothing
/// reports it, so it must stay `Unknown` and silent, per §10. Adding a source
/// here without a reporter behind it manufactures a permanent false alarm,
/// which is the failure this whole mechanism exists to avoid in the other
/// direction.
pub const EXPECTED_SOURCES: &[SensorSource] = &[
SensorSource::Proximity,
SensorSource::Light,
SensorSource::Accelerometer,
];
/// How long proximity must read `near` before the machine believes it.
///
/// §9.5 specified Android's `DisplayPowerProximityStateController` — 0 ms
/// positive, 250 ms negative — and that is the wrong shape for this device.
/// Measured on the trail 2026-07-26: 44 near-episodes over 2.4 hours, median
/// dwell **1 second**, 15 of them sub-second, and a median 115 s of quiet
/// between them. That is not a sensor bouncing around a threshold; it is
/// isolated one-second blips. A negative debounce delays believing `far`, so it
/// would have turned each 1 s blip into a 1.25 s blip and left all 88
/// transitions in place.
///
/// Android debounces the negative edge because there `near` means *screen off
/// at the ear, immediately* — a positive delay would be felt. That constraint
/// left when proximity stopped actuating: it now only vetoes tap-to-wake, where
/// waiting is imperceptible and a false veto is the more annoying failure.
///
/// Nine of the 44 episodes ran ≥5 s (max 173 s). Those are the real ones —
/// pocket, ear, deliberate cover — and they survive this threshold untouched.
pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
/// How long proximity must read `far` before the machine believes it.
///
/// Zero. An uncovered sensor is believed at once: the reading that ends a veto
/// should never be the slow one, and erring toward `far` errs toward letting a
/// wake through, which is the recoverable direction.
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
/// How long `Observed` must be held before a `far` reading may end it.
///
/// Measured on the phone 2026-08-03, which is what this is for. The trail was
/// almost entirely `Locked → Observed → Locked`, and reading the snapshots
/// showed why: `prox=true` on the way in and `prox=false` **one second later**
/// on the way out, over and over. `near` must hold 700 ms to be believed and
/// `far` is believed instantly, so a one-second blip is long enough to enter
/// and its end is immediate — the asymmetry that protects the wake veto is the
/// same asymmetry that makes this state chatter.
///
/// The debounce itself cannot be the place to fix it. [`suppress_wake`] reads
/// the *debounced* value, so slowing `far` there would keep vetoing tap-to-wake
/// after the sensor was uncovered, which is precisely what
/// [`PROXIMITY_FAR_DEBOUNCE`]'s zero exists to prevent. One reading, two
/// consumers, opposite needs: the veto wants `far` fast, the state wants it
/// stable. So the hysteresis goes here, on the state, and the veto keeps its
/// instant edge.
///
/// 3 s, and the bound comes from data already in this file rather than from
/// feel: the measured blips ran ~1 s, and of the 44 recorded proximity episodes
/// the nine real ones — pocket, ear, deliberate cover — all ran **≥5 s**
/// (max 173 s). 3 s sits above the noise and below every genuine episode, so it
/// suppresses the chatter without shortening a single real one.
///
/// This reduces the flapping rather than abolishing it: a sensor that keeps
/// blipping still enters `Observed` on each ≥700 ms `near`. Raising the *entry*
/// bar would need the same split applied to the near edge, and that is a second
/// change with its own justification to earn.
pub const OBSERVED_MIN_DWELL: Duration = Duration::from_secs(3);
/// How long a changed bearer preference must hold before the machine acts.
///
/// 20 s, chosen against the failure rather than against a feel. The gate this
/// replaces reacted at NM-event speed and produced ~7 tunnel recycles a
/// minute; a wifi association that comes and goes during a roam settles well
/// inside 20 s, and a genuine bearer change (walking out of range) does not
/// reverse itself within one. The cost of being slow here is 20 s on the wrong
/// link; the cost of being fast was an unusable phone.
pub const BEARER_SETTLE: Duration = Duration::from_secs(20);
/// How long a button must stay down to be a hold rather than a tap.
///
/// **Provisional, and the number is the weakest part of this file.** §9.5's
/// lesson is the standing warning here: the proximity debounce was specified
/// from Android's prior art, and when it came time to build it the trail said
/// the specification was wrong for a device whose constraints had changed. 500
/// ms is AOSP's long-press default and it is a *reference*, not a measurement.
/// The trail now records every gesture with the duration that produced it, so
/// this can be set from real presses the way `PROXIMITY_NEAR_DEBOUNCE` was —
/// do that before defending the value.
pub const BUTTON_HOLD: Duration = Duration::from_millis(500);
/// Held past this, still down. The point where a destructive binding may fire
/// without the user having meant a hold.
pub const BUTTON_LONG_HOLD: Duration = Duration::from_millis(2000);
/// How long after a release to keep waiting for another tap.
///
/// This one is felt directly and in the wrong direction: it is the delay
/// between a single tap and anything happening, because a single cannot fire
/// until the window proves no second tap is coming. Too long and the phone
/// feels broken; too short and a double-tap fires a single first. 300 ms is
/// AOSP's double-tap timeout. Same caveat as above — measure it.
pub const BUTTON_MULTI_TAP_WINDOW: Duration = Duration::from_millis(300);
/// How long a blank waits for the compositor to acknowledge the lock it asked
/// for before going dark anyway.
///
/// `LOCK-DPMS-LESSONS.md` §1 ("Ordering: lock, then off") fixes this at ≤2 s
/// for the power-button path and gives the reason for the fail-open: "if the
/// lock IPC fails, blank anyway — dark-but-unlocked is recoverable,
/// lit-and-unlocked in a pocket is not." Doctrine §8 supplies the other half:
/// the panel may proceed, but the session is never *recorded* as locked. The
/// timeout is an `error-security` in the trail, not a silent success.
pub const LOCK_ACK_BUDGET: Duration = Duration::from_secs(2);
/// Tunable policy for the machine's timed behaviour.
///
/// These are settings, not constants: the shape is iOS's Auto-Lock — a
/// user-chosen timeout with a visible dim shortly before it, and a "never"
/// option for the desk-clock case. The Settings control center (TASK-19) is
/// meant to be a view over this struct, so the values live here rather than
/// being baked into the rule.
/// `serde(default)` at the container level, so a policy file written by an
/// older sessiond loads with the new fields filled from `Default` instead of
/// failing outright. Without it, adding one field here would make every
/// existing device fall back to built-in timers on the next upgrade and lose
/// whatever the user had set — silently, which is the failure mode this whole
/// area keeps producing.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DeviceStatePolicy {
/// Locked, panel lit, no input for this long → blank. `None` = never.
pub lock_blank_after: Option<Duration>,
/// Same, when evidence says the device is being held.
pub lock_blank_after_held: Option<Duration>,
/// How long the dimmed warning shows before the blank.
pub dim_grace: Duration,
/// Evidence older than this is unknown.
pub evidence_ttl: Duration,
/// Whether the dim pre-warning is used at all.
pub dim_warning: bool,
/// How long a pending blank waits for the lock it asked for.
pub lock_ack_budget: Duration,
/// Unlocked, panel lit, no input for this long → lock, then blank.
/// `None` = never, and that is the default: the shell's `IdleCoordinator`
/// owns the unlocked idle→lock timer through `ext-idle-notify` (doctrine
/// §5), and a second unlocked timer here would recreate the competing-owner
/// 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,
/// Grace from daemon start before an expected-but-silent source is
/// reported `Absent`. See `SOURCE_EXPECTED_WITHIN`.
pub source_expected_within: Duration,
/// How long proximity must hold `near` before the machine believes it.
pub proximity_near_debounce: Duration,
/// How long proximity must hold `far` before the machine believes it.
pub proximity_far_debounce: Duration,
/// SSIDs that are the home LAN.
///
/// Identity, never an address prefix. The gate this replaces tested
/// `inet 10.10.` and so read the foreign network this phone lives on
/// (`10.10.30.213`) as home, taking the tunnel down as redundant. Empty
/// means "never claim to be home", which is the safe default: an unknown
/// network is not a reason to drop the tunnel.
pub home_ssids: Vec<String>,
/// How long a bearer preference must hold before the machine acts on it.
///
/// This is the entire anti-flap mechanism, and it is a debounce rather
/// than a rate limit on purpose. `wiuf-vpn-gate` recycled the tunnel 652
/// times in 90 minutes because every correction re-entered the controller;
/// a decision that has to survive a settling window cannot oscillate at
/// event speed no matter how the events arrive.
pub bearer_settle: Duration,
}
impl DeviceStatePolicy {
/// Where the user's choices live between runs.
///
/// They did not live anywhere until 2026-07-26. `SetPolicy` mutated the
/// in-memory struct and nothing wrote it down, so every lock-screen timer
/// set in Settings survived exactly until the next restart and then
/// silently reverted to the built-in 15 s. The Settings page was honest
/// about reading the daemon — the daemon was the one forgetting.
pub fn path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("souveraine").join("device-state-policy.json"))
}
/// Load the saved policy, or the defaults.
///
/// A file that exists but cannot be read or parsed is LOUD and then
/// ignored: continuing on built-in timers is the only thing a session
/// authority can do at startup, but doing it quietly would present
/// defaults as if they were the user's settings.
pub fn load() -> Self {
let Some(path) = Self::path() else {
warn!(
"[device-state] no HOME or XDG_CONFIG_HOME — policy cannot be persisted this run"
);
return Self::default();
};
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
warn!("[device-state] policy at {} is unreadable ({e}) — RUNNING ON DEFAULTS, not on your settings", path.display());
return Self::default();
}
};
match serde_json::from_str(&raw) {
Ok(p) => {
info!("[device-state] policy loaded from {}", path.display());
p
}
Err(e) => {
warn!("[device-state] policy at {} is malformed ({e}) — RUNNING ON DEFAULTS, not on your settings", path.display());
Self::default()
}
}
}
/// Write the policy out. Errors are returned, never swallowed — the caller
/// tells the Settings page, so a control that appears to have taken effect
/// has actually taken effect past the next reboot.
pub fn save(&self) -> Result<(), String> {
let path = Self::path().ok_or_else(|| "no HOME or XDG_CONFIG_HOME".to_string())?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
let body = serde_json::to_string_pretty(self)
.map_err(|e| format!("serializing the policy: {e}"))?;
// Write-then-rename, so a crash mid-write cannot leave a truncated
// file that the next boot reads as malformed and discards.
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, body).map_err(|e| format!("writing {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path)
.map_err(|e| format!("renaming into {}: {e}", path.display()))?;
Ok(())
}
}
impl Default for DeviceStatePolicy {
fn default() -> Self {
Self {
lock_blank_after: Some(LOCK_BLANK_AFTER),
lock_blank_after_held: Some(LOCK_BLANK_AFTER_HELD),
dim_grace: LOCK_DIM_GRACE,
evidence_ttl: EVIDENCE_TTL,
dim_warning: true,
lock_ack_budget: LOCK_ACK_BUDGET,
unlocked_blank_after: None,
source_down_after: SOURCE_DOWN_AFTER,
source_expected_within: SOURCE_EXPECTED_WITHIN,
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
home_ssids: Vec::new(),
bearer_settle: BEARER_SETTLE,
}
}
}
/// What the machine wants done. The machine decides; it never runs a command
/// itself. `tick` returns intent and the daemon executes it, so the decision
/// stays testable without a compositor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// Dim the panel as the pre-warning. Saves the current brightness first.
Dim,
/// Restore brightness after a dim. Runs `blueline-undim`, never a bare
/// `brightnessctl -r`: the restore needs the floor, or a save that landed
/// while already dim leaves the panel at 10/255 and every later wake
/// looks like dead glass (Pixel3Arch b9f83f0).
Restore,
/// Blank the panel through the DPMS executor.
Blank,
/// One step of output volume, up or down.
///
/// Under Hyprland these were `wpctl` calls bound to `XF86AudioRaiseVolume`
/// / `LowerVolume` in `hyprland.lua`, with key repeat. viewtop consumes
/// the volume keys as device buttons — they must not be interceptable by a
/// fullscreen client any more than the power button is — so the binding
/// had to come somewhere, and §12 is explicit that the somewhere is an
/// `Action` through this table rather than a small daemon that reads a
/// signal and calls a tool. A second writer to the sink is the eighth
/// blind actor.
///
/// Emitted on the **down edge**, not from a recognised gesture:
/// `BUTTON_MULTI_TAP_WINDOW` is 300 ms, so a tap-driven volume key would
/// lag a third of a second behind the press and feel broken. Volume is one
/// of the two controls whose whole quality is immediacy.
Volume { up: bool },
/// Light the panel through the same executor.
///
/// The machine could turn the screen off and had no way to turn it back
/// on: `apply_gesture` answers a power tap on a dark panel with `Restore`,
/// which is *brightness*, and there was no unblank in this table at all.
/// That worked only because the wake never came through the daemon —
/// `hyprland.lua` bound the physical key straight to
/// `blueline-screen-toggle`, so the compositor woke the panel and sessiond
/// merely heard about it afterwards. Under viewtop the compositor consumes
/// that key (a device button must not be interceptable by whatever is
/// fullscreen), and the gap became a phone that could sleep and never wake.
///
/// So the machine owns both directions now, which is what §1 asks for in
/// the first place: seven blind actors became one authority, and an
/// authority that can only act in one direction is half an authority. It
/// also makes waking something the agent can *reach* — doctrine §13, a
/// verb she cannot reach is a defect — rather than a side effect of a
/// keybinding in a compositor config she has no say over.
///
/// Unlike [`Action::Blank`] this carries no ordering obligation: §1 is
/// about paths to a *dark* panel. Lighting one discloses nothing that the
/// lock surface is not already responsible for covering, and
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
/// panel, only sessiond moves the lock".
Unblank,
/// Raise the window action sheet for one window.
///
/// A touch gesture bound the way every other control is: viewtop reports
/// what the fingers did, the machine decides what it means, and the
/// behaviour leaves here through the executor table. §12 is explicit that
/// the alternative — the compositor recognising three fingers and calling
/// `qs ipc` itself — is the eighth blind actor, and `wiuf-vpn-gate` is what
/// that costs: 652 tunnel recycles in ninety minutes with no way to turn it
/// off.
///
/// The compositor has to do the *recognition* because contacts only exist
/// there, which is why this arrives already named rather than as raw
/// touches. What it must not do is decide what a name means.
///
/// This replaced `Overview`, which the same tap used to raise. Two gestures
/// reaching one surface is the one-decider problem in miniature: the rail's
/// swipe already opens the overview, so the tap spent its whole existence
/// duplicating a gesture the thumb already had. `target` is what makes the
/// difference — the sheet is *about a window*, and the tap now knows which.
WindowSheet { target: u64 },
/// Raise the power menu — the device's own verbs, on a hold.
///
/// The counterpart to [`Self::WindowSheet`]: that one is about a window,
/// this one is about the device. Restart, power off, and the USB-C port's
/// state — whether it is host or device, whether it is sourcing or sinking
/// power, and what is on the other end.
///
/// Those last ones are here rather than in a settings page because the port
/// is not a preference; it is what the machine currently *is*, and it
/// changes what every other peripheral means. Casey, 2026-08-05: "that
/// whole power usb state probably belongs in the power switch options".
PowerMenu,
/// End the session's power state: off, restart, or asleep.
///
/// The counterpart to every other row here, on the one transition that
/// cannot be walked back. It arrives as an `Action` for the same reason
/// `Volume` and `WindowSheet` do — §12 forbids the small actor that reads a
/// signal and calls a tool — but the stakes are the argument's strongest
/// case rather than its weakest: a poweroff executed outside this table
/// leaves no entry anywhere, and there is no later moment to notice.
Power(PowerVerb),
/// Change the port's gadget posture through usb-signaller.
///
/// The mode daemon is the mechanism and souveraine-upower is its adjacent
/// power sensor. Neither decides. sessiond emits this action so the mode
/// change is named, audited, and later refusable when a known peer or an
/// active probe lease says the port is occupied.
UsbMode(UsbMode),
/// Request the session lock, because something wants the panel dark and
/// the session is not locked yet.
///
/// This is the machine's half of `LOCK-DPMS-LESSONS.md` §1 — the ordering
/// is lock, *then* off, and it is an invariant the authority enforces
/// rather than a coincidence of two timers (hypridle's 300 s lock landing
/// before its own 600 s blank). The daemon routes this to the shell's lock
/// surface while a live shell owns steady state, and takes the lock itself
/// otherwise; either way the panel does not go dark until it is answered
/// or the ack budget expires.
Lock,
/// Carry ordinary traffic over this bearer.
///
/// Expressed to NetworkManager as a route metric, never written with `ip
/// route`: NM stays the single writer of routes, which is TASK-49's
/// acceptance #6. Emitted only when the decision *changes* and has held
/// for `bearer_settle`, so a link flapping at event speed cannot produce
/// a command per event — the failure that recycled the tunnel 652 times.
PreferLink(Bearer),
/// Pin the WireGuard endpoint's host route to this bearer.
///
/// The tunnel is not a bearer; it rides one. Over clat it was measured
/// sending and never receiving, and pinning the endpoint via wlan0
/// produced a handshake in seconds. This does not turn the tunnel on or
/// off — that switch is the user's and stays the user's.
PinTunnelUnderlay(Bearer),
}
/// When each evidence source last reported. Kept beside `SensorEvidence`
/// rather than inside it so the weights stay a pure function of the readings.
#[derive(Debug, Clone, Default)]
pub struct EvidenceSeen {
pub proximity: Option<Instant>,
pub accel: Option<Instant>,
pub light: Option<Instant>,
pub touch: Option<Instant>,
}
impl EvidenceSeen {
/// Last time this source was heard from, if ever. `None` means never —
/// which is a different claim from "not recently", and the one the
/// `Absent` check is built on.
pub fn get(&self, source: SensorSource) -> Option<Instant> {
match source {
SensorSource::Proximity => self.proximity,
SensorSource::Accelerometer => self.accel,
SensorSource::Light => self.light,
SensorSource::Touch => self.touch,
}
}
}
/// 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, and nothing is expected
/// to be. Correct for hardware with no reporter: `Touch` has none, so it
/// sits here forever and says nothing. A signal that fires for a source
/// nobody wired up is a signal nobody reads.
#[default]
Unknown,
/// Reporting within `source_down_after`.
Live,
/// Reported once and has now been silent past the threshold.
Down,
/// A reporter is expected to serve this source and it has never once
/// spoken, past `source_expected_within` from daemon start.
///
/// This variant exists because `Unknown` was silent by design and that
/// silence hid a real outage for a whole boot (2026-07-27). `Down` cannot
/// catch it: `evaluate_source_health` starts from the last-seen stamp, and
/// a source that has never reported has no stamp, so it is skipped and
/// stays `Unknown` forever. `souveraine-sensord` — the one reporter for
/// every iio-sensor-proxy source (§12) — was `enabled` but never started,
/// because its unit hung off `graphical-session.target` and nothing on
/// this device starts that target. The machine ran the entire session with
/// zero evidence, and the trail recorded not one line about it.
///
/// §10 is explicit that "no evidence" and "evidence says nothing is
/// happening" must not be the same state. It made that true for a source
/// that dies mid-session. This makes it true for one that never lived.
Absent,
}
impl SourceHealth {
pub fn as_str(self) -> &'static str {
match self {
SourceHealth::Unknown => "unknown",
SourceHealth::Live => "live",
SourceHealth::Down => "down",
SourceHealth::Absent => "absent",
}
}
}
/// 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 the machine should be hearing from is not
/// arriving — whether it died mid-session (`Down`) or never started
/// (`Absent`). This is the single question the rest of the system asks: a
/// surface showing "sensors degraded" does not need to know which one, and
/// a decision taken on absent evidence is no sounder than one taken on
/// evidence that stopped.
pub fn any_down(&self) -> bool {
[self.proximity, self.accel, self.light, self.touch]
.iter()
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
}
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
// SessionAudit trail. The audit trail says "lock-requested"; the
// forensic trail says "lock-requested because idle timer fired at T,
// state was Active, no inhibitor held, IdleCoordinator was at Dimmed,
// last sensor input was proximity-far at T-30s."
/// How large the live trail may grow before it rotates.
///
/// The trail was unbounded until 2026-07-26 and measured at ~3.6 MB/day, so
/// "unbounded" meant "fills the disk on a device that has no room for it".
/// Bounding it is the other half of making it durable: a file that survives
/// reboot is only an improvement if it also stops growing.
pub const FORENSIC_MAX_BYTES: u64 = 4 * 1024 * 1024;
/// How many rotated generations are kept behind the live file.
///
/// Three files in total, so the ceiling is 12 MiB — about three days at the
/// volume measured before proximity debounce (§9.5) exists, and considerably
/// more once it does. The number is a floor on how far back an incident can be
/// reconstructed, which is the only thing it is for.
pub const FORENSIC_KEEP: usize = 2;
/// A forensic entry — one decision point in the device state machine.
///
/// `hash` is deliberately not a field here. It is computed over this struct's
/// serialization and injected into the written line as the last key, exactly
/// as `SessionAudit.qml` does, so one verifier reads both trails: strip the
/// trailing `,"hash":"<hex>"`, close the object, SHA-256, compare.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForensicEntry {
/// Monotonic sequence (from the audit trail).
pub seq: u64,
/// The hash of the previous entry, empty at the head of a chain.
///
/// §5 called this trail tamper-evident while it carried nothing but a
/// sequence number, which detects a gap and nothing else — any past entry
/// could be edited in place and the file still read as consistent. The
/// chain is what the word was always claiming.
#[serde(default)]
pub prev: String,
/// Wall-clock timestamp (seconds since epoch).
pub ts: u64,
/// What happened.
pub event: ForensicEvent,
/// Full state snapshot at this moment.
pub snapshot: StateSnapshot,
/// Why this decision was made (human-readable).
pub reason: String,
/// The caller's declared intent, when one was given.
///
/// A chain is one decision and several verbs (doctrine §13). Without this
/// the trail records the leaves and loses the thing that produced them —
/// reconstruction sees four unrelated calls and cannot tell a considered
/// sequence from four accidents. Skipped entirely when absent, so entries
/// without an intent serialize byte-for-byte as they did before and the
/// hash contract does not move.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
}
/// What kind of forensic event occurred.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForensicEvent {
/// A state transition happened (or was refused).
Transition {
from: DeviceState,
to: DeviceState,
legal: bool,
},
/// Sensor input was received and evaluated.
SensorInput {
source: SensorSource,
value: SensorValue,
confidence: f32,
},
/// A wake event occurred (screen on, dt2w, power button).
Wake { trigger: WakeTrigger },
/// An error occurred that affected device state.
Error {
component: String,
action: String,
error: String,
},
/// A decision was made (e.g., "suppress DPMS wake" or "promote idle").
Decision {
decision: String,
inputs: serde_json::Value,
},
/// Periodic heartbeat snapshot (for reconstructing timeline gaps).
Heartbeat,
}
/// What triggered a wake event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WakeTrigger {
PowerButton,
DoubleTapToWake,
Squeeze,
ProximityFar,
RtcAlarm,
ModemIrq,
UserInput,
Unknown,
}
/// Full state snapshot — everything needed to reconstruct the decision.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot {
pub device_state: DeviceState,
pub locked: bool,
pub display_active: bool,
pub phase: String,
pub shell_alive: bool,
pub proximity_near: bool,
pub confidence: f32,
pub suppress_dpms_wake: bool,
pub promote_idle_faster: bool,
pub screen_locked: bool,
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,
/// Where the machine believed the phone was, and how sure it was.
///
/// On every snapshot for the same reason `sensors_degraded` is: a decision
/// is only reconstructable if the trail records what it was deciding on.
/// "Refused a wake" and "refused a wake believing this was a pocket at 0.35"
/// are the same line and different facts.
pub placement: Placement,
}
/// Forensic log — accumulates entries for post-hoc analysis.
/// Thread-safe; entries can be added from any thread.
pub struct ForensicLog {
entries: Arc<Mutex<Vec<ForensicEntry>>>,
/// Sequence, chain head, and file health under **one** lock.
///
/// They were two locks and a write that happened after both were dropped,
/// which was survivable while the only content was a sequence number and
/// is not once entries chain: two threads could take the same `prev` and
/// write in either order, and the resulting file would read as tampered.
writer: Arc<Mutex<TrailWriter>>,
}
/// The durable half of the trail: which file, where the chain is, and whether
/// writing is currently working.
struct TrailWriter {
/// `None` means in-memory only — no writable home (LOUD at startup), or a
/// test that has no business touching the real trail.
path: Option<PathBuf>,
next_seq: u64,
/// Hash of the last entry written, which the next entry's `prev` carries.
/// Deliberately **not** reset by rotation: the chain runs across the file
/// boundary, so a rotated set verifies end to end rather than as three
/// unrelated logs.
last_hash: String,
/// Bytes in the live file, tracked rather than stat'd per append.
bytes: u64,
/// The rotation threshold. A field rather than the constant read inline so
/// the rotation rules can be tested at a few hundred bytes instead of by
/// writing 12 MiB of real entries.
max_bytes: u64,
/// The intent the current caller declared, stamped onto every entry
/// produced while it is set. Lives here because `append` already takes
/// this lock — anywhere else would need threading through every call site
/// that records anything.
intent: Option<String>,
/// True while writes are failing, so the warning is one per outage edge
/// instead of one per tick. A trail that cannot write is exactly the kind
/// of failure that must not drown out what it was recording.
failed: bool,
}
impl ForensicLog {
/// Open the durable trail at its standard path.
pub fn new() -> Self {
// Tests get an in-memory trail unless they ask for a file. Otherwise
// every `DeviceStateMachine::new()` in the suite would append to the
// developer's real trail and rotate it — the file-backed behaviour is
// covered by tests that name their own path.
#[cfg(test)]
let path = None;
#[cfg(not(test))]
let path = match forensic_log_path() {
Some(p) => Some(p),
None => {
warn!(
"[device-state] no HOME or XDG_STATE_HOME — the forensic trail is MEMORY-ONLY this run and dies with the daemon"
);
None
}
};
Self::open(path)
}
/// Open the trail at an explicit path.
#[cfg(test)]
pub fn with_path(path: PathBuf) -> Self {
Self::open_bounded(Some(path), FORENSIC_MAX_BYTES)
}
/// Open the trail with an explicit rotation threshold. Tests only — the
/// live bound is `FORENSIC_MAX_BYTES` and is not a per-caller choice.
#[cfg(test)]
pub fn with_path_bounded(path: PathBuf, max_bytes: u64) -> Self {
Self::open_bounded(Some(path), max_bytes)
}
fn open(path: Option<PathBuf>) -> Self {
Self::open_bounded(path, FORENSIC_MAX_BYTES)
}
fn open_bounded(path: Option<PathBuf>, max_bytes: u64) -> Self {
let writer = match path {
Some(path) => TrailWriter::open(path, max_bytes),
None => TrailWriter::memory_only(),
};
Self {
entries: Arc::new(Mutex::new(Vec::new())),
writer: Arc::new(Mutex::new(writer)),
}
}
/// Where the trail is being written, or `None` when it is memory-only.
pub fn path(&self) -> Option<PathBuf> {
self.writer
.lock()
.unwrap_or_else(|e| e.into_inner())
.path
.clone()
}
/// How the chain the daemon just opened relates to the one before it.
pub fn chain_state(&self) -> &'static str {
let w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
if w.path.is_none() {
"memory-only"
} else if w.next_seq == 0 {
"new"
} else {
"resumed"
}
}
/// Append a forensic entry. The seq is auto-incremented and the entry is
/// chained to the one before it, then written to the durable trail.
pub fn append(&self, event: ForensicEvent, snapshot: StateSnapshot, reason: &str) {
let mut w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
let entry = ForensicEntry {
seq: w.next_seq,
prev: w.last_hash.clone(),
ts: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
event,
snapshot,
reason: reason.to_string(),
intent: w.intent.clone(),
};
w.next_seq += 1;
w.write(&entry);
drop(w);
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.push(entry);
// Keep last 1000 entries in memory (the JSONL file is the
// durable store; this is for IPC queries).
let len = entries.len();
if len > 1000 {
entries.drain(0..len - 1000);
}
}
/// Declare what the caller is doing. Every entry recorded until this is
/// cleared carries it. Set around one request, not held across them — an
/// intent that outlives its chain mislabels whatever comes next.
pub fn set_intent(&self, intent: Option<String>) {
self.writer.lock().unwrap_or_else(|e| e.into_inner()).intent = intent;
}
/// Get recent entries (for IPC queries).
pub fn recent(&self, count: usize) -> Vec<ForensicEntry> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let start = entries.len().saturating_sub(count);
entries[start..].to_vec()
}
/// Entries recorded after `seq`, oldest first.
///
/// The cursor is the sequence number the trail already mints for its hash
/// chain, so a consumer that reconnects resumes exactly where it stopped
/// and a restarted daemon cannot silently replay — the chain and the
/// stream agree by construction rather than by a second counter kept in
/// step by hand.
pub fn since(&self, seq: u64) -> Vec<ForensicEntry> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.iter().filter(|e| e.seq > seq).cloned().collect()
}
/// The highest sequence the in-memory buffer holds.
pub fn head_seq(&self) -> u64 {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.last().map(|e| e.seq).unwrap_or(0)
}
}
/// One button's edges, accumulating into gestures.
///
/// This is the chordotonal principle applied to a button: the thing that turns
/// a run of drivers (down, up, down, up) into one piece of evidence ("double
/// tap") lives below the decision, and only the compressed answer travels
/// upward. A consumer never sees the edges.
///
/// Deliberately per-button and stateless about *meaning*. It recognises; it
/// does not decide what a triple-tap is for. Binding is policy and lives above.
#[derive(Debug, Clone, Default)]
pub struct ButtonRecognizer {
/// When the button went down, while it is down.
down_since: Option<Instant>,
/// Holds already announced for the current press, so `tick` does not
/// re-fire them every second while a finger rests on the button.
hold_fired: bool,
long_hold_fired: bool,
/// Releases that have not yet resolved into a tap gesture, and when the
/// most recent one landed.
pending_taps: u8,
last_release: Option<Instant>,
}
impl ButtonRecognizer {
/// A press edge. Returns nothing — a press alone is never a gesture, and
/// pretending otherwise is what makes a double-tap fire a single first.
pub fn down(&mut self, now: Instant) {
self.down_since = Some(now);
self.hold_fired = false;
self.long_hold_fired = false;
}
/// A release edge. Returns a gesture only when the release *completes* one
/// immediately — which is never, for taps. A hold that already fired
/// resolves to nothing here: it was announced while the button was down,
/// and announcing it again on release would double-fire every binding.
pub fn up(&mut self, now: Instant) -> Option<ButtonGesture> {
let held = self.down_since.take().map(|t| now.duration_since(t));
if self.hold_fired {
// A hold was already announced. Releasing ends it and starts no
// tap — a long press is not also a tap, and counting it as one is
// how "hold to power off" also toggles your screen.
self.pending_taps = 0;
self.last_release = None;
return None;
}
if held.is_some_and(|d| d >= BUTTON_HOLD) {
// Held long enough, but tick never saw it (a press shorter than one
// tick interval that still crossed the threshold). Announce now.
self.pending_taps = 0;
self.last_release = None;
return Some(ButtonGesture::Hold);
}
self.pending_taps = self.pending_taps.saturating_add(1);
self.last_release = Some(now);
None
}
/// Called on every tick. Fires holds while the button is still down, and
/// resolves pending taps once the multi-tap window has closed.
pub fn tick(&mut self, now: Instant) -> Option<ButtonGesture> {
if let Some(since) = self.down_since {
let held = now.duration_since(since);
if held >= BUTTON_LONG_HOLD && !self.long_hold_fired {
self.long_hold_fired = true;
return Some(ButtonGesture::LongHold);
}
if held >= BUTTON_HOLD && !self.hold_fired {
self.hold_fired = true;
return Some(ButtonGesture::Hold);
}
return None;
}
let last = self.last_release?;
if now.duration_since(last) < BUTTON_MULTI_TAP_WINDOW {
return None;
}
let n = std::mem::take(&mut self.pending_taps);
self.last_release = None;
match n {
0 => None,
1 => Some(ButtonGesture::Tap),
2 => Some(ButtonGesture::DoubleTap),
// Four taps is a triple plus a stray, not a new gesture. Saturating
// here beats inventing a QuadrupleTap nobody asked for.
_ => Some(ButtonGesture::TripleTap),
}
}
}
/// Is this entry worth waking a mind for?
///
/// The whole value of the subscribe stream is this predicate. §10 is the
/// argument in miniature: a sensor resting at `far` and a sensor whose stack
/// took a CHRE fatal produce byte-identical silence, so "no evidence" and
/// "evidence says nothing is happening" had to become different states. The
/// same distinction decides what crosses to the agent — an edge means
/// something, a level does not.
///
/// What crosses:
/// - **Transitions**, legal or refused. A refused one is the more interesting:
/// the machine wanted to move and its own guard said no.
/// - **Errors.** Both classes. `error-security` is a violated guarantee;
/// `error-operational` is an actuator that did not do as it was told, which
/// is how a `panel-off` recorded during four dead hours stops reading like a
/// healthy one.
/// - **Decisions**, which is where `source-down` / `source-recovered` and
/// cross-sensor disagreement already land.
///
/// What never crosses: `Heartbeat` (it exists to fill timeline gaps for a
/// reader, and a mind is not a reader), `SensorInput` (a reading is a driver
/// wearing evidence's clothes — 2,900 proximity lines a day saying nothing
/// changed, and the exact stream P1 says an inferring model must not have),
/// and `Wake`, which is already implied by the transition it causes.
pub fn is_notable(event: &ForensicEvent) -> bool {
match event {
ForensicEvent::Transition { .. } => true,
ForensicEvent::Error { .. } => true,
ForensicEvent::Decision { .. } => true,
ForensicEvent::SensorInput { .. } => false,
ForensicEvent::Wake { .. } => false,
ForensicEvent::Heartbeat => false,
}
}
impl TrailWriter {
fn memory_only() -> Self {
Self {
path: None,
next_seq: 0,
last_hash: String::new(),
bytes: 0,
max_bytes: FORENSIC_MAX_BYTES,
intent: None,
failed: false,
}
}
/// Open the trail, continuing the existing chain where there is one.
fn open(path: PathBuf, max_bytes: u64) -> Self {
let mut w = Self {
path: Some(path.clone()),
next_seq: 0,
last_hash: String::new(),
bytes: 0,
max_bytes,
intent: None,
failed: false,
};
if let Some(dir) = path.parent() {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!(
"[device-state] cannot create {} ({e}) — the forensic trail is MEMORY-ONLY this run",
dir.display()
);
w.path = None;
return w;
}
}
let existing = match std::fs::read_to_string(&path) {
Ok(body) => body,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return w,
Err(e) => {
warn!(
"[device-state] cannot read the existing trail at {} ({e}) — the forensic trail is MEMORY-ONLY this run",
path.display()
);
w.path = None;
return w;
}
};
// Walk back to the last line that parses.
//
// A torn *final* line is the expected result of losing power mid-write,
// not evidence of tampering — measured 2026-07-26, when a hard reboot
// left the file at exactly 4096 bytes, a page boundary, with the last
// entry cut in half. Treating that as tampering cost the chain its
// continuity across precisely the event most worth investigating.
//
// So: discard the torn tail, resume from the last good entry, and say
// so. A bad hash in the *middle* is a different claim and this does not
// hide it — the verifier still reads every line, and everything up to
// the tear stays intact and chained.
let mut good_end = 0usize; // byte offset just past the last good line
let mut last_good: Option<serde_json::Value> = None;
for line in existing.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
if trimmed.trim().is_empty() {
good_end += line.len();
continue;
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(v) => {
good_end += line.len();
last_good = Some(v);
}
Err(_) => break, // torn or damaged — everything from here goes
}
}
let discarded = existing.len() - good_end;
if discarded > 0 {
let Some(_) = last_good.as_ref() else {
// Nothing in the file parses at all. That is not a torn tail;
// it is a file we cannot chain onto. Keep it as evidence.
warn!(
"[device-state] no parseable entry in {} — rotating it aside and STARTING A NEW CHAIN; the old file is kept",
path.display()
);
w.rotate();
return w;
};
warn!(
"[device-state] discarding {discarded} torn bytes from the tail of {} — a write that did not reach disk, almost certainly a hard reboot; the chain resumes from the last intact entry",
path.display()
);
if let Err(e) = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.and_then(|f| f.set_len(good_end as u64))
{
warn!(
"[device-state] could not truncate the torn tail of {} ({e}) — the forensic trail is MEMORY-ONLY this run",
path.display()
);
w.path = None;
return w;
}
}
let Some(v) = last_good else {
return w; // present but empty: a fresh chain, not a damaged one
};
w.next_seq = v.get("seq").and_then(|s| s.as_u64()).unwrap_or(0) + 1;
w.last_hash = v
.get("hash")
.and_then(|h| h.as_str())
.unwrap_or_default()
.to_string();
w.bytes = good_end as u64;
info!(
"[device-state] forensic trail resumed at {} (seq {}, {} KiB)",
path.display(),
w.next_seq,
w.bytes / 1024
);
w
}
/// Serialize, hash, and write one entry. Every failure is loud once.
fn write(&mut self, entry: &ForensicEntry) {
if self.path.is_none() {
return;
}
let body = match serde_json::to_string(entry) {
Ok(b) => b,
Err(e) => {
// Not a file problem: the entry itself will not serialize.
// Never silent — a decision that cannot be recorded is one the
// trail would otherwise imply never happened.
warn!(
"[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED",
entry.seq
);
return;
}
};
let hash = sha256_hex(&body);
// The hash goes in as the last key, so the hashed bytes are the line
// with `,"hash":"<hex>"` removed and the object closed again.
let line = match body.strip_suffix('}') {
Some(open) => format!("{open},\"hash\":\"{hash}\"}}\n"),
None => {
warn!("[device-state] forensic entry seq {} serialized to something that is not an object — NOT RECORDED", entry.seq);
return;
}
};
if self.bytes + line.len() as u64 > self.max_bytes {
self.rotate();
}
let Some(path) = self.path.clone() else {
return;
};
let written = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| {
use std::io::Write;
f.write_all(line.as_bytes())
});
match written {
Ok(()) => {
self.bytes += line.len() as u64;
if self.failed {
self.failed = false;
info!(
"[device-state] forensic trail at {} is writable again",
path.display()
);
}
// The chain only advances on a line that actually landed.
// Advancing it on a failed write would leave the next entry
// pointing at a `prev` no file contains, which reads as
// tampering rather than as the outage it is.
self.last_hash = hash;
}
Err(e) => {
if !self.failed {
self.failed = true;
warn!(
"[device-state] CANNOT WRITE the forensic trail at {} ({e}) — decisions are being taken and not recorded",
path.display()
);
}
}
}
}
/// Shift the generations along and start a new live file.
///
/// `last_hash` survives this on purpose: the first entry of the new file
/// carries the hash of the last entry of the rotated one, so a rotated set
/// verifies as one chain. A verifier reading a single file in isolation
/// finds a non-empty `prev` on line 1, which is the correct answer — its
/// predecessor is the next file along, not nothing.
fn rotate(&mut self) {
let Some(path) = self.path.clone() else {
return;
};
let gen = |n: usize| path.with_extension(format!("jsonl.{n}"));
let _ = std::fs::remove_file(gen(FORENSIC_KEEP));
for n in (1..FORENSIC_KEEP).rev() {
let _ = std::fs::rename(gen(n), gen(n + 1));
}
if let Err(e) = std::fs::rename(&path, gen(1)) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(
"[device-state] cannot rotate the forensic trail at {} ({e}) — it will keep growing",
path.display()
);
return;
}
}
self.bytes = 0;
info!(
"[device-state] forensic trail rotated at {} bytes, keeping {} generations",
self.max_bytes, FORENSIC_KEEP
);
}
}
fn sha256_hex(body: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(body.as_bytes());
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
/// Path to the forensic log file.
///
/// `$XDG_STATE_HOME/souveraine/forensic.jsonl`, which is `~/.local/state` in
/// practice and is where the XDG spec puts logs — the same directory as
/// `crashes.log`. It was `$XDG_RUNTIME_DIR` until 2026-07-26, which is tmpfs:
/// RAM on a 3.5 GB phone, erased on every reboot. §5 calls this trail
/// tamper-evident and pairs it with the audit hash chain; a file that
/// evaporates when the device restarts cannot be either. Nothing is migrated
/// from the old location because there is never anything there to migrate.
///
/// `None` when there is no home to write to. There is no `/tmp` fallback: a
/// trail nobody can find later is not a trail, and pretending otherwise is how
/// this one spent a month looking durable.
#[cfg(not(test))]
fn forensic_log_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?;
Some(base.join("souveraine").join("forensic.jsonl"))
}
// Re-export for use in protocol.rs
pub use self::ForensicEvent as DeviceStateForensicEvent;
pub use self::WakeTrigger as DeviceStateWakeTrigger;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeviceState {
/// Screen on, user present, unlocked or lockable.
Active,
/// Screen dim, user idle, not yet locked.
Dimmed,
/// Screen locked, compositor secure, user may or may not be present.
/// This is the base locked state. Observed/Doze are sub-states.
Locked,
/// Locked + sensor evidence of user presence. EVIDENCE, not FACT.
/// Gates: DPMS wake suppression, idle tier promotion.
/// Never gates: lock/unlock, security tiers, personal data.
Observed,
/// Locked + idle N min. App tier frozen. Wi-Fi power-save.
DozeLight,
/// Locked + idle M min. Network fetchers stopped. RTC wake only.
DozeDeep,
/// PrepareForSleep(true). Inhibitor held. Waiting for lock secure.
Suspending,
/// s2idle. Panel off. Touch in gesture mode. RTC + modem IRQs only.
Asleep,
}
/// Transition table — legal (from, to) pairs. Anything not in this list
/// is refused and logged.
const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
// Active → idle dim/lock
(DeviceState::Active, DeviceState::Dimmed),
(DeviceState::Active, DeviceState::Locked),
// Dimmed → user input or idle lock
(DeviceState::Dimmed, DeviceState::Active),
(DeviceState::Dimmed, DeviceState::Locked),
// Locked → user auth, sensor evidence, or idle promotion
(DeviceState::Locked, DeviceState::Active),
(DeviceState::Locked, DeviceState::Observed),
(DeviceState::Locked, DeviceState::DozeLight),
// PAM auth unlocks from ANY locked sub-state, not just the base one.
// Without these, unlocking a phone that was in Observed (proximity had
// fired) or dozing was refused as an illegal transition.
(DeviceState::Observed, DeviceState::Active),
(DeviceState::DozeLight, DeviceState::Active),
(DeviceState::DozeDeep, DeviceState::Active),
// Observed → back to Locked (proximity far) or deeper doze
(DeviceState::Observed, DeviceState::Locked),
(DeviceState::Observed, DeviceState::DozeLight),
// DozeLight → user wake or deeper doze
(DeviceState::DozeLight, DeviceState::Locked),
(DeviceState::DozeLight, DeviceState::DozeDeep),
// DozeDeep → user wake (RTC, modem, input)
(DeviceState::DozeDeep, DeviceState::Locked),
// Any pre-sleep → Suspending (logind is the authority)
(DeviceState::Active, DeviceState::Suspending),
(DeviceState::Dimmed, DeviceState::Suspending),
(DeviceState::Locked, DeviceState::Suspending),
(DeviceState::Observed, DeviceState::Suspending),
(DeviceState::DozeLight, DeviceState::Suspending),
(DeviceState::DozeDeep, DeviceState::Suspending),
// Suspending → Asleep (lock secure + inhibitor released)
(DeviceState::Suspending, DeviceState::Asleep),
// Asleep → Locked (wake, lock persists)
(DeviceState::Asleep, DeviceState::Locked),
];
/// Which states count as "locked" for security purposes.
/// Where the phone believes it is. See [`DeviceStateMachine::placement`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlacementBelief {
Hand,
Table,
Pocket,
/// A call is the only thing that makes a covered sensor mean a face, and
/// there is no call-state input yet (§4, "Owed: the call"). Never inferred.
Face,
Unknown,
}
/// A placement belief and the evidence that carried it.
///
/// The facts travel with the answer so a refusal can say what it believed and
/// why, rather than asserting a conclusion the caller cannot argue with.
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct Placement {
pub belief: PlacementBelief,
pub confidence: f32,
pub covered: bool,
pub locked: bool,
pub lit: bool,
pub active: bool,
}
/// How sure the machine must be it is in a pocket before it refuses a wake.
///
/// Below what a locked, covered phone earns, so the veto still does the job §4
/// keeps for it — and above what anything else here reaches, so only a pocket
/// reading can fire it. A threshold rather than a hardcoded rule because the
/// call-state input §4 calls owed, or a stillness signal, changes the numbers
/// and not the shape.
const POCKET_VETO_CONFIDENCE: f32 = 0.45;
pub fn is_locked(state: DeviceState) -> bool {
matches!(
state,
DeviceState::Locked
| DeviceState::Observed
| DeviceState::DozeLight
| DeviceState::DozeDeep
| DeviceState::Suspending
| DeviceState::Asleep
)
}
/// Which states count as "display active" for poller gating.
pub fn is_display_active(state: DeviceState) -> bool {
matches!(state, DeviceState::Active | DeviceState::Dimmed)
}
/// The device state machine. Owns the current state and enforces
/// transition guards.
pub struct DeviceStateMachine {
state: DeviceState,
/// Sensor evidence for the Observed state.
pub sensor_evidence: SensorEvidence,
/// Forensic log — captures every decision point for post-hoc analysis.
pub forensic: ForensicLog,
/// Panel power. A field, not a ninth state: panel-off is orthogonal to
/// the doze tier, and the enum had no cell for "locked, screen dark".
panel_on: bool,
/// When the compositor's quiet period began, or `None` while the user is
/// interacting. This is reported by `ext-idle-notify`, never inferred: a
/// continuous gesture produces no events at all, so a machine that stamped
/// "last input" itself would blank in the middle of a swipe.
idle_since: Option<Instant>,
/// The power verb already in flight, if any.
///
/// Held because the machine cannot observe the outcome: the successful
/// case is the process ending. A second request is refused rather than
/// executed so that "poweroff, then reboot" cannot leave the machine
/// having asked for both.
power_requested: Option<PowerVerb>,
/// 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,
/// When this run of the daemon began. The only thing that can tell an
/// expected source which has not reported *yet* from one that is never
/// going to — see `SOURCE_EXPECTED_WITHIN`.
started_at: Instant,
/// Per-button gesture recognition. Keyed rather than a field per button so
/// adding volume costs nothing and binds nothing.
buttons: std::collections::HashMap<Button, ButtonRecognizer>,
/// When `Observed` was entered, for [`OBSERVED_MIN_DWELL`].
observed_since: Option<Instant>,
/// Whether the panel was dark when the current press began.
///
/// Latched on the DOWN edge because **the press itself changes the
/// answer.** `note_input` fires on that same edge — correctly, a finger on
/// the button is a user present — which takes the device out of `Locked`,
/// and the executor lights the panel and reports it back through
/// [`Self::set_panel`]. By the time the UP edge resolves the tap,
/// `panel_on` is already true, so the press that woke the screen is read as
/// a press to blank it: wake, lock screen, black.
///
/// One physical press, two reports, and the second cannot see what the
/// first did. The level is unreadable by then; only the edge is true. That
/// makes this the sixth edge-versus-level bug here, after `locked_ack`,
/// `ChargeRate`, `bootBloomActive`, `hasLoginctl` and the dormant OR in the
/// compositor's `disclosure_locked`.
press_began_dark: std::collections::HashMap<Button, bool>,
/// True once a blank has been asked for, so we ask exactly once per wake
/// instead of every tick.
blank_requested: bool,
/// True while the pre-warning dim is showing. The machine tracks this so
/// brightness is saved exactly once per dim; the old hypridle `-s/-r`
/// pair had no such memory, which is how it wedged at 10/255.
dimmed: bool,
/// A blank is decided but withheld until the session locks. Carries the
/// deadline after which the panel goes dark regardless — see
/// `LOCK_ACK_BUDGET` for why that fail-open is the documented choice.
pending_blank: Option<Instant>,
/// The exact panel brightness captured immediately before dimming.
///
/// The dim used to be `brightnessctl -s set 10` and the restore a
/// save/restore pair with a floor: anything that came back under 26/255
/// was pushed to 40%. That floor was defending against the old hypridle
/// `-s`/`-r` wedge — a second save while already dim pinned brightness at
/// 10 and every later wake looked like dead glass — but sessiond's
/// `dimmed` field already makes a double save impossible, so the floor was
/// guarding a bug that no longer exists and corrupting a real setting to
/// do it. A phone deliberately run dark came back *brighter* than it
/// started, which is the jump on tap-to-dismiss.
///
/// The machine remembers the number instead of inferring it. `None` means
/// the capture failed, and only then is the floor the right answer.
brightness_before_dim: Option<u32>,
/// The last raw proximity reading, before debounce. `sensor_evidence`
/// carries the believed value; this carries what the sensor actually said.
proximity_raw: bool,
/// When the raw reading last changed. The debounce measures from here.
proximity_since: Option<Instant>,
/// Timed policy. Settable, so Settings can own it.
pub policy: DeviceStatePolicy,
/// Last bearer posture read off the system. Evidence, refreshed by the
/// daemon and never inferred here — the same contract `panel_on` has with
/// the DPMS executor.
pub bearer: BearerEvidence,
/// The bearer the machine has actually acted on, and when the current
/// candidate first differed from it.
///
/// These two fields *are* the anti-flap rule. A preference is only emitted
/// once it has differed from `bearer_applied` continuously for
/// `bearer_settle`; anything that reverses inside that window never
/// produces a command at all. `wiuf-vpn-gate` had no such memory, which is
/// why every NM event it caused became another correction.
bearer_applied: Option<Bearer>,
bearer_candidate_since: Option<(Bearer, Instant)>,
/// Whether the tunnel was deaf last time we looked, so the trail records
/// the edge rather than one entry per tick.
bearer_deaf: bool,
}
/// Sensor inputs that feed the Observed state. Each is a reading, not
/// an authority — the machine decides what to do with them.
#[derive(Debug, Clone, Default)]
pub struct SensorEvidence {
/// Proximity: true = near (in-pocket, face-down), false = far.
pub proximity_near: bool,
/// Accelerometer: true = device is moving.
pub accel_moving: bool,
/// Light sensor: true = ambient light is changing.
pub light_changing: bool,
/// Touch: true = recent touch input detected.
pub touch_active: bool,
}
impl SensorEvidence {
/// Compute confidence that the user is present. 0.01.0.
/// Each sensor contributes weighted evidence. Cross-sensor
/// disagreements reduce confidence (§9: "accelerometer says
/// face-down, light sensor says bright — one of them is lying").
pub fn confidence(&self) -> f32 {
let mut c = 0.0f32;
if self.proximity_near {
c += 0.4;
}
if self.accel_moving {
c += 0.3;
}
if self.light_changing {
c += 0.2;
}
if self.touch_active {
c += 0.1;
}
// Proximity near while the device is moving is genuinely ambiguous —
// a phone walking in a pocket and a phone held to an ear read the
// same. It lowers confidence and nothing more; it does not decide the
// wake, which is a question about the session, not the sensors.
if self.proximity_near && self.accel_moving {
c -= 0.2;
}
c.clamp(0.0, 1.0)
}
/// Should idle tier promotion be accelerated?
pub fn should_promote_idle_faster(&self) -> bool {
self.confidence() >= 0.6
}
}
impl DeviceStateMachine {
pub fn new() -> Self {
let machine = Self {
state: DeviceState::Active,
sensor_evidence: SensorEvidence::default(),
forensic: ForensicLog::new(),
panel_on: true,
idle_since: None,
power_requested: None,
evidence_seen: EvidenceSeen::default(),
source_health: SourceHealthTable::default(),
started_at: Instant::now(),
buttons: std::collections::HashMap::new(),
blank_requested: false,
observed_since: None,
// Boot comes up lit, so no press is in flight and the latch would
// only ever be read after a real DOWN edge has set it.
press_began_dark: std::collections::HashMap::new(),
dimmed: false,
pending_blank: None,
brightness_before_dim: None,
proximity_raw: false,
proximity_since: None,
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
// and a setting that reverts on reboot is not a setting.
policy: DeviceStatePolicy::load(),
bearer: BearerEvidence::default(),
bearer_applied: None,
bearer_candidate_since: None,
bearer_deaf: false,
};
// First entry of the run, and the only one that proves the trail is
// writable before something worth recording needs it. It also marks
// the boot boundary, which the trail could not show while it lived on
// tmpfs — every reboot simply produced an empty file.
machine.record_decision(
"trail-opened",
serde_json::json!({
"path": machine.forensic.path().map(|p| p.display().to_string()),
"chain": machine.forensic.chain_state(),
"max_bytes": FORENSIC_MAX_BYTES,
"generations": FORENSIC_KEEP,
}),
"sessiond started and opened the forensic trail",
);
machine
}
/// The clock. Called on a fixed interval by the daemon.
///
/// This is the piece the machine did not have. Everything else was
/// event-driven, so the machine could only answer other actors and could
/// never notice that time had passed — which is why it sat in `Active`
/// with an empty forensic log while the phone locked and unlocked around
/// it. Every timed tier (blank, dim, doze promotion, wake windows) hangs
/// off this one function.
pub fn tick(&mut self) -> Vec<Action> {
self.tick_at(Instant::now())
}
/// `tick` with the clock passed in, so the rules can be tested at any
/// point on the timeline instead of by sleeping.
/// A button edge. Returns any actions the resulting gesture asks for.
///
/// A press is user input regardless of what it turns out to mean, so the
/// idle budget resets on the DOWN edge, not on the gesture. Waiting for
/// recognition would let the multi-tap window count against a user who is
/// visibly touching the device.
pub fn button_edge(&mut self, button: Button, edge: ButtonEdge) -> Vec<Action> {
self.button_edge_at(button, edge, Instant::now())
}
pub fn button_edge_at(
&mut self,
button: Button,
edge: ButtonEdge,
now: Instant,
) -> Vec<Action> {
// Before anything below can change it. `note_input` on this same edge
// is what lights the panel, so this is the last moment the answer is
// still about the screen the user actually pressed against.
if edge == ButtonEdge::Down {
// Per button, not one field for all of them.
//
// A tap never resolves on the UP edge — `ButtonRecognizer::up`
// returns `None` and the tap lands when the 300 ms multi-tap window
// closes on a later tick, up to about 1.3 s after the press. A
// single shared latch had to survive that whole gap, and any other
// button's DOWN edge inside it overwrote the answer: power wakes a
// dark phone, the volume rocker beside it gets nudged within the
// second, and the pending power tap resolves as "the panel was lit"
// and blanks the screen. That is the exact regression the latch was
// added to fix, coming back through a second button.
//
// `buttons` is keyed per button for this reason already.
self.press_began_dark.insert(button, !self.panel_on);
}
let rec = self.buttons.entry(button).or_default();
let gesture = match edge {
ButtonEdge::Down => {
rec.down(now);
None
}
ButtonEdge::Up => rec.up(now),
};
let mut actions = Vec::new();
// Volume acts on the press, not on a recognised gesture. See
// `Action::Volume`: the multi-tap window would otherwise put 300 ms
// between the press and the sound changing.
//
// The gesture recogniser still sees these edges and still resolves
// taps and holds from them — that is how a future binding table gets
// volume double-tap or hold-to-ramp without this changing. It simply
// is not what produces the step today.
if edge == ButtonEdge::Down && matches!(button, Button::VolumeUp | Button::VolumeDown) {
actions.push(Action::Volume {
up: button == Button::VolumeUp,
});
}
if edge == ButtonEdge::Down {
// A hardware button is intent — §4's table says of the power button
// "can it lie? no — hardware signal" — so it is never subject to the
// proximity veto that governs tap-to-wake and squeeze. Input is
// noted on the DOWN edge because a finger on the button is a user
// present, whatever the press turns out to mean; making the idle
// budget wait for recognition would count the multi-tap window
// against someone visibly touching the device.
actions.extend(self.note_input(InputTrigger::PowerButton));
}
if let Some(g) = gesture {
actions.extend(self.apply_gesture(button, g, now));
}
actions
}
/// Bind a recognised gesture to behaviour.
///
/// **This match is a placeholder for a binding table.** The end state is
/// Activator-shaped: `(button, gesture) -> action` as editable, persisted
/// data, so a triple-tap can be pointed at something without a rebuild.
/// `DeviceStatePolicy` is already the persisted home for exactly this kind
/// of setting ("a setting that reverts on reboot is not a setting") and is
/// where the table belongs. Recognition is deliberately separate from
/// binding so that change touches only this function.
///
/// Only the power tap is bound today, and it is bound to what the button
/// already did — wake if the panel is dark, otherwise lock-then-blank
/// through `request_blank()` like every other path. Everything else is
/// recognised, recorded, and inert on purpose: a gesture that fires
/// something nobody chose is worse than one that fires nothing.
fn apply_gesture(
&mut self,
button: Button,
gesture: ButtonGesture,
now: Instant,
) -> Vec<Action> {
let bound =
button == Button::Power && matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
self.record_decision(
"button-gesture",
serde_json::json!({
"button": button.as_str(),
"gesture": gesture.as_str(),
"bound": bound,
}),
"hardware button recognised",
);
// Hold raises the power menu — the surface that owns restart, power
// off, and what the USB-C port currently *is* (data role, power
// direction, what is attached). That state is proprioception, not a
// setting, which is why it belongs to this machine and is reached
// through a verb rather than a config page.
//
// Deliberately NOT gated on the panel or the lock. A hold on a dark
// panel is still a request for the menu, and on the lockscreen it must
// still work — powering off is a thing you do to a locked phone, and a
// menu that vanishes when locked would send the user to the hardware
// button they are already holding. Disclosure is unaffected: the menu
// shows device verbs, never session content.
if button == Button::Power && gesture == ButtonGesture::Hold {
self.press_began_dark.remove(&button);
return vec![Action::PowerMenu];
}
if button != Button::Power || gesture != ButtonGesture::Tap {
return Vec::new();
}
// The panel as it was when THIS button's press began, not as it is
// now. See `press_began_dark`: this same press already woke it.
if self
.press_began_dark
.remove(&button)
.unwrap_or(!self.panel_on)
{
// Dark: the tap is a wake, and a power-button wake is never vetoed.
//
// `Unblank` *then* `Restore`, and the order is the whole of it: the
// panel has to come back before the brightness it comes back at
// means anything. `Restore` alone was what this returned, which was
// only ever enough because the compositor woke the panel through a
// keybinding before the daemon was ever consulted — see
// `Action::Unblank`. Under viewtop nothing else does it, so a tap
// set the brightness of a screen that stayed off.
//
// `Restore` puts back the brightness the dim captured rather than a
// guessed floor — see the comment on that capture.
self.blank_requested = false;
// Recorded, because the blank branch below records and this one did
// not: the trail showed `button-gesture … recognised` and then
// silence, so "the press woke it" and "the press did nothing" were
// the same entry. A trail that cannot tell those apart is the one
// question anyone debugging a dark phone is actually asking.
self.record_decision(
"panel-on",
serde_json::json!({ "button": "power", "gesture": "tap" }),
"a tap on a dark panel is a wake",
);
return vec![Action::Unblank, Action::Restore];
}
self.request_blank(
now,
serde_json::json!({ "button": "power", "gesture": "tap" }),
"power button tap",
)
}
/// Bind a recognised touch gesture to behaviour.
///
/// The same shape as [`Self::apply_gesture`] for buttons, and deliberately
/// as small: three fingers raise the overview, everything else is
/// recognised, recorded and inert. A gesture that fires something nobody
/// chose is worse than one that fires nothing, and the binding table this
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
/// agent-writable, so a triple-tap can be re-pointed without a rebuild.
pub fn touch_gesture(
&mut self,
fingers: u8,
gesture: TouchGesture,
target: Option<u64>,
) -> Vec<Action> {
// A three-finger tap raises the window action sheet for the window it
// landed on. It used to raise the overview, and that was wrong twice
// over: the rail's swipe already reaches the same surface — a gesture
// that duplicates another gesture is a defect by construction — and
// the tap arrived with no subject at all, because the compositor
// dropped the centroid, so the overview was the only thing it *could*
// name. Now that the target travels with the gesture, the tap can be
// about the window under the fingers, which is what it was always for.
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap) && target.is_some();
self.record_decision(
"touch-gesture",
serde_json::json!({
"fingers": fingers,
"gesture": gesture.as_str(),
"target": target,
"bound": bound,
}),
"touch gesture recognised",
);
if !bound {
return Vec::new();
}
// Not while the screen is dark: the sheet is content, and putting
// content on an unauthenticated glass is what §4's disclosure rules
// exist to prevent. A tap on a dark panel is a wake, and that is the
// power button's business, not this one's.
if !self.panel_on {
return Vec::new();
}
// `target` is Some — `bound` required it. A tap on the wallpaper falls
// out above with `bound: false` in the trail rather than raising an
// empty sheet, which is the "why did a blank sheet appear" bug the
// no-window case exists to avoid.
vec![Action::WindowSheet {
target: target.unwrap_or_default(),
}]
}
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
// Buttons first: a hold that crossed its threshold during this tick
// should be announced before the idle rules below decide anything, or
// a hold and a blank can land in the same tick in the wrong order.
let mut button_actions = Vec::new();
let pending: Vec<(Button, ButtonGesture)> = self
.buttons
.iter_mut()
.filter_map(|(b, r)| r.tick(now).map(|g| (*b, g)))
.collect();
for (button, gesture) in pending {
button_actions.extend(self.apply_gesture(button, gesture, now));
}
let mut actions = self.tick_rules_at(now);
button_actions.append(&mut actions);
return button_actions;
}
fn tick_rules_at(&mut self, now: Instant) -> Vec<Action> {
// Expiry first. A source that has gone stale must not be allowed to
// win a pending debounce and then be cleared in the same tick — that
// would put a transition in the trail for a reading nobody confirmed.
self.expire_stale_evidence(now);
self.resolve_proximity_debounce(now);
self.evaluate_source_health(now);
let mut actions = self.bearer_actions(now);
// A blank is already decided and waiting on the lock it asked for.
// Nothing else may run while that is outstanding — the whole point is
// that the panel does not go dark ahead of the lock.
if let Some(deadline) = self.pending_blank {
if is_locked(self.state) {
self.pending_blank = None;
self.blank_requested = true;
info!("[device-state] lock acked — blanking");
self.record_decision(
"panel-off",
serde_json::json!({ "waited_for": "lock-ack" }),
"the lock this blank asked for was acknowledged",
);
actions.push(Action::Blank);
} else if now >= deadline {
// Fail open on the panel, never on the claim. §1: a lit,
// unlocked phone in a pocket is the worse outcome, so the
// blank proceeds — but doctrine §8 forbids pretending the
// session locked, so this is an error in the trail, loudly,
// and the machine's state is left untouched.
self.pending_blank = None;
self.blank_requested = true;
warn!(
"[device-state] lock NOT acked within {}s — blanking an UNLOCKED session",
self.policy.lock_ack_budget.as_secs()
);
self.record_error(
"device-state",
"blank-without-lock",
"lock ack did not arrive within the budget; panel blanked unlocked",
);
actions.push(Action::Blank);
}
return actions;
}
if !self.panel_on || self.blank_requested {
return actions;
}
// Proximity does not blank the panel. It used to, on any locked
// screen, which is `blueline-proximity-lock`'s old job moved inward
// and kept too powerful: a covered sensor is a pocket, a face, a
// table, or a thumb, and the machine cannot tell which. Turning the
// screen off on that reading is only right during a call — and the
// machine has no call state yet, so for now it is never right.
//
// What remains of proximity is evidence: it drives `Observed`, it is
// in every snapshot, and it gates tap-to-wake (`suppress_wake`). The
// idle budget below blanks a locked screen soon enough anyway.
// The compositor says the user is interacting. Not our business yet —
// and crucially this is what keeps a long swipe from being blanked
// out from under the user's thumb.
let Some(idle_since) = self.idle_since else {
return actions;
};
let held = self.sensor_evidence.accel_moving;
let budget = if !is_locked(self.state) {
// An unlocked screen is not this rule's business by default; the
// shell's IdleCoordinator owns that timer. When it IS set, the
// blank still routes through lock-then-off below.
self.policy.unlocked_blank_after
} else if held {
self.policy.lock_blank_after_held
} else {
self.policy.lock_blank_after
};
// "Never" is a legitimate setting (plugged in at the desk).
let Some(budget) = budget else {
return actions;
};
let idle_for = now.saturating_duration_since(idle_since);
// Stage two: the grace ran out, go dark.
if idle_for >= budget {
info!(
"[device-state] {}, idle {:.1}s of {}s — blanking",
if is_locked(self.state) {
"locked"
} else {
"unlocked"
},
idle_for.as_secs_f32(),
budget.as_secs()
);
return self.request_blank(
now,
serde_json::json!({
"idle_secs": idle_for.as_secs(),
"budget_secs": budget.as_secs(),
"held": held,
"was_dimmed": self.dimmed,
"confidence": self.sensor_evidence.confidence(),
}),
"panel lit, no input within the blank budget",
);
}
// Stage one: the pre-warning. The panel visibly fades and a tap
// inside the grace window cancels the blank — the user gets told
// what is about to happen instead of the screen simply dying.
if self.policy.dim_warning && !self.dimmed {
let dim_at = budget.saturating_sub(self.policy.dim_grace);
if idle_for >= dim_at {
self.dimmed = true;
info!(
"[device-state] locked, idle {:.1}s — dimming, {}s to tap",
idle_for.as_secs_f32(),
self.policy.dim_grace.as_secs()
);
self.record_decision(
"panel-dim",
serde_json::json!({
"idle_secs": idle_for.as_secs(),
"blank_at_secs": budget.as_secs(),
"grace_secs": self.policy.dim_grace.as_secs(),
"held": held,
}),
"pre-warning before blanking; input inside the grace cancels it",
);
actions.push(Action::Dim);
}
}
actions
}
/// Every path to a dark panel goes through here.
///
/// `LOCK-DPMS-LESSONS.md` §1 is "Ordering: lock, then off", and until now
/// that held only because hypridle's 300 s lock listener happened to fire
/// before its own 600 s blank listener. That is an assumption, not an
/// invariant: anything that skipped the lock (an idle inhibitor, the
/// native coordinator disabled, a dead shell) still got blanked, unlocked
/// and silently. Routing every blank through one function makes the
/// ordering a property of the authority instead of a coincidence of two
/// timers owned by a config file.
fn request_blank(
&mut self,
now: Instant,
inputs: serde_json::Value,
reason: &str,
) -> Vec<Action> {
if is_locked(self.state) {
self.blank_requested = true;
self.record_decision("panel-off", inputs, reason);
return vec![Action::Blank];
}
info!(
"[device-state] blanking an unlocked session — locking first, {}s to ack",
self.policy.lock_ack_budget.as_secs()
);
self.pending_blank = Some(now + self.policy.lock_ack_budget);
self.record_decision(
"lock-before-blank",
inputs,
"a blank was decided for an unlocked session; the lock goes first",
);
vec![Action::Lock]
}
/// Drop evidence nobody has refreshed. Stale readings are cleared rather
/// than trusted, and clearing errs the safe way: a stale proximity no
/// longer suppresses a wake, and a stale accelerometer no longer buys the
/// longer blank budget. Never the reverse.
fn expire_stale_evidence(&mut self, now: Instant) {
let ttl = self.policy.evidence_ttl;
let expired = |seen: &mut Option<Instant>, flag: &mut bool| -> bool {
let stale = match *seen {
Some(t) => now.saturating_duration_since(t) > ttl,
None => true,
};
if stale && *flag {
*flag = false;
*seen = None;
return true;
}
false
};
let mut dropped: Vec<&str> = Vec::new();
if expired(
&mut self.evidence_seen.proximity,
&mut self.sensor_evidence.proximity_near,
) {
// Clear the raw reading and any pending debounce with it. Stale
// means unknown, and a half-resolved edge left behind an expiry
// would let a reading nobody has confirmed in 30 s win the moment
// the next tick ran.
self.proximity_raw = false;
self.proximity_since = None;
dropped.push("proximity");
}
if expired(
&mut self.evidence_seen.accel,
&mut self.sensor_evidence.accel_moving,
) {
dropped.push("accel");
}
if expired(
&mut self.evidence_seen.light,
&mut self.sensor_evidence.light_changing,
) {
dropped.push("light");
}
if expired(
&mut self.evidence_seen.touch,
&mut self.sensor_evidence.touch_active,
) {
dropped.push("touch");
}
if !dropped.is_empty() {
warn!(
"[device-state] evidence went stale, treating as unknown: {}",
dropped.join(", ")
);
self.record_decision(
"evidence-stale",
serde_json::json!({ "sources": dropped, "ttl_secs": ttl.as_secs() }),
"no reading within the evidence TTL — a dead sensor is not a quiet one",
);
}
}
/// 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());
}
// Expected sources that have never spoken at all. `Down` above starts
// from a last-seen stamp and so structurally cannot see these: no
// stamp, no entry, silence forever. That is how a reporter which never
// started produced a whole session of evidence-free decisions with
// nothing in the trail (2026-07-27).
let mut newly_absent: Vec<&str> = Vec::new();
if now.saturating_duration_since(self.started_at) > self.policy.source_expected_within {
for &source in EXPECTED_SOURCES {
if self.evidence_seen.get(source).is_some() {
continue;
}
let health = self.source_health.get_mut(source);
if *health == SourceHealth::Absent {
continue;
}
*health = SourceHealth::Absent;
newly_absent.push(source.as_str());
}
}
// One entry per edge, not per tick — same contract as source-down.
for source in &newly_absent {
warn!(
"[device-state] evidence source {source} has never reported in {}s since start — its reporter is not running",
self.policy.source_expected_within.as_secs()
);
self.record_error(
"sensor-health",
"source-never-reported",
&format!(
"{source} is expected on this device but has never reported in the {}s since sessiond started; its reporter is not running, so every rule reading it is deciding on absence, not on a negative",
self.policy.source_expected_within.as_secs()
),
);
}
// 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.
/// The bearer actually in force, for the readout. `None` until the first
/// preference settles — which is a real state and not the same as "wifi".
pub fn bearer_applied_str(&self) -> Option<&'static str> {
self.bearer_applied.map(|b| b.as_str())
}
/// Fresh bearer evidence from the daemon's probe.
///
/// Ingress only. The machine never reads the network itself for the same
/// reason it never reads logind itself: a reader that can also act is one
/// refactor away from being a controller, and the controller this replaces
/// is the one that recycled the tunnel 652 times.
pub fn note_bearer(&mut self, evidence: BearerEvidence) {
self.bearer = evidence;
}
/// Decide the bearer posture, subject to the settling window.
///
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
/// on a change that has held. A steady state produces no actions at all,
/// which is what makes this safe to call every tick.
fn bearer_actions(&mut self, now: Instant) -> Vec<Action> {
let mut actions = Vec::new();
// Report the tunnel going deaf on the edge. This is the one thing that
// is worth saying even when nothing can be done about it: every other
// readout on the device calls a deaf tunnel "connected".
let deaf = self.bearer.tunnel_is_deaf();
if deaf != self.bearer_deaf {
self.bearer_deaf = deaf;
if deaf {
warn!("[device-state] tunnel is up but receiving nothing");
}
self.record_decision(
"bearer-tunnel-health",
serde_json::json!({ "tunnel": self.bearer.tunnel.as_str() }),
if deaf {
"tunnel up but no bytes received — up is not carrying"
} else {
"tunnel handshake healthy"
},
);
}
let Some(want) = self.bearer.preferred() else {
// Nothing usable. Deliberately not an action: with no link worth
// preferring there is nothing to prefer it over, and issuing a
// command here would be the machine flailing at a dead network.
self.bearer_candidate_since = None;
return actions;
};
if Some(want) == self.bearer_applied {
self.bearer_candidate_since = None;
return actions;
}
// A changed candidate has to survive the settling window. Restarting
// the clock whenever the candidate itself changes is what stops a link
// oscillating between two answers from ever accumulating enough time.
let since = match self.bearer_candidate_since {
Some((b, t)) if b == want => t,
_ => {
self.bearer_candidate_since = Some((want, now));
now
}
};
if now.duration_since(since) < self.policy.bearer_settle {
return actions;
}
let previous = self.bearer_applied;
self.bearer_applied = Some(want);
self.bearer_candidate_since = None;
info!(
"[device-state] bearer: {} -> {}",
previous.map(|b| b.as_str()).unwrap_or("none"),
want.as_str()
);
self.record_decision(
"bearer-preferred",
serde_json::json!({
"from": previous.map(|b| b.as_str()),
"to": want.as_str(),
"wifi": self.bearer.wifi.as_str(),
"cellular": self.bearer.cellular.as_str(),
"tunnel": self.bearer.tunnel.as_str(),
"home": self.bearer.home,
"ssid": self.bearer.ssid,
"settled_for_secs": now.duration_since(since).as_secs(),
}),
"bearer preference changed and held for the settling window",
);
actions.push(Action::PreferLink(want));
// The tunnel follows the underlay, and only when it is already up.
if let Some(underlay) = self.bearer.tunnel_underlay() {
actions.push(Action::PinTunnelUnderlay(underlay));
}
actions
}
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
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;
// Absent recovers by the same path as Down. Both mean "the machine was
// deciding without this source", and both need the closing entry that
// bounds the window — an outage with no end tells you when evidence
// died and never when it came back.
if matches!(was, SourceHealth::Down | SourceHealth::Absent) {
// 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 (was {})",
source.as_str(),
was.as_str()
);
self.record_decision(
"source-recovered",
serde_json::json!({ "source": source.as_str(), "was": was.as_str() }),
"a source the machine had no evidence from has resumed reporting",
);
}
}
/// Real user input, with the machine's own veto applied first.
///
/// `note_input` is unconditional by design: a power button is intent and is
/// never refused (§4, "no — hardware signal"). A squeeze is not a button.
/// It is a strain reading, and a phone in a tight pocket is a squeezed
/// chassis — the same failure a covered proximity sensor already vetoes for
/// tap-to-wake. Routing squeeze through here rather than straight to
/// `note_input` is what stops a pocket from resetting the idle budget and
/// opening a verb surface.
///
/// The refusal is recorded, not dropped. §10 spent a section establishing
/// that absence must never be mistaken for a negative; a producer whose
/// reports vanish silently is indistinguishable from a dead one.
pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> {
if self.suppress_wake(trigger) {
let placement = self.placement();
warn!(
"[device-state] {:?} refused — believed {:?} at {:.2}",
trigger, placement.belief, placement.confidence
);
self.record_decision(
"input-refused",
serde_json::json!({
"trigger": format!("{:?}", trigger),
"placement": placement,
"confidence": self.sensor_evidence.confidence(),
}),
"a placement belief above the veto threshold (DEVICE-STATE-MACHINE §4)",
);
return None;
}
Some(self.note_input(trigger))
}
/// Real user input. Resets the blank budget, re-arms the rule, and undoes
/// the pre-warning dim if one is showing — that cancel is the whole point
/// of the grace window, so it returns the action rather than waiting for
/// the next tick to notice.
pub fn note_input(&mut self, trigger: InputTrigger) -> Vec<Action> {
self.idle_since = None;
self.blank_requested = false;
// Input inside the lock-ack window cancels the blank outright. The
// lock request already went out and is not recalled — a lock the user
// interrupted is a lock, and only PAM leaves it.
if self.pending_blank.take().is_some() {
info!("[device-state] input during the lock-ack window — blank cancelled");
}
let mut actions = Vec::new();
if self.dimmed {
self.dimmed = false;
info!("[device-state] input during the dim warning — restoring");
actions.push(Action::Restore);
}
let wake = match trigger {
InputTrigger::PowerButton => WakeTrigger::PowerButton,
InputTrigger::DoubleTapToWake => WakeTrigger::DoubleTapToWake,
InputTrigger::Squeeze => WakeTrigger::Squeeze,
InputTrigger::Touch | InputTrigger::Key => WakeTrigger::UserInput,
InputTrigger::Unknown => WakeTrigger::Unknown,
};
self.record_wake(wake, &format!("input: {:?}", trigger));
// A deliberate wake on a dark panel has to actually light it.
//
// This function recorded the wake and returned `Restore` — which is
// *brightness* — so double-tap-to-wake reported to the machine and the
// screen stayed off. It was invisible for as long as the wake never
// came through the daemon: `hyprland.lua` bound `XF86WakeUp` straight
// to `blueline-screen-toggle on`, so the compositor lit the panel and
// sessiond only heard about it afterwards. viewtop consumes that key,
// and dt2w stopped working.
//
// Only the *deliberate* wakes, and only when the panel is actually
// dark. `Touch`/`Key`/`Unknown` are excluded because they are not wake
// gestures — the touch controller is in gesture mode while the panel is
// off, so a raw touch arriving there is not a request to wake.
//
// `PowerButton` is excluded for a different reason: it already has a
// wake, from `apply_gesture` resolving the tap. Emitting one here too
// would put two unblanks in flight for one press, and the second would
// land against a panel the first had already lit — which is how the
// wake loop happened in the compositor earlier today.
//
// Proximity has already had its say: this is reached through
// `note_input_gated`, whose veto is the pocket check. A power button is
// never vetoed and a double tap always is, because a pocket can produce
// the second and not the first (§4).
if !self.panel_on
&& matches!(
trigger,
InputTrigger::DoubleTapToWake | InputTrigger::Squeeze
)
{
info!("[device-state] {:?} on a dark panel — waking", trigger);
self.blank_requested = false;
// Before any `Restore`: the brightness a panel comes back at means
// nothing until the panel is back.
actions.insert(0, Action::Unblank);
}
actions
}
/// What the machine currently believes the panel is doing.
///
/// A belief, and named as one: the executor reports it (`set_panel`) and
/// nothing here infers it, so this is the last thing the hardware said
/// rather than an assumption about what it must be doing now.
pub fn panel_on(&self) -> bool {
self.panel_on
}
/// Ask for the panel, from outside the machine's own rules.
///
/// The agent's verb lands here (`{"op":"screen"}`), and so does anything
/// else that wants the glass lit or dark. It is deliberately not a
/// shortcut past anything: **off routes through `request_blank()`** like
/// every other path, so it locks first and waits out `LOCK_ACK_BUDGET`,
/// and **on** is the wake that direction never had.
///
/// Doctrine §13 in one function. Operation is hers — she may turn the
/// screen off — and the ordering invariant is not a permission she is
/// missing, it is a property of the machine that applies to every caller
/// including itself.
pub fn request_screen(&mut self, on: bool, why: &str) -> Vec<Action> {
if on {
if self.panel_on {
return Vec::new();
}
self.blank_requested = false;
let mut actions = vec![Action::Unblank];
// A wake must never come up dim, for the same reason `set_panel`
// restores: a blank that landed while the dim warning was showing
// left the saved brightness low.
if self.dimmed {
self.dimmed = false;
actions.push(Action::Restore);
}
return actions;
}
if !self.panel_on {
return Vec::new();
}
self.request_blank(
Instant::now(),
serde_json::json!({ "verb": "screen", "on": false }),
why,
)
}
/// Ask for a USB gadget posture.
///
/// The decision is intentionally tiny today: all advertised device-role
/// modes are admissible. The seam matters because attachment identity and
/// probe ownership will make that conditional; when they land, the refusal
/// belongs here rather than in QML or usb-signaller. Every request is
/// written now, so that future rule has a trail to inherit.
pub fn request_usb_mode(&mut self, mode: UsbMode, why: &str) -> Vec<Action> {
self.record_decision(
"usb-mode-request",
serde_json::json!({ "mode": mode.as_str() }),
why,
);
vec![Action::UsbMode(mode)]
}
/// Ask to power the machine off, restart it, or put it to sleep.
///
/// Not gated on the lock. Powering off a locked phone is a thing people do
/// deliberately, and a refusal here would send them to the hardware button
/// they are already holding — the same argument `PowerMenu.qml` makes for
/// showing the sheet while locked.
///
/// No state transition either. `Suspending` is entered from logind's
/// `PrepareForSleep`, which is the event that actually means it; setting it
/// here would be a second writer to the state the protocol owns, and a
/// suspend that logind then refuses would leave the machine believing it
/// had gone to sleep (doctrine §4).
pub fn request_power(&mut self, verb: PowerVerb, why: &str) -> Result<Vec<Action>, String> {
if let Some(in_flight) = self.power_requested {
let refusal = format!("{} is already in flight", in_flight.as_str());
self.record_decision(
"power-request-refused",
serde_json::json!({ "verb": verb.as_str(), "in_flight": in_flight.as_str() }),
&refusal,
);
return Err(refusal);
}
self.power_requested = Some(verb);
self.record_decision(
"power-request",
serde_json::json!({ "verb": verb.as_str() }),
why,
);
Ok(vec![Action::Power(verb)])
}
/// Release the in-flight latch after a power verb failed to happen.
///
/// Only the failure path calls this. Success is the machine going down, so
/// there is nothing to clear and nobody left to clear it — but a refused
/// suspend (lid policy, a blocking inhibitor) returns to a running system,
/// and a latch left set there would refuse every later attempt for the rest
/// of the uptime.
pub fn power_request_failed(&mut self, verb: PowerVerb) {
if self.power_requested == Some(verb) {
self.power_requested = None;
}
}
/// The executor reports the panel's real state. Turning on counts as
/// input, so a dt2w wake starts the blank budget from the wake itself.
///
/// A *report*, never a request — [`Self::request_screen`] is the asking
/// half. Keeping them apart is what stops a stale report from driving the
/// panel, and stops an ask from quietly editing the machine's idea of what
/// the hardware is doing.
pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
if self.panel_on == on {
return Vec::new();
}
self.panel_on = on;
info!("[device-state] panel {}", if on { "on" } else { "off" });
self.blank_requested = false;
// A blank waiting on a lock ack is moot once the panel is dark by any
// route — the power button, the proximity service, a wake that never
// came. Leaving it armed makes the next tick emit a second `Blank` at
// an already-dark panel, or worse, fire the `blank-without-lock` error
// path for a blank nobody is waiting on.
if !on {
self.pending_blank = None;
}
if on {
self.idle_since = None;
// A wake must never come up dim. If the blank landed while the
// warning was showing, the saved brightness is still low, so the
// restore has to run before the user sees the panel.
if self.dimmed {
self.dimmed = false;
return vec![Action::Restore];
}
}
Vec::new()
}
/// The compositor reported a quiet period beginning at `since`.
pub fn note_idle_start(&mut self, since: Instant) {
if self.idle_since.is_none() {
self.idle_since = Some(since);
}
}
/// Seconds of compositor-reported quiet, or 0 while the user is active.
pub fn idle_secs(&self) -> u64 {
match self.idle_since {
Some(t) => Instant::now().saturating_duration_since(t).as_secs(),
None => 0,
}
}
/// Whether the compositor currently reports the user as interacting.
pub fn user_active(&self) -> bool {
self.idle_since.is_none()
}
/// 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 {
StateSnapshot {
device_state: self.state,
locked: is_locked(self.state),
display_active: is_display_active(self.state),
phase: phase.to_string(),
shell_alive,
proximity_near: self.sensor_evidence.proximity_near,
confidence: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
screen_locked,
screen_lock_secure,
idle_coordinator_state: idle_state.to_string(),
sleep_inhibitor_held: inhibitor_held,
sensors_degraded: self.source_health.any_down(),
placement: self.placement(),
}
}
pub fn state(&self) -> DeviceState {
self.state
}
/// Record the brightness the panel was at when the dim fired. The executor
/// reads the hardware; the machine is what remembers.
pub fn note_brightness_before_dim(&mut self, value: Option<u32>) {
self.brightness_before_dim = value;
}
/// The captured pre-dim brightness, consumed by the restore. Taken rather
/// than read: a stale value surviving into the next dim cycle is exactly
/// the class of bug this replaced.
pub fn take_brightness_before_dim(&mut self) -> Option<u32> {
self.brightness_before_dim.take()
}
/// Should this wake be refused because something is over the sensor?
///
/// Tap-to-wake only. A double tap is the one wake source a pocket can
/// produce by itself, so a covered sensor is the right veto for it. A
/// power button press is intent (§4: "no — hardware signal") and is never
/// refused; neither is a wake the machine itself asked for.
///
/// This used to be a confidence question — the pair proximity-near +
/// accel-moving read as 0.5 and suppressed everything. Motion cannot tell
/// a pocket from an ear from a hand, so it was never the instrument.
///
/// **Owed:** a call. Screen dark at the ear is wanted whether or not the
/// session is locked, and it is the only case where proximity should turn
/// a panel *off* rather than decline to turn one on. That needs a
/// call-state input (ModemManager / callaudiod) — a factor, never an
/// authority.
/// May a `far` reading end `Observed` yet? See [`OBSERVED_MIN_DWELL`].
///
/// Anything other than `Observed` answers yes: this gates one edge, and a
/// state we are not in has no dwell to serve. `None` also answers yes —
/// an `Observed` entered before this was tracked (a daemon that restarted
/// into it) must still be able to leave, or the phone would sit in a state
/// nothing could clear.
fn observed_dwell_elapsed(&self, now: Instant) -> bool {
if !matches!(self.state, DeviceState::Observed) {
return true;
}
self.observed_since
.map(|t| now.saturating_duration_since(t) >= OBSERVED_MIN_DWELL)
.unwrap_or(true)
}
/// Where the phone believes it is. Interpretation, not a reading.
///
/// `proximity_near` was being read as "pocket" and refusing things on that
/// basis. §4 says it cannot carry that meaning — *"A covered sensor is a
/// pocket, a face, a table or a thumb, and nothing in the machine can tell
/// which"* — and measured 2026-08-05 it was drastically wrong: seven
/// deliberate squeezes refused as pocket-dials while the phone was in a
/// hand, unlocked, screen lit.
///
/// So the four candidates §4 names get separated by the evidence that
/// actually distinguishes them, and each answer carries a confidence,
/// because they are not equally knowable:
///
/// - **Hand** — unlocked with the panel lit. Proximity near is *compatible*
/// with this, not evidence against it: holding a phone is what puts a
/// palm over the sensor.
/// - **Table** — lit and unlocked but nothing has touched it in a while.
/// Casey, 2026-08-05: *"on table is more accurate as I'm not staring it
/// down but if it flashed I'd notice"* — which is the useful part. Table
/// is *attention available*, not absence.
/// - **Pocket** — the only one that needs all of locked, dark and covered,
/// and still tops out **low**. Every one of those three is a state the
/// phone is often in on a desk in a dark room, and the sensor that would
/// settle it is the one §4's table marks as able to lie.
/// - **Face** — needs a call, and there is no call-state input yet. §4 names
/// it as owed; until it exists this is never answered rather than guessed.
///
/// Confidence is deliberately capped below certainty everywhere. This is
/// interpretation consuming evidence, and it gates one thing (tap-to-wake).
/// It is on the snapshot so the trail records what was believed, and it is
/// never an authority.
pub fn placement(&self) -> Placement {
let covered = self.sensor_evidence.proximity_near;
let locked = is_locked(self.state);
let lit = self.panel_on;
// `idle_since` is cleared by real input, so `None` is "something touched
// this recently" without needing a clock passed in.
let active = self.idle_since.is_none();
// Covered is tested first: it is the only pocket-specific evidence
// here, and every other branch describes a phone in the open. Ordering
// it after "unlocked and lit" meant a covered sensor never reached this
// at all, because an unlocked lit phone matched Hand first.
let (belief, confidence) = if covered {
// Lock and darkness *grade* the reading rather than gate it.
// Gating on either disarmed the one thing §4 keeps the veto for —
// the pocket case worth stopping is a double tap about to light the
// screen up, which happens before the panel is dark and can happen
// before the lock hint catches up. Capped below Hand and Table
// throughout, because a face-down phone on a desk produces this
// exact signature and nothing here separates them.
let c = match (locked, lit) {
(true, false) => 0.7,
(true, true) => 0.6,
_ => 0.5,
};
(PlacementBelief::Pocket, c)
} else if !locked && lit && active {
(PlacementBelief::Hand, 0.8)
} else if !locked && lit {
// Unlocked and bright with nobody touching it. Casey's case, and
// the one the old boolean got most wrong.
(PlacementBelief::Table, 0.7)
} else if locked {
(PlacementBelief::Table, 0.5)
} else {
(PlacementBelief::Unknown, 0.0)
};
Placement {
belief,
confidence,
covered,
locked,
lit,
active,
}
}
/// Tap-to-wake, and nothing else.
///
/// §4 settles the scope in one line — *"It vetoes tap-to-wake, and nothing
/// else"* — and the argument is that a double tap is the one input **a
/// pocket can produce by itself**. Squeeze was in this list and does not
/// meet that test: six strain gauges deflecting past a calibrated baseline
/// is a hand closing on the phone, and holding it in order to squeeze it is
/// exactly what covers the proximity sensor.
///
/// If pocket squeezes turn out to be real, the answer is the producer's
/// threshold — it owns the gauges and their per-device sensitivities — not
/// a veto from the one sensor that cannot tell a pocket from a thumb.
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
let p = self.placement();
matches!(trigger, InputTrigger::DoubleTapToWake)
&& p.belief == PlacementBelief::Pocket
&& p.confidence >= POCKET_VETO_CONFIDENCE
}
/// Attempt a state transition. Returns true if the transition was
/// legal and applied, false if refused (logged as a warning).
/// Emits a forensic entry for every attempt (legal or not).
pub fn transition(&mut self, next: DeviceState) -> bool {
if self.state == next {
return true; // idempotent
}
let legal = LEGAL_TRANSITIONS
.iter()
.any(|(from, to)| *from == self.state && *to == next);
let prev = self.state;
if !legal {
warn!(
"[device-state] ILLEGAL transition {:?} → {:?} (refused)",
self.state, next
);
// Forensic: record the refused transition with a minimal snapshot
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: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
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(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Transition {
from: prev,
to: next,
legal: false,
},
snapshot,
&format!("illegal transition {:?}{:?} refused", prev, next),
);
return false;
}
self.state = next;
// Cleared here, on any path out, so a later entry cannot inherit an old
// timestamp and believe its dwell is already served. The *stamp* is set
// by the caller instead — `Instant::now()` here would read the real
// clock while every test drives an injected one, and the dwell would
// then be untestable.
if !matches!(next, DeviceState::Observed) {
self.observed_since = None;
}
// Log the pair the right way round. This read
// "Locked → Locked (from Active)" on the first live run, which looks
// like a refused self-transition rather than the real Active → Locked.
info!("[device-state] {:?} → {:?}", prev, next);
// Forensic: record the successful transition
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: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
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(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Transition {
from: prev,
to: next,
legal: true,
},
snapshot,
&format!("{:?}{:?}", prev, next),
);
true
}
/// Adopt logind's `LockedHint` as the lock truth.
///
/// This replaces the machine deciding for itself. It had no unlock ingress
/// outside its own fallback surface, so after the first unlock it believed
/// the session was locked forever, and every rule gated on
/// `is_locked(state)` — the blank budget AND the proximity blank — fired
/// against an unlocked, in-use phone. Doctrine §4: never hold state the
/// protocol owns.
///
/// Locking moves the base tier to `Locked`; unlocking returns to `Active`
/// from whichever locked sub-state we were in. Sub-state detail
/// (`Observed`, doze tiers) is ours to track *within* locked, but whether
/// we are locked at all is not.
pub fn set_session_locked(&mut self, locked: bool) -> Vec<Action> {
if locked == is_locked(self.state) {
return Vec::new();
}
if locked {
self.transition(DeviceState::Locked);
return Vec::new();
}
// Unlocked. Anything the machine decided on the strength of believing
// it was locked is void: a pending blank was only legitimate because
// the screen was a lock screen, and the dim warning belongs to that
// same rule.
//
// But only if the machine can actually leave where it is. There is no
// legal Suspending → Active or Asleep → Active edge (waking goes
// through Locked), so an unlock reported while the device is asleep is
// either a logind race or a genuine impossibility. Voiding lock-screen
// intent while the state stays Asleep would leave the two disagreeing,
// which is the whole class of bug this function exists to end.
if !self.transition(DeviceState::Active) {
warn!(
"[device-state] logind says unlocked but {:?} has no edge to Active — keeping lock-screen intent",
self.state
);
self.record_error(
"device-state",
"unlock-refused",
&format!("no legal transition from {:?} to Active", self.state),
);
return Vec::new();
}
info!("[device-state] session unlocked (logind) — clearing lock-screen intent");
self.blank_requested = false;
self.pending_blank = None;
let mut actions = Vec::new();
if self.dimmed {
self.dimmed = false;
actions.push(Action::Restore);
}
actions
}
/// Decide whether to believe a raw proximity reading yet.
///
/// Returns the *believed* value. A raw reading that disagrees with the
/// believed one starts a clock; it only wins once it has held for the
/// direction's threshold. `resolve_proximity_debounce` finishes the job on
/// the tick, for the case where the sensor reports once and goes quiet.
fn debounce_proximity(&mut self, raw: bool, now: Instant) -> bool {
if raw != self.proximity_raw {
self.proximity_raw = raw;
self.proximity_since = Some(now);
}
let believed = self.sensor_evidence.proximity_near;
if raw == believed {
// Nothing pending — the sensor agrees with what we already think.
self.proximity_since = None;
return believed;
}
let held_for = self
.proximity_since
.map(|t| now.saturating_duration_since(t))
.unwrap_or_default();
let threshold = if raw {
self.policy.proximity_near_debounce
} else {
self.policy.proximity_far_debounce
};
if held_for >= threshold {
self.proximity_since = None;
raw
} else {
believed
}
}
/// Let a held reading win once its threshold passes with no new report.
///
/// Without this the debounce would only resolve when the next reading
/// arrives, and a reporter that heartbeats every 30 s would make a real
/// `near` take up to half a minute to be believed.
fn resolve_proximity_debounce(&mut self, now: Instant) {
if self.proximity_raw == self.sensor_evidence.proximity_near {
return;
}
let held_for = self
.proximity_since
.map(|t| now.saturating_duration_since(t))
.unwrap_or_default();
let believed = self.debounce_proximity(self.proximity_raw, now);
if believed != self.sensor_evidence.proximity_near {
self.sensor_evidence.proximity_near = believed;
// Record the edge. Without this the trail would carry a
// Locked → Observed transition with no sensor input behind it —
// the reading was filtered on the way in and believed a tick
// later, so nothing would say why the machine moved. Filtered
// blips stay out of the trail deliberately; the edge that wins
// does not.
let confidence = self.sensor_evidence.confidence();
let snapshot = self.snapshot("", false, false, false, "", false);
self.forensic.append(
ForensicEvent::SensorInput {
source: SensorSource::Proximity,
value: SensorValue::Near(believed),
confidence,
},
snapshot,
&format!(
"proximity={believed} believed after holding {} ms",
held_for.as_millis()
),
);
let should_be_observed = self.state == DeviceState::Locked && believed;
if should_be_observed {
if self.transition(DeviceState::Observed) {
self.observed_since = Some(now);
}
} else if matches!(self.state, DeviceState::Observed)
&& !believed
&& self.observed_dwell_elapsed(now)
{
self.transition(DeviceState::Locked);
}
}
}
/// Proximity-shaped convenience wrapper.
pub fn update_sensors(&mut self, evidence: SensorEvidence) {
self.update_sensors_from(SensorSource::Proximity, evidence)
}
/// Update sensor evidence and, if the current state is Locked,
/// potentially transition to/from Observed.
/// Emits forensic entries for every sensor input.
pub fn update_sensors_from(&mut self, source: SensorSource, evidence: SensorEvidence) {
self.update_sensors_from_at(source, evidence, Instant::now())
}
/// `update_sensors_from` with the clock passed in, so the debounce can be
/// tested on a timeline instead of by sleeping.
pub fn update_sensors_from_at(
&mut self,
source: SensorSource,
mut evidence: SensorEvidence,
now: Instant,
) {
// Proximity is debounced before it is believed. The rest of the
// evidence is taken as reported — none of it flaps the way this one
// does, and none of it has the measurement behind it that would justify
// picking a threshold.
if source == SensorSource::Proximity {
evidence.proximity_near = self.debounce_proximity(evidence.proximity_near, now);
} else {
evidence.proximity_near = self.sensor_evidence.proximity_near;
}
let was_observed = matches!(self.state, DeviceState::Observed);
let proximity_near = evidence.proximity_near;
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;
// 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,
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
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(),
placement: self.placement(),
};
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 {
if self.transition(DeviceState::Observed) {
self.observed_since = Some(now);
}
} else if was_observed && !proximity_near && self.observed_dwell_elapsed(now) {
self.transition(DeviceState::Locked);
}
}
/// Record a wake event (screen on, dt2w, power button, etc.).
pub fn record_wake(&self, trigger: WakeTrigger, reason: &str) {
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: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
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(),
placement: self.placement(),
};
self.forensic
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
}
/// Record an error that affected device state.
pub fn record_error(&self, component: &str, action: &str, error: &str) {
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: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
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(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Error {
component: component.to_string(),
action: action.to_string(),
error: error.to_string(),
},
snapshot,
&format!("[{}] {}: {}", component, action, error),
);
}
/// Record a decision (e.g., "suppress DPMS wake", "promote idle").
pub fn record_decision(&self, decision: &str, inputs: serde_json::Value, reason: &str) {
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: self.sensor_evidence.confidence(),
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
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(),
placement: self.placement(),
};
self.forensic.append(
ForensicEvent::Decision {
decision: decision.to_string(),
inputs,
},
snapshot,
reason,
);
}
/// Serialize the current state for IPC.
pub fn to_ipc_json(&self) -> serde_json::Value {
serde_json::json!({
"device_state": self.state,
"locked": is_locked(self.state),
"display_active": is_display_active(self.state),
"observed": matches!(self.state, DeviceState::Observed),
"observed_confidence": self.sensor_evidence.confidence(),
"suppress_dpms_wake": self.suppress_wake(InputTrigger::DoubleTapToWake),
"panel_on": self.panel_on,
"user_active": self.user_active(),
"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.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
})
}
}
impl fmt::Display for DeviceState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeviceState::Active => write!(f, "active"),
DeviceState::Dimmed => write!(f, "dimmed"),
DeviceState::Locked => write!(f, "locked"),
DeviceState::Observed => write!(f, "observed"),
DeviceState::DozeLight => write!(f, "doze_light"),
DeviceState::DozeDeep => write!(f, "doze_deep"),
DeviceState::Suspending => write!(f, "suspending"),
DeviceState::Asleep => write!(f, "asleep"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sessiond::bearer::{LinkHealth, TunnelHealth};
#[test]
fn initial_state_is_active() {
let sm = DeviceStateMachine::new();
assert_eq!(sm.state(), DeviceState::Active);
}
/// A locked machine with a lit panel, where the compositor has just
/// reported that input stopped at `t0`.
fn locked_and_lit() -> (DeviceStateMachine, Instant) {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
assert!(sm.panel_on);
let t0 = Instant::now();
sm.note_idle_start(t0);
(sm, t0)
}
/// Unlocked, panel lit, an explicit unlocked blank budget set. This is the
/// configuration `unlocked_blank_after` exists for; the default is None.
fn unlocked_and_lit() -> (DeviceStateMachine, Instant) {
let mut sm = DeviceStateMachine::new();
assert!(!is_locked(sm.state));
assert!(sm.panel_on);
sm.policy.unlocked_blank_after = Some(Duration::from_secs(30));
sm.policy.dim_warning = false;
let t0 = Instant::now();
sm.note_idle_start(t0);
(sm, t0)
}
#[test]
fn an_unlock_reported_while_asleep_is_refused_not_half_applied() {
// There is no Asleep → Active edge. Voiding lock-screen intent while
// the state stays Asleep would leave the two disagreeing.
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
sm.transition(DeviceState::Suspending);
sm.transition(DeviceState::Asleep);
sm.dimmed = true;
sm.blank_requested = true;
assert!(sm.set_session_locked(false).is_empty());
assert_eq!(sm.state, DeviceState::Asleep, "state must not move");
assert!(sm.dimmed, "intent must not be voided by a refused unlock");
assert!(sm.blank_requested);
}
#[test]
fn logind_unlock_releases_the_machine_from_locked() {
// The bug this closes, measured on hardware 2026-07-26: the machine
// sat in Locked for 40 minutes while LockedHint said no, because
// locked_ack was the only lock ingress and there was no unlock one.
let (mut sm, _t0) = locked_and_lit();
assert!(is_locked(sm.state));
sm.set_session_locked(false);
assert_eq!(sm.state, DeviceState::Active);
assert!(!is_locked(sm.state));
}
#[test]
fn unlocking_cancels_a_pending_blank_and_restores_the_dim() {
let (mut sm, t0) = locked_and_lit();
// Get into the dim pre-warning.
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
assert_eq!(
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
vec![Action::Dim]
);
assert!(sm.dimmed);
// Unlocking voids lock-screen intent and undims.
assert_eq!(sm.set_session_locked(false), vec![Action::Restore]);
assert!(!sm.dimmed);
assert!(sm.pending_blank.is_none());
assert!(!sm.blank_requested);
}
#[test]
fn relocking_puts_the_machine_back() {
let (mut sm, _t0) = locked_and_lit();
sm.set_session_locked(false);
assert_eq!(sm.state, DeviceState::Active);
sm.set_session_locked(true);
assert!(is_locked(sm.state));
}
#[test]
fn an_unlocked_blank_asks_for_the_lock_first() {
let (mut sm, t0) = unlocked_and_lit();
// 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!(sm.pending_blank.is_some());
assert!(!sm.blank_requested, "the panel must not be dark yet");
}
#[test]
fn the_agents_screen_verb_cannot_blank_an_unlocked_session() {
// Doctrine §13 in one assertion. Operation is hers, so she may ask for
// the screen off — and the ordering invariant is not a permission she
// lacks, it is a property of the machine that applies to every caller.
// She gets `Lock` first, exactly as the idle timer does.
let (mut sm, _t0) = unlocked_and_lit();
assert_eq!(sm.request_screen(false, "agent"), vec![Action::Lock]);
assert!(sm.pending_blank.is_some());
assert!(!sm.blank_requested, "the panel must not be dark yet");
}
#[test]
fn double_tap_to_wake_actually_wakes_the_panel() {
// The regression that made viewtop feel worse than Hyprland. This
// function recorded the wake and returned `Restore`, which is
// brightness — so dt2w reported to the machine and the screen stayed
// dark. It only ever worked because hyprland.lua bound XF86WakeUp
// straight to the DPMS toggle and the daemon was never consulted.
let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false);
let actions = sm.note_input(InputTrigger::DoubleTapToWake);
assert!(
actions.contains(&Action::Unblank),
"a double tap on a dark panel must light it: {actions:?}"
);
}
#[test]
fn a_wake_on_a_lit_panel_does_not_unblank_it() {
let (mut sm, _t0) = unlocked_and_lit();
assert!(sm.panel_on());
let actions = sm.note_input(InputTrigger::DoubleTapToWake);
assert!(!actions.contains(&Action::Unblank));
}
#[test]
fn the_power_button_does_not_unblank_twice() {
// `apply_gesture` already wakes on a resolved tap. If `note_input`
// also woke on the down edge there would be two unblanks in flight for
// one press, and the second would land against a panel the first had
// lit — which is exactly how the wake loop happened in the compositor.
let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false);
let actions = sm.note_input(InputTrigger::PowerButton);
assert!(
!actions.contains(&Action::Unblank),
"the power button's wake belongs to apply_gesture: {actions:?}"
);
}
#[test]
fn a_volume_press_changes_volume_immediately() {
// On the DOWN edge, not on a resolved gesture: BUTTON_MULTI_TAP_WINDOW
// is 300ms and a volume key that lags a third of a second behind the
// press feels broken.
let (mut sm, t0) = unlocked_and_lit();
let up = sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0);
assert!(up.contains(&Action::Volume { up: true }), "{up:?}");
let down = sm.button_edge_at(Button::VolumeDown, ButtonEdge::Down, t0);
assert!(down.contains(&Action::Volume { up: false }), "{down:?}");
}
#[test]
fn releasing_a_volume_key_does_not_change_volume_again() {
let (mut sm, t0) = unlocked_and_lit();
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0);
let up = sm.button_edge_at(
Button::VolumeUp,
ButtonEdge::Up,
t0 + Duration::from_millis(60),
);
assert!(
!up.iter().any(|a| matches!(a, Action::Volume { .. })),
"one press is one step: {up:?}"
);
}
#[test]
fn the_screen_verb_wakes_a_dark_panel() {
// The direction the machine did not have. Before `Action::Unblank`
// there was no way out of a blank at all from inside the daemon: a
// power tap on a dark panel answered `Restore`, which is brightness,
// and the panel stayed off. It only ever worked because Hyprland's
// keybinding woke the panel before sessiond was consulted.
let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false);
assert!(!sm.panel_on());
assert_eq!(sm.request_screen(true, "agent"), vec![Action::Unblank]);
}
#[test]
fn a_power_tap_on_a_dark_panel_unblanks_before_it_restores() {
// Order is the whole of it: the brightness a panel comes back at means
// nothing until the panel is back. `Restore` alone was what this
// returned, and under viewtop that set the brightness of a screen that
// stayed off.
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
let actions = sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
let actions = if actions.is_empty() {
// Taps resolve on the multi-tap window expiring, not on release.
sm.tick_at(t0 + Duration::from_secs(2))
} else {
actions
};
let unblank = actions.iter().position(|a| *a == Action::Unblank);
let restore = actions.iter().position(|a| *a == Action::Restore);
assert!(
unblank.is_some(),
"a tap on a dark panel must wake it: {actions:?}"
);
if let (Some(u), Some(r)) = (unblank, restore) {
assert!(u < r, "the panel must come back before its brightness does");
}
}
#[test]
fn three_fingers_raise_the_sheet_for_the_window_they_landed_on() {
let (mut sm, _t0) = unlocked_and_lit();
let actions = sm.touch_gesture(3, TouchGesture::Tap, Some(7));
assert_eq!(actions, vec![Action::WindowSheet { target: 7 }]);
}
#[test]
fn holding_power_raises_the_power_menu() {
let (mut sm, t0) = unlocked_and_lit();
let actions = sm.apply_gesture(Button::Power, ButtonGesture::Hold, t0);
assert_eq!(actions, vec![Action::PowerMenu]);
}
#[test]
fn the_power_menu_still_opens_on_a_dark_panel() {
// Not gated on the panel or the lock: powering off is a thing you do
// to a locked phone, and a menu that vanished when locked would send
// the user back to the button they are already holding.
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
let actions = sm.apply_gesture(Button::Power, ButtonGesture::Hold, t0);
assert_eq!(actions, vec![Action::PowerMenu]);
}
#[test]
fn a_three_finger_tap_on_the_wallpaper_raises_nothing() {
// The no-window case, decided rather than discovered on device: a tap
// with no subject is inert. The alternative is a sheet with nothing to
// act on, which is the "why did a blank sheet appear" bug.
let (mut sm, _t0) = unlocked_and_lit();
assert!(sm.touch_gesture(3, TouchGesture::Tap, None).is_empty());
}
#[test]
fn every_other_touch_gesture_is_recognised_and_inert() {
// Deliberate. A gesture that fires something nobody chose is worse
// than one that fires nothing, and the binding table this is a
// placeholder for is DeviceStatePolicy's to hold.
let (mut sm, _t0) = unlocked_and_lit();
for g in [
TouchGesture::SwipeUp,
TouchGesture::SwipeDown,
TouchGesture::SwipeLeft,
TouchGesture::SwipeRight,
] {
assert!(sm.touch_gesture(3, g, Some(1)).is_empty(), "{g:?}");
}
for fingers in [1u8, 2, 4, 5] {
assert!(
sm.touch_gesture(fingers, TouchGesture::Tap, Some(1))
.is_empty(),
"{fingers} fingers"
);
}
}
#[test]
fn the_sheet_does_not_open_on_a_dark_panel() {
// The sheet is content, and content on an unauthenticated glass is
// what the disclosure rules exist to prevent. A tap on a dark panel is
// a wake, and that is the power button's business.
let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false);
assert!(sm.touch_gesture(3, TouchGesture::Tap, Some(1)).is_empty());
}
#[test]
fn a_press_that_woke_the_panel_does_not_then_blank_it() {
// The regression Casey hit: press to wake, the lock screen appears, and
// the screen goes black again. One press, two reports.
//
// `note_input` on the DOWN edge takes the device out of Locked and the
// executor lights the panel, reporting it back through `set_panel`
// before the finger is even off the button. The test above never
// simulated that report, which is why it passed while the phone failed.
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
// The wake this very press caused, landing between the edges.
sm.set_panel(true);
let mut actions = sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
if actions.is_empty() {
actions = sm.tick_at(t0 + Duration::from_secs(2));
}
assert!(
!actions.iter().any(|a| matches!(a, Action::Blank)),
"the press that woke the panel must not also blank it: {actions:?}"
);
assert!(
!actions.iter().any(|a| matches!(a, Action::Lock)),
"and it must not lock in order to blank: {actions:?}"
);
}
#[test]
fn a_volume_nudge_does_not_make_the_power_tap_blank_the_screen() {
// The latch used to be one field for every button. A tap resolves on
// the multi-tap window closing, up to ~1.3 s after the press, so any
// other button's DOWN edge inside that gap overwrote it — and on a
// Pixel 3 the volume rocker is under the same grip as power.
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
sm.set_panel(true); // the wake this press caused
sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
// The rocker, well inside the multi-tap window.
sm.button_edge_at(
Button::VolumeUp,
ButtonEdge::Down,
t0 + Duration::from_millis(120),
);
sm.button_edge_at(
Button::VolumeUp,
ButtonEdge::Up,
t0 + Duration::from_millis(200),
);
let actions = sm.tick_at(t0 + Duration::from_secs(2));
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::Blank | Action::Lock)),
"the volume press must not decide what the power press meant: {actions:?}"
);
}
#[test]
fn a_press_against_a_lit_panel_still_blanks_it() {
// The other half — without this the fix would simply disable the power
// button's only binding.
let (mut sm, t0) = unlocked_and_lit();
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
let mut actions = sm.button_edge_at(
Button::Power,
ButtonEdge::Up,
t0 + Duration::from_millis(80),
);
if actions.is_empty() {
actions = sm.tick_at(t0 + Duration::from_secs(2));
}
assert!(
actions
.iter()
.any(|a| matches!(a, Action::Lock | Action::Blank)),
"a tap on a lit panel is still lock-then-blank: {actions:?}"
);
}
#[test]
fn asking_for_the_state_the_panel_is_already_in_does_nothing() {
let (mut sm, _t0) = unlocked_and_lit();
assert!(sm.panel_on());
assert!(sm.request_screen(true, "agent").is_empty());
}
#[test]
fn a_pending_blank_fires_once_the_lock_is_acked() {
let (mut sm, t0) = unlocked_and_lit();
let t1 = t0 + Duration::from_secs(31);
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
// Nothing while the lock is still outstanding, well inside the budget.
assert!(sm.tick_at(t1 + Duration::from_millis(500)).is_empty());
// The lock lands. Now — and only now — the panel goes dark.
sm.transition(DeviceState::Locked);
assert_eq!(
sm.tick_at(t1 + Duration::from_millis(600)),
vec![Action::Blank]
);
assert!(sm.pending_blank.is_none());
}
#[test]
fn a_lock_that_never_acks_blanks_anyway_and_says_so() {
let (mut sm, t0) = unlocked_and_lit();
let t1 = t0 + Duration::from_secs(31);
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
let before = sm.forensic.recent(200).len();
// Past the ack budget with no lock. §1: dark-but-unlocked beats
// lit-and-unlocked in a pocket, so the blank proceeds.
assert_eq!(
sm.tick_at(t1 + LOCK_ACK_BUDGET + Duration::from_millis(10)),
vec![Action::Blank]
);
// But doctrine §8 forbids pretending it locked: the state is untouched
// and the trail carries an error, not a decision.
assert!(!is_locked(sm.state));
let entries = sm.forensic.recent(200);
assert!(entries.len() > before);
assert!(
entries.iter().any(|e| matches!(
&e.event,
ForensicEvent::Error { action, .. } if action == "blank-without-lock"
)),
"the unlocked blank must be a loud error in the trail"
);
}
#[test]
fn input_inside_the_ack_window_cancels_the_blank() {
let (mut sm, t0) = unlocked_and_lit();
let t1 = t0 + Duration::from_secs(31);
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
// The user came back within the second-or-so window.
sm.note_input(InputTrigger::Touch);
assert!(sm.pending_blank.is_none());
// And the panel stays lit past where the deadline would have been.
assert!(sm
.tick_at(t1 + LOCK_ACK_BUDGET + Duration::from_secs(1))
.is_empty());
assert!(!sm.blank_requested);
}
#[test]
fn a_locked_blank_still_goes_straight_to_dark() {
// The ordering rule must not add a lock round-trip to the case that
// was already correct: locked screens blank immediately.
let (mut sm, t0) = locked_and_lit();
assert_eq!(
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
vec![Action::Blank]
);
assert!(sm.pending_blank.is_none());
}
#[test]
fn lockscreen_dims_as_a_warning_then_blanks() {
let (mut sm, t0) = locked_and_lit();
// Nothing at all early on.
assert!(sm.tick_at(t0 + Duration::from_secs(1)).is_empty());
// Dim lands one grace-window before the blank, not at it.
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
assert_eq!(
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
vec![Action::Dim]
);
// The warning is asked for once, not on every tick.
assert!(sm.tick_at(t0 + dim_at + Duration::from_secs(1)).is_empty());
// Then the panel goes dark when the budget runs out.
assert_eq!(
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
vec![Action::Blank]
);
// And that is asked for once too.
assert!(sm
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(5))
.is_empty());
}
#[test]
fn tap_inside_the_grace_window_cancels_the_blank() {
let (mut sm, t0) = locked_and_lit();
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
assert_eq!(
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
vec![Action::Dim]
);
// The user taps: brightness comes back and the budget restarts.
assert_eq!(sm.note_input(InputTrigger::Touch), vec![Action::Restore]);
assert!(sm.user_active(), "a tap means the user is here");
// While the compositor reports the user as active, nothing happens at
// all — this is the case a self-stamped clock gets wrong.
assert!(sm.tick_at(t0 + LOCK_BLANK_AFTER * 10).is_empty());
// Quiet resumes later; the budget runs from there, not from before.
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));
// And it does eventually blank, one full budget after the tap.
assert_eq!(
sm.tick_at(tap + LOCK_BLANK_AFTER + Duration::from_millis(10)),
vec![Action::Blank]
);
}
#[test]
fn proximity_alone_never_blanks_the_panel() {
// A covered sensor is a pocket, a face, a table or a thumb, and the
// machine cannot tell which. The only reading that justifies turning
// the screen off is a call, and the machine has no call state yet.
for locked in [true, false] {
let (mut sm, t0) = locked_and_lit();
if !locked {
sm.set_session_locked(false);
}
sm.sensor_evidence.proximity_near = true;
sm.mark_evidence_seen(SensorSource::Proximity);
assert!(
sm.tick_at(t0 + Duration::from_millis(10)).is_empty(),
"locked={locked}: proximity is evidence, not an actuator"
);
}
}
#[test]
fn an_unlocked_screen_is_never_blanked_by_this_rule() {
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.note_idle_start(t0);
assert_eq!(sm.state(), DeviceState::Active);
// Hours idle and unlocked: not this rule's business.
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
}
#[test]
fn never_is_a_real_setting() {
let (mut sm, t0) = locked_and_lit();
sm.policy.lock_blank_after = None;
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
}
#[test]
fn a_held_device_gets_the_longer_budget() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.accel_moving = true;
sm.mark_evidence_seen(SensorSource::Accelerometer);
// Past the table budget, but a hand is on it: the panel has not gone
// dark. (A dim warning is due here — the held budget's grace window
// starts exactly where the table budget would have blanked.)
assert!(!sm
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(1))
.contains(&Action::Blank));
// The longer budget still ends.
assert_eq!(
sm.tick_at(t0 + LOCK_BLANK_AFTER_HELD + Duration::from_millis(10)),
vec![Action::Blank]
);
}
#[test]
fn stale_evidence_stops_buying_the_longer_budget() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.accel_moving = true;
sm.mark_evidence_seen(SensorSource::Accelerometer);
// The sensor dies: no further readings. Once the TTL passes the
// reading is unknown, and unknown must not extend the budget —
// erring toward saving the panel, never toward burning it.
let late = t0 + EVIDENCE_TTL + LOCK_BLANK_AFTER;
let actions = sm.tick_at(late);
assert!(!sm.sensor_evidence.accel_moving, "stale reading must clear");
assert_eq!(actions, vec![Action::Blank]);
}
#[test]
fn a_squeeze_is_never_vetoed_by_proximity_and_neither_is_a_button() {
let (mut sm, _t0) = locked_and_lit();
sm.sensor_evidence.proximity_near = true;
sm.mark_evidence_seen(SensorSource::Proximity);
// A squeeze is NOT vetoed by a covered sensor, and this test used to
// assert the opposite. Measured 2026-08-05: seven deliberate squeezes
// at deflection 2509-3320 were all refused while the phone was in a
// hand — holding it to squeeze it is what covers the sensor. Six strain
// gauges past a calibrated baseline is the stronger signal of the two,
// and §4 scopes the veto to tap-to-wake alone.
assert!(!sm.suppress_wake(InputTrigger::Squeeze));
assert!(sm.note_input_gated(InputTrigger::Squeeze).is_some());
// Intent from a hardware button is never refused (§4).
assert!(!sm.suppress_wake(InputTrigger::PowerButton));
assert!(sm.note_input_gated(InputTrigger::PowerButton).is_some());
}
#[test]
fn an_uncovered_squeeze_is_real_input() {
let (mut sm, _t0) = locked_and_lit();
sm.sensor_evidence.proximity_near = false;
assert!(!sm.suppress_wake(InputTrigger::Squeeze));
assert!(sm.note_input_gated(InputTrigger::Squeeze).is_some());
// It resets the idle budget like any other real input.
assert!(sm.idle_since.is_none());
}
#[test]
fn stale_proximity_stops_suppressing_wake() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.proximity_near = true;
sm.mark_evidence_seen(SensorSource::Proximity);
assert!(sm.suppress_wake(InputTrigger::DoubleTapToWake));
// A dead proximity sensor must not keep vetoing wakes forever.
sm.tick_at(t0 + EVIDENCE_TTL + Duration::from_secs(1));
assert!(!sm.suppress_wake(InputTrigger::DoubleTapToWake));
}
#[test]
fn waking_from_a_dimmed_blank_restores_brightness_first() {
let (mut sm, t0) = locked_and_lit();
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
sm.tick_at(t0 + dim_at + Duration::from_millis(10));
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10));
sm.set_panel(false);
// dt2w wake: the saved brightness is still the dim value, so the
// restore has to run or the panel comes up looking dead.
assert_eq!(sm.set_panel(true), vec![Action::Restore]);
}
#[test]
fn pam_unlock_is_legal_from_every_locked_substate() {
for from in [
DeviceState::Locked,
DeviceState::Observed,
DeviceState::DozeLight,
DeviceState::DozeDeep,
] {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
// Walk the real graph to get there; doze tiers promote in order.
match from {
DeviceState::Locked => {}
DeviceState::Observed => assert!(sm.transition(DeviceState::Observed)),
DeviceState::DozeLight => assert!(sm.transition(DeviceState::DozeLight)),
DeviceState::DozeDeep => {
assert!(sm.transition(DeviceState::DozeLight));
assert!(sm.transition(DeviceState::DozeDeep));
}
other => panic!("unexpected setup state {other:?}"),
}
assert!(
sm.transition(DeviceState::Active),
"unlock from {from:?} must be legal"
);
}
}
#[test]
fn legal_transitions_work() {
let mut sm = DeviceStateMachine::new();
assert!(sm.transition(DeviceState::Dimmed));
assert_eq!(sm.state(), DeviceState::Dimmed);
assert!(sm.transition(DeviceState::Locked));
assert_eq!(sm.state(), DeviceState::Locked);
}
#[test]
fn illegal_transitions_refused() {
let mut sm = DeviceStateMachine::new();
// Active → DozeLight is not legal (must go through Locked first)
assert!(!sm.transition(DeviceState::DozeLight));
assert_eq!(sm.state(), DeviceState::Active); // unchanged
}
#[test]
fn idempotent_transition() {
let mut sm = DeviceStateMachine::new();
assert!(sm.transition(DeviceState::Active)); // same state
assert_eq!(sm.state(), DeviceState::Active);
}
#[test]
fn active_to_suspending_allowed() {
let mut sm = DeviceStateMachine::new();
// Any pre-sleep state → Suspending is legal (logind authority)
assert!(sm.transition(DeviceState::Suspending));
assert_eq!(sm.state(), DeviceState::Suspending);
}
#[test]
fn asleep_to_locked() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
sm.transition(DeviceState::Suspending);
sm.transition(DeviceState::Asleep);
assert!(sm.transition(DeviceState::Locked));
assert_eq!(sm.state(), DeviceState::Locked);
}
#[test]
fn sensor_evidence_confidence() {
let e = SensorEvidence {
proximity_near: true,
accel_moving: false,
light_changing: false,
touch_active: false,
};
assert!((e.confidence() - 0.4).abs() < 0.01);
let e2 = SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: true,
touch_active: true,
};
// 0.4 + 0.3 + 0.2 + 0.1 - 0.2 (disagreement) = 0.8
assert!((e2.confidence() - 0.8).abs() < 0.01);
}
#[test]
fn sensor_disagreement_reduces_confidence() {
// Proximity near + accel moving: a pocket, an ear and a hand all read
// this way. It lowers confidence and decides nothing.
let e = SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: false,
touch_active: false,
};
// 0.4 + 0.3 - 0.2 = 0.5
assert!((e.confidence() - 0.5).abs() < 0.01);
// Idle promotion not accelerated (0.5 < 0.6).
assert!(!e.should_promote_idle_faster());
}
// ── Proximity debounce ────────────────────────────────────────────
/// A locked machine and a clock, for driving proximity on a timeline.
fn locked_for_proximity() -> (DeviceStateMachine, Instant) {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
(sm, Instant::now())
}
fn prox(near: bool) -> SensorEvidence {
SensorEvidence {
proximity_near: near,
..Default::default()
}
}
/// Report proximity the way `server.rs` does — the reading and the
/// freshness stamp together. A test that only does the first half has a
/// source that is instantly stale.
fn report_prox(sm: &mut DeviceStateMachine, near: bool, at: Instant) {
sm.mark_evidence_seen_at(SensorSource::Proximity, at);
sm.update_sensors_from_at(SensorSource::Proximity, prox(near), at);
}
/// Report `near` and let it hold long enough to be believed.
fn hold_prox_near(sm: &mut DeviceStateMachine, at: Instant) -> Instant {
report_prox(sm, true, at);
let settled = at + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1);
sm.mark_evidence_seen_at(SensorSource::Proximity, settled);
sm.tick_at(settled);
settled
}
#[test]
fn a_believed_near_followed_by_far_a_second_later_does_not_flap() {
// The measured case, 2026-08-03. The phone's trail was almost entirely
// Locked -> Observed -> Locked, and the snapshots said why: prox=true
// on the way in, prox=false one second later on the way out. `near`
// holds 700 ms and is believed; `far` is believed instantly; the round
// trip costs two transitions and two trail entries for a reading that
// never meant anything.
let (mut sm, t0) = locked_for_proximity();
let settled = hold_prox_near(&mut sm, t0);
assert_eq!(sm.state, DeviceState::Observed, "a held near is believed");
// Far, one second after entering — inside the dwell.
let blip_ends = settled + Duration::from_secs(1);
report_prox(&mut sm, false, blip_ends);
sm.tick_at(blip_ends);
assert_eq!(
sm.state,
DeviceState::Observed,
"a one-second episode must not bounce the state back"
);
// Past the dwell, `far` still ends it — this must not become a state
// the phone cannot leave.
let after = settled + OBSERVED_MIN_DWELL + Duration::from_millis(1);
report_prox(&mut sm, false, after);
sm.tick_at(after);
assert_eq!(
sm.state,
DeviceState::Locked,
"the dwell delays the exit, it does not remove it"
);
}
#[test]
fn a_real_proximity_episode_still_ends_when_it_ends() {
// The nine real episodes of the 44 measured all ran >= 5 s. The dwell
// is 3 s precisely so it sits under every one of them: covering a
// sensor for a real interval must behave exactly as it did before.
let (mut sm, t0) = locked_for_proximity();
let settled = hold_prox_near(&mut sm, t0);
assert_eq!(sm.state, DeviceState::Observed);
let uncovered = settled + Duration::from_secs(5);
report_prox(&mut sm, false, uncovered);
sm.tick_at(uncovered);
assert_eq!(
sm.state,
DeviceState::Locked,
"a five-second episode ends on the reading that ends it"
);
}
#[test]
fn the_wake_veto_still_lifts_the_instant_the_sensor_clears() {
// The dwell must not leak into `suppress_wake`. That reads the
// debounced evidence, and PROXIMITY_FAR_DEBOUNCE is zero exactly so a
// wake is never refused after the phone is out of the pocket. Holding
// the *state* longer must not hold the *veto* longer.
let (mut sm, t0) = locked_for_proximity();
let settled = hold_prox_near(&mut sm, t0);
assert!(
sm.suppress_wake(InputTrigger::DoubleTapToWake),
"covered: a pocket double-tap is vetoed"
);
let clear = settled + Duration::from_millis(200);
report_prox(&mut sm, false, clear);
assert!(
!sm.suppress_wake(InputTrigger::DoubleTapToWake),
"uncovered: the veto lifts at once, dwell or no dwell"
);
assert_eq!(
sm.state,
DeviceState::Observed,
"and the state is still holding its dwell, which is the whole point"
);
}
#[test]
fn a_sub_second_near_blip_is_never_believed() {
// The measured case, 2026-07-26: 44 near-episodes in 2.4 hours, median
// dwell 1 s, 15 of them sub-second. Each one produced two transitions
// and two trail entries for a reading that meant nothing.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
assert_eq!(sm.state, DeviceState::Locked, "near is not believed yet");
assert!(!sm.sensor_evidence.proximity_near);
report_prox(&mut sm, false, t0 + Duration::from_millis(400));
sm.tick_at(t0 + Duration::from_millis(500));
assert_eq!(
sm.state,
DeviceState::Locked,
"the blip must leave no transition behind"
);
}
#[test]
fn a_held_near_is_believed_once_it_has_held() {
// The nine real episodes ran 5 s to 173 s. They must survive untouched.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
assert_eq!(sm.state, DeviceState::Locked);
// No further reading arrives — the tick has to finish the job, or a
// reporter that heartbeats every 30 s would delay a real near by half
// a minute.
hold_prox_near(&mut sm, t0);
assert!(sm.sensor_evidence.proximity_near);
assert_eq!(sm.state, DeviceState::Observed);
}
#[test]
fn far_is_believed_at_once() {
// The reading that ends a veto is never the slow one.
let (mut sm, t0) = locked_for_proximity();
hold_prox_near(&mut sm, t0);
assert_eq!(sm.state, DeviceState::Observed);
let t1 = t0 + Duration::from_secs(10);
report_prox(&mut sm, false, t1);
assert!(!sm.sensor_evidence.proximity_near);
assert_eq!(sm.state, DeviceState::Locked);
}
#[test]
fn a_flapping_sensor_that_never_settles_is_never_believed() {
// Alternating faster than the threshold: the near edge keeps restarting
// its clock, so nothing is ever believed and the trail stays quiet.
let (mut sm, t0) = locked_for_proximity();
let mut t = t0;
for _ in 0..20 {
report_prox(&mut sm, true, t);
t += Duration::from_millis(200);
report_prox(&mut sm, false, t);
t += Duration::from_millis(200);
}
assert_eq!(sm.state, DeviceState::Locked);
assert!(!sm.sensor_evidence.proximity_near);
}
#[test]
fn a_heartbeat_repeat_does_not_restart_the_debounce() {
// Reporters re-send their last value every 30 s (§10). A repeat is the
// same edge continuing, not a new one — if it reset the clock, a held
// near would never be believed.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
report_prox(&mut sm, true, t0 + Duration::from_millis(500));
report_prox(
&mut sm,
true,
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1),
);
assert!(sm.sensor_evidence.proximity_near);
assert_eq!(sm.state, DeviceState::Observed);
}
#[test]
fn a_non_proximity_reading_does_not_disturb_the_debounce() {
// accel/light/touch share the evidence struct; updating one must not
// silently overwrite a proximity value mid-debounce.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
let accel = SensorEvidence {
proximity_near: false, // the caller's stale copy — must be ignored
accel_moving: true,
..Default::default()
};
sm.mark_evidence_seen_at(SensorSource::Accelerometer, t0 + Duration::from_millis(100));
sm.update_sensors_from_at(
SensorSource::Accelerometer,
accel,
t0 + Duration::from_millis(100),
);
sm.mark_evidence_seen_at(
SensorSource::Proximity,
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1),
);
sm.tick_at(t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1));
assert!(sm.sensor_evidence.accel_moving);
assert!(
sm.sensor_evidence.proximity_near,
"the proximity edge must still resolve on its own clock"
);
}
#[test]
fn the_pre_dim_brightness_is_remembered_and_consumed_once() {
// Casey's report: the panel dims, and a tap to dismiss brings it back
// at a different level. Cause was `blueline-undim`'s floor — anything
// restoring under 26/255 was pushed to 40%, so a phone deliberately
// run dark came back brighter than it started. The machine remembers
// the number now instead of the executor guessing at it.
let mut sm = DeviceStateMachine::new();
assert_eq!(
sm.take_brightness_before_dim(),
None,
"nothing captured yet"
);
sm.note_brightness_before_dim(Some(18));
assert_eq!(
sm.take_brightness_before_dim(),
Some(18),
"a value below the old floor must survive the round trip unchanged"
);
assert_eq!(
sm.take_brightness_before_dim(),
None,
"taken, not read — a stale capture must not survive into the next dim"
);
}
#[test]
fn a_failed_brightness_read_falls_through_to_the_floor() {
// The one case the floor is right for: nothing knows what the panel
// was at, so coming back dark is worse than coming back wrong.
let mut sm = DeviceStateMachine::new();
sm.note_brightness_before_dim(None);
assert_eq!(sm.take_brightness_before_dim(), None);
}
#[test]
fn only_tap_to_wake_is_vetoed_by_a_covered_sensor() {
// A double tap is the one wake a pocket can produce by itself. The
// power button is intent and is never refused, whatever the sensors
// say, locked or not.
let mut sm = DeviceStateMachine::new();
sm.sensor_evidence.proximity_near = true;
sm.sensor_evidence.accel_moving = true;
for state in [DeviceState::Active, DeviceState::Locked] {
sm.transition(state);
assert!(sm.suppress_wake(InputTrigger::DoubleTapToWake));
assert!(!sm.suppress_wake(InputTrigger::PowerButton));
assert!(!sm.suppress_wake(InputTrigger::Touch));
}
}
#[test]
fn proximity_triggers_observed() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
// Held, not blipped — near is debounced now, so a reading that does not
// last is a reading the machine never believed.
hold_prox_near(&mut sm, Instant::now());
assert_eq!(sm.state(), DeviceState::Observed);
}
#[test]
fn proximity_far_returns_to_locked() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
// Enter observed
let settled = hold_prox_near(&mut sm, Instant::now());
assert_eq!(sm.state(), DeviceState::Observed);
// Leave observed. Past OBSERVED_MIN_DWELL, because a `far` inside the
// dwell is now deliberately ignored — see that constant. The property
// this test is named for is unchanged: far ends Observed.
let after = settled + OBSERVED_MIN_DWELL + Duration::from_millis(1);
sm.update_sensors_from_at(
SensorSource::Proximity,
SensorEvidence {
proximity_near: false,
..Default::default()
},
after,
);
assert_eq!(sm.state(), DeviceState::Locked);
}
#[test]
fn is_locked_covers_all_locked_states() {
assert!(!is_locked(DeviceState::Active));
assert!(!is_locked(DeviceState::Dimmed));
assert!(is_locked(DeviceState::Locked));
assert!(is_locked(DeviceState::Observed));
assert!(is_locked(DeviceState::DozeLight));
assert!(is_locked(DeviceState::DozeDeep));
assert!(is_locked(DeviceState::Suspending));
assert!(is_locked(DeviceState::Asleep));
}
#[test]
fn display_active_only_active_and_dimmed() {
assert!(is_display_active(DeviceState::Active));
assert!(is_display_active(DeviceState::Dimmed));
assert!(!is_display_active(DeviceState::Locked));
assert!(!is_display_active(DeviceState::Asleep));
}
#[test]
fn full_lifecycle_with_forensic_log() {
// Exercise the entire state machine: a phone's day in 30 seconds.
let mut sm = DeviceStateMachine::new();
// 1. User picks up the phone — Active
assert_eq!(sm.state(), DeviceState::Active);
sm.record_wake(WakeTrigger::UserInput, "user picked up phone");
// 2. User stops touching — screen dims after 120s
sm.transition(DeviceState::Dimmed);
sm.record_decision(
"dim screen",
serde_json::json!({"idle_seconds": 120, "inhibitor": false}),
"native IdleMonitor fired, no inhibitor held",
);
// 3. User still idle — lock after 300s
sm.transition(DeviceState::Locked);
sm.record_decision(
"lock session",
serde_json::json!({"idle_seconds": 300, "inhibitor": false}),
"native IdleMonitor fired, requesting lock",
);
// 4. Phone goes into pocket — proximity near, held. A pocket is not a
// one-second blip; the debounce is what tells them apart.
hold_prox_near(&mut sm, Instant::now());
sm.update_sensors(SensorEvidence {
proximity_near: true,
accel_moving: false,
light_changing: false,
touch_active: false,
});
assert_eq!(sm.state(), DeviceState::Observed);
sm.record_decision(
"suppress DPMS wake",
serde_json::json!({"confidence": 0.4, "proximity": "near"}),
"proximity near, confidence 0.4 >= 0.3 threshold",
);
// 5. Phone stays in pocket — promote to DozeLight faster
sm.transition(DeviceState::DozeLight);
sm.record_decision(
"freeze app tier",
serde_json::json!({"idle_minutes": 5, "promoted_faster": true}),
"observed state accelerated promotion, freezing apps.slice",
);
// 6. Phone still idle — promote to DozeDeep
sm.transition(DeviceState::DozeDeep);
sm.record_decision(
"stop network fetchers",
serde_json::json!({"idle_minutes": 15}),
"deep doze, only RTC + modem IRQs active",
);
// 7. User presses power button — wake to lock screen
sm.transition(DeviceState::Locked);
sm.record_wake(WakeTrigger::PowerButton, "user pressed power button");
// 8. Phone goes back in pocket. Held — "briefly" is precisely what the
// debounce now refuses to believe, which is the point of it.
let pocketed = hold_prox_near(&mut sm, Instant::now());
assert_eq!(sm.state(), DeviceState::Observed);
// 9. Phone comes back out — after OBSERVED_MIN_DWELL, since a pocket
// that lasted less than that is a blip rather than a pocket.
sm.update_sensors_from_at(
SensorSource::Proximity,
SensorEvidence {
proximity_near: false,
..Default::default()
},
pocketed + OBSERVED_MIN_DWELL + Duration::from_millis(1),
);
assert_eq!(sm.state(), DeviceState::Locked);
// 10. System suspends
sm.transition(DeviceState::Suspending);
sm.transition(DeviceState::Asleep);
// 11. RTC alarm wakes the phone
sm.transition(DeviceState::Locked);
sm.record_wake(WakeTrigger::RtcAlarm, "RTC alarm for notification check");
// 12. User unlocks with PAM
sm.transition(DeviceState::Active);
// Verify the full lifecycle completed
assert_eq!(sm.state(), DeviceState::Active);
// Dump the forensic log
let entries = sm.forensic.recent(100);
println!("\n=== FORENSIC LOG ({} entries) ===", entries.len());
for entry in &entries {
let event_name = match &entry.event {
ForensicEvent::Transition { from, to, legal } => {
format!("transition {:?}{:?} (legal={})", from, to, legal)
}
ForensicEvent::SensorInput {
source, confidence, ..
} => {
format!("sensor {:?} conf={:.2}", source, confidence)
}
ForensicEvent::Wake { trigger } => {
format!("wake {:?}", trigger)
}
ForensicEvent::Error {
component, action, ..
} => {
format!("error [{}] {}", component, action)
}
ForensicEvent::Decision { decision, .. } => {
format!("decision: {}", decision)
}
ForensicEvent::Heartbeat => "heartbeat".to_string(),
};
println!(
" seq={:3} ts={} {:30} state={:12} locked={} conf={:.2} reason={}",
entry.seq,
entry.ts,
event_name,
format!("{:?}", entry.snapshot.device_state),
entry.snapshot.locked,
entry.snapshot.confidence,
entry.reason,
);
}
println!("=== END FORENSIC LOG ===\n");
// Verify we captured the key events
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()
);
// 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()
);
// 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()
);
}
#[test]
fn illegal_transition_is_forensically_logged() {
let mut sm = DeviceStateMachine::new();
// 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"
);
match &refused[0].event {
ForensicEvent::Transition { from, to, legal } => {
assert_eq!(*from, DeviceState::Active);
assert_eq!(*to, DeviceState::DozeLight);
assert!(!legal);
}
_ => unreachable!(),
}
}
#[test]
fn cross_sensor_disagreement_logged() {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked);
// Proximity near + accel moving = walking with phone. Near is
// debounced, so it has to be held before the pair is on the record.
let t0 = Instant::now();
hold_prox_near(&mut sm, t0);
sm.update_sensors_from_at(
SensorSource::Accelerometer,
SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: false,
touch_active: false,
},
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(2),
);
let entries = sm.forensic.recent(10);
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[sensor_entries.len() - 1].event {
ForensicEvent::SensorInput { 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 an_expected_source_that_never_reports_is_absent_and_loud() {
// The regression this exists for: souveraine-sensord was `enabled` but
// never started (its unit hung off a target nothing activates), so
// proximity/light/accel had no stamp at all. `Down` starts from a
// stamp, so it could not see this, and the machine ran a whole session
// on no evidence with nothing in the trail.
let mut sm = DeviceStateMachine::new();
let t0 = sm.started_at;
// Inside the grace window: a reporter is allowed to arrive late.
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN - Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Unknown);
assert!(!sm.source_health.any_down());
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
assert_eq!(sm.source_health.light, SourceHealth::Absent);
assert_eq!(sm.source_health.accel, SourceHealth::Absent);
assert!(
sm.source_health.any_down(),
"absent evidence is degraded evidence"
);
// Touch has no reporter on this device, so it must stay silent — a
// permanent false alarm is the same defect in the other direction.
assert_eq!(sm.source_health.touch, SourceHealth::Unknown);
let errors: Vec<_> = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
.collect();
assert_eq!(errors.len(), 3, "one entry per expected source, once");
}
#[test]
fn an_absent_source_is_recorded_once_not_every_tick() {
let mut sm = DeviceStateMachine::new();
let t0 = sm.started_at;
for i in 0..10 {
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1 + i));
}
let errors = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
.count();
assert_eq!(errors, 3, "three expected sources, one entry each");
}
#[test]
fn a_late_reporter_clears_absent_and_bounds_the_gap() {
let mut sm = DeviceStateMachine::new();
let t0 = sm.started_at;
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
// The reporter finally starts. Absent must close like Down does, or
// the trail says when evidence went missing and never when it returned.
sm.mark_evidence_seen_at(
SensorSource::Proximity,
t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5),
);
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
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 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 t0 = Instant::now();
let settled = hold_prox_near(&mut sm, t0);
assert_eq!(sm.state, DeviceState::Active);
assert!(sm.sensor_evidence.proximity_near, "near is believed by now");
sm.transition(DeviceState::Locked);
report_prox(&mut sm, true, settled + Duration::from_secs(1));
assert_eq!(
sm.state,
DeviceState::Observed,
"an unchanged reading must still be re-evaluated against the new state"
);
}
#[test]
fn a_policy_round_trips_through_json() {
// The bug this closes: SetPolicy mutated memory and nothing wrote it
// down, so every timer set in Settings reverted on the next restart.
let mut p = DeviceStatePolicy::default();
p.lock_blank_after = Some(Duration::from_secs(300));
p.dim_warning = false;
let raw = serde_json::to_string(&p).expect("policy serializes");
let back: DeviceStatePolicy = serde_json::from_str(&raw).expect("policy parses");
assert_eq!(back.lock_blank_after, Some(Duration::from_secs(300)));
assert!(!back.dim_warning);
}
#[test]
fn a_policy_file_missing_new_fields_still_loads() {
// An older sessiond's file must not make the device fall back to
// built-in timers on upgrade. `serde(default)` at the container level
// is what guarantees it; this is the test that keeps it there.
let raw = r#"{"lock_blank_after":{"secs":300,"nanos":0},"dim_warning":true}"#;
let p: DeviceStatePolicy = serde_json::from_str(raw).expect("partial policy parses");
assert_eq!(p.lock_blank_after, Some(Duration::from_secs(300)));
// Filled from Default, not left at zero.
assert_eq!(p.lock_ack_budget, LOCK_ACK_BUDGET);
assert_eq!(p.source_down_after, SOURCE_DOWN_AFTER);
assert_eq!(p.evidence_ttl, EVIDENCE_TTL);
}
// ── The trail itself: durable, bounded, chained ───────────────────
fn a_snapshot() -> StateSnapshot {
DeviceStateMachine::new().snapshot("test", false, false, false, "", false)
}
fn write_n(log: &ForensicLog, n: usize) {
let snap = a_snapshot();
for i in 0..n {
log.append(
ForensicEvent::Heartbeat,
snap.clone(),
&format!("entry {i}"),
);
}
}
/// Recompute a line's hash the way an external verifier must: strip the
/// trailing `hash` key, close the object, SHA-256 what is left.
fn rehash(line: &str) -> String {
let cut = line.rfind(",\"hash\":").expect("the hash is the last key");
sha256_hex(&format!("{}}}", &line[..cut]))
}
fn chain_of(path: &std::path::Path) -> Vec<(u64, String, String)> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|line| {
let v: serde_json::Value = serde_json::from_str(line).expect("a parseable entry");
let hash = v["hash"].as_str().expect("every entry carries its hash");
assert_eq!(rehash(line), hash, "the hash must cover the entry body");
(
v["seq"].as_u64().expect("seq"),
v["prev"].as_str().unwrap_or_default().to_string(),
hash.to_string(),
)
})
.collect()
}
#[test]
fn every_entry_chains_to_the_one_before_it() {
// §5 called the trail tamper-evident while it carried only a seq,
// which detects a deleted line and nothing else. This is the claim.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forensic.jsonl");
write_n(&ForensicLog::with_path(path.clone()), 4);
let chain = chain_of(&path);
assert_eq!(chain.len(), 4);
assert_eq!(chain[0].1, "", "the head of a new chain has no predecessor");
for pair in chain.windows(2) {
assert_eq!(pair[1].0, pair[0].0 + 1, "seq is contiguous");
assert_eq!(pair[1].1, pair[0].2, "prev is the previous entry's hash");
}
}
#[test]
fn the_trail_survives_a_reopen_and_keeps_one_chain() {
// The whole point of leaving tmpfs: a reboot must not erase what the
// machine decided, and the chain must not restart at zero either.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forensic.jsonl");
write_n(&ForensicLog::with_path(path.clone()), 3);
write_n(&ForensicLog::with_path(path.clone()), 3);
let chain = chain_of(&path);
assert_eq!(chain.len(), 6, "the earlier run is still there");
assert_eq!(chain[3].0, 3, "seq continues across the restart");
assert_eq!(
chain[3].1, chain[2].2,
"the new run chains onto the old one rather than starting over"
);
}
#[test]
fn the_trail_rotates_and_the_set_stays_bounded() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forensic.jsonl");
// Small enough that a handful of entries fills a generation.
write_n(&ForensicLog::with_path_bounded(path.clone(), 2048), 400);
let live = std::fs::metadata(&path).expect("a live file").len();
assert!(live <= 2048, "the live file is bounded: {live}");
let mut total = live;
for n in 1..=FORENSIC_KEEP {
let g = path.with_extension(format!("jsonl.{n}"));
total += std::fs::metadata(&g).map(|m| m.len()).unwrap_or(0);
}
assert!(
total <= 2048 * (FORENSIC_KEEP as u64 + 1),
"the whole set is bounded: {total}"
);
// And nothing beyond the kept generations survives.
assert!(
!path
.with_extension(format!("jsonl.{}", FORENSIC_KEEP + 1))
.exists(),
"generations past the keep count are deleted, not accumulated"
);
}
#[test]
fn the_chain_runs_across_a_rotation() {
// A rotated set must verify as one chain, or bounding the trail would
// have quietly cost the property that durability was for.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forensic.jsonl");
write_n(&ForensicLog::with_path_bounded(path.clone(), 2048), 12);
let rotated = chain_of(&path.with_extension("jsonl.1"));
let live = chain_of(&path);
assert!(
!rotated.is_empty() && !live.is_empty(),
"a rotation happened"
);
let last_rotated = rotated.last().expect("rotated entries");
assert_eq!(
live[0].1, last_rotated.2,
"the first live entry chains onto the last rotated one"
);
assert_eq!(live[0].0, last_rotated.0 + 1, "seq does not restart");
}
#[test]
fn a_torn_tail_is_discarded_and_the_chain_resumes() {
// Measured on hardware 2026-07-26: a hard reboot left the trail at
// exactly 4096 bytes — a page boundary — with the final entry cut in
// half. The first version treated that as tampering and rotated the
// whole file aside, so every hard reboot started a new chain at the
// one moment continuity is worth most.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forensic.jsonl");
write_n(&ForensicLog::with_path(path.clone()), 3);
// Tear the last write the way losing power does: a partial line, no
// newline, mid-object.
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.expect("open");
write!(f, "{{\"seq\":3,\"prev\":\"abc\",\"ts\":17850").expect("tear it");
}
write_n(&ForensicLog::with_path(path.clone()), 1);
assert!(
!path.with_extension("jsonl.1").exists(),
"a torn tail is not a rotation event"
);
let chain = chain_of(&path);
assert_eq!(chain.len(), 4, "three intact entries plus the new one");
assert_eq!(chain[3].0, 3, "seq continues rather than restarting");
assert_eq!(
chain[3].1, chain[2].2,
"the entry after the tear chains onto the last intact one"
);
}
#[test]
fn a_file_with_nothing_parseable_is_still_rotated_aside() {
// Appending onto a line we cannot parse would produce a chain that
// fails verification forever after, which reads as tampering. The
// damaged file is evidence, so it is kept, not deleted.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forensic.jsonl");
std::fs::write(&path, "this is not JSON at all\nnor is this\n").expect("write junk");
write_n(&ForensicLog::with_path(path.clone()), 1);
let kept = path.with_extension("jsonl.1");
assert!(kept.exists(), "the unusable trail is kept as evidence");
assert_eq!(
std::fs::read_to_string(&kept).unwrap().lines().count(),
2,
"kept whole, not truncated"
);
let fresh = chain_of(&path);
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].0, 0, "the new chain starts clean");
assert_eq!(
fresh[0].1, "",
"and does not claim a predecessor it cannot verify"
);
}
#[test]
fn an_intent_stamps_every_entry_it_covers_and_no_others() {
// The point of the field: a chain is one decision and several verbs,
// and a trail that records only the leaves cannot tell a considered
// sequence from four accidents.
let log = ForensicLog::new();
let snap = a_snapshot();
log.append(ForensicEvent::Heartbeat, snap.clone(), "before");
log.set_intent(Some("quiet the room".into()));
log.append(ForensicEvent::Heartbeat, snap.clone(), "during");
log.append(ForensicEvent::Heartbeat, snap.clone(), "still during");
log.set_intent(None);
log.append(ForensicEvent::Heartbeat, snap, "after");
let got: Vec<_> = log
.recent(10)
.into_iter()
.map(|e| (e.reason, e.intent))
.collect();
assert_eq!(got[0].1, None, "entries before the intent are untouched");
assert_eq!(got[1].1.as_deref(), Some("quiet the room"));
assert_eq!(got[2].1.as_deref(), Some("quiet the room"));
assert_eq!(
got[3].1, None,
"an intent that outlived its request would mislabel whatever came next"
);
}
#[test]
fn an_entry_without_an_intent_serializes_exactly_as_it_did_before() {
// `skip_serializing_if` is what keeps the hash contract from moving:
// an entry with no intent must not gain an `"intent":null`, or every
// line written before today would fail verification against the code
// that wrote it.
let entry = ForensicEntry {
seq: 0,
prev: String::new(),
ts: 0,
event: ForensicEvent::Heartbeat,
snapshot: a_snapshot(),
reason: "x".into(),
intent: None,
};
let json = serde_json::to_string(&entry).expect("serializes");
assert!(!json.contains("intent"), "absent must mean absent: {json}");
}
#[test]
fn a_trail_with_nowhere_to_write_still_answers_ipc() {
// Memory-only is the honest answer when there is no home; it must not
// take the in-memory buffer down with it, because the IPC query is how
// the shell reads the trail.
let log = ForensicLog::new();
assert_eq!(log.chain_state(), "memory-only");
write_n(&log, 3);
assert_eq!(log.recent(10).len(), 3);
}
// ---- bearer: the anti-flap rule is the whole point ----
fn machine_with_bearer(wifi: LinkHealth, cellular: LinkHealth) -> DeviceStateMachine {
let mut m = DeviceStateMachine::new();
m.note_bearer(BearerEvidence {
wifi,
cellular,
..Default::default()
});
m
}
#[test]
fn a_bearer_change_waits_for_the_settling_window() {
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Associated);
let t0 = Instant::now();
// First sight of a preference is not enough. This is the property the
// 652-recycle gate did not have: it acted on every event it saw.
let a = m.tick_at(t0);
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"acted before the window: {a:?}"
);
let early = t0 + BEARER_SETTLE - Duration::from_secs(1);
let a = m.tick_at(early);
assert!(!a.iter().any(|x| matches!(x, Action::PreferLink(_))));
let late = t0 + BEARER_SETTLE + Duration::from_secs(1);
let a = m.tick_at(late);
assert!(
a.contains(&Action::PreferLink(Bearer::Wifi)),
"should have settled on wifi: {a:?}"
);
}
#[test]
fn a_preference_that_reverses_inside_the_window_never_acts() {
// The flap case, stated exactly: wifi appears, then goes away again
// before it settled. No command may be produced by that at all.
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Carrying);
let t0 = Instant::now();
let _ = m.tick_at(t0);
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Down,
cellular: LinkHealth::Carrying,
..Default::default()
});
let mid = t0 + BEARER_SETTLE / 2;
assert!(!m
.tick_at(mid)
.iter()
.any(|x| matches!(x, Action::PreferLink(_))));
// Back to wifi before either candidate matured.
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Carrying,
cellular: LinkHealth::Carrying,
..Default::default()
});
let later = t0 + BEARER_SETTLE + Duration::from_secs(1);
let a = m.tick_at(later);
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"the clock must restart when the candidate changes, not accumulate: {a:?}"
);
}
#[test]
fn a_settled_preference_is_not_re_emitted_every_tick() {
// Idempotence at the decision, not at the executor. A command per tick
// would be the same disease as a command per event.
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Absent);
let t0 = Instant::now();
let _ = m.tick_at(t0);
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
assert!(m
.tick_at(settled)
.contains(&Action::PreferLink(Bearer::Wifi)));
for i in 1..5 {
let a = m.tick_at(settled + Duration::from_secs(i));
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"re-emitted on a steady state at +{i}s: {a:?}"
);
}
}
#[test]
fn no_usable_link_produces_no_action() {
let mut m = machine_with_bearer(LinkHealth::Down, LinkHealth::Absent);
let t0 = Instant::now();
let _ = m.tick_at(t0);
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"flailed at a dead network: {a:?}"
);
}
#[test]
fn the_tunnel_is_pinned_with_the_link_but_never_switched_on() {
let mut m = DeviceStateMachine::new();
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Carrying,
cellular: LinkHealth::Associated,
tunnel: TunnelHealth::Handshaking,
..Default::default()
});
let t0 = Instant::now();
let _ = m.tick_at(t0);
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
assert!(a.contains(&Action::PinTunnelUnderlay(Bearer::Wifi)));
// Tunnel off: the preference still lands, the pin does not. Deciding
// to bring a tunnel up is the user's call, not this machine's.
let mut m = DeviceStateMachine::new();
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Carrying,
tunnel: TunnelHealth::Off,
..Default::default()
});
let _ = m.tick_at(t0);
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
assert!(a.contains(&Action::PreferLink(Bearer::Wifi)));
assert!(
!a.iter().any(|x| matches!(x, Action::PinTunnelUnderlay(_))),
"pinned an underlay for a tunnel the user switched off: {a:?}"
);
}
}