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>
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock
state, disable attempts and usage buckets now persist across restarts.
**Why a new crate rather than a module inside `blocker-core`.** The purity
contract says the engine does no I/O, and `tests/purity.rs` enforces that
mechanically by failing if `blocker-core` has any dependency at all. Putting
rusqlite in `blocker-core` would have forced that check to degrade into a
source-text grep — something a careful developer can route around and nobody
reads in six months. `blocker-storage` depends on `blocker-core`; the reverse
edge does not exist, so the zero-dependency proof stays automatic.
Both crates sit in the same layer of the section 2 diagram: storage is shared by
every platform and contains no platform code.
**Schema is relational, not JSON-in-a-text-column.** `schedule_target` and
`schedule_condition` are separate tables because target rows are queried by
target, and an index on a JSON string is not a plan. Serialisation is
hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency,
and section 0 rule 3 requires asking before adding one.
**Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged
file aside and starts clean, keeping the original for diagnosis. Confirmed
against the real binary, not only in tests:
```
$ printf 'SQLite format 3\x00 not a database' > .../blocker.db
$ blocker-daemon
blocker-daemon: database was corrupt and has been moved to
.../blocker.db.corrupt; starting clean, so no schedules are active
until you re-add them
EXIT=0
```
A truncated file is covered separately — that is what a power loss during a
write actually produces, and it fails differently from garbage bytes.
**Two bugs this PR's own tests caught:**
1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since
that row is by definition the one being tested, the cutoff always sat before
it and the delete matched nothing — the table grew forever, which is the slow
disk leak the function exists to prevent. Now anchored on `MAX`.
2. An off-by-one in the same function: with a strict `<` against
`newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1`
rows. Both are covered by `old_usage_is_pruned`.
**Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB
to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of
the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real
margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system
trade-off a genuine open question rather than a settled one.
The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination
dropped a crate the placeholder binary never referenced.
Co-Authored-By: Claude Code <noreply@anthropic.com>
ci.yml calls `scripts/ci.sh` rather than restating the checks: a workflow that
reimplements them becomes a second definition of "done", and the two drift.
Android runs as a separate job. It fails when the SDK is absent while ci.sh
skips it — a developer should not be blocked by a missing toolchain, but a
silently skipped Android build in CI means regressions ship unnoticed. Every
action is pinned to an exact version.
LICENSE.md records that no licence is chosen yet, and why it is not a formality:
the choice is upstream of the UI toolkit decision, since Slint is GPL-3.0.
Co-Authored-By: Claude Code <noreply@anthropic.com>
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>
`scripts/ci.sh` is the source of truth for what must pass before a PR is
mergeable. CLAUDE.md section 10.4 requires the Gitea workflow to run the same
checks; the workflow calls this script rather than reimplementing the steps, so
the two cannot drift apart.
Runs: cargo fmt --check, clippy -D warnings, cargo test, the section 5 size
gate, Android lint/assembleRelease, and a gitleaks scan.
Verified behaviour, rather than assumed:
- **Failures really fail.** Injected a syntax error into `core/src/lib.rs` and
confirmed `ci.sh --keep-going` exits 1 and names all four dependent checks;
then restored the file and confirmed a clean exit 0. `--keep-going` captures
failures per step in a subshell instead of letting `set -e` abort the run, so
the summary records what already passed.
- **Skips are never passes.** Android and gitleaks both skip on this machine
(no SDK, no gitleaks), and each SKIP names what was skipped and why, with the
summary listing them separately from the passes. A skip that reads as a green
tick is how CI quietly stops testing anything.
- **Runs from any CWD.** `cargo` resolves a workspace from the CWD, not from
the script's location, so `ci.sh` cds to the repo root. Verified by running it
from `/tmp`.
- **`cargo` is on PATH.** rustup adds `~/.cargo/bin` via `~/.bashrc`, which a
non-interactive shell does not read — so a script invoked from CI or by
absolute path failed with "command not found" until `lib.sh` prepends it.
- **Size gate resolves absolute paths.** `CARGO_TARGET_DIR` is absolute by
default, so joining it onto the repo root produced a doubled path; the default
is now only joined when relative.
Build artifacts go to `$HOME/.blocker-target`, outside the repo, so `target/`
never appears in `git status` and can be deleted in one command.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Establishes the cargo workspace and the shared `blocker-core` crate. The
engine is deliberately written before any platform code, because CLAUDE.md
section 2 makes it the single source of decision truth and M1 acceptance
requires it to be pure.
**Workspace** — five crates plus a test crate, zero external dependencies.
Section 3 requires every crate to justify its binary-size cost, so M0 adds
none. [profile.release] is workspace-only (cargo rejects it in members) and
carries the section 5 settings that make the 5 MB stripped budget reachable.
**Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no
disk and no OS API; everything arrives in the input. `tests/purity.rs`
enforces this mechanically by asserting blocker-core has zero dependencies, so
a stray I/O reach-in fails the build rather than silently making the golden
tables useless as a parity contract.
Decisions worth calling out:
- **Monotonic time is a separate type from wall time.** Section 1.5.3 requires
a system date change never to shorten a lock. Keeping them apart makes that
structural rather than a rule to remember.
- **`start > end` means overnight.** No `crosses_midnight` flag: the comparison
already encodes it and a second field is a second way to be wrong. The
weekday for a window that started yesterday evening is yesterday's, which
`overnight_window_survives_past_midnight` pins down.
- **`next_wakeup` is armed over every non-paused schedule, not only active
ones.** Filtering to active conditions first would let the daemon sleep
straight through the moment a schedule is due to start.
- **Allowlist needs the installed-app inventory.** `EngineInput` carries
`installed_apps`; an empty slice fails open rather than guessing.
- **Wall-clock correction extends, never shortens, and clamps to the 24h cap.**
Extending unbounded would satisfy anti-tamper and violate section 1.5.4.
- **`cast_possible_truncation` is denied workspace-wide.** Truncating a
millisecond counter would corrupt a lock timer.
Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings`
clean, stripped release daemon 0.279 MB against the 5 MB budget.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Bootstrap the repository with the shared configuration every other piece
depends on:
- .gitignore: strict. Keeps build output, cargo caches, Android keystores,
signing keys, .env files, local SQLite databases, IDE state and any
credential-shaped filename out of history. CLAUDE.md 10.1 requires these
never enter the repo.
- .editorconfig, rustfmt.toml, clippy.toml: formatting and lint policy.
clippy.toml is written in the lints-file form so lints are not duplicated
into every crate manifest.
CLAUDE.md is committed verbatim; it is the project spec and the parity
contract for every platform layer.
Co-Authored-By: Claude Code <noreply@anthropic.com>