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

489 lines
20 KiB
Markdown

# 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, in its own crate | Accepted, one trade-off open |
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
| [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 Rust, not JSON | Accepted |
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
---
<a id="adr-1"></a>
## 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.
---
<a id="adr-2"></a>
## 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.
---
<a id="adr-3"></a>
## 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.
---
<a id="adr-4"></a>
## 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).
---
<a id="adr-5"></a>
## 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.
---
<a id="adr-6"></a>
## 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.
---
<a id="adr-7"></a>
## 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.
---
<a id="adr-8"></a>
## 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.
---
<a id="adr-9"></a>
## 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`.
---
<a id="adr-10"></a>
## 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.
---
<a id="adr-11"></a>
## 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.
---
<a id="adr-12"></a>
## 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.
---
<a id="adr-13"></a>
## 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.
---
<a id="adr-14"></a>
## 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.
---
<a id="adr-15"></a>
## 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.
---
<a id="adr-16"></a>
## 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".
---
<a id="adr-17"></a>
## 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.