//! 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::protocol::{InputTrigger, SensorSource, SensorValue};

/// 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 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,
}

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,
        }
    }
}

/// 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,
    /// 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,
}

/// 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,
    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,
}

/// 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()
    }
}

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.
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>,
    /// 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,
    /// 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,
}

/// 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.0–1.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,
            evidence_seen: EvidenceSeen::default(),
            source_health: SourceHealthTable::default(),
            started_at: Instant::now(),
            blank_requested: false,
            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(),
        };

        // 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.
    pub fn tick_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 = Vec::new();

        // 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.
    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. 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::Touch | InputTrigger::Key => WakeTrigger::UserInput,
            InputTrigger::Unknown => WakeTrigger::Unknown,
        };
        self.record_wake(wake, &format!("input: {:?}", trigger));
        actions
    }

    /// 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.
    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(),
        }
    }

    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.
    pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
        matches!(trigger, InputTrigger::DoubleTapToWake) && self.sensor_evidence.proximity_near
    }

    /// 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(),
            };
            self.forensic.append(
                ForensicEvent::Transition {
                    from: prev,
                    to: next,
                    legal: false,
                },
                snapshot,
                &format!("illegal transition {:?} → {:?} refused", prev, next),
            );
            return false;
        }
        self.state = next;
        // 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(),
        };
        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 {
                self.transition(DeviceState::Observed);
            } else if matches!(self.state, DeviceState::Observed) && !believed {
                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(),
            };
            self.forensic.append(
                ForensicEvent::SensorInput {
                    // The reporting source, not a hardcoded Proximity. Accel,
                    // light and touch readings were all being written to the trail
                    // labelled as proximity, which makes post-hoc reconstruction —
                    // the entire point of the trail — lie about what was measured.
                    source,
                    value: match source {
                        SensorSource::Proximity => SensorValue::Near(proximity_near),
                        SensorSource::Accelerometer => {
                            SensorValue::Moving(self.sensor_evidence.accel_moving)
                        }
                        SensorSource::Light => {
                            SensorValue::Changing(self.sensor_evidence.light_changing)
                        }
                        SensorSource::Touch => {
                            SensorValue::Active(self.sensor_evidence.touch_active)
                        }
                    },
                    confidence,
                },
                snapshot,
                &format!(
                    "proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
                    proximity_near, confidence, should_be_observed, was_observed
                ),
            );
        }

        if should_be_observed && !was_observed {
            self.transition(DeviceState::Observed);
        } else if was_observed && !proximity_near {
            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(),
        };
        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(),
        };
        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(),
        };
        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::*;

    #[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 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 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_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);
        let _ = settled;

        // Leave observed
        sm.update_sensors(SensorEvidence {
            proximity_near: false,
            ..Default::default()
        });
        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.
        hold_prox_near(&mut sm, Instant::now());
        assert_eq!(sm.state(), DeviceState::Observed);

        // 9. Phone comes back out
        sm.update_sensors(SensorEvidence {
            proximity_near: false,
            ..Default::default()
        });
        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);
    }
}
