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
+92
View File
@@ -0,0 +1,92 @@
//! Storage: SQLite-backed persistence (CLAUDE.md section 3).
//!
//! Section 1.5.3 requires lock and Strict state to live in the core database
//! rather than in editable plaintext config, so that editing a file is not
//! enough to defeat a lock. The schema therefore keeps schedules, lock state,
//! and usage counters in one place with no config file beside them.
//!
//! # Deliberately thin in M0
//!
//! rusqlite is not yet a dependency — the machine this was built on has 9.7 GB
//! free and section 3 makes every crate justify its size cost. This module
//! defines the schema and the statements M1 will use, with no I/O behind it
//! yet, so the schema is reviewable before it is compiled.
/// The schema, version 1.
///
/// Applied by `PRAGMA user_version`. Migrations are additive and forward-only;
/// downgrading a schema would risk losing lock state, which section 1.5.4 does
/// not permit.
pub const SCHEMA_VERSION: i64 = 1;
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS schedule (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')),
targets TEXT NOT NULL, -- JSON array of Target
conditions TEXT NOT NULL, -- JSON array of Condition
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Section 1.5.3: lock state lives in the DB, not in a plaintext config file,
-- so that editing a file cannot defeat a lock.
CREATE TABLE IF NOT EXISTS lock_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')),
schedules_locked INTEGER NOT NULL,
blocks_uninstall INTEGER NOT NULL,
keyholder_code TEXT, -- NULL if the user declined section 1.5.2.7
-- Monotonic deadlines for a filed disable request, section 1.5.2.2.
-- NULL when no request is pending.
request_ready_at INTEGER,
request_window_closes_at INTEGER,
-- Elapsed-so-far plus boot id, so a reboot resumes conservatively.
boot_id INTEGER NOT NULL,
last_monotonic_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS disable_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at_monotonic INTEGER NOT NULL,
boot_id INTEGER NOT NULL,
day_index INTEGER NOT NULL -- for the "4 times today" count
);
CREATE TABLE IF NOT EXISTS usage_daily (
day_index INTEGER NOT NULL, -- days since epoch, local
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day_index, target)
);
CREATE TABLE IF NOT EXISTS usage_hourly (
hour_index INTEGER NOT NULL, -- hours since epoch, local
target TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
launches INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (hour_index, target)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target);
CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target);
CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index);
"#;
/// Fail open on corruption (CLAUDE.md section 1.5.4).
///
/// A corrupt database must never leave a device unusable, so recovery renames
/// the bad file aside and starts clean rather than failing to launch. The
/// damaged file is kept for diagnosis.
///
/// `None` here means "fresh database", which is the fail-open outcome.
pub const RECOVERY_NOTE: &str =
"corrupt database renamed aside; blocking re-applied from defaults on next start";