diff --git a/Cargo.lock b/Cargo.lock
index d302ddb..fbd37c3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
[[package]]
name = "blocker-core"
version = "0.1.0"
@@ -11,6 +17,7 @@ name = "blocker-daemon"
version = "0.1.0"
dependencies = [
"blocker-core",
+ "blocker-storage",
]
[[package]]
@@ -27,6 +34,14 @@ dependencies = [
"blocker-core",
]
+[[package]]
+name = "blocker-storage"
+version = "0.1.0"
+dependencies = [
+ "blocker-core",
+ "rusqlite",
+]
+
[[package]]
name = "blocker-tests"
version = "0.1.0"
@@ -37,3 +52,104 @@ dependencies = [
[[package]]
name = "blocker-ui"
version = "0.1.0"
+
+[[package]]
+name = "cc"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "rusqlite"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
diff --git a/Cargo.toml b/Cargo.toml
index 5ca4810..34021f1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"core",
+ "storage",
"tests",
"desktop/daemon",
"desktop/ui",
diff --git a/README.md b/README.md
index 19341ac..35cc5c6 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ enforces anything yet. No platform has a working build.
| Milestone | State |
|---|---|
| M0 repo bootstrap | in progress |
-| M1 core engine | engine, lock timers and safety guarantees written and tested; storage has no I/O yet |
+| M1 core engine | engine, lock timers and safety guarantees written and tested; SQLite persistence landed in `storage/` |
| M2-Linux | not started — daemon is a placeholder |
| M3-Android | not started |
| M4 conditions | not started |
@@ -74,6 +74,7 @@ summary as not-passes. A skip is never reported as a pass.
```
core/ the decision engine — pure, no OS calls, no dependencies
+storage/ SQLite persistence (rusqlite, bundled), fail-open recovery
tests/ golden decision tables, and the engine purity check
desktop/daemon/ long-lived observer and enforcer (5 MB budget)
desktop/ui/ block-screen window, launched on demand
@@ -106,9 +107,10 @@ scripts/ ci.sh, size-check.sh
| Daemon binary | ≤ 5 MB stripped |
| APK size | ≤ 10 MB |
-Currently measured: **0.279 MB** stripped daemon — a placeholder that prints two
-lines. The meaningful number comes in M2-Linux. Enforced in CI from the first
-commit by `scripts/size-check.sh`.
+Currently measured: **1.364 MB** stripped daemon, which includes bundled SQLite.
+Of the 5 MB budget that leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real
+margin rather than a comfortable one. Enforced in CI from the first commit by
+`scripts/size-check.sh`.
## Licence
diff --git a/desktop/daemon/Cargo.toml b/desktop/daemon/Cargo.toml
index 3dac2a3..fad918a 100644
--- a/desktop/daemon/Cargo.toml
+++ b/desktop/daemon/Cargo.toml
@@ -16,4 +16,5 @@ path = "src/main.rs"
# section 3 requires every crate to justify its size cost. The runtime pieces
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
[dependencies]
-blocker-core = { path = "../../core" }
\ No newline at end of file
+blocker-core = { path = "../../core" }
+blocker-storage = { path = "../../storage" }
\ No newline at end of file
diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs
index 69dfdd0..0ffad93 100644
--- a/desktop/daemon/src/main.rs
+++ b/desktop/daemon/src/main.rs
@@ -2,16 +2,16 @@
//!
//! The daemon observes the OS and enforces the decisions `blocker-core` makes.
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
-//! daemon ever decides that something should be blocked, the Android and
-//! desktop builds will drift and the golden tables stop being a parity contract.
+//! daemon ever decides that something should be blocked, the Android and desktop
+//! builds will drift and the golden tables stop being a parity contract.
//!
//! # Shape
//!
//! Two processes, deliberately:
//!
-//! - the **daemon** — small, long-lived, stays resident, holds the block window
-//! machinery and the engine connection. This is the binary with the 5 MB
-//! stripped budget (section 5).
+//! - the **daemon** — small, long-lived, holds the block window machinery and
+//! the engine connection. This is the binary with the 5 MB stripped budget
+//! (section 5).
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block
//! screen, which exits when the block lifts.
//!
@@ -20,14 +20,74 @@
//!
//! # Event loop
//!
-//! M2-Linux replaces this placeholder with a loop that blocks on exactly three
-//! things: a focus-change event from the active
-//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the
+//! M2-Linux replaces the startup path below with a loop that blocks on exactly
+//! three things: a focus-change event from the active
+//! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
//! section 5 idle-CPU budget is met.
+//!
+//! # Where the database lives
+//!
+//! Under the XDG data directory, never beside the binary. The binary may be
+//! installed read-only in `/usr/bin`, while the database must be writable and
+//! must survive an upgrade.
-fn main() {
- println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
- println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
+fn database_path() -> PathBuf {
+ let base = std::env::var_os("XDG_DATA_HOME")
+ .map(PathBuf::from)
+ .filter(|p| p.is_absolute())
+ .unwrap_or_else(|| {
+ let home =
+ std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
+ home.join(".local/share")
+ });
+ base.join("blocker").join("blocker.db")
+}
+
+fn main() -> ExitCode {
+ let path = database_path();
+
+ // Section 1.5.4: a corrupt or unusable database must never leave the device
+ // unusable. `Store::open` handles corruption by moving the damaged file
+ // aside and starting clean, so what reaches the `Err` arm is genuinely fatal
+ // — an unwritable directory, say. Even then we exit with a message rather
+ // than panicking: a daemon that crashes on startup is worse than one that
+ // reports the problem and stops.
+ let store = match blocker_storage::Store::open(&path) {
+ Ok(store) => store,
+ Err(e) => {
+ eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display());
+ eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md");
+ return ExitCode::FAILURE;
+ }
+ };
+
+ if store.recovered_from_corruption() {
+ // Logged, not swallowed: section 1.5.4 wants the recovery recorded.
+ eprintln!(
+ "blocker-daemon: database was corrupt and has been moved to {}.corrupt; \
+ starting clean, so no schedules are active until you re-add them",
+ path.display()
+ );
+ }
+
+ let schedules = match store.load_schedules() {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("blocker-daemon: cannot read schedules: {e}");
+ return ExitCode::FAILURE;
+ }
+ };
+
+ println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
+ println!("database: {}", path.display());
+ println!("schedules: {}", schedules.len());
+ println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
+
+ ExitCode::SUCCESS
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 76c51f0..e843726 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -182,10 +182,17 @@ APK size cost is unacceptable, which is a measurement before it is a decision.
| `usage` | foreground-time and launch-count accumulation into daily and hourly buckets |
| `lock` | lock levels, the friction ladder's timing, monotonic anti-tamper |
| `never_block` | the section 1.5.4 safety list |
-| `storage` | the SQLite schema and its fail-open recovery policy |
+| `storage` | *(separate crate: `blocker-storage`)* the SQLite schema and its fail-open recovery policy |
-`storage` has no I/O behind it yet — see `LIMITATIONS.md`. The schema is written
-and reviewed as text so it can be reviewed before it is compiled.
+`blocker-storage` is a sibling crate, not a submodule. It depends on
+`blocker-core`; `blocker-core` does not depend on it, and depends on nothing at
+all. That direction is what lets `tests/purity.rs` keep checking "blocker-core has
+zero dependencies" — an automatic proof that the engine cannot do I/O — instead of
+the weaker and easily-routed-around alternative of grepping the source for
+`rusqlite`.
+
+Both crates occupy the same layer of the diagram above: storage is shared by every
+platform and contains no platform code.
## Where the platform crates start and stop
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 9cb2402..d9bbc9e 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -25,7 +25,7 @@ which parts to trust.
| [1](#adr-1) | Rust for core and desktop, Kotlin for the Android shell | Accepted |
| [2](#adr-2) | One shared core; the engine is a pure function | Accepted |
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
-| [4](#adr-4) | SQLite via rusqlite, bundled | Proposed |
+| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
@@ -108,24 +108,46 @@ it is supposed to start blocking.
---
-## ADR-4: SQLite via rusqlite, bundled
+## ADR-4: SQLite via rusqlite
-**Status:** Proposed — dependency not yet added
+**Status:** Accepted and implemented, with one trade-off still open
**Context.** Section 3 specifies `rusqlite` with bundled SQLite. Section 1.5.3
requires lock and Strict state to live in the core database rather than in
editable plaintext config.
-**Decision.** `rusqlite` with `bundled`, storing schedules, lock state, and usage
-counters in one file.
+**Decision.** `rusqlite` with `bundled`, in a **separate `blocker-storage` crate**
+that depends on `blocker-core`, never the reverse.
-**Consequences.** The schema is written and reviewed in
-`core/src/storage.rs` before the crate is added, so it can be reviewed as text.
-Bundling compiles SQLite from source, which costs build time and binary size but
-removes a system dependency — a real consideration on the machines this ships to.
+**Why the separate crate.** The purity contract says the engine does no I/O, and
+`tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any
+dependency at all. Putting `rusqlite` in `blocker-core` would have forced that
+check to degrade into a source-text grep — something a careful developer can route
+around, and something nobody reads in six months. Splitting keeps the check
+automatic.
-The binary-size cost has not yet been measured against the 5 MB budget. That is
-an open item, not a settled fact.
+Both crates sit in the same layer of the section 2 diagram: storage is shared by
+every platform and contains no platform code. This is a refinement that makes the
+guarantee checkable, not an architectural departure.
+
+**Measured consequences.**
+
+- Bundled SQLite costs **~1.1 MB** of the 5 MB stripped daemon budget (1.364 MB
+ total, from 0.279 MB without it). That leaves 3.8 MB for D-Bus, X11 and a UI
+ toolkit — a real margin, not a comfortable one.
+- The build compiles SQLite from C source, which is a noticeable first-build cost.
+- System-linking would reclaim most of the 1.1 MB at the price of a runtime
+ dependency on the host's libsqlite3, and a schema-format mismatch that only
+ appears on a user's machine.
+
+**Still open:** bundled vs system-linked. Measured cost is now known; the decision
+is the owner's.
+
+**Schema notes.** Relational (`schedule_target`, `schedule_condition`) rather than
+JSON blobs in a text column, because target rows are queried by target and an
+index on a JSON string is not a plan. Serialisation is hand-written in
+`storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule
+3 requires asking before adding one (ADR-13).
---
diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md
index 19d69ea..5d734ce 100644
--- a/docs/LIMITATIONS.md
+++ b/docs/LIMITATIONS.md
@@ -177,9 +177,9 @@ not mistaken for platform facts.
- **`ProcessBackend` is not implemented.** ADR-14 lists three backends; the KWin
and X11 paths land in M2-Linux, and the process fallback is weaker than the
spec assumes because of the `/proc/connectors` gap above.
-- **`rusqlite` has not been added.** The schema is written and reviewed in
- `core/src/storage.rs` (ADR-4) but storage has no I/O behind it yet, so nothing
- persists. A restart loses all state.
+- **`rusqlite` vs system-linked SQLite is unresolved** (ADR-4). Bundled is built
+ and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most
+ of that but adds a runtime dependency on the host's libsqlite3.
- **Lock state is not yet persisted or enforced.** `core/src/lock.rs` has the
timers, the escalation, and the wall-clock correction, all tested. The state
machine is not yet driven from a UI, and `LockState::evaluate` currently returns
@@ -188,6 +188,8 @@ not mistaken for platform facts.
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
is hand-written unit tests in the same spirit.
+- **`LockState` still always evaluates to `Normal`** — persistence works, but no
+ UI drives the lock state machine yet.
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
than the schedule's own target set, until targets carry usage keys.
- **Windows is entirely unimplemented** and cannot be compiled or tested on the
diff --git a/docs/PERF.md b/docs/PERF.md
index 9665d64..bb51ac4 100644
--- a/docs/PERF.md
+++ b/docs/PERF.md
@@ -29,17 +29,26 @@ Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1
| Metric | Target | Measured | When |
|---|---|---|---|
-| Daemon binary (stripped, release) | ≤ 5 MB | **0.279 MB** | 2026-10-01, M0 |
+| Daemon binary (stripped, release) | ≤ 5 MB | **1.364 MB** | 2026-10-01, M1 |
| Block latency | ≤ 150 ms | not measured | — |
| Idle CPU | ~0% | not measured | — |
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
| Idle wakeups | ≤ 1/min | not measured | — |
| APK size | ≤ 10 MB | not measured | — |
-The binary figure is real but nearly meaningless right now: the daemon is two
-`println!` lines. The meaningful measurement comes in M2-Linux once enforcement
-exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are
-where 5 MB would actually be threatened.
+**What the 1.364 MB contains, and why it matters.** That figure is with bundled
+SQLite linked in and the daemon actually opening a database — not the 0.279 MB
+placeholder from M0, which was two `println!` lines and excluded SQLite entirely
+through dead-code elimination.
+
+So the honest read is: **bundled SQLite costs roughly 1.1 MB of a 5 MB budget.**
+That leaves 3.8 MB for D-Bus, X11, and the UI toolkit. It is a real constraint
+rather than a comfortable margin, and it is the first number that makes the
+section 5 budget feel like a budget instead of a formality.
+
+It also makes ADR-4 (rusqlite bundled vs system-linked) a genuine trade-off rather
+than a settled one: system-linking would recover most of that 1.1 MB at the cost
+of a runtime dependency on the host's libsqlite3. Recorded as an open question.
## How to measure
diff --git a/storage/Cargo.toml b/storage/Cargo.toml
new file mode 100644
index 0000000..8097351
--- /dev/null
+++ b/storage/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "blocker-storage"
+version = "0.1.0"
+edition = "2021"
+description = "SQLite persistence for Blocker: schedules, lock state, and usage counters."
+license = "UNLICENSED"
+
+[dependencies]
+blocker-core = { path = "../core" }
+
+# rusqlite with bundled SQLite, per CLAUDE.md section 3. Bundled rather than
+# system-linked because the app ships to machines where libsqlite3 may be absent
+# or a different major version, and a storage-format mismatch that only shows up
+# on a user's machine is not a failure mode worth carrying.
+#
+# THIS CRATE IS NOT DEPENDENT ON BY blocker-core.
+#
+# That is the entire reason this crate exists separately. CLAUDE.md section 2
+# requires the engine to be a pure function with no I/O, and the strongest
+# mechanical check for that is "blocker-core has zero dependencies" — a test
+# that reads Cargo.lock and fails on any entry. Keeping storage in its own crate
+# lets that test stay strict and automatic, instead of degrading into a source
+# grep that someone can route around.
+#
+# Both crates sit in the same layer: storage is shared by every platform and
+# contains no platform code, so this is a refinement of the section 2 diagram,
+# not a departure from it.
+rusqlite = { version = "0.37", features = ["bundled"] }
+
+[lints]
+workspace = true
+
+[lib]
+name = "blocker_storage"
+path = "src/lib.rs"
\ No newline at end of file
diff --git a/storage/src/codec.rs b/storage/src/codec.rs
new file mode 100644
index 0000000..ab88965
--- /dev/null
+++ b/storage/src/codec.rs
@@ -0,0 +1,347 @@
+//! Encoding between model types and their database representation.
+//!
+//! Split out from the SQL so the tricky part — turning a `Condition` into
+//! nullable columns and back without silently losing a field — is testable
+//! without a database, and so a decode failure has somewhere precise to point.
+//!
+//! # Why this is hand-written rather than serde
+//!
+//! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires
+//! stopping to ask before adding one. The encoding is a few hundred lines of
+//! mechanical match arms, which is a real cost — and it is the cost of not adding
+//! a dependency without permission. If serde is approved later, this module is the
+//! only thing that changes.
+//!
+//! # Decoding never panics
+//!
+//! Every decode failure returns [`DecodeError`]. A database written by a newer
+//! version, or corrupted by hand, must produce a diagnosable error rather than a
+//! panic: this is enforcement code, and section 1.5.4 requires failing open.
+
+use blocker_core::model::{
+ Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
+};
+
+/// Why stored bytes could not become a model value.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DecodeError {
+ /// A `mode` column held something other than the two known values.
+ UnknownMode(String),
+ /// A `level` column held something other than the three known values.
+ UnknownLevel(String),
+ /// A `kind` column held something unrecognised.
+ UnknownKind(String),
+ /// A target string was empty.
+ EmptyTarget,
+ /// A `start`/`end` time was not `HH:MM`, or was outside 0..1440.
+ BadTime(String),
+ /// A time of day fell outside 0..1440 minutes.
+ MinuteOutOfRange(i64),
+ /// A `days` list was malformed, or held a value outside 0..=6.
+ BadDays(String),
+ /// A boolean column was neither 0 nor 1.
+ BadFlag(i64),
+ /// A numeric column was negative where only a count is meaningful.
+ NegativeCount(i64),
+}
+
+impl std::fmt::Display for DecodeError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"),
+ Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"),
+ Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"),
+ Self::EmptyTarget => write!(f, "target value is empty"),
+ Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"),
+ Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"),
+ Self::BadDays(d) => write!(f, "day list {d:?} is malformed"),
+ Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"),
+ Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"),
+ }
+ }
+}
+
+impl std::error::Error for DecodeError {}
+
+/// `ScheduleMode` as stored.
+pub fn mode_to_str(mode: ScheduleMode) -> &'static str {
+ match mode {
+ ScheduleMode::Blocklist => "Blocklist",
+ ScheduleMode::Allowlist => "Allowlist",
+ }
+}
+
+pub fn mode_from_str(raw: &str) -> Result {
+ match raw {
+ "Blocklist" => Ok(ScheduleMode::Blocklist),
+ "Allowlist" => Ok(ScheduleMode::Allowlist),
+ other => Err(DecodeError::UnknownMode(other.to_owned())),
+ }
+}
+
+/// `LockLevel` as stored.
+pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str {
+ match level {
+ blocker_core::lock::LockLevel::Normal => "Normal",
+ blocker_core::lock::LockLevel::Strict => "Strict",
+ blocker_core::lock::LockLevel::Stubborn => "Stubborn",
+ }
+}
+
+pub fn level_from_str(raw: &str) -> Result {
+ use blocker_core::lock::LockLevel;
+ match raw {
+ "Normal" => Ok(LockLevel::Normal),
+ "Strict" => Ok(LockLevel::Strict),
+ "Stubborn" => Ok(LockLevel::Stubborn),
+ other => Err(DecodeError::UnknownLevel(other.to_owned())),
+ }
+}
+
+/// A decoded target.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TargetRow {
+ /// Position within the schedule, preserving the user's ordering.
+ pub ordinal: i64,
+ /// Discriminator: `app`, `website`, or `keyword`.
+ pub kind: String,
+ /// The identifier itself.
+ pub value: String,
+}
+
+/// Targets in database form.
+pub fn encode_targets(targets: &[Target]) -> Vec {
+ targets
+ .iter()
+ .enumerate()
+ .map(|(ordinal, t)| {
+ let (kind, value) = match t {
+ Target::App(v) => ("app", v.clone()),
+ Target::Website(v) => ("website", v.clone()),
+ Target::Keyword(v) => ("keyword", v.clone()),
+ };
+ TargetRow {
+ ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
+ kind: kind.to_owned(),
+ value,
+ }
+ })
+ .collect()
+}
+
+pub fn decode_targets(rows: &[TargetRow]) -> Result, DecodeError> {
+ let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len());
+ for row in rows {
+ if row.value.is_empty() {
+ return Err(DecodeError::EmptyTarget);
+ }
+ let target = match row.kind.as_str() {
+ "app" => Target::App(row.value.clone()),
+ "website" => Target::Website(row.value.clone()),
+ "keyword" => Target::Keyword(row.value.clone()),
+ other => return Err(DecodeError::UnknownKind(other.to_owned())),
+ };
+ out.push((row.ordinal, target));
+ }
+ // Ordinals come from the database unordered; sorting restores user order,
+ // which matters for a list the user arranged deliberately.
+ out.sort_by_key(|(ordinal, _)| *ordinal);
+ Ok(out.into_iter().map(|(_, t)| t).collect())
+}
+
+/// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the
+/// wrong place is caught here rather than becoming a silently defaulted
+/// condition.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ConditionRow {
+ pub ordinal: i64,
+ pub kind: String,
+ /// Comma-separated weekdays, Monday = 0.
+ pub days: Option,
+ pub start_min: Option,
+ pub end_min: Option,
+ pub limit_value: Option,
+ pub window: Option,
+ pub ssid: Option,
+ pub latitude: Option,
+ pub longitude: Option,
+ pub radius_m: Option,
+ pub outside: Option,
+}
+
+pub fn encode_conditions(conditions: &[Condition]) -> Vec {
+ conditions
+ .iter()
+ .enumerate()
+ .map(|(ordinal, c)| {
+ let mut row = ConditionRow {
+ ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
+ kind: String::new(),
+ days: None,
+ start_min: None,
+ end_min: None,
+ limit_value: None,
+ window: None,
+ ssid: None,
+ latitude: None,
+ longitude: None,
+ radius_m: None,
+ outside: None,
+ };
+ match c {
+ Condition::Time(w) => {
+ row.kind = "Time".to_owned();
+ row.days = Some(w.days.iter().map(u8::to_string).collect::>().join(","));
+ row.start_min = Some(i64::from(w.start));
+ row.end_min = Some(i64::from(w.end));
+ }
+ Condition::UsageLimit { limit_minutes, window } => {
+ row.kind = "UsageLimit".to_owned();
+ row.limit_value = Some(i64::from(*limit_minutes));
+ row.window = Some(window_to_str(*window).to_owned());
+ }
+ Condition::LaunchCount { limit, window } => {
+ row.kind = "LaunchCount".to_owned();
+ row.limit_value = Some(i64::from(*limit));
+ row.window = Some(window_to_str(*window).to_owned());
+ }
+ Condition::WifiSsid(ssid) => {
+ row.kind = "WifiSsid".to_owned();
+ row.ssid = Some(ssid.clone());
+ }
+ Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
+ row.kind = "Location".to_owned();
+ row.latitude = Some(*latitude);
+ row.longitude = Some(*longitude);
+ row.radius_m = Some(i64::from(*radius_meters));
+ row.outside = Some(i64::from(*block_when_outside));
+ }
+ }
+ row
+ })
+ .collect()
+}
+
+pub fn decode_conditions(rows: &[ConditionRow]) -> Result, DecodeError> {
+ let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len());
+ for row in rows {
+ out.push((row.ordinal, decode_condition(row)?));
+ }
+ out.sort_by_key(|(ordinal, _)| *ordinal);
+ Ok(out.into_iter().map(|(_, c)| c).collect())
+}
+
+fn decode_condition(row: &ConditionRow) -> Result {
+ match row.kind.as_str() {
+ "Time" => {
+ let days = row.days.as_deref().ok_or(DecodeError::BadDays("".into()))?;
+ let start = require_minute(row.start_min)?;
+ let end = require_minute(row.end_min)?;
+ Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end }))
+ }
+ "UsageLimit" => Ok(Condition::UsageLimit {
+ limit_minutes: require_count(row.limit_value)?,
+ window: window_from_str(row.window.as_deref())?,
+ }),
+ "LaunchCount" => Ok(Condition::LaunchCount {
+ limit: require_count(row.limit_value)?,
+ window: window_from_str(row.window.as_deref())?,
+ }),
+ "WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)),
+ "Location" => Ok(Condition::Location {
+ latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?,
+ longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?,
+ radius_meters: require_count(row.radius_m)?,
+ block_when_outside: decode_flag(row.outside.unwrap_or(0))?,
+ }),
+ other => Err(DecodeError::UnknownKind(other.to_owned())),
+ }
+}
+
+fn require_minute(value: Option) -> Result {
+ let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?;
+ u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v))
+}
+
+fn require_count(value: Option) -> Result {
+ let v = value.ok_or(DecodeError::NegativeCount(-1))?;
+ u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v))
+}
+
+fn decode_flag(value: i64) -> Result {
+ match value {
+ 0 => Ok(false),
+ 1 => Ok(true),
+ other => Err(DecodeError::BadFlag(other)),
+ }
+}
+
+fn window_to_str(window: UsageWindow) -> &'static str {
+ match window {
+ UsageWindow::Daily => "Daily",
+ UsageWindow::Hourly => "Hourly",
+ }
+}
+
+fn window_from_str(raw: Option<&str>) -> Result {
+ match raw {
+ Some("Daily") => Ok(UsageWindow::Daily),
+ Some("Hourly") => Ok(UsageWindow::Hourly),
+ _ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))),
+ }
+}
+
+fn decode_days(raw: &str) -> Result, DecodeError> {
+ if raw.is_empty() {
+ return Ok(Vec::new());
+ }
+ raw.split(',')
+ .map(|part| {
+ let trimmed = part.trim();
+ trimmed
+ .parse::()
+ .ok()
+ .filter(|d| *d < 7)
+ .ok_or_else(|| DecodeError::BadDays(raw.to_owned()))
+ })
+ .collect()
+}
+
+/// `HH:MM` to minutes since midnight. Used by the golden-table loader, which
+/// reads times as strings.
+pub fn parse_hhmm(raw: &str) -> Result {
+ let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?;
+ let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
+ let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
+ if m >= 60 || h >= 24 {
+ return Err(DecodeError::BadTime(raw.to_owned()));
+ }
+ Ok(h * 60 + m)
+}
+
+/// Reassemble a schedule from its header and its decoded children.
+///
+/// Kept here rather than in `lib.rs` so the ordering contract — children sorted
+/// by ordinal, attached to the right parent — is tested in one place.
+pub fn decode_schedule(
+ id: &str,
+ name: &str,
+ icon: Option<&str>,
+ mode: &str,
+ targets: &[TargetRow],
+ conditions: &[ConditionRow],
+ paused: bool,
+) -> Result {
+ Ok(Schedule {
+ id: id.to_owned(),
+ name: name.to_owned(),
+ icon: icon.map(str::to_owned),
+ mode: mode_from_str(mode)?,
+ targets: decode_targets(targets)?,
+ conditions: decode_conditions(conditions)?,
+ paused,
+ })
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/storage/src/codec/tests.rs b/storage/src/codec/tests.rs
new file mode 100644
index 0000000..170acd8
--- /dev/null
+++ b/storage/src/codec/tests.rs
@@ -0,0 +1,201 @@
+//! Tests for the storage encoding layer.
+//!
+//! These need no database: the codec is where a silently-dropped field would
+//! live, and a round-trip test here is far cheaper to run and to read than the
+//! same assertion through SQLite.
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ reason = "test fixtures: a failing assertion or a broken test is not a runtime \
+condition to handle gracefully"
+)]
+
+use super::*;
+use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
+
+/// Every condition shape must survive encode -> decode unchanged.
+///
+/// A field that is written but not read back is invisible in production and
+/// catastrophic here: the schedule quietly stops working the next time the app
+/// restarts.
+#[test]
+fn every_condition_round_trips() {
+ let cases = vec![
+ Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }),
+ // Overnight: start > end must not be normalised or dropped on the way
+ // through the database. This is the representation the whole overnight
+ // rule depends on.
+ Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }),
+ Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
+ Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly },
+ Condition::WifiSsid("CoffeeShop".into()),
+ Condition::Location {
+ latitude: 52.52,
+ longitude: 13.405,
+ radius_meters: 500,
+ block_when_outside: true,
+ },
+ ];
+
+ for original in cases {
+ let rows = encode_conditions(std::slice::from_ref(&original));
+ let decoded = decode_conditions(&rows).expect("decode must succeed");
+ assert_eq!(decoded, vec![original], "round trip changed the condition");
+ }
+}
+
+/// A schedule's ordinal order must survive, since the user arranges targets
+/// deliberately and the block screen shows them in that order.
+#[test]
+fn target_order_is_preserved() {
+ let targets = vec![
+ Target::App("b.app".into()),
+ Target::Website("example.com".into()),
+ Target::Keyword("casino".into()),
+ Target::App("a.app".into()),
+ ];
+ let mut rows = encode_targets(&targets);
+ // Simulate the database returning rows in an arbitrary order.
+ rows.reverse();
+ assert_eq!(decode_targets(&rows).unwrap(), targets);
+}
+
+/// Unknown discriminators must error, not default.
+///
+/// Defaulting here would turn a typo or a newer-version row into a schedule
+/// that silently blocks the wrong thing.
+#[test]
+fn unknown_kinds_are_errors_not_defaults() {
+ let bad_target =
+ vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }];
+ assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into())));
+
+ let bad_condition = vec![ConditionRow {
+ ordinal: 0,
+ kind: "Timey".into(),
+ days: None,
+ start_min: None,
+ end_min: None,
+ limit_value: None,
+ window: None,
+ ssid: None,
+ latitude: None,
+ longitude: None,
+ radius_m: None,
+ outside: None,
+ }];
+ assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into())));
+}
+
+/// A time of day outside 0..1440 must be rejected rather than wrapping.
+///
+/// A wrapped minute would produce a window that never matches, or one that
+/// always matches — both look like "the app ignores my schedule".
+#[test]
+fn out_of_range_minutes_are_rejected() {
+ let bad = ConditionRow {
+ ordinal: 0,
+ kind: "Time".into(),
+ days: Some("0".into()),
+ start_min: Some(-60),
+ end_min: Some(1020),
+ limit_value: None,
+ window: None,
+ ssid: None,
+ latitude: None,
+ longitude: None,
+ radius_m: None,
+ outside: None,
+ };
+ assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60)));
+}
+
+/// Weekday values outside 0..=6 must be rejected.
+#[test]
+fn out_of_range_weekdays_are_rejected() {
+ assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into())));
+ assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into())));
+ assert_eq!(decode_days(""), Ok(vec![]));
+ assert_eq!(decode_days("0,6"), Ok(vec![0, 6]));
+}
+
+/// A flag column holding something other than 0 or 1 must be rejected.
+#[test]
+fn bad_flags_are_rejected() {
+ assert_eq!(decode_flag(0), Ok(false));
+ assert_eq!(decode_flag(1), Ok(true));
+ assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2)));
+ assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1)));
+}
+
+/// Negative counts must be rejected — a negative usage limit would mean
+/// "block immediately", which is not what a corrupted row should do.
+#[test]
+fn negative_counts_are_rejected() {
+ assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5)));
+ assert_eq!(require_count(Some(0)), Ok(0));
+ assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1)));
+}
+
+/// `HH:MM` parsing, as the golden tables use it.
+#[test]
+fn hhmm_parsing() {
+ assert_eq!(parse_hhmm("09:00"), Ok(540));
+ assert_eq!(parse_hhmm("22:30"), Ok(1350));
+ assert_eq!(parse_hhmm("00:00"), Ok(0));
+ assert_eq!(parse_hhmm("9:5"), Ok(545));
+ assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into())));
+ assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into())));
+ assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into())));
+ assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into())));
+}
+
+/// Lock level and mode round-trip through their string forms.
+#[test]
+fn level_and_mode_round_trip() {
+ for level in [
+ blocker_core::lock::LockLevel::Normal,
+ blocker_core::lock::LockLevel::Strict,
+ blocker_core::lock::LockLevel::Stubborn,
+ ] {
+ assert_eq!(level_from_str(level_to_str(level)).unwrap(), level);
+ }
+ assert!(level_from_str("Paranoid").is_err());
+
+ for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] {
+ assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode);
+ }
+ assert!(mode_from_str("Whitelist").is_err());
+}
+
+/// A full schedule round-trips through its parts.
+#[test]
+fn schedule_round_trips_through_parts() {
+ let original = Schedule {
+ id: "s1".into(),
+ name: "Evening Focus".into(),
+ icon: Some("/icons/focus.png".into()),
+ mode: ScheduleMode::Blocklist,
+ targets: vec![Target::App("com.example.social".into())],
+ conditions: vec![Condition::Time(TimeWindow {
+ days: vec![0, 1, 2, 3, 4],
+ start: 22 * 60,
+ end: 6 * 60,
+ })],
+ paused: false,
+ };
+
+ let decoded = decode_schedule(
+ &original.id,
+ &original.name,
+ original.icon.as_deref(),
+ mode_to_str(original.mode),
+ &encode_targets(&original.targets),
+ &encode_conditions(&original.conditions),
+ original.paused,
+ )
+ .unwrap();
+
+ assert_eq!(decoded, original);
+}
diff --git a/storage/src/lib.rs b/storage/src/lib.rs
new file mode 100644
index 0000000..b8e910a
--- /dev/null
+++ b/storage/src/lib.rs
@@ -0,0 +1,644 @@
+//! SQLite persistence for Blocker (CLAUDE.md section 3, `rusqlite` bundled).
+//!
+//! # Why this is a separate crate from `blocker-core`
+//!
+//! Section 2 requires the engine to be a pure function with no I/O, and this
+//! project's `tests/purity.rs` enforces that mechanically: it reads the resolved
+//! dependency graph and fails if `blocker-core` has any dependency at all. That
+//! check is only meaningful while storage lives elsewhere.
+//!
+//! Both crates occupy the same layer of the section 2 diagram — storage is shared
+//! by every platform and contains no platform code — so this is a refinement that
+//! makes the purity guarantee mechanically checkable, not an architectural
+//! departure.
+//!
+//! # Fail open (section 1.5.4)
+//!
+//! A corrupt database must never leave a device unusable. [`Store::open`] moves a
+//! damaged file aside and starts clean rather than returning an error the caller
+//! might respond to by disabling enforcement. The damaged file is kept for
+//! diagnosis; blocking is then re-applied from defaults on the next start.
+//!
+//! Losing a schedule list is a far smaller harm than a phone that cannot make
+//! calls, and section 1.5.4 makes that trade explicit.
+
+use std::path::{Path, PathBuf};
+
+use blocker_core::engine::{BootId, CivilTime, Monotonic};
+use rusqlite::{Connection, OpenFlags};
+
+use crate::codec::DecodeError;
+
+/// Current schema version, applied with `PRAGMA user_version`.
+pub const SCHEMA_VERSION: i64 = 1;
+
+/// Everything that can go wrong opening or preparing a store.
+///
+/// Notably **absent**: a corruption variant that is fatal. Corruption is
+/// handled inside [`Store::open`] by starting clean, because section 1.5.4
+/// requires failing open rather than refusing to run.
+#[derive(Debug)]
+pub enum StoreError {
+ /// SQLite could not be initialised at all — for example the bundled library
+ /// could not be loaded. Fatal, and genuinely exceptional.
+ Init(rusqlite::Error),
+ /// The directory holding the database could not be created.
+ Io(std::io::Error),
+ /// Stored bytes could not be decoded into the model.
+ Decode(DecodeError),
+}
+
+impl std::fmt::Display for StoreError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Init(e) => write!(f, "could not initialise storage: {e}"),
+ Self::Io(e) => write!(f, "storage directory unusable: {e}"),
+ Self::Decode(e) => write!(f, "stored data could not be decoded: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for StoreError {}
+
+/// A handle to the Blocker database.
+///
+/// Cheap to clone conceptually but deliberately not `Clone`: the daemon is the
+/// only writer, and an accidental second connection would mean two processes
+/// fighting over lock state.
+#[derive(Debug)]
+pub struct Store {
+ conn: Connection,
+ path: PathBuf,
+ /// Set when the database was found corrupt and moved aside, so the caller can
+ /// log it. Section 1.5.4 wants this recorded, not silently swallowed.
+ recovered_from_corruption: bool,
+}
+
+impl Store {
+ /// Open, creating if absent, and ensure the schema is current.
+ ///
+ /// Never returns an error for corruption: a damaged database is renamed
+ /// aside and a fresh one created. See the module docs.
+ pub fn open(path: &Path) -> Result {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(StoreError::Io)?;
+ }
+
+ let mut recovered = false;
+
+ // First attempt: open normally and verify it is actually usable. A
+ // corrupt file often opens fine and only fails on first query, so the
+ // probe below is what actually detects corruption.
+ let mut attempt = Self::try_open(path)?;
+ if let Err(probe) =
+ attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
+ {
+ recovered = true;
+ Self::quarantine(path)?;
+ attempt = Self::try_open(path)?;
+ // Keep the probe result meaningful: if this one also fails, the
+ // problem is not the file.
+ if let Err(second) =
+ attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
+ {
+ return Err(StoreError::Init(second));
+ }
+ let _ = probe;
+ }
+
+ attempt.migrate()?;
+ attempt.recovered_from_corruption = recovered;
+ Ok(attempt)
+ }
+
+ fn try_open(path: &Path) -> Result {
+ let conn = Connection::open_with_flags(
+ path,
+ OpenFlags::SQLITE_OPEN_READ_WRITE
+ | OpenFlags::SQLITE_OPEN_CREATE
+ | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ )
+ .map_err(StoreError::Init)?;
+
+ // WAL keeps a reader from blocking the daemon's writes, which matters
+ // because the UI reads while the daemon writes. It also means a crash
+ // cannot corrupt the main file, which is part of failing open.
+ // Errors here are not fatal: a filesystem without WAL still works, just
+ // with less concurrency.
+ let _ = conn.pragma_update(None, "journal_mode", "WAL");
+ let _ = conn.pragma_update(None, "foreign_keys", "ON");
+
+ Ok(Self { conn, path: path.to_path_buf(), recovered_from_corruption: false })
+ }
+
+ /// Move a damaged database aside, keeping it for diagnosis.
+ fn quarantine(path: &Path) -> Result<(), StoreError> {
+ let backup = path.with_extension("corrupt");
+ std::fs::rename(path, &backup).map_err(StoreError::Io)?;
+ Ok(())
+ }
+
+ /// Whether this store was recovered from corruption on open.
+ pub fn recovered_from_corruption(&self) -> bool {
+ self.recovered_from_corruption
+ }
+
+ fn migrate(&mut self) -> Result<(), StoreError> {
+ let current: i64 = self
+ .conn
+ .query_row("PRAGMA user_version", [], |r| r.get(0))
+ .map_err(StoreError::Init)?;
+
+ if current > SCHEMA_VERSION {
+ // Forward-only by design (section 1.5.3: downgrading risks losing
+ // lock state, which the safety rules do not permit). A newer file is
+ // left alone rather than destroyed.
+ return Err(StoreError::Init(rusqlite::Error::InvalidQuery));
+ }
+ if current == SCHEMA_VERSION {
+ return Ok(());
+ }
+
+ self.conn.execute_batch(SCHEMA).map_err(StoreError::Init)?;
+ self.conn.pragma_update(None, "user_version", SCHEMA_VERSION).map_err(StoreError::Init)?;
+ Ok(())
+ }
+
+ /// Load every schedule, with its targets and conditions.
+ ///
+ /// Two queries rather than a join: a schedule's children are needed together
+ /// to reassemble the model, and SQLite's JSON functions are not used here so
+ /// the relational shape stays portable. Schedules without children still
+ /// appear, with empty vectors — `validation` is what rejects those, not the
+ /// database.
+ pub fn load_schedules(&self) -> Result, StoreError> {
+ let headers = {
+ let mut stmt = self
+ .conn
+ .prepare(
+ "SELECT id, name, icon, mode, paused FROM schedule ORDER BY created_at, id",
+ )
+ .map_err(StoreError::Init)?;
+ let rows = stmt
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, Option>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, bool>(4)?,
+ ))
+ })
+ .map_err(StoreError::Init)?;
+
+ let mut out = Vec::new();
+ for row in rows {
+ out.push(row.map_err(StoreError::Init)?);
+ }
+ out
+ };
+
+ let mut schedules = Vec::with_capacity(headers.len());
+ for (id, name, icon, mode, paused) in headers {
+ let targets = self.load_targets(&id)?;
+ let conditions = self.load_conditions(&id)?;
+ schedules.push(
+ codec::decode_schedule(
+ &id,
+ &name,
+ icon.as_deref(),
+ &mode,
+ &targets,
+ &conditions,
+ paused,
+ )
+ .map_err(StoreError::Decode)?,
+ );
+ }
+ Ok(schedules)
+ }
+
+ fn load_targets(&self, schedule_id: &str) -> Result, StoreError> {
+ let mut stmt = self
+ .conn
+ .prepare(
+ "SELECT ordinal, kind, value FROM schedule_target
+ WHERE schedule_id = ?1 ORDER BY ordinal",
+ )
+ .map_err(StoreError::Init)?;
+ let rows = stmt
+ .query_map([schedule_id], |row| {
+ Ok(codec::TargetRow { ordinal: row.get(0)?, kind: row.get(1)?, value: row.get(2)? })
+ })
+ .map_err(StoreError::Init)?;
+ let mut out = Vec::new();
+ for row in rows {
+ out.push(row.map_err(StoreError::Init)?);
+ }
+ Ok(out)
+ }
+
+ fn load_conditions(&self, schedule_id: &str) -> Result, StoreError> {
+ let mut stmt = self
+ .conn
+ .prepare(
+ "SELECT ordinal, kind, days, start_min, end_min, limit_value, window,
+ ssid, latitude, longitude, radius_m, outside
+ FROM schedule_condition WHERE schedule_id = ?1 ORDER BY ordinal",
+ )
+ .map_err(StoreError::Init)?;
+ let rows = stmt
+ .query_map([schedule_id], |row| {
+ Ok(codec::ConditionRow {
+ ordinal: row.get(0)?,
+ kind: row.get(1)?,
+ days: row.get(2)?,
+ start_min: row.get(3)?,
+ end_min: row.get(4)?,
+ limit_value: row.get(5)?,
+ window: row.get(6)?,
+ ssid: row.get(7)?,
+ latitude: row.get(8)?,
+ longitude: row.get(9)?,
+ radius_m: row.get(10)?,
+ outside: row.get(11)?,
+ })
+ })
+ .map_err(StoreError::Init)?;
+ let mut out = Vec::new();
+ for row in rows {
+ out.push(row.map_err(StoreError::Init)?);
+ }
+ Ok(out)
+ }
+
+ /// Persist the full schedule set, replacing what was there.
+ ///
+ /// Written as a single transaction so a crash cannot leave a half-applied
+ /// schedule list — which on a blocking tool would mean some targets blocked
+ /// and others not, with no way for the user to tell which.
+ pub fn save_schedules(
+ &mut self,
+ schedules: &[blocker_core::model::Schedule],
+ ) -> Result<(), StoreError> {
+ let tx = self.conn.transaction().map_err(StoreError::Init)?;
+
+ // Children cascade on delete, so clearing the parents is enough and
+ // avoids a stale row outliving its schedule.
+ tx.execute("DELETE FROM schedule", []).map_err(StoreError::Init)?;
+
+ {
+ let mut insert_target = tx
+ .prepare(
+ "INSERT INTO schedule_target (schedule_id, ordinal, kind, value)
+ VALUES (?1, ?2, ?3, ?4)",
+ )
+ .map_err(StoreError::Init)?;
+ let mut insert_condition = tx
+ .prepare(
+ "INSERT INTO schedule_condition
+ (schedule_id, ordinal, kind, days, start_min, end_min, limit_value,
+ window, ssid, latitude, longitude, radius_m, outside)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
+ )
+ .map_err(StoreError::Init)?;
+
+ for (index, s) in schedules.iter().enumerate() {
+ tx.execute(
+ "INSERT INTO schedule (id, name, icon, mode, paused, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ rusqlite::params![
+ s.id,
+ s.name,
+ s.icon,
+ codec::mode_to_str(s.mode),
+ i64::from(s.paused),
+ i64::try_from(index).unwrap_or(i64::MAX),
+ ],
+ )
+ .map_err(StoreError::Init)?;
+
+ for row in codec::encode_targets(&s.targets) {
+ insert_target
+ .execute(rusqlite::params![s.id, row.ordinal, row.kind, row.value])
+ .map_err(StoreError::Init)?;
+ }
+ for row in codec::encode_conditions(&s.conditions) {
+ insert_condition
+ .execute(rusqlite::params![
+ s.id,
+ row.ordinal,
+ row.kind,
+ row.days,
+ row.start_min,
+ row.end_min,
+ row.limit_value,
+ row.window,
+ row.ssid,
+ row.latitude,
+ row.longitude,
+ row.radius_m,
+ row.outside,
+ ])
+ .map_err(StoreError::Init)?;
+ }
+ }
+ }
+
+ tx.commit().map_err(StoreError::Init)
+ }
+
+ /// Persist lock state, including the monotonic deadline a pending disable
+ /// request is waiting on (section 1.5.2.2).
+ pub fn save_lock(
+ &mut self,
+ state: &blocker_core::lock::LockState,
+ boot_id: BootId,
+ last_monotonic_ms: u64,
+ ) -> Result<(), StoreError> {
+ let pending = state.pending.as_ref();
+ self.conn
+ .execute(
+ "INSERT INTO lock_config
+ (id, level, schedules_locked, blocks_uninstall, keyholder_code,
+ request_ready_at, request_window_closes_at, boot_id, last_monotonic_ms)
+ VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
+ ON CONFLICT(id) DO UPDATE SET
+ level = excluded.level,
+ schedules_locked = excluded.schedules_locked,
+ blocks_uninstall = excluded.blocks_uninstall,
+ keyholder_code = excluded.keyholder_code,
+ request_ready_at = excluded.request_ready_at,
+ request_window_closes_at = excluded.request_window_closes_at,
+ boot_id = excluded.boot_id,
+ last_monotonic_ms = excluded.last_monotonic_ms",
+ rusqlite::params![
+ codec::level_to_str(state.level),
+ i64::from(state.schedules_locked),
+ i64::from(state.blocks_uninstall()),
+ state.keyholder_code,
+ pending.map(|p| i64::try_from(p.ready_at.0).unwrap_or(i64::MAX)),
+ pending.map(|p| i64::try_from(p.window_closes_at.0).unwrap_or(i64::MAX)),
+ i64::try_from(boot_id.0).unwrap_or(i64::MAX),
+ i64::try_from(last_monotonic_ms).unwrap_or(i64::MAX),
+ ],
+ )
+ .map(|_| ())
+ .map_err(StoreError::Init)
+ }
+
+ /// The monotonic deadline and boot id of a pending disable request.
+ ///
+ /// Used on resume to distinguish "the daemon restarted" from "the machine
+ /// rebooted" (section 1.5.3), so a lock is resumed conservatively rather
+ /// than assumed to have continued.
+ pub fn load_pending_request(&self) -> Result