Files
blocker/docs/ARCHITECTURE.md
T
SellPL 5073c7321d docs: add ADR log, architecture, limitations, permissions, perf, testing
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>
2026-10-01 15:22:24 +03:00

10 KiB
Raw Blame History

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.