Files
blocker/docs/DECISIONS.md
SellPL f05836f528
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
feat(tests): add the golden decision tables, and fix two engine bugs they found
The parity contract from CLAUDE.md section 7 now exists: 42 cases across 11
scenario groups, with a runner that asserts every case and a coverage check that
asserts every mandatory scenario is claimed.

**A design change, and why.** ADR-16 originally specified JSON tables, justified
by "serde_json will already be in the tree". That was wrong — `rusqlite` does not
pull `serde` by default and `uniffi` has not landed, so nothing provided it, and
`serde` is not a section 3 dependency (rule 0.3). The tables are therefore a Rust
builder DSL: the same declarations, but a typo in a field name is a compile error
rather than a test that silently asserts the wrong thing. For a document whose
entire job is to be a contract, compile-time checking beats diffability.
Converting to JSON later is mechanical if serde is approved.

**Two engine bugs the tables caught, both real:**

1. **Overnight windows woke the daemon immediately.** `time_window_boundary`
   computed the end as `end - minute` and clamped at zero. For a 22:00–06:00
   window active at 23:00, "06:00 today" is in the past, so the boundary became
   *now* — and the daemon would wake, re-evaluate, get the same answer, and wake
   again, forever. Not a missed block, but a busy loop burning the section 5 idle
   budget. Fixed by detecting the late half of an overnight window and crossing
   midnight properly.

2. **An inactive schedule woke a day early.** The next-start search iterated day
   offsets to test membership but computed the delta as `start - minute` only,
   wrapping by 1440 without advancing the day. A Monday–Friday schedule
   evaluated on Saturday reported Monday's start as 21 hours away — Sunday 09:00,
   a day not in the set. The block still fired, because the next evaluation
   recomputed it, but the daemon woke a day early for nothing. Fixed by folding
   the day offset into the delta.

**Four of my own table cases were wrong too**, which is the other half of why
they are worth having: a UTC+5 offset is 15:00 local and therefore *inside*
09:00–17:00 (I had asserted outside); the same for the DST cases. The engine was
right and the test was wrong in all four.

**Coverage bookkeeping is checked, not promised.** `coverage::Location` records
whether a scenario is covered by a table or by a code fixture — clock tampering
and the 24-hour cap are about the passage of time and a bound across lock levels,
not a decision at an instant, so they cannot be expressed as data. Both
directions fail: an uncovered scenario is an error, and so is a `covers` tag that
is not on the mandatory list, which is how a case quietly stops testing what its
name claims.

**`BlockReason` now carries its target.** A schedule may name twenty targets and
they do not all become blocked — some are paused, some belong to a condition that
is not currently active, some are on the never-block list. Without carrying which
target a reason is about, two blocked apps under one schedule produce two
identical reasons and the UI has to re-derive the pairing, which is exactly the
drift `BlockReason` exists to prevent. The tables could not assert per-app
attribution without it.

**The DST gap is recorded, not papered over.** `CivilTime` is an epoch plus an
offset with no timezone database behind it, so the tables pin that a fixed local
window stays fixed as the offset moves — including across a DST transition — but
cannot express a spring-forward time that does not exist or a fall-back time that
is ambiguous. Those are section 7 cases that are genuinely not covered, and
LIMITATIONS.md now says so.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:29:46 +03:00

20 KiB

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 Rust for core and desktop, Kotlin for the Android shell Accepted
2 One shared core; the engine is a pure function Accepted
3 Event-driven with next_wakeup, never polling Accepted
4 SQLite via rusqlite, in its own crate Accepted, one trade-off open
5 uniffi for core-to-Kotlin bindings Proposed
6 Desktop UI toolkit — Slint vs egui Proposed, needs owner
7 Desktop website blocking mechanism Accepted — owner chose extension
8 Lock levels and explicit opt-in Accepted
9 Monotonic clock for all lock timers Accepted
10 Never-block list is hardcoded Accepted
11 We do not block ADB, safe mode, or recovery Accepted
12 v1 scope excludes iOS, cloud, sync, billing Accepted
13 Zero external dependencies in M0 Accepted
14 Linux enforcement backend order: KWin, X11, process Accepted
15 Linux-first milestone order (amends section 8) Accepted
16 Golden tables are Rust, not JSON Accepted
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

Status: Accepted and implemented, with one trade-off still open

Context. Section 3 specifies rusqlite with bundled SQLite. Section 1.5.3 requires lock and Strict state to live in the core database rather than in editable plaintext config.

Decision. rusqlite with bundled, in a separate blocker-storage crate that depends on blocker-core, never the reverse.

Why the separate crate. The purity contract says the engine does no I/O, and tests/purity.rs enforces that mechanically by failing if blocker-core has any dependency at all. Putting rusqlite in blocker-core would have forced that check to degrade into a source-text grep — something a careful developer can route around, and something nobody reads in six months. Splitting keeps the check automatic.

Both crates sit in the same layer of the section 2 diagram: storage is shared by every platform and contains no platform code. This is a refinement that makes the guarantee checkable, not an architectural departure.

Measured consequences.

  • Bundled SQLite costs ~1.1 MB of the 5 MB stripped daemon budget (1.364 MB total, from 0.279 MB without it). That leaves 3.8 MB for D-Bus, X11 and a UI toolkit — a real margin, not a comfortable one.
  • The build compiles SQLite from C source, which is a noticeable first-build cost.
  • System-linking would reclaim most of the 1.1 MB at the price of a runtime dependency on the host's libsqlite3, and a schema-format mismatch that only appears on a user's machine.

Still open: bundled vs system-linked. Measured cost is now known; the decision is the owner's.

Schema notes. Relational (schedule_target, schedule_condition) rather than JSON blobs in a text column, because target rows are queried by target and an index on a JSON string is not a plan. Serialisation is hand-written in storage/src/codec.rs — serde is not a section 3 dependency, and section 0 rule 3 requires asking before adding one (ADR-13).


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/<pid>/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 Rust, not JSON

Status: Accepted and implemented. The original JSON rationale turned out to be wrong, and is recorded below rather than quietly replaced.

Context. Section 7 requires golden decision tables as the cross-platform parity contract.

What was originally decided, and why it was wrong. JSON, on the reasoning that serde_json would already be in the dependency graph. That was falsified in practice: rusqlite does not pull serde by default, and uniffi had not landed, so nothing in the graph provided it. serde is not a section 3 dependency, and section 0 rule 3 requires asking before adding one.

Decision. A builder DSL in Rust. tests/src/golden.rs holds the format and harness, tests/src/tables.rs the cases, tests/src/coverage.rs the coverage bookkeeping, tests/src/run.rs the runner.

Rationale. The same declarations, with two properties JSON cannot offer: the compiler rejects a typo in a field name, and a wrong variant is a type error rather than a test that quietly asserts the wrong thing. For a document whose entire job is to be a contract, compile-time checking is worth more than diffability.

Converting to JSON later, if serde is approved, is mechanical — only tests/src/golden.rs changes and the case bodies become literals instead of builder calls.

Also settled here. Three scenarios are deliberately not table-shaped. Clock tampering and the 24-hour cap are about the passage of time and a bound across lock levels, not a decision at an instant, so they live as code fixtures in tests/src/purity.rs. coverage::Location records which is which, so "covered" can never quietly mean "listed but not tested".


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.