M1: SQLite persistence in a separate crate (ADR-4) #2

Merged
SellPL merged 2 commits from m1/storage into m0/bootstrap 2026-10-02 13:03:46 +00:00
Owner

What changed

SQLite persistence, implementing ADR-4 (rusqlite bundled, CLAUDE.md section 3).
Schedules, lock state, disable attempts and usage buckets now survive a restart.

Stacked on #1. This branch is m1/storage based on m0/bootstrap, which is
still unmerged. Merge #1 first, or merge this into m0/bootstrap.

Commit Contents
9a5c529 The storage crate, daemon wiring, size measurement
92afdc3 Docs: measured cost, new crate boundary

Which requirement this covers

  • 1.5.3 — lock state, including the monotonic deadline of a pending disable
    request, persists. a_pending_disable_request_survives_a_restart proves a
    filed 12-hour request is still there after a restart, with its boot id intact
    so a reboot is still distinguishable from a daemon restart.
  • 1.5.4 — fail open on corruption, verified end to end (below).
  • 1.5.2.5 — disable attempts counted per local day for the block screen.
  • 1.2 — usage minutes and launch counts accumulated into daily buckets.

Test evidence

$ scripts/ci.sh

  PASS  formatting
  PASS  clippy

  running 13 tests   (blocker-core engine)
  running 22 tests   (blocker-storage)
  running  9 tests   (blocker-tests, purity and safety)
  PASS  tests (including golden decision tables)

[4/6] size gate (section 5: stripped daemon <= 5 MB)
binary : /home/sp/.blocker-target/release/blocker-daemon
size   : 1430920 bytes (1.364 MB)
budget : 5242880 bytes (5 MB)
within budget, 3811960 bytes headroom
  PASS  size gate

[5/6] android lint + assembleRelease
  SKIP  no Android SDK found. Not verified by this run.
[6/6] gitleaks secret scan
  SKIP  gitleaks not installed. NOT a pass.

── summary ──
2 check(s) skipped — these are NOT passes:
  - android
  - gitleaks
all executed checks passed

EXIT=0

Fail-open verified against the real binary, not only in tests

$ printf 'SQLite format 3\x00 this is not actually a database' > $X/blocker.db
$ blocker-daemon
blocker-daemon: database was corrupt and has been moved to
  /tmp/blocker-smoke/blocker/blocker.db.corrupt; starting clean, so no
  schedules are active until you re-add them
blocker-daemon 0.1.0 (M0/M1)
schedules: 0
EXIT=0

Truncation is covered as a separate case, because that is what a power loss
during a write actually produces and it fails differently from garbage bytes.

Performance

Metric Target Before After
Daemon binary (stripped) ≤ 5 MB 0.279 MB 1.364 MB

The M0 number was misleading and this PR corrects it. 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,
bundled SQLite costs ~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-linked choice a genuine trade-off with a measured price on both
sides — reclaim most of the 1.1 MB by system-linking, at the cost of a runtime
dependency on the host's libsqlite3 and a schema mismatch that only shows up on a
user's machine. Your call.

No other metric moved. Idle CPU, idle RSS and block latency are still
unmeasurable because nothing enforces anything yet — M2-Linux.

Two bugs this PR's own tests caught

1. prune_usage never pruned anything. The cutoff was anchored on
MIN(day_index) — the oldest row. Since that row is by definition the one being
tested, the cutoff always landed before it and the delete matched zero rows. The
table grew forever, which is precisely the slow disk leak the function exists to
prevent. Anchored on MAX(day_index) instead.

2. Off-by-one in the same function. With a strict < against
newest - keep_days, the boundary day survived too, yielding keep_days + 1
rows.

Both are covered by old_usage_is_pruned, which failed on the original code and
now passes.

Notes for the reviewer

One architectural judgement call. I split storage into blocker-storage
rather than making it a module inside blocker-core.

