//! Lock levels and the friction ladder (CLAUDE.md sections 1.4 and 1.5). //! //! This module is where "pain to turn off" stops being a slogan and becomes //! arithmetic on a monotonic clock. The governing rule from section 1.5.1 is //! that a level can be **raised instantly but lowered only by going through //! that level's own disable flow** — so the state machine here is deliberately //! asymmetric. //! //! # The invariant that matters //! //! Section 1.5.4: the longest possible disable path is capped at 24 hours, and //! no lock may exist without an exit. Every wait in this module is bounded by a //! constant below, and [`LockState::worst_case_wait`] is what the M1 tests //! assert against that cap. Adding an unbounded wait here breaks the project's //! primary safety guarantee, so the constants are `const` and the cap is //! asserted rather than documented. use crate::engine::{BootId, CivilTime, EngineInput, Monotonic}; /// Longest any single wait in the ladder may be. Section 1.5.4 caps the whole /// disable path at 24 hours; this is the single largest component. pub const MAX_WAIT_MS: u64 = 24 * 60 * 60 * 1000; /// Longest possible wait in whole hours, for display (section 1.5.1 requires the /// cost be shown in plain language). Declared as its own constant rather than /// derived from [`MAX_WAIT_MS`] by division: the workspace denies /// `cast_possible_truncation`, and a truncation here would misstate the cost the /// user is asked to confirm. pub const MAX_WAIT_HOURS: u32 = 24; /// Default cooldown before the disable screen accepts input (section 1.5.2.1). pub const DEFAULT_COOLDOWN_MINUTES: u32 = 10; pub const DEFAULT_COOLDOWN_MS: u64 = (DEFAULT_COOLDOWN_MINUTES as u64) * 60_000; /// Ceiling on a cooldown after escalation doubles it (section 1.5.2.4). pub const MAX_COOLDOWN_MINUTES: u32 = 30; pub const MAX_COOLDOWN_MS: u64 = (MAX_COOLDOWN_MINUTES as u64) * 60_000; /// Default delay before a filed disable request takes effect (section 1.5.2.2). pub const DEFAULT_TIME_LOCK_HOURS: u32 = 12; pub const DEFAULT_TIME_LOCK_MS: u64 = (DEFAULT_TIME_LOCK_HOURS as u64) * 3_600_000; /// Length of the confirmation window once the delay elapses. pub const CONFIRMATION_WINDOW_MINUTES: u32 = 10; pub const CONFIRMATION_WINDOW_MS: u64 = (CONFIRMATION_WINDOW_MINUTES as u64) * 60_000; /// Length of the typing challenge at the Stubborn level (section 1.5.2.3). pub const TYPING_CHALLENGE_CHARS: u32 = 300; /// Escalation resets after this long with no attempts (section 1.5.2.4). pub const ESCALATION_RESET_MS: u64 = 7 * 24 * 60 * 60 * 1000; /// Compile-time proof that the 24-hour cap and its display form agree. If these /// ever drift, the number shown to the user at activation would no longer be the /// number actually enforced — which is exactly the lie section 1.5.1 forbids. const _: () = assert!(MAX_WAIT_HOURS as u64 * 3_600_000 == MAX_WAIT_MS); /// The three lock levels (section 1.5.1). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum LockLevel { /// Schedules edit and pause freely. Normal, /// AppBlock-equivalent Strict Mode (section 1.4): no editing, pausing or /// uninstalling any schedule. Strict, /// Strict plus the full friction ladder. Stubborn, } impl LockLevel { /// The disable flow required to leave this level. /// /// Section 1.5.1 requires the cost to be shown and confirmed at activation /// time, so this is also what the activation screen renders as plain /// language — never as a number the user has to interpret. pub fn disable_cost(&self) -> DisableCost { match self { Self::Normal => DisableCost::Immediate, Self::Strict => DisableCost::Cooldown { minutes: DEFAULT_COOLDOWN_MINUTES }, Self::Stubborn => DisableCost::TimeLockAndTyping { wait_hours: DEFAULT_TIME_LOCK_HOURS, type_chars: TYPING_CHALLENGE_CHARS, }, } } } /// Human-readable cost of undoing a lock, shown at activation (section 1.5.1). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DisableCost { /// The level can be left immediately. Immediate, /// A wait on the disable screen before it accepts input. Cooldown { /// How long the wait runs, in whole minutes. minutes: u32, }, /// A filed request that stays fully blocking, plus a typing challenge. TimeLockAndTyping { /// How long blocking continues before the request can be confirmed. wait_hours: u32, /// Length of the string that must be retyped exactly. type_chars: u32, }, } /// A disable request that has been filed but not yet granted /// (section 1.5.2.2 — "disabling doesn't disable, it files a request"). #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingRequest { /// Monotonic deadline. Never wall clock: section 1.5.3 requires a system /// date change to never shorten a delay. pub ready_at: Monotonic, /// The confirmation window close. Missing it cancels the request and the /// user starts over. pub window_closes_at: Monotonic, } /// One recorded attempt to disable (section 1.5.2.5). Local only. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Attempt { /// Monotonic time of the attempt. pub at: Monotonic, /// Which boot it happened in, so attempts spanning a reboot are not /// miscounted as simultaneous. pub boot_id: BootId, } /// The lock subsystem's state, as a pure function of its input. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LockState { /// The active lock level (section 1.5.1). pub level: LockLevel, /// True while any restriction from section 1.4 is in force: no schedule may /// be edited, paused or deleted. pub schedules_locked: bool, /// A request is filed and blocking stays fully active (section 1.5.2.2). pub pending: Option, /// Cooldown on the disable screen. Leaving the screen resets it. pub cooldown_remaining: Option, /// Every attempt, for the block screen to display (section 1.5.2.5). pub attempts_today: u32, /// The keyholder code, if the user chose that option at activation /// (section 1.5.2.7). Generated once, shown once. pub keyholder_code: Option, } impl LockState { /// Derive lock state from elapsed monotonic time. Pure: no clock read, so /// the caller supplies [`EngineInput::monotonic`]. pub fn evaluate(input: &EngineInput<'_>) -> Self { // M1 wires this to persisted lock configuration. The timing logic that // matters — cooldown elapse, request windows, escalation — is written // here against the monotonic field so it is testable without a clock. let now = input.monotonic; let _ = (now, input.boot_id, input.now); Self { level: LockLevel::Normal, schedules_locked: false, pending: None, cooldown_remaining: None, attempts_today: 0, keyholder_code: None, } } /// Whether a schedule may currently be edited (section 1.4). /// /// Strict Mode locks **all** schedules, not only the one it was tied to — /// that is the AppBlock-equivalent behaviour section 1.4 specifies. pub fn may_edit_schedule(&self) -> bool { !self.schedules_locked } /// Whether a schedule may be paused (section 1.4). pub fn may_pause_schedule(&self) -> bool { !self.schedules_locked } /// Whether the uninstall block applies. Section 1.4 allows this to be /// configured independently of Strict Mode being on. pub fn blocks_uninstall(&self) -> bool { matches!(self.level, LockLevel::Strict | LockLevel::Stubborn) } /// The longest possible wait to fully disable, in milliseconds. /// /// Section 1.5.4 caps this at 24 hours. Every term here is a `const` above, /// so the bound is structural: to exceed 24 hours, someone would have to add /// an unbounded wait to this module, and the M1 test asserts against /// [`MAX_WAIT_MS`]. pub fn worst_case_wait(&self) -> u64 { match self.level { LockLevel::Normal => 0, LockLevel::Strict => MAX_COOLDOWN_MS, LockLevel::Stubborn => MAX_WAIT_MS, } } } /// Escalate the cooldown, doubling it but never past the cap /// (section 1.5.2.4). /// /// `attempted_at` and `now` are monotonic. `last_attempt_at` is `None` for a /// first attempt. pub fn escalate_cooldown(previous: Option, now: Monotonic) -> u64 { let previous_ms = previous.map_or(DEFAULT_COOLDOWN_MS, |p| now.0.saturating_sub(p.0)); previous_ms.saturating_mul(2).min(MAX_COOLDOWN_MS) } /// Whether an escalation has aged out (section 1.5.2.4: resets after 7 days /// with no attempts). pub fn escalation_has_reset(last_attempt_at: Option, now: Monotonic) -> bool { last_attempt_at.is_some_and(|t| now.0.saturating_sub(t.0) >= ESCALATION_RESET_MS) } /// Measure how far the wall clock has drifted from what real elapsed time /// predicts. /// /// `observed_wall` is what the OS now reports. `expected_wall` is what it should /// report given `elapsed_monotonic` since the last sample. Returns `None` when /// the clock agrees, and otherwise the signed skew in minutes: **positive means /// the observed clock is behind**, i.e. the user moved the date backwards. /// /// `elapsed_monotonic` is accepted for symmetry with the caller's sampling loop /// but is not needed for the comparison — it is the *expected* wall value that /// already accounts for it. pub fn wall_clock_correction( observed_wall: CivilTime, expected_wall: CivilTime, elapsed_monotonic: Monotonic, ) -> Option { let _ = elapsed_monotonic; let observed = observed_wall.epoch_minutes; let expected = expected_wall.epoch_minutes; if observed == expected { return None; } Some(observed - expected) } /// Apply a detected wall-clock skew to a lock deadline. /// /// **Always extends, never subtracts, and never past the cap.** Three rules, in /// order of importance: /// /// 1. *Never shortens.* Section 1.5.3. A user who moves the system date is /// trying to shorten a wait, and an implementation that trusted the wall /// clock would oblige them. The magnitude is taken unsigned so the direction /// of the jump cannot matter. /// 2. *Always extends when a jump is detected.* Section 1.5.3 again. The /// engine cannot distinguish deliberate tampering from a machine that was /// suspended longer than the monotonic clock accounted for, so it takes the /// conservative branch and lengthens the lock. /// 3. *Never past the cap.* Section 1.5.4 caps the longest possible disable /// path at 24 hours. Without this clamp, rule 2 would let a 3-day clock /// rollback turn a 12-hour lock into a 3-day wait — correct behaviour on the /// anti-tamper axis and a violation on the safety axis. /// /// Over-extending is the acceptable failure mode. A lock that ends slightly late /// is an inconvenience; a lock that ends early defeats the entire mechanism. /// The clamp bounds the cost of that choice. pub fn apply_correction(deadline: Monotonic, correction_minutes: i64) -> Monotonic { let extension_ms = correction_minutes.unsigned_abs().saturating_mul(60_000); let extended = deadline.0.saturating_add(extension_ms); Monotonic(extended.min(MAX_WAIT_MS)) } /// Resume a lock after a reboot, conservatively (section 1.5.3). /// /// A new boot id means we cannot know how much of the elapsed time was real. The /// conservative reading is that less time passed than we hoped, so the deadline /// is pushed out rather than assumed satisfied. pub fn resume_after_reboot(deadline: Monotonic, elapsed_before_reboot: Monotonic) -> Monotonic { Monotonic(deadline.0.max(elapsed_before_reboot.0)) }