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 004479a032
commit 5073c7321d
6 changed files with 1314 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
# Performance
Two tables. **Targets** are from CLAUDE.md section 5 and are fixed. **Results** is
what was actually measured, and it is currently almost empty because almost
nothing is built yet.
The separation is deliberate. A single table mixing aspiration with measurement
invites reading the aspiration as the result.
## Targets (CLAUDE.md section 5)
| Metric | Android | Windows / Linux daemon |
|---|---|---|
| Idle CPU | ~0% (no wakeups without events) | ~0%, ≤ 1 wakeup/min when idle |
| Idle RAM | ≤ 30 MB PSS | ≤ 15 MB RSS (daemon), UI process exits when its window closes |
| Block latency (foreground change → block shown) | ≤ 150 ms | ≤ 150 ms |
| Daemon binary | — | ≤ 5 MB stripped |
| APK size | ≤ 10 MB (release, R8) | — |
| Battery | no wakelocks outside active enforcement | — |
The release profile in the workspace `Cargo.toml` carries `opt-level = "z"`, LTO,
`codegen-units = 1`, `panic = "abort"`, and `strip = true`, which is what makes the
size budget reachable. `scripts/size-check.sh` enforces it in CI from the first
commit.
## Results
Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1.
| Metric | Target | Measured | When |
|---|---|---|---|
| Daemon binary (stripped, release) | ≤ 5 MB | **0.279 MB** | 2026-10-01, M0 |
| Block latency | ≤ 150 ms | not measured | — |
| Idle CPU | ~0% | not measured | — |
| Idle RAM (daemon) | ≤ 15 MB RSS | not measured | — |
| Idle wakeups | ≤ 1/min | not measured | — |
| APK size | ≤ 10 MB | not measured | — |
The binary figure is real but nearly meaningless right now: the daemon is two
`println!` lines. The meaningful measurement comes in M2-Linux once enforcement
exists, and the real risk is at that point — D-Bus, X11, and a UI toolkit are
where 5 MB would actually be threatened.
## How to measure
### Linux daemon
```sh
# Binary size — the gate CI runs
scripts/size-check.sh
# Idle RSS
./systemctl --user start blocker
ps -o rss= -C blocker-daemon | awk '{printf "%.1f MB\n", $1/1024}'
# Idle CPU — sample over a minute, not instantaneously
pidstat -p "$(pidof blocker-daemon)" 10 6
# Block latency — timestamp on the compositor event, timestamp on the window map
# TODO: script this in M2-Linux once the KWin backend emits both.
```
`perf stat -e context-switches,cpu-migrations` is useful for the wakeup count. If
idle wakeups exceed one per minute, a timer is firing that `next_wakeup` did not
ask for — that is the first thing to look for.
### Windows
Process Explorer, or ETW. The relevant counters are working set (RAM) and the
process's CPU over a sampled window.
### Android
```sh
adb shell dumpsys meminfo vu.valiq.blocker # PSS, must be <= 30 MB
adb shell dumpsys batterystats vu.valiq.blocker
adb shell am profile start --sampling 1 vu.valiq.blocker /data/local/tmp/p
```
Perfetto for the wakeup analysis. A wakeup from the blocked package when nothing is
happening is a bug in `next_wakeup` accounting, and battery stats will show it
long before a user complains.
## Why idle is hard, and what breaks it
The budget is zero idle wakeups, so the common causes are worth naming.
1. **A timer that fires to check something.** The engine's job is to make these
unnecessary. Every condition that contributes a `next_wakeup` boundary is a
deliberate, named commitment. A new timer that does not correspond to a
boundary is a bug.
2. **Conditions that should contribute no boundary but do.** Wi-Fi and location
schedules are changed by OS events — NetworkManager's callback, the geofencing
API. Arming a timer for them would be a wakeup for nothing.
`wifi_schedule_declares_no_time_wakeup` pins this.
3. **The UI holding a GL context.** A resident graphics context costs both RAM and
compositor wakeups. This is why the daemon and the block window are separate
processes: the window exits when the block lifts, and nothing holds a context
while idle.
4. **Unbounded `Vec` growth in the engine.** `blocked_apps` is rebuilt per
evaluation. With a handful of schedules it is small, but a schedule set in the
thousands would allocate per focus event. `next_wakeup` returning `None` means
the daemon is asleep and this does not matter, which is exactly why the budget
and the design reinforce each other.
5. **Storage polling.** Nothing in the design polls the database. If a future
change introduces it, it will show up here first.
## Recording a result
Add a row to the table above with the date and the tool used. Then note in the
commit message whether the change touches the hot path — section 9 requires a
re-measurement when it does.
A milestone is not done until its acceptance checks pass **and** its numbers are
recorded here (section 7).