feat(core): add workspace skeleton and the pure decision engine

Establishes the cargo workspace and the shared `blocker-core` crate. The
engine is deliberately written before any platform code, because CLAUDE.md
section 2 makes it the single source of decision truth and M1 acceptance
requires it to be pure.

**Workspace** — five crates plus a test crate, zero external dependencies.
Section 3 requires every crate to justify its binary-size cost, so M0 adds
none. [profile.release] is workspace-only (cargo rejects it in members) and
carries the section 5 settings that make the 5 MB stripped budget reachable.

**Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no
disk and no OS API; everything arrives in the input. `tests/purity.rs`
enforces this mechanically by asserting blocker-core has zero dependencies, so
a stray I/O reach-in fails the build rather than silently making the golden
tables useless as a parity contract.

Decisions worth calling out:

- **Monotonic time is a separate type from wall time.** Section 1.5.3 requires
  a system date change never to shorten a lock. Keeping them apart makes that
  structural rather than a rule to remember.
- **`start > end` means overnight.** No `crosses_midnight` flag: the comparison
  already encodes it and a second field is a second way to be wrong. The
  weekday for a window that started yesterday evening is yesterday's, which
  `overnight_window_survives_past_midnight` pins down.
- **`next_wakeup` is armed over every non-paused schedule, not only active
  ones.** Filtering to active conditions first would let the daemon sleep
  straight through the moment a schedule is due to start.
- **Allowlist needs the installed-app inventory.** `EngineInput` carries
  `installed_apps`; an empty slice fails open rather than guessing.
- **Wall-clock correction extends, never shortens, and clamps to the 24h cap.**
  Extending unbounded would satisfy anti-tamper and violate section 1.5.4.
- **`cast_possible_truncation` is denied workspace-wide.** Truncating a
  millisecond counter would corrupt a lock timer.

Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings`
clean, stripped release daemon 0.279 MB against the 5 MB budget.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:02:20 +03:00
parent aa1c582f3c
commit c723efb186
25 changed files with 2794 additions and 31 deletions
+276
View File
@@ -0,0 +1,276 @@
//! 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<PendingRequest>,
/// Cooldown on the disable screen. Leaving the screen resets it.
pub cooldown_remaining: Option<Monotonic>,
/// 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<String>,
}
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<Monotonic>, 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<Monotonic>, 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<i64> {
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))
}