Files
blocker/docs/DECISIONS.md
T
SellPL 9f1acb1421
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
test(linux): verify the KWin script against the live Plasma session
The KWin JavaScript had never been executed. It is now.

A probe script was loaded into the running Plasma 6.6.6 compositor, which
reported:

    js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
             caption=... internalId={6866aecf-...} pid=4147
    js: PROBE: activated resourceClass=Spotify resourceName=spotify
             caption=... internalId={c99b88b4-...} pid=74378

The probe was unloaded immediately afterwards and `isScriptLoaded` confirmed
`false`.

**This proved one thing I had wrong.** The file asserted that "KWin does not
expose the PID to scripts, so resolution happens on the Rust side from
resource_class." It does expose it — `pid=4147` for Konsole, `pid=74378` for
Spotify. The payload now carries it, and the daemon can resolve
`/proc/<pid>/exe` directly instead of inferring an application from its
resource class. Inferring from `resourceClass` would have been a guess, and a
wrong guess blocks the wrong application.

Also confirmed: `workspace.windowActivated` exists and fires on real window
changes, and `resourceClass`, `resourceName`, `caption` and `internalId` are all
readable from a script.

**Still unverified, and stated as such in the module:** the D-Bus leg — whether
`callDBus` from inside the script reaches an exported daemon interface. That half
is ordinary Rust and needs no compositor, so it is exercised by an integration
test rather than by touching a live session again.

Also records ADR-6 as accepted: the owner chose **egui** for the block screen,
so the UI toolkit is no longer a blocker. Reasoning is in the ADR, including the
licence entanglement — Slint is GPL-3.0, which is why ADR-17 sits upstream of
this decision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 21:06:07 +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 — egui Accepted
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 — egui

Status: Accepted (the owner chose egui, 2026-10-01)

Context. Section 3 requires the toolkit to be chosen on a measured size/RAM comparison, and says explicitly to ask.

Decision. egui, licensed MIT OR Apache-2.0.

Why egui.

  • The licence decides part of it. Slint is GPL-3.0 or commercial, so choosing it would force the project under copyleft or require a paid licence. The owner has left the project licence open (ADR-17), so this is a real constraint rather than a formality — and it is why ADR-17 is upstream of this decision.
  • The budget is adequate. 2.9 MB of the 5 MB stripped daemon budget remained after storage (1.364 MB) and the D-Bus/X11 backends (2.237 MB) were linked in. A block screen is text and a countdown; it does not need more.
  • Idle cost is the deciding factor. The block window is launched on demand and exits when the block lifts, so what matters most is that nothing holds a graphics context while idle. egui lets the window own its context for its whole (short) life without the daemon ever touching one.

What this does not decide. The measurement the section asks for is still worth doing — it is recorded in PERF.md — but it is now confirmatory rather than decisive.

Constraints that apply to whatever is built

  • The block screen shows text, a countdown, and one always-present link. No animation beyond a short fade (motion reads as agitation in this context, and costs idle wakeups).
  • Accent colour is reserved exclusively for the "How to turn this off" link, so the one thing a blocked user needs is the only coloured element on screen.
  • No red, and no shaming copy.

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.