92afdc3bf6
Updates the ADR log, architecture, limitations, perf table and README to match what now exists, rather than what was expected. **The perf number was wrong and is now honest.** The M0 figure of 0.279 MB excluded SQLite entirely — the placeholder binary never referenced the crate, so dead-code elimination dropped it. With the daemon actually opening a database it is **1.364 MB**, of which bundled SQLite is ~1.1 MB. That is 1.1 MB of the 5 MB budget spoken for, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. This is the first number that makes the section 5 budget feel like a budget rather than a formality, and it turns ADR-4's bundled-vs-system-linked choice into a real trade-off with a measured price on both sides. - ADR-4 marked accepted and implemented, with that trade-off left open. - ARCHITECTURE.md documents why `blocker-storage` is a sibling crate and not a submodule: the dependency direction is what keeps the zero-dependency purity proof automatic rather than a grep someone can route around. - LIMITATIONS.md drops the stale "storage has no I/O" shortfall and records the bundled-vs-system question instead, plus the fact that `LockState` still always evaluates to Normal. - README status table and layout updated. Co-Authored-By: Claude Code <noreply@anthropic.com>
472 lines
19 KiB
Markdown
472 lines
19 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 JSON, not TOML | Proposed |
|
|
| [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 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.
|
|
|
|
---
|
|
|
|
<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. |