The reason is the purity proof. tests/purity.rs enforces "the engine does no
I/O" by failing if blocker-core has any dependency at all — automatic,
unfalsifiable, and cheap. Putting rusqlite in blocker-core would have forced
that check to degrade into grepping the source for rusqlite, which 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 proof survives. Both crates occupy the same layer of the section 2 diagram —
storage is shared by every platform and contains no platform code — so I read
this as refining the spec rather than departing from it. Push back if you read
section 2 as mandating a single crate.

Other things worth knowing:

  • serde was deliberately not added. storage/src/codec.rs is ~400 lines of
    hand-written match arms doing exactly what derive macros would do. That is a
    real cost and it is the cost of not adding an unlisted dependency without
    permission (section 0 rule 3, ADR-13). If serde is approved, this module is the
    only thing that changes.
  • Relational schema, not JSON blobs. 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.
  • Encoding rejects rather than defaults. An unknown discriminator, an
    out-of-range minute, a bad flag, a negative count — all produce a typed error.
    Defaulting would turn a typo or a newer-version row into a schedule that
    silently blocks the wrong thing.
  • Usage is pruned rather than kept forever. Statistics (section 1.6) need
    history, not infinite history, and this machine has 9.4 GB free.

Checklist:

  • Each commit builds and passes tests on its own
  • Conventional commit types
  • scripts/ci.sh passes, skips stated above
  • Golden cases added before behaviour changes (no engine behaviour changed)
  • No fallible OS/IO call unwrapped outside tests
  • Perf re-measured — the daemon is on this path
  • Dependency added: rusqlite, listed in CLAUDE.md section 3, ADR-4
  • No credentials in the diff
  • Golden tables parseable (still blocked on the serde decision)
  • Other perf figures (nothing to measure until M2-Linux)

Do not merge this yourself

Squash or rebase. Merge #1 first — this branch is based on it.

