a43a8e8231
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>
232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# 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
|
||
|
||
```rust
|
||
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
|
||
|
||
```rust
|
||
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**:
|
||
|
||
```rust
|
||
// 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
|
||
|
||
```rust
|
||
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:
|
||
|
||
```rust
|
||
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. |