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>
11 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 |
(separate crate: blocker-storage) the SQLite schema and its fail-open recovery policy |
blocker-storage is a sibling crate, not a submodule. It depends on
blocker-core; blocker-core does not depend on it, and depends on nothing at
all. That direction is what lets tests/purity.rs keep checking "blocker-core has
zero dependencies" — an automatic proof that the engine cannot do I/O — instead of
the weaker and easily-routed-around alternative of grepping the source for
rusqlite.
Both crates occupy the same layer of the diagram above: storage is shared by every platform and contains no platform code.
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.