From 5073c7321d37c8c5e698e79218b7fca716692b2f Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 15:22:24 +0300 Subject: [PATCH] docs: add ADR log, architecture, limitations, permissions, perf, testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation set CLAUDE.md sections 2, 6, 8 and 10.1 call for, written as records of decisions actually made rather than as a description of intent. **DECISIONS.md** — 17 ADRs. Two are deliberately left open and say so: ADR-6 (the desktop UI toolkit, which section 3 requires be decided on measurement and which needs the owner's call, partly because Slint is GPL-3.0) and ADR-17 (the licence, which is a legal commitment nobody should make on the owner's behalf). Where CLAUDE.md gives a reason for a decision, that reason is recorded; where it does not, the file says the reason is unrecorded rather than inventing one. **LIMITATIONS.md** — all eight section 6 limits plus the section 1.5.4 safety guarantees, stated as facts about the operating systems. The words "unbypassable", "tamper-proof", "guaranteed" and "secure" appear nowhere, per sections 6.1 and 6.7. Ends with a separate list of shortfalls in the *current code* — storage has no I/O, the golden tables cannot yet be parsed, LockState always returns Normal — so a known gap in this implementation is never mistaken for a platform limit. **ARCHITECTURE.md** — the layering, the purity contract and its mechanical enforcement, why `next_wakeup` must be armed over inactive schedules too, the overnight weekday-of-start rule, and the end-to-end flow for one block event. **PERF.md** — targets and results as separate tables, so an aspiration is never read as a measurement. The one real number recorded (0.279 MB stripped daemon) is labelled as nearly meaningless, since the daemon is two print statements. Lists the common causes of idle-wakeup regressions. **PERMISSIONS.md** — per platform, what each permission is for and what degrades without it. Flags the two invasive ones (Android accessibility, extension host permissions) rather than burying them, and records that unprivileged eBPF is *assumed* unavailable here because the sysctls were never read. **TESTING.md** — the golden-table contract, the add-the-case-first rule, and why clock tampering needs code fixtures instead of data. Co-Authored-By: Claude Code --- docs/ARCHITECTURE.md | 225 ++++++++++++++++++++++ docs/DECISIONS.md | 450 +++++++++++++++++++++++++++++++++++++++++++ docs/LIMITATIONS.md | 195 +++++++++++++++++++ docs/PERF.md | 115 +++++++++++ docs/PERMISSIONS.md | 133 +++++++++++++ docs/TESTING.md | 196 +++++++++++++++++++ 6 files changed, 1314 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DECISIONS.md create mode 100644 docs/LIMITATIONS.md create mode 100644 docs/PERF.md create mode 100644 docs/PERMISSIONS.md create mode 100644 docs/TESTING.md 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