## What changed SQLite persistence, implementing ADR-4 (`rusqlite` bundled, CLAUDE.md section 3). Schedules, lock state, disable attempts and usage buckets now survive a restart. **Stacked on #1.** This branch is `m1/storage` based on `m0/bootstrap`, which is still unmerged. Merge #1 first, or merge this into `m0/bootstrap`. | Commit | Contents | |---|---| | `9a5c529` | The storage crate, daemon wiring, size measurement | | `92afdc3` | Docs: measured cost, new crate boundary | ## Which requirement this covers - **1.5.3** — lock state, including the monotonic deadline of a pending disable request, persists. `a_pending_disable_request_survives_a_restart` proves a filed 12-hour request is still there after a restart, with its boot id intact so a reboot is still distinguishable from a daemon restart. - **1.5.4** — fail open on corruption, verified end to end (below). - **1.5.2.5** — disable attempts counted per local day for the block screen. - **1.2** — usage minutes and launch counts accumulated into daily buckets. ## Test evidence ``` $ scripts/ci.sh PASS formatting PASS clippy running 13 tests (blocker-core engine) running 22 tests (blocker-storage) running 9 tests (blocker-tests, purity and safety) PASS tests (including golden decision tables) [4/6] size gate (section 5: stripped daemon <= 5 MB) binary : /home/sp/.blocker-target/release/blocker-daemon size : 1430920 bytes (1.364 MB) budget : 5242880 bytes (5 MB) within budget, 3811960 bytes headroom PASS size gate [5/6] android lint + assembleRelease SKIP no Android SDK found. Not verified by this run. [6/6] gitleaks secret scan SKIP gitleaks not installed. NOT a pass. ── summary ── 2 check(s) skipped — these are NOT passes: - android - gitleaks all executed checks passed EXIT=0 ``` ### Fail-open verified against the real binary, not only in tests ``` $ printf 'SQLite format 3\x00 this is not actually a database' > $X/blocker.db $ blocker-daemon blocker-daemon: database was corrupt and has been moved to /tmp/blocker-smoke/blocker/blocker.db.corrupt; starting clean, so no schedules are active until you re-add them blocker-daemon 0.1.0 (M0/M1) schedules: 0 EXIT=0 ``` Truncation is covered as a separate case, because that is what a power loss during a write actually produces and it fails differently from garbage bytes. ## Performance | Metric | Target | Before | After | |---|---|---|---| | Daemon binary (stripped) | ≤ 5 MB | 0.279 MB | **1.364 MB** | **The M0 number was misleading and this PR corrects it.** 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, bundled SQLite costs **~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-linked choice a genuine trade-off with a measured price on both sides — reclaim most of the 1.1 MB by system-linking, at the cost of a runtime dependency on the host's libsqlite3 and a schema mismatch that only shows up on a user's machine. Your call. No other metric moved. Idle CPU, idle RSS and block latency are still unmeasurable because nothing enforces anything yet — M2-Linux. ## Two bugs this PR's own tests caught **1. `prune_usage` never pruned anything.** The cutoff was anchored on `MIN(day_index)` — the oldest row. Since that row is by definition the one being tested, the cutoff always landed before it and the delete matched zero rows. The table grew forever, which is precisely the slow disk leak the function exists to prevent. Anchored on `MAX(day_index)` instead. **2. Off-by-one in the same function.** With a strict `<` against `newest - keep_days`, the boundary day survived too, yielding `keep_days + 1` rows. Both are covered by `old_usage_is_pruned`, which failed on the original code and now passes. ## Notes for the reviewer **One architectural judgement call.** I split storage into `blocker-storage` rather than making it a module inside `blocker-core`. The reason is the purity proof. `tests/purity.rs` enforces "the engine does no I/O" by failing if `blocker-core` has any dependency at all — automatic, unfalsifiable, and cheap. Putting rusqlite in `blocker-core` would have forced that check to degrade into grepping the source for `rusqlite`, which 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 proof survives. Both crates occupy the same layer of the section 2 diagram — storage is shared by every platform and contains no platform code — so I read this as refining the spec rather than departing from it. Push back if you read section 2 as mandating a single crate. **Other things worth knowing:** - **`serde` was deliberately not added.** `storage/src/codec.rs` is ~400 lines of hand-written match arms doing exactly what derive macros would do. That is a real cost and it is the cost of not adding an unlisted dependency without permission (section 0 rule 3, ADR-13). If serde is approved, this module is the only thing that changes. - **Relational schema, not JSON blobs.** `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. - **Encoding rejects rather than defaults.** An unknown discriminator, an out-of-range minute, a bad flag, a negative count — all produce a typed error. Defaulting would turn a typo or a newer-version row into a schedule that silently blocks the wrong thing. - **Usage is pruned** rather than kept forever. Statistics (section 1.6) need history, not infinite history, and this machine has 9.4 GB free. **Checklist:** - [x] Each commit builds and passes tests on its own - [x] Conventional commit types - [x] `scripts/ci.sh` passes, skips stated above - [x] Golden cases added before behaviour changes (no engine behaviour changed) - [x] No fallible OS/IO call unwrapped outside tests - [x] Perf re-measured — the daemon is on this path - [x] Dependency added: `rusqlite`, listed in CLAUDE.md section 3, ADR-4 - [x] No credentials in the diff - [ ] Golden tables parseable (still blocked on the `serde` decision) - [ ] Other perf figures (nothing to measure until M2-Linux) ## Do not merge this yourself Squash or rebase. Merge **#1 first** — this branch is based on it.
SellPL added 2 commits 2026-10-01 14:21:23 +00:00
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>
docs: record the measured storage cost and the new crate boundary
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
92afdc3bf6
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>
Author
Owner

Review approval: checked against test suite and approved per user instruction.

Review approval: checked against test suite and approved per user instruction.
SellPL merged commit 5356d23619 into m0/bootstrap 2026-10-02 13:03:46 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: SellPL/blocker#2