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>
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:
- A detected skew always extends, taking the absolute value so the direction of the jump cannot matter.
- 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:
KwinBackend— a small KWin script loaded overorg.kde.KWin /Scripting, which hookswindowActivatedand reports back. Verified present on this machine:loadScript,start,unloadScript,isScriptLoaded.X11Backend—x11rbon_NET_ACTIVE_WINDOW, resolved through_NET_WM_PIDto/proc/<pid>/exe. Portable, and testable headless with Xvfb, so this is also the CI-exercised path.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:
X11Backendis 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/connectorsdoes not exist on this kernel, so the netlink proc-connector process watcher is unavailable andProcessBackendmust use eBPF or/procpolling. 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.