5073c7321d
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 <noreply@anthropic.com>
450 lines
18 KiB
Markdown
450 lines
18 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, 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** |
|
|
|
|
---
|
|
|
|
<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, 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.
|
|
|
|
---
|
|
|
|
<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. |