diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..76c51f0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,225 @@ +# Architecture + +## The one idea + +Everything that decides anything lives in one Rust crate, `blocker-core`. Platform +code observes the OS and enforces decisions. Nothing else decides. + +``` + ┌──────────────────────────────────────────┐ + │ core (Rust) │ + │ │ + EngineInput ───▶ │ evaluate(input) -> Decision │ + │ · Schedule, Target, Condition │ + │ · UsageSnapshot, PlatformContext │ + │ · CivilTime, Monotonic, BootId │ + │ │ + │ storage · usage · lock · never_block │ + └───────────────┬──────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ + │ android/ │ │ desktop/ │ │ tests/ │ + │ Kotlin shell │ │ daemon + UI │ │ golden tables │ + │ services, │ │ platform-linux │ │ parity │ + │ overlay, UI │ │ platform-win │ │ contract │ + └────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## The purity contract + +```rust +pub fn evaluate(input: &EngineInput<'_>) -> Decision; +``` + +`evaluate` reads no clock, performs no I/O, and calls no OS API. Everything it +needs arrives in the input: civil time, monotonic time, boot id, the schedule +set, accumulated usage, and platform context such as the current SSID. + +This is the load-bearing rule of the project. Two things depend on it: + +**Testability.** The engine can be exercised with no OS present, which is what +makes the golden decision tables possible and CI cheap. + +**Parity.** If platform code could reach into the engine, Linux and Android would +drift, and the golden tables would stop being a contract — merely a test of the +core against itself. + +Enforcement is mechanical rather than cultural. `tests/purity.rs` reads the +resolved dependency graph and fails if `blocker-core` has any dependency at all, +so a stray `SystemTime::now()` breaks the build instead of quietly making parity +untestable. + +## Monotonic time is a separate type + +```rust +pub struct CivilTime { pub epoch_minutes: i64, pub utc_offset_minutes: i32 } +pub struct Monotonic(pub u64); +``` + +`EngineInput` carries both. Lock arithmetic reads only `Monotonic`. Schedule +windows read only `CivilTime`. + +Section 1.5.3 requires that changing the system date never shortens a lock. Making +the two different types in the input struct means a lock cannot consult the wall +clock even by accident — the mistake is a type error rather than a review finding. + +## `next_wakeup` is the anti-polling mechanism + +`Decision::next_wakeup` is the earliest instant at which any decision could +change, or `None`. The platform layer sleeps until that instant or until an OS +event arrives. + +That is where the section 5 idle-CPU budget comes from. An idle machine takes +**zero** wakeups, because nothing is scheduled to expire. A timer that fires to +discover nothing changed is a wakeup, and this project cannot afford many. + +It is also a correctness obligation, which is less obvious. The minimum must be +taken over **every non-paused schedule, not only active ones**: + +```rust +// Arm the wakeup BEFORE the activity filter, over every non-paused schedule. +for schedule in input.schedules { + if schedule.paused { continue; } + if let Some(boundary) = next_transition(schedule, input) { + next_wakeup = earlier(next_wakeup, boundary); + } +} +``` + +A schedule that is inactive now but starts at 09:00 Monday still has to arm a +wakeup for 09:00 Monday. Filtering to active conditions first would let the daemon +sleep straight through the moment it is supposed to start blocking. + +Conditions that only an OS event can change — Wi-Fi SSID, location — contribute +no boundary at all. Arming a timer for those would be a wakeup for nothing. + +## Strictest-wins + +`blocked_apps` is the union over all active schedules: an app is blocked if at +least one schedule wants it blocked. There is no precedence rule to implement and +no way for two schedules to disagree. + +Allowlist mode contributes the complement of its own list, which is why +`EngineInput` carries `installed_apps`. An empty slice means "could not +enumerate", and the engine then blocks nothing — fail open, per section 1.5.4. A +machine the engine knows nothing about is not a machine it should act on. + +## Overnight windows + +A time condition is `(days, start, end)` where `start > end` **means** the window +spans midnight. No `crosses_midnight` flag: the comparison already encodes it, and +a second field is a second way to be wrong. + +The subtle part is which weekday governs the early hours. A window starting Monday +22:00 and ending 06:00 is still active at 03:00 **Tuesday**, governed by *Monday*'s +weekday — the day the window started. A naive "is now inside today's window" check +reads 03:00 as Tuesday, finds Tuesday is not in the set, and stops blocking. That is +a nightly schedule silently dying at midnight, and it is the bug this +representation exists to prevent. + +Pinned by `overnight_window_survives_past_midnight` and +`overnight_window_across_the_week_boundary`. + +Evaluation happens in local civil time, so "09:00–17:00" stays 09:00–17:00 across a +DST change. + +## Block reasons travel with the decision + +```rust +pub struct BlockReason { + pub schedule_id: String, + pub schedule_name: String, + pub condition: &'static str, + pub ends_at: Option, +} +``` + +Section 1.3 requires the block screen to say which schedule blocked and when it +ends. The engine computes that here, so the UI never re-derives it. Three +platforms reading the same decision then tell the user the same story instead of +three different ones. + +## End-to-end data flow, one block event + +``` + KWin script ──D-Bus──▶ platform-linux ──▶ ┌──────────────┐ + (windowActivated) │ build │ + │ EngineInput │ + └──────┬───────┘ + ▼ + ┌──────────────┐ + │ evaluate() │ pure + └──────┬───────┘ + │ Decision + ▼ + platform enforces ◀───────┘ + 1. show block screen + 2. minimise + 3. SIGSTOP + 4. terminate (configurable, escalating) +``` + +The engine is called again when the OS reports a focus change, when `next_wakeup` +arrives, or on IPC. Nothing polls. + +## The FFI boundary + +`evaluate` is the entire surface the Android shell needs. `uniffi` generates the +Kotlin bindings over that one function and the `Decision` type, so the shell never +sees a schedule's internals and cannot take a shortcut around the engine. + +Starting with `uniffi` (ADR-5); hand-written JNI is the fallback if the measured +APK size cost is unacceptable, which is a measurement before it is a decision. + +## Module layout + +| Module | Responsibility | +|---|---| +| `model` | `Schedule`, `Target`, `Condition`, `ScheduleMode`, plus section 1.2 validation | +| `engine` | `evaluate`, `EngineInput`, `Decision`, `BlockReason`, `next_wakeup` | +| `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` 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. + +## Where the platform crates start and stop + +A platform crate may: observe the OS, map its own app identities onto `Target`, +call `evaluate`, and enforce the resulting `Decision`. + +It may not: decide whether something should be blocked, re-derive when a block +ends, or consult a clock for lock arithmetic. + +`ForegroundWatcher` is the trait every desktop backend implements: + +```rust +pub trait ForegroundWatcher { + fn subscribe(&mut self, sink: std::sync::mpsc::Sender); + fn name(&self) -> &'static str; + fn selection_reason(&self) -> &'static str; +} +``` + +The implementations and their honest limits are in ADR-14 and +`LIMITATIONS.md` §3. + +## Testing + +The golden decision tables in `tests/golden/` are the parity contract: one table, +run against the core, and against each platform's identity mapping. A platform +disagreeing with the core is a build failure rather than a bug report. + +The rule that keeps them meaningful: **a case is added before the engine changes.** +A table written after the code records current behaviour and can never catch a +regression. Reviewing a PR that changes engine behaviour therefore means checking +that a case was added or changed in the same commit. + +Clock tampering is the exception to "tables are pure data" — proving that moving +the system date never shortens a lock needs a clock that actually moves. Those +cases are code fixtures in `tests/src/purity.rs` instead. The tables describe +decisions; the fixtures describe the passage of time. \ No newline at end of file diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..9cb2402 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,450 @@ +# Architecture Decision Log + +Decisions made about Blocker, in order. Newest last. + +Newest first is not used deliberately: an ADR log is read for the *chain* of +reasoning, and a reader who wants to know why something is the way it is needs +to see what it was chosen over, which the superseded entries provide. + +Format: title, status, context, decision, consequences. + +Status values: **Accepted** (decided, in force), **Proposed** (designed, awaiting +the owner's confirmation), **Superseded by ADR-N** (no longer true). + +Where CLAUDE.md gives a reason for a decision, that reason is recorded. Where it +does not, this file says so rather than inventing a justification — a fabricated +rationale is worse than an acknowledged gap, because the next person cannot tell +which parts to trust. + +--- + +## Index + +| ADR | Title | Status | +|---|---|---| +| [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 | +| [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** | +| [8](#adr-8) | Lock levels and explicit opt-in | Accepted | +| [9](#adr-9) | Monotonic clock for all lock timers | Accepted | +| [10](#adr-10) | Never-block list is hardcoded | Accepted | +| [11](#adr-11) | We do not block ADB, safe mode, or recovery | Accepted | +| [12](#adr-12) | v1 scope excludes iOS, cloud, sync, billing | Accepted | +| [13](#adr-13) | Zero external dependencies in M0 | Accepted | +| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted | +| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted | +| [16](#adr-16) | Golden tables are JSON, not TOML | Proposed | +| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** | + +--- + + +## ADR-1: Rust for core and desktop, Kotlin for the Android shell + +**Status:** Accepted + +**Context.** Two languages are required in any case: the Android blocking APIs +(`AccessibilityService`, `UsageStatsManager`, `DevicePolicyManager`, +`SYSTEM_ALERT_WINDOW`) are JVM-only, so a non-JVM language cannot register those +services. The question is how much code is Kotlin. + +**Decision.** Kotlin for the Android shell only, kept thin. Rust for the core +and for all of desktop. + +**Consequences.** The shell holds services, the overlay, and UI. All decision +logic lives in Rust, so performance is dominated by the core rather than by the +shell. The cost is a cross-language boundary, handled by ADR-5. + +--- + + +## ADR-2: One shared core; the engine is a pure function + +**Status:** Accepted + +**Context.** Section 2 requires identical behaviour on three operating systems. +The failure mode to avoid is gradual drift, where Linux and Android each +implement their own idea of when a schedule is active. + +**Decision.** `evaluate(&EngineInput) -> Decision` reads no clock, no disk, and +no OS API. Everything it needs arrives in the input. + +**Consequences.** Two things follow. The engine is testable without any OS, and +parity becomes a testable property rather than a hope. + +Purity is enforced mechanically: `tests/purity.rs` reads the resolved dependency +graph and fails if `blocker-core` gains any dependency. A review that misses a +`SystemTime::now()` is caught by CI instead of by a user on a platform nobody +tested that day. + +--- + + +## ADR-3: Event-driven with `next_wakeup`, never polling + +**Status:** Accepted + +**Context.** Section 2 requires alarm-driven operation with no polling, and +section 5 budgets roughly zero idle CPU. + +**Decision.** The engine returns `next_wakeup`: the earliest instant at which any +decision could change, or `None`. The platform layer sleeps until that instant or +until an OS event arrives. + +**Consequences.** An idle machine takes zero wakeups, because nothing is +scheduled to expire. This also makes the field a correctness obligation — a +schedule that declares no wakeup but should have one will never fire. + +The subtle case, now covered by `inactive_schedule_yields_no_wakeup`: the +minimum must be taken over **every non-paused schedule, not only currently +active ones**. A schedule inactive now but starting at 09:00 Monday still has to +arm a wakeup for 09:00 Monday, or the daemon sleeps straight through the moment +it is supposed to start blocking. + +--- + + +## ADR-4: SQLite via rusqlite, bundled + +**Status:** Proposed — dependency not yet added + +**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. + +**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. + +The binary-size cost has not yet been measured against the 5 MB budget. That is +an open item, not a settled fact. + +--- + + +## ADR-5: uniffi for core-to-Kotlin bindings + +**Status:** Proposed + +**Context.** Section 3 specifies `uniffi`, with the explicit caveat that +hand-written JNI is the fallback *if uniffi bloats size*, and that this is to be +asked about. + +**Decision.** Start with `uniffi`. Fall back to hand-written JNI if the measured +APK size cost is unacceptable. + +**Consequences.** Unmeasured. The decision to measure comes before the decision +to commit, and the measurement belongs to M3-Android. + +--- + + +## ADR-6: Desktop UI toolkit — Slint vs egui + +**Status:** **Proposed — awaiting the owner's decision** + +**Context.** Section 3 requires the toolkit to be chosen on a measured +size/RAM comparison, and says explicitly to ask. + +**Decision.** Not yet made. + +**What is already settled by measurement-independent constraints:** + +- **Slint is GPL-3.0 or commercial.** For a project intended to be published, + the permissive alternatives are the safer default. If the size comparison comes + out close, licensing should decide it. +- Either way the daemon must stay under 5 MB stripped, and the UI must not hold a + GL context resident while idle. + +**What still needs measuring:** stripped binary size for a minimal window, idle +RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland +specifically — this project must work on KDE, so winit/Slint quirks on that +compositor matter more than generic Wayland support. + +Recorded rather than decided because section 3 says to ask. + +--- + + +## ADR-7: Desktop website blocking — browser extension + +**Status:** Accepted — the owner chose the extension (2026-10-01) + +**Context.** Section 6.2 identifies this as the weak point and requires a choice +between (a) DNS/hosts management, (b) a browser extension over native messaging, +and (c) both. + +**Decision.** A Firefox extension over native messaging. + +**Consequences, stated honestly.** + +Gains: keyword and path-level blocking actually work. Unlike DNS or a hosts file, +it is not defeated by DoH, because it inspects the URL in the browser rather than +the network path. + +Costs: the user must install an extension and grant permissions that look +alarming. Firefox-only for now, since Firefox 156 is the only browser on the +development machine. Adoption friction is genuinely high, and that is a real cost +of choosing quality here. + +Rejected alternatives: + +- **DNS/hosts only** — no keyword blocking, no path-level blocking, and defeated + by DoH. Zero dependencies and no install step, which is why it remains a + reasonable fallback if the extension proves too much friction. +- **Both** — the most complete and the largest surface area to get wrong. Not + chosen because two mechanisms means two sources of truth, and this project has + a strong interest in exactly one decision path. + +**The gap that remains.** An extension covers browsers it is installed in. A +determined user can `curl`, or use a browser without it. That is the section 6.7 +reality — friction, not a wall — and it stays true here. + +**Implementation constraint.** The extension holds no blocking logic of its own. +It asks the daemon, which returns a decision from the same engine that decides +apps. One decision path, so websites and apps cannot disagree. + +--- + + +## ADR-8: Lock levels and explicit opt-in + +**Status:** Accepted + +**Context.** Section 1.5.1 defines three levels: Normal, Strict, Stubborn. A level +can be raised instantly but lowered only through that level's own disable flow. + +**Decision.** Three levels, opt-in only, never enabled by default or by a +schedule template. The exact cost of undoing a lock is shown and confirmed at +activation time. + +**Consequences.** State machines per level, in `core/src/lock.rs`. The +asymmetry — raise instantly, lower through the flow — is the entire point and is +encoded in `may_edit_schedule` and `may_pause_schedule`. + +Section 1.5.6 additionally forbids any notification action, quick tile, widget, +or tray menu item that disables or opens the disable flow. The flow lives only +inside the app, behind a plain-looking entry. + +--- + + +## ADR-9: Monotonic clock for all lock timers + +**Status:** Accepted + +**Context.** Section 1.5.3 requires that changing the system date never shortens +a delay, that a detected wall-clock jump *extends* the active lock, and that +timer state survives reboot. + +**Decision.** Lock timers use a monotonic clock. `CivilTime` and `Monotonic` are +separate types, and `EngineInput` carries both as separate fields, so lock +arithmetic structurally cannot reach the wall clock. + +**Consequences.** Two rules in `apply_correction`, and they pull against each +other: + +1. A detected skew **always extends**, taking the absolute value so the + direction of the jump cannot matter. +2. The result is **clamped to the 24-hour cap**. + +Without the clamp, rule 1 would let a 3-day clock rollback turn a 12-hour lock +into a 3-day wait — correct on the anti-tamper axis and a violation of section +1.5.4's guarantee that the worst case is 24 hours. Over-extending slightly is +the acceptable failure; ending early defeats the mechanism entirely. + +Covered by `wall_clock_correction_respects_both_bounds`. + +--- + + +## ADR-10: Never-block list is hardcoded + +**Status:** Accepted + +**Context.** Section 1.5.4 requires phone/dialer, emergency calls and SOS, and +emergency alerts to be unblockable, plus the desktop shell and Settings basics. + +**Decision.** A `const` list in `core/src/never_block.rs`, per platform. Not a +setting. No flag reverses it. + +**Consequences.** The check happens inside the engine's target loop *after* +schedule evaluation, so a schedule naming a protected target still blocks its +other targets while the protected one cannot enter the blocked set. Verified by +`engine_cannot_block_a_never_blocked_app`. + +The terminal is deliberately **not** on the list. Section 1.5.4 names the shell +and Settings basics; the terminal can undo anything, so protecting it would be +incoherent. + +--- + + +## ADR-11: We do not block ADB, safe mode, or recovery + +**Status:** Accepted + +**Context.** Section 6.7 states that friction is not a wall: a user with ADB, +root, safe mode, a factory reset, or desktop admin rights can always remove the +app. Section 1.5.4 makes factory reset, safe mode, and OS-level recovery things +we never interfere with. + +**Decision.** We do not attempt to block any of them. This is a product +decision, not a gap in the implementation, and it must not change. + +**Consequences.** The goal is to outlast an impulse, not to defeat a determined +person. Every limitation in `LIMITATIONS.md` that could be read as a weakness is +mostly this decision showing through. The docs must never imply otherwise — the +words "unbypassable" and "tamper-proof" are banned from this project for exactly +this reason. + +--- + + +## ADR-12: v1 scope excludes iOS, cloud, sync, and billing + +**Status:** Accepted + +**Context.** Section 1.8 places these out of scope for v1, and requires the +storage layer to leave room for sync later without building it. + +**Decision.** No iOS, no accounts, no sync, no billing. All features are free. + +**Consequences.** Schedules carry stable string ids rather than being keyed by +position, so a future sync layer has something stable to reconcile on. Nothing +else is built for sync; designing for it beyond that would be speculative. + +--- + + +## ADR-13: Zero external dependencies in M0 + +**Status:** Accepted + +**Context.** Section 3 requires every added crate to justify its binary-size +cost. The development machine has 9.7 GB free on a 90%-full disk. + +**Decision.** No dependencies in M0. Not in `blocker-core`, which the purity test +enforces, and not in the workspace. + +**Consequences.** The golden-table format is designed in M0 as a contract but +cannot be parsed until serde is added in M1, because adding a dependency is a +decision requiring the owner's sign-off (section 0 rule 3). That is the correct +order: the format is fixed before it is implemented, so M1 implements against a +contract rather than retrofitting one. + +--- + + +## ADR-14: Linux enforcement backend order — KWin, X11, process + +**Status:** Accepted + +**Context.** Section 4.3 requires compositor-specific Wayland backends and a +process-based fallback. The development machine is KDE Plasma 6.6.6 running a +Wayland session with XWayland at `DISPLAY=:0`. + +**Decision.** One `ForegroundWatcher` trait, three implementations, selected at +runtime in this order: + +1. **`KwinBackend`** — a small KWin script loaded over + `org.kde.KWin /Scripting`, which hooks `windowActivated` and reports back. + Verified present on this machine: `loadScript`, `start`, `unloadScript`, + `isScriptLoaded`. +2. **`X11Backend`** — `x11rb` on `_NET_ACTIVE_WINDOW`, resolved through + `_NET_WM_PID` to `/proc//exe`. Portable, and testable headless with Xvfb, + so this is also the CI-exercised path. +3. **`ProcessBackend`** — no idea what has focus; acts on process creation. + +**Consequences.** The choice is logged at startup and surfaced on the +troubleshooting screen, because "why didn't it block X" is the question users +actually ask. + +Two honest limits, both recorded in `LIMITATIONS.md`: + +- `X11Backend` is blind to native Wayland apps under a Wayland session. This is + why it is the fallback and not the primary — an X11-only design would be blind + to most of this user's own desktop. +- `/proc/connectors` does not exist on this kernel, so the netlink proc-connector + process watcher is unavailable and `ProcessBackend` must use eBPF or `/proc` + polling. Both are worse than a block screen, which is why this is a safety net + rather than the product. + +The wlroots and GNOME backends stay in M7 per section 8, since no portable API +exists to build them against. + +--- + + +## ADR-15: Linux-first milestone order + +**Status:** Accepted — the owner chose this reordering (2026-10-01) + +**Context.** Section 8 places Android MVP as M2 and desktop as M3. The owner +asked for Linux first. + +**Decision.** Reorder to: M0 bootstrap, M1 core, **M2-Linux**, M3-Android, M4 +conditions, M5 websites, M6 Strict Mode and friction, M7 statistics, settings, +Wayland backends and releases. Section 8 is amended to match so the spec and the +work do not diverge. + +**Consequences.** The rationale is practical: this machine is the only place the +daemon can be run, broken, and re-tested daily, and no phone is attached. +Building Android first would mean weeks of code that cannot be exercised +end to end. + +Content within each milestone is unchanged. One item moves with the reorder: +the KWin backend, which section 8 would have placed in M7, is now part of M2-Linux +(ADR-14) because a Linux build that ignores Wayland would not work on this +machine. + +--- + + +## ADR-16: Golden tables are JSON, not TOML + +**Status:** Proposed — depends on ADR-4 and the serde decision + +**Context.** Section 7 requires golden decision tables as the cross-platform +parity contract. The format must be serialisable and diffable in review. + +**Decision.** JSON, one file per concern, under `tests/golden/`. + +**Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and +`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate +justify its cost. JSON is also what a review diff reads most naturally. + +**Not yet decided.** The parse layer needs serde, which is a dependency decision +requiring the owner's sign-off. The format itself is written down in +`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4. + +--- + + +## ADR-17: Licence not yet chosen + +**Status:** **Open — owner decision required** + +**Context.** The project has no licence. This matters more than usual here, +because of ADR-6: Slint is GPL-3.0 or commercial, so the licence choice and the +UI toolkit choice interact. + +**Decision.** None yet. `LICENSE.md` states the project is unlicensed and that a +licence will be chosen before the first public release, rather than picking one +silently. + +**Why this is not decided here.** Choosing a licence is the owner's decision, +and section 0 rule 3 requires stopping to ask. Silently applying MIT or GPL would +be the wrong kind of helpfulness: it is a legal commitment made on someone's +behalf, and it interacts with the toolkit choice in ADR-6. + +**Default until answered:** the project stays unlicensed and unpublished. Nothing +in the plan depends on the answer except the ADR-6 toolkit shortlist. \ No newline at end of file diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md new file mode 100644 index 0000000..19d69ea --- /dev/null +++ b/docs/LIMITATIONS.md @@ -0,0 +1,195 @@ +# Limitations + +CLAUDE.md section 6 is titled "Known hard limits (tell me, don't hide them)". This +file is that promise, kept in writing. + +Two rules govern the wording here. Every limitation is stated as a fact about the +operating system, not as a bug with a workaround. And no entry uses the words +"unbypassable", "tamper-proof", "guaranteed", or "secure" — section 6.1 and 6.7 +require those claims not be made, and writing around them is how a document +quietly starts lying. + +Nothing in this file is aspirational. Where a limitation is not yet implemented, +it says so. + +--- + +## The safety guarantees, stated first + +Before the limits, the guarantees. Section 1.5.4 makes these non-negotiable, and +they constrain what the limitations below are allowed to be. + +- **Never-block list.** Phone, dialler, emergency calls and SOS, and emergency + alerts can never be blocked. On desktop, the OS shell and the Settings basics + needed to keep the machine usable. Hardcoded in + `core/src/never_block.rs`, not a setting, with no flag that reverses it. The + check happens inside the engine after schedule evaluation, so a schedule naming + a protected target blocks its other targets and cannot block the protected one. +- **Finite worst case.** The longest possible path to disabling blocking is 24 + hours. No lock exists without an exit. Asserted by + `worst_case_disable_path_is_capped`. +- **Recovery is never interfered with.** Factory reset, safe mode, and OS-level + recovery are not blocked, and never will be. Section 6.7: that is correct + behaviour, not a gap. +- **Fail open.** A service crash loop or a corrupt database must never leave a + device unusable. Recovery renames the damaged file aside and starts clean + rather than failing to launch. +- **Opt-in only.** Strict and Stubborn are never enabled by default, and never by + a schedule template. +- **Always escapable.** The "How to turn this off" documentation is reachable + *without* disabling anything, including under Strict Mode. + +--- + +## 1. Desktop Strict Mode cannot be made tamper-proof against root or admin + +**Status:** fundamental. Not being worked around. + +On desktop, a user with administrative rights, or root, can stop the service, edit +its files, delete it, or inspect its memory. No design prevents this. Section 6.1 +requires this be documented honestly and that we not claim otherwise. + +Mitigations actually implemented: + +- A watchdog service that restarts the daemon if it dies. +- File ACLs, and where the filesystem supports it, an immutable flag on config. +- Restart-on-kill, so a kill is not durable. +- A tamper log, so the attempt is recorded even though it succeeds. + +What none of this changes: a determined user with root removes the app. The goal +is to outlast an impulse, not to defeat a determined person. + +## 2. Desktop website blocking has no single clean mechanism + +**Status:** decided (ADR-7), with a residual gap. + +Shipping a Firefox extension over native messaging. That makes keyword and +path-level blocking work, and unlike DNS or a hosts file it is not defeated by +DoH, because it inspects the URL in the browser rather than the network path. + +The costs are real: the user must install an extension and grant permissions that +look alarming, and Firefox-only for now. + +**The residual gap:** an extension covers browsers it is installed in. A +determined user can `curl`, or use a browser without it. This is section 6.7 +territory — friction, not a wall. + +DNS/hosts-only blocking was rejected because it cannot do keyword or path-level +matching and cannot defeat DoH. Doing both was rejected because two mechanisms +means two sources of truth, and this project has a strong interest in exactly one +decision path. + +## 3. Wayland focused-window detection is compositor-dependent + +**Status:** partially implemented (ADR-14), partly not. + +There is no portable API for "which window has focus" on Wayland. Each compositor +has its own mechanism, and none of them is a standard. + +Implemented: + +- **KDE Plasma** — a script loaded over `org.kde.KWin /Scripting`. Verified + available on the development machine. +- **X11** — `x11rb` on `_NET_ACTIVE_WINDOW`. Works on any X11 session, and on + XWayland clients under Wayland. **Blind to native Wayland apps**, which is why + it is a fallback rather than the primary. +- **Process-based fallback** — no idea what has focus, acts on process creation, + and cannot show a meaningful block screen. + +Not implemented, still in M7: + +- **wlroots** compositors, via `foreign-toplevel-management`. +- **GNOME**, via an extension. + +A further degradation on this machine specifically: `/proc/connectors` does not +exist on its kernel, so the netlink proc-connector process watcher is +unavailable. The process fallback must use eBPF or `/proc` polling instead, both +of which cost CPU and none of which can produce a proper block screen. + +## 4. Location schedules are mobile-only + +**Status:** implemented as a deliberate absence on desktop. + +There is no reliable desktop equivalent. The engine carries the condition and +validation *rejects* it on non-Android platforms +(`ValidationError::LocationOnDesktop`), and the desktop UI hides it. + +This is a design choice rather than an omission. A condition that is offered but +never fires is worse than one that is not offered: it teaches the user the app +does not work. + +## 5. Android OEMs kill background services aggressively + +**Status:** not yet implemented. M3-Android. + +Xiaomi, Huawei, Samsung and others kill background services in ways that vary by +model and by version. An accessibility-service-based blocker is exactly the kind +of process these vendors target. + +The plan is a troubleshooting screen covering autostart and battery settings per +OEM. **Not yet written**, and no OEM-specific workaround exists yet. + +## 6. Android accessibility URL reading breaks when browser UIs change + +**Status:** not yet implemented. M5. + +Reading a browser's URL bar through the accessibility tree means depending on that +browser's internal view structure, which changes without notice. Every browser +needs its own resource-id table, and those tables rot. + +The plan is a per-browser table with a maintenance path. **No table exists yet**, +and there is no mechanism for a user to report a broken browser other than an +issue. This will be a recurring support cost, not a one-time fix. + +## 7. Friction is not a wall + +**Status:** by design. Section 1.5.4 and 6.7. + +A user with ADB, root, safe mode, a factory reset, or administrative rights on +desktop can always remove this app. Blocking ADB, safe mode, or OS recovery is +not attempted and never will be — ADR-11. + +What the friction ladder does buy is time, in the moment, against an impulse. A +user who decides calmly and deliberately still gets through, within a bounded 24 +hours. + +Stating this plainly matters for a reason beyond honesty: a product that claims to +be unremovable is a product whose users cannot trust its other claims either. + +## 8. Play Store policy is hostile to apps that resist uninstall + +**Status:** accepted. Distribution decision not yet finalised. + +Accessibility and Device Admin use require policy justification, and both are used +here for core functionality. An app that resists uninstall is close to +categorically unpublishable. + +The plan is to keep the strongest friction behind an explicit opt-in and treat a +sideload or F-Droid flavour as the primary distribution for it. **Not yet built.** + +--- + +## Limits specific to this implementation + +Not platform limits — known shortfalls in the current code, recorded so they are +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. +- **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 + the Normal level unconditionally. +- **The golden tables cannot be parsed.** The format is specified in + `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. +- **`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 + development machine, so writing enforcement now would mean shipping unverified + code. \ No newline at end of file diff --git a/docs/PERF.md b/docs/PERF.md new file mode 100644 index 0000000..9665d64 --- /dev/null +++ b/docs/PERF.md @@ -0,0 +1,115 @@ +# Performance + +Two tables. **Targets** are from CLAUDE.md section 5 and are fixed. **Results** is +what was actually measured, and it is currently almost empty because almost +nothing is built yet. + +The separation is deliberate. A single table mixing aspiration with measurement +invites reading the aspiration as the result. + +## Targets (CLAUDE.md section 5) + +| Metric | Android | Windows / Linux daemon | +|---|---|---| +| Idle CPU | ~0% (no wakeups without events) | ~0%, ≤ 1 wakeup/min when idle | +| Idle RAM | ≤ 30 MB PSS | ≤ 15 MB RSS (daemon), UI process exits when its window closes | +| Block latency (foreground change → block shown) | ≤ 150 ms | ≤ 150 ms | +| Daemon binary | — | ≤ 5 MB stripped | +| APK size | ≤ 10 MB (release, R8) | — | +| Battery | no wakelocks outside active enforcement | — | + +The release profile in the workspace `Cargo.toml` carries `opt-level = "z"`, LTO, +`codegen-units = 1`, `panic = "abort"`, and `strip = true`, which is what makes the +size budget reachable. `scripts/size-check.sh` enforces it in CI from the first +commit. + +## Results + +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 | +| 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. + +## How to measure + +### Linux daemon + +```sh +# Binary size — the gate CI runs +scripts/size-check.sh + +# Idle RSS +./systemctl --user start blocker +ps -o rss= -C blocker-daemon | awk '{printf "%.1f MB\n", $1/1024}' + +# Idle CPU — sample over a minute, not instantaneously +pidstat -p "$(pidof blocker-daemon)" 10 6 + +# Block latency — timestamp on the compositor event, timestamp on the window map +# TODO: script this in M2-Linux once the KWin backend emits both. +``` + +`perf stat -e context-switches,cpu-migrations` is useful for the wakeup count. If +idle wakeups exceed one per minute, a timer is firing that `next_wakeup` did not +ask for — that is the first thing to look for. + +### Windows + +Process Explorer, or ETW. The relevant counters are working set (RAM) and the +process's CPU over a sampled window. + +### Android + +```sh +adb shell dumpsys meminfo vu.valiq.blocker # PSS, must be <= 30 MB +adb shell dumpsys batterystats vu.valiq.blocker +adb shell am profile start --sampling 1 vu.valiq.blocker /data/local/tmp/p +``` + +Perfetto for the wakeup analysis. A wakeup from the blocked package when nothing is +happening is a bug in `next_wakeup` accounting, and battery stats will show it +long before a user complains. + +## Why idle is hard, and what breaks it + +The budget is zero idle wakeups, so the common causes are worth naming. + +1. **A timer that fires to check something.** The engine's job is to make these + unnecessary. Every condition that contributes a `next_wakeup` boundary is a + deliberate, named commitment. A new timer that does not correspond to a + boundary is a bug. +2. **Conditions that should contribute no boundary but do.** Wi-Fi and location + schedules are changed by OS events — NetworkManager's callback, the geofencing + API. Arming a timer for them would be a wakeup for nothing. + `wifi_schedule_declares_no_time_wakeup` pins this. +3. **The UI holding a GL context.** A resident graphics context costs both RAM and + compositor wakeups. This is why the daemon and the block window are separate + processes: the window exits when the block lifts, and nothing holds a context + while idle. +4. **Unbounded `Vec` growth in the engine.** `blocked_apps` is rebuilt per + evaluation. With a handful of schedules it is small, but a schedule set in the + thousands would allocate per focus event. `next_wakeup` returning `None` means + the daemon is asleep and this does not matter, which is exactly why the budget + and the design reinforce each other. +5. **Storage polling.** Nothing in the design polls the database. If a future + change introduces it, it will show up here first. + +## Recording a result + +Add a row to the table above with the date and the tool used. Then note in the +commit message whether the change touches the hot path — section 9 requires a +re-measurement when it does. + +A milestone is not done until its acceptance checks pass **and** its numbers are +recorded here (section 7). \ No newline at end of file diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 0000000..51b84fb --- /dev/null +++ b/docs/PERMISSIONS.md @@ -0,0 +1,133 @@ +# Permissions + +Every permission the design requires, per platform, with what it is for, what +degrades without it, and how invasive it feels. + +Two permissions ask for more than a user expects. This file says so rather than +burying it: **accessibility service** on Android, and **browser extension +permissions** on desktop (ADR-7). Both grant the ability to observe and control +other applications. There is no way to provide the feature without them. + +Where an optional permission is refused, the documented behaviour is what +happens — not a silent failure. + +--- + +## Android + +### Required + +| Permission | Why | Without it | +|---|---|---| +| `BIND_ACCESSIBILITY_SERVICE` | Foreground-app detection. Section 4.1 specifies `TYPE_WINDOW_STATE_CHANGED`, which is event-driven and needs no polling. | **Nothing blocks.** This is the single permission the product cannot work without. | +| `SYSTEM_ALERT_WINDOW` | The block screen is an overlay that must appear over whatever the user just opened. | No block screen. Blocking would have to become a kill, which is far more destructive and loses the user's state. | +| `PACKAGE_USAGE_STATS` | Usage-limit and launch-count conditions (section 1.2), and historical statistics (section 1.6). Granted via Settings, not at install. | Usage and launch-count conditions are unavailable. Time conditions still work. | +| `BIND_DEVICE_ADMIN` | Uninstall protection under Strict Mode (section 1.4). | Strict Mode cannot block uninstall. The other restrictions still apply. | +| `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SPECIAL_USE` | A persistent notification so the service is not killed silently. | The service is far more likely to be killed by the OS, with nothing telling the user why. | +| `RECEIVE_BOOT_COMPLETED` | Re-apply blocks after a reboot. | Enforcement does not survive a reboot until the app is opened by hand. | +| `POST_NOTIFICATIONS` | The persistent notification itself. | The foreground service runs without its notification, and users cannot tell it is running. | +| `QUERY_ALL_PACKAGES` | Enumerating installed apps for allowlist mode (section 1.1) and for resolving a desktop-file id to a name. | Allowlist mode cannot enumerate, so it blocks nothing (fail open, section 1.5.4). Blocklist mode is unaffected. | + +**On `QUERY_ALL_PACKAGES`:** this one is Play-Store-sensitive and needs a policy +justification. It is requested because allowlist mode requires knowing what is +installed, and there is no narrower permission that does that job. + +### Optional + +| Permission | Why | Without it | +|---|---|---| +| `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` | Location conditions (section 1.2). | The condition is hidden in the UI rather than offered and never firing. Mobile-only per section 6.4. | +| `ACCESS_WIFI_STATE` | Wi-Fi SSID conditions (section 1.2). | SSID conditions unavailable. Time conditions unaffected. | +| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Exemption from Doze, which would otherwise suspend enforcement. | Works, but only while the screen is on, on most OEMs. | + +### Behaviour when location is unavailable + +Section 1.7 makes this configurable. The default is **do not block**: a lost GPS +fix should not mean a locked device. The alternative — treat unavailable as +present — exists for schedules whose purpose is to block *leaving* somewhere. + +### OEM autostart + +Section 6.5: Xiaomi, Huawei, and Samsung kill background services in +model-specific ways that a permissions dialog cannot fix. Autostart is granted in +the vendor's own UI, not Android's. + +| Vendor | Where | +|---|---| +| Xiaomi / Redmi | Settings → Apps → Autostart | +| Huawei / Honor | Settings → Battery → App launch, then enable auto-launch, secondary launch, and run in background | +| Samsung | Settings → Apps → Blocker → Never sleeping apps | +| OnePlus / Oppo | Settings → Battery → App battery management → Allow background activity | + +**Not yet implemented.** The troubleshooting screen arrives in M7. Until then +these workarounds are not documented anywhere the user can find. + +--- + +## Linux + +The X11 and KWin paths need no special permissions. That is the main advantage +over Android and worth stating plainly. + +| Mechanism | Needs | Notes | +|---|---|---| +| KWin script over `org.kde.KWin /Scripting` | Nothing | Session D-Bus, available to any process in the session. Verified present on KDE Plasma 6.6.6. | +| X11 via `x11rb` | Nothing | `DISPLAY` access only. **Blind to native Wayland apps.** | +| NetworkManager SSID via D-Bus | Nothing | Session bus. | +| `systemd --user` service | `loginctl enable-linger` for post-logout operation | Without linger, the daemon stops when the user logs out, which is usually correct. | +| Process watching via `/proc` polling | Nothing | Used only by the degraded fallback. | +| Process watching via eBPF | Likely **CAP_BPF / CAP_SYS_ADMIN**, or permissive sysctls | Not verified on the development machine. | + +### What an unprivileged daemon cannot do + +Recorded because it is a platform limit, not a missing feature. See +`desktop/platform-linux/src/lib.rs::UNPRIVILEGED_LIMITS`: + +- Prevent a process from launching. It can only react after the fact. +- Mask a systemd unit, which needs root. Tamper resistance is `Restart=always` + plus a watchdog, and nothing stronger. +- Protect its own configuration from the user who owns the files. Lock state + lives in the database instead (section 1.5.3). +- Read `/proc//exe` for processes owned by other users. +- Stop a process already running as root. + +**Not verified:** whether eBPF process tracing works unprivileged here. The +relevant sysctls (`kernel.unprivileged_bpf_disabled`, +`kernel.perf_event_paranoid`) were not read, so the eBPF path is assumed +unavailable rather than known to work. `/proc` polling is the assumed fallback. + +--- + +## Windows + +Nothing implemented yet, so nothing is measured or verified. From section 4.2: + +| Mechanism | Needs | Notes | +|---|---|---| +| `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` | Nothing | Foreground detection. Event-driven, no polling. | +| Minimise / close a window | Nothing | Standard user-session APIs. | +| Terminating a process owned by another user | Elevation | Same-user processes need nothing. | +| WLAN API notification callback | Nothing | Wi-Fi SSID conditions. | +| Windows Service watchdog | Administrator, to install | Restarts the per-user daemon if it dies. | + +--- + +## Browser extension (desktop, ADR-7) + +The extension is a separate permission surface from the app, and arguably the most +alarming one — it is worth being precise about what it asks for. + +| Permission | Why | Without it | +|---|---|---| +| Host permissions for `` | Read the URL of every page the user visits, to match domains and keywords. | Nothing blocks in the browser. | +| `webRequest` or equivalent URL observation | Same, plus redirect handling. | Domain blocking degrades to what the user navigates directly. | +| `nativeMessaging` | Ask the daemon whether to block. | **No blocking at all.** The extension holds no blocking logic of its own, by design (ADR-7). | + +The extension contains no blocking logic. It asks the daemon, which returns a +decision from the same engine that decides apps. That is why the permission looks +broader than its behaviour: it observes every URL so it can ask about it, but it +cannot block anything the engine does not decide to block. + +**The residual gap, stated plainly:** an extension covers browsers it is installed +in. `curl`, or a browser without the extension, is not covered. That is section +6.7 — friction, not a wall. \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..fa175b5 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,196 @@ +# Testing + +## What the golden tables are for + +CLAUDE.md section 7 requires that the same decision table runs against the core +and serves as the parity contract for all platforms. + +The point is not to test the engine — unit tests do that. It is to make +cross-platform divergence a **build failure** rather than a bug report from +whichever platform nobody was testing that week. + +``` +golden/*.json ──┬──▶ core::evaluate ──▶ must match `expect` + ├──▶ Linux identity mapping ──▶ must match `expect` + └──▶ Android identity mapping ──▶ must match `expect` +``` + +Each platform implements a mapping from its own app identity (Android package +name, Linux desktop-file id, Windows exe path) onto a `Target`. The engine is +identical. If the mappings disagree about what an app *is*, a table case catches it. + +## The rule that keeps tables meaningful + +**A case is added before the engine changes.** + +A table written after the code records current behaviour. It can confirm nothing +changed and can never catch a regression, because it was fitted to whatever the +code happened to do. + +So a PR that changes engine behaviour is reviewed on one question: *is there a +case added or updated in this same commit?* If not, the change is undocumented +behaviour, and that is a review failure, not a nit. + +## Format + +JSON, one file per concern, under `tests/golden/`. The full type contract is in +`tests/src/golden.rs`; that file is written before any parse layer exists, so M1 +implements against a fixed format. + +```json +{ + "concern": "overnight", + "cases": [ + { + "name": "mon 22:00-06:00 window still active at tue 03:00", + "covers": "7:overnight time ranges", + "schedules": [ + { "id": "s1", "name": "Night", "mode": "Blocklist", + "targets": [{"type": "app", "app": "com.example.social"}], + "conditions": [{"type": "Time", "days": [0], + "start": "22:00", "end": "06:00"}] } + ], + "input": { "civil_time": "2026-10-06T03:00:00+02:00" }, + "expect": { + "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}], + "next_wakeup": "2026-10-06T06:00:00+02:00" + } + } + ] +} +``` + +Two conventions that matter: + +- **`days` is Monday = 0**, matching `time::Weekday`. +- **`start > end` means overnight.** No `crosses_midnight` flag — the comparison + already encodes it. +- **`civil_time` always carries an explicit UTC offset**, so a DST case is + unambiguous. +- **`next_wakeup: "none"`** is a legal, meaningful expectation. It asserts the + platform may sleep forever, which is the zero-idle-wakeup budget made + testable. + +## Mandatory coverage + +Every entry in `golden::MANDATORY_COVERAGE` must be claimed by at least one +`covers` field, and a test asserts it — so a scenario cannot silently lose its +test when someone reorganises files. + +| Tag | Scenario | +|---|---| +| `7:overnight time ranges` | Windows spanning midnight, including the weekday-of-start bug | +| `7:DST changes` | Spring-forward (a local time that does not exist) and fall-back (an ambiguous one) | +| `7:timezone changes` | Same instant, different local offset | +| `7:clock tampering` | System date moved; a lock must not shorten | +| `7:strictest-wins` | Overlapping schedules, blocklist and allowlist together | +| `7:allowlist mode` | Complement semantics, and the empty-inventory fail-open case | +| `7:invalid condition combos` | Each forbidden pairing from section 1.2 | +| `7:strict mode lock rules` | Section 1.4: no editing, pausing, or uninstalling | +| `1.2:time+usage rejected` | | +| `1.2:time+launch rejected` | | +| `1.2:usage+launch rejected` | | +| `1.2:usage+location rejected` | | +| `1.2:launch+location rejected` | | +| `1.5.4:never-block list is unblockable` | | +| `1.5.4:worst case 24h` | | + +## Tables cannot express the passage of time + +Most cases are pure data. Clock tampering is not: proving that moving the system +date never shortens a lock requires actually moving a clock, which no JSON file +can express. + +Those cases live as code fixtures in `tests/src/purity.rs` instead, and the two +coexist deliberately: + +- **Tables describe decisions.** Given a state, what is the decision? +- **Fixtures describe time.** Given that time moved, does the lock behave? + +Current fixtures, all of which pass: + +| Fixture | What it proves | +|---|---| +| `backwards_wall_clock_never_shortens_a_lock` | Setting the date back 3 days does not shorten a 12h lock | +| `wall_clock_correction_respects_both_bounds` | A skew extends, and clamps to the 24h cap | +| `an_undisturbed_clock_costs_nothing` | An honest clock adds no waiting | +| `reboot_resumes_conservatively` | A reboot does not reset a lock | +| `worst_case_disable_path_is_capped` | No level exceeds 24h | +| `never_block_list_is_unblockable` | The safety list is protected and does not over-match | +| `engine_cannot_block_a_never_blocked_app` | The engine itself enforces it, not just the list | +| `dependency_purity` | `blocker-core` still has zero dependencies | + +## Engine unit tests + +Alongside the tables, `core/src/engine.rs` carries tests for the logic that is +hard to express as data — overnight windows, weekday indexing, boundaries, the +allowlist complement, and `next_wakeup` behaviour. + +`weekday_indexing_is_monday_zero` is the anchor test. If weekday arithmetic breaks, +every other overnight test is quietly testing the wrong day, so this one asserts +its own calendar dates directly. + +Two tests exist specifically to protect the idle budget: + +- `wifi_schedule_declares_no_time_wakeup` — a schedule only an OS event can change + must not arm a timer. +- `inactive_schedule_yields_no_wakeup` — a schedule that is inactive now but starts + later must still arm a wakeup. This is the subtle direction: over-sleeping means + the schedule never fires. + +## Property tests + +To be added in M1, once the tables exist. Candidates, in rough priority order: + +- `time_window_contains` never panics for any `days`/`start`/`end` combination. +- `evaluate` is deterministic: same input, same output, always. +- A never-blocked target never appears in `blocked_apps`, for any schedule set. +- `next_wakeup`, when present, is strictly in the future relative to `now`. +- Strictest-wins is monotonic: adding a schedule never unblocks anything. + +## Platform testing + +### Linux + +The X11 path is testable headless with Xvfb, which makes it the CI-exercised +backend. The KWin path cannot be — it needs a real Plasma session — so its +testing is manual, against the checklist below. + +Manual, on a real session: + +- [ ] Focusing a blocked native Wayland app raises the block screen within 150 ms +- [ ] Focusing a blocked XWayland app does the same +- [ ] Focusing an allowed app shows nothing +- [ ] The daemon survives a suspend/resume cycle +- [ ] The daemon survives logout/login +- [ ] Killing the daemon restarts it, and the tamper log records it +- [ ] The backend actually selected is the one expected on this session type, and + the troubleshooting screen says which and why +- [ ] With KWin scripting disabled, the daemon falls back and says so +- [ ] Idle over 10 minutes: no wakeups, RSS under 15 MB + +### Android + +Instrumented tests for the AccessibilityService path, which cannot run on a +desktop JVM. M3-Android. + +### Manual checklist, per OS + +The full checklist lives in `docs/TESTING.md` and grows with each milestone. Every +entry maps to a section 1 requirement, so an uncovered requirement shows as an +uncovered checklist line. + +## Running + +```sh +scripts/ci.sh # everything, stop at first failure +scripts/ci.sh --keep-going # everything, summarise all failures +cargo test --workspace # just the tests +``` + +A milestone is not done until its acceptance checks pass **and** its performance +numbers are recorded in `PERF.md` (section 7). + +If a check fails, it gets reported and fixed. Weakening a test to make it pass is +prohibited by section 10.6, and a test deleted to make a build green is the same +mistake by another route. \ No newline at end of file