Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock state, disable attempts and usage buckets now persist across restarts. **Why a new crate rather than a module inside `blocker-core`.** 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 nobody reads in six months. `blocker-storage` depends on `blocker-core`; the reverse edge does not exist, so the zero-dependency proof stays automatic. Both crates sit in the same layer of the section 2 diagram: storage is shared by every platform and contains no platform code. **Schema is relational, not JSON-in-a-text-column.** `schedule_target` and `schedule_condition` are separate tables 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. **Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged file aside and starts clean, keeping the original for diagnosis. Confirmed against the real binary, not only in tests: ``` $ printf 'SQLite format 3\x00 not a database' > .../blocker.db $ blocker-daemon blocker-daemon: database was corrupt and has been moved to .../blocker.db.corrupt; starting clean, so no schedules are active until you re-add them EXIT=0 ``` A truncated file is covered separately — that is what a power loss during a write actually produces, and it fails differently from garbage bytes. **Two bugs this PR's own tests caught:** 1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since that row is by definition the one being tested, the cutoff always sat before it and the delete matched nothing — the table grew forever, which is the slow disk leak the function exists to prevent. Now anchored on `MAX`. 2. An off-by-one in the same function: with a strict `<` against `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1` rows. Both are covered by `old_usage_is_pruned`. **Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system trade-off a genuine open question rather than a settled one. The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination dropped a crate the placeholder binary never referenced. Co-Authored-By: Claude Code <noreply@anthropic.com>
Blocker
A cross-platform app and website blocker for Android, Windows and Linux.
One shared Rust core owns every decision. Platform code observes the OS and
enforces what the core decides. That is what makes the same schedule behave
identically on all three, and it is testable rather than aspirational — the golden
decision tables in tests/ are the parity contract, so a platform disagreeing
with the core fails the build instead of producing a bug report.
Status
M0 — repository bootstrap. The core engine exists and is tested; nothing 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 |
| M2-Linux | not started — daemon is a placeholder |
| M3-Android | not started |
| M4 conditions | not started |
| M5 websites | not started |
| M6 Strict Mode + friction | not started |
| M7 statistics, settings, releases | not started |
Milestone order is Linux-first rather than Android-first. See ADR-15.
What it will do
- Schedules of apps, websites and keywords, with time windows, usage limits, launch counts, Wi-Fi and location conditions
- Blocklist and allowlist modes, with strictest-wins when schedules overlap
- QuickBlock: block a chosen set for a chosen duration
- Strict Mode and a configurable friction ladder, so turning blocking off takes deliberate effort in a weak moment while remaining always possible
- Local usage statistics
What it will not do
This is a friction tool, not a wall. A user with root, ADB, safe mode, a factory reset, or desktop admin rights can always remove it, and that is intended. Factory reset, safe mode and OS recovery are never interfered with. The goal is to outlast an impulse, not to defeat a determined decision.
There is also a hardcoded list of things that can never be blocked — the phone and dialler, emergency calls and alerts, and on desktop the OS shell and the Settings basics needed to keep the machine usable. There is no setting that changes it.
See docs/LIMITATIONS.md for all eight platform limits,
stated plainly, plus the shortfalls in the current code.
Build and test
Requires a stable Rust toolchain. The toolchain is pinned in rust-toolchain.toml.
scripts/ci.sh # everything, stops at the first failure
scripts/ci.sh --keep-going # everything, summarises all failures
cargo test --workspace # tests only
cargo build --workspace # build only
scripts/size-check.sh # the 5 MB stripped daemon gate
Build artifacts go to $HOME/.blocker-target, outside the repository, so target/
never shows up in git status and can be deleted in one command. Override with
CARGO_TARGET_DIR.
Two checks skip on a machine without the relevant toolchain — Android without an SDK, the secret scan without gitleaks. Both skip loudly and are listed in the summary as not-passes. A skip is never reported as a pass.
Layout
core/ the decision engine — pure, no OS calls, no dependencies
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
desktop/platform-linux/ ForegroundWatcher backends, enforcement
desktop/platform-windows/ same, Windows
android/ not started
docs/ architecture, ADRs, limitations, permissions, perf, testing
scripts/ ci.sh, size-check.sh
Documentation
| Document | What it is for |
|---|---|
docs/ARCHITECTURE.md |
The purity contract, next_wakeup, and why the engine has no dependencies |
docs/DECISIONS.md |
17 ADRs, including the two still awaiting a decision |
docs/LIMITATIONS.md |
What the OS will not let us do, and what is not built yet |
docs/PERMISSIONS.md |
Every permission, and what degrades without it |
docs/PERF.md |
Section 5 budgets, and the measurements taken so far |
docs/TESTING.md |
The golden-table contract and the manual checklists |
CLAUDE.md |
The project specification. It governs. |
Performance budgets
| Metric | Target |
|---|---|
| Idle CPU | ~0%, no wakeups without events |
| Idle RAM (daemon) | ≤ 15 MB RSS |
| Block latency | ≤ 150 ms |
| 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.
Licence
Not yet chosen. See ADR-17. Choosing a licence is the owner's decision, and it interacts with the UI toolkit choice in ADR-6 (Slint is GPL-3.0 or commercial), so it is not something to settle by accident.
A note on origin
Blocker is a functional reimplementation of the behaviour of existing app blockers. It reuses no third-party name, logo, icon, copy, or screenshot — only behaviour.