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>
10 KiB
Architecture
The one idea
Everything that decides anything lives in one Rust crate, blocker-core. Platform
code observes the OS and enforces decisions. Nothing else decides.
┌──────────────────────────────────────────┐
│ core (Rust) │
│ │
EngineInput ───▶ │ evaluate(input) -> Decision │
│ · Schedule, Target, Condition │
│ · UsageSnapshot, PlatformContext │
│ · CivilTime, Monotonic, BootId │
│ │
│ storage · usage · lock · never_block │
└───────────────┬──────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ android/ │ │ desktop/ │ │ tests/ │
│ Kotlin shell │ │ daemon + UI │ │ golden tables │
│ services, │ │ platform-linux │ │ parity │
│ overlay, UI │ │ platform-win │ │ contract │
└────────────────┘ └─────────────────┘ └─────────────────┘
The purity contract
pub fn evaluate(input: &EngineInput<'_>) -> Decision;
evaluate reads no clock, performs no I/O, and calls no OS API. Everything it
needs arrives in the input: civil time, monotonic time, boot id, the schedule
set, accumulated usage, and platform context such as the current SSID.
This is the load-bearing rule of the project. Two things depend on it:
Testability. The engine can be exercised with no OS present, which is what makes the golden decision tables possible and CI cheap.
Parity. If platform code could reach into the engine, Linux and Android would drift, and the golden tables would stop being a contract — merely a test of the core against itself.
Enforcement is mechanical rather than cultural. tests/purity.rs reads the
resolved dependency graph and fails if blocker-core has any dependency at all,
so a stray SystemTime::now() breaks the build instead of quietly making parity
untestable.
Monotonic time is a separate type
pub struct CivilTime { pub epoch_minutes: i64, pub utc_offset_minutes: i32 }
pub struct Monotonic(pub u64);
EngineInput carries both. Lock arithmetic reads only Monotonic. Schedule
windows read only CivilTime.
Section 1.5.3 requires that changing the system date never shortens a lock. Making the two different types in the input struct means a lock cannot consult the wall clock even by accident — the mistake is a type error rather than a review finding.
next_wakeup is the anti-polling mechanism
Decision::next_wakeup is the earliest instant at which any decision could
change, or None. The platform layer sleeps until that instant or until an OS
event arrives.
That is where the section 5 idle-CPU budget comes from. An idle machine takes zero wakeups, because nothing is scheduled to expire. A timer that fires to discover nothing changed is a wakeup, and this project cannot afford many.
It is also a correctness obligation, which is less obvious. The minimum must be taken over every non-paused schedule, not only active ones:
// Arm the wakeup BEFORE the activity filter, over every non-paused schedule.
for schedule in input.schedules {
if schedule.paused { continue; }
if let Some(boundary) = next_transition(schedule, input) {
next_wakeup = earlier(next_wakeup, boundary);
}
}
A schedule that is inactive now but starts at 09:00 Monday still has to arm a wakeup for 09:00 Monday. Filtering to active conditions first would let the daemon sleep straight through the moment it is supposed to start blocking.
Conditions that only an OS event can change — Wi-Fi SSID, location — contribute no boundary at all. Arming a timer for those would be a wakeup for nothing.
Strictest-wins
blocked_apps is the union over all active schedules: an app is blocked if at
least one schedule wants it blocked. There is no precedence rule to implement and
no way for two schedules to disagree.
Allowlist mode contributes the complement of its own list, which is why
EngineInput carries installed_apps. An empty slice means "could not
enumerate", and the engine then blocks nothing — fail open, per section 1.5.4. A
machine the engine knows nothing about is not a machine it should act on.
Overnight windows
A time condition is (days, start, end) where start > end means the window
spans midnight. No crosses_midnight flag: the comparison already encodes it, and
a second field is a second way to be wrong.
The subtle part is which weekday governs the early hours. A window starting Monday 22:00 and ending 06:00 is still active at 03:00 Tuesday, governed by Monday's weekday — the day the window started. A naive "is now inside today's window" check reads 03:00 as Tuesday, finds Tuesday is not in the set, and stops blocking. That is a nightly schedule silently dying at midnight, and it is the bug this representation exists to prevent.
Pinned by overnight_window_survives_past_midnight and
overnight_window_across_the_week_boundary.
Evaluation happens in local civil time, so "09:00–17:00" stays 09:00–17:00 across a DST change.
Block reasons travel with the decision
pub struct BlockReason {
pub schedule_id: String,
pub schedule_name: String,
pub condition: &'static str,
pub ends_at: Option<CivilTime>,
}
Section 1.3 requires the block screen to say which schedule blocked and when it ends. The engine computes that here, so the UI never re-derives it. Three platforms reading the same decision then tell the user the same story instead of three different ones.
End-to-end data flow, one block event
KWin script ──D-Bus──▶ platform-linux ──▶ ┌──────────────┐
(windowActivated) │ build │
│ EngineInput │
└──────┬───────┘
▼
┌──────────────┐
│ evaluate() │ pure
└──────┬───────┘
│ Decision
▼
platform enforces ◀───────┘
1. show block screen
2. minimise
3. SIGSTOP
4. terminate (configurable, escalating)
The engine is called again when the OS reports a focus change, when next_wakeup
arrives, or on IPC. Nothing polls.
The FFI boundary
evaluate is the entire surface the Android shell needs. uniffi generates the
Kotlin bindings over that one function and the Decision type, so the shell never
sees a schedule's internals and cannot take a shortcut around the engine.
Starting with uniffi (ADR-5); hand-written JNI is the fallback if the measured
APK size cost is unacceptable, which is a measurement before it is a decision.
Module layout
| Module | Responsibility |
|---|---|
model |
Schedule, Target, Condition, ScheduleMode, plus section 1.2 validation |
engine |
evaluate, EngineInput, Decision, BlockReason, next_wakeup |
usage |
foreground-time and launch-count accumulation into daily and hourly buckets |
lock |
lock levels, the friction ladder's timing, monotonic anti-tamper |
never_block |
the section 1.5.4 safety list |
storage |
the SQLite schema and its fail-open recovery policy |
storage has no I/O behind it yet — see LIMITATIONS.md. The schema is written
and reviewed as text so it can be reviewed before it is compiled.
Where the platform crates start and stop
A platform crate may: observe the OS, map its own app identities onto Target,
call evaluate, and enforce the resulting Decision.
It may not: decide whether something should be blocked, re-derive when a block ends, or consult a clock for lock arithmetic.
ForegroundWatcher is the trait every desktop backend implements:
pub trait ForegroundWatcher {
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
fn name(&self) -> &'static str;
fn selection_reason(&self) -> &'static str;
}
The implementations and their honest limits are in ADR-14 and
LIMITATIONS.md §3.
Testing
The golden decision tables in tests/golden/ are the parity contract: one table,
run against the core, and against each platform's identity mapping. A platform
disagreeing with the core is a build failure rather than a bug report.
The rule that keeps them meaningful: a case is added before the engine changes. A table written after the code records current behaviour and can never catch a regression. Reviewing a PR that changes engine behaviour therefore means checking that a case was added or changed in the same commit.
Clock tampering is the exception to "tables are pure data" — proving that moving
the system date never shortens a lock needs a clock that actually moves. Those
cases are code fixtures in tests/src/purity.rs instead. The tables describe
decisions; the fixtures describe the passage of time.