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>
This commit is contained in:
2026-10-01 15:22:24 +03:00
parent 5c50742a61
commit 8a521e760e
6 changed files with 1314 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
# 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` | 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:
```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.