M0: repo bootstrap, core engine skeleton, docs and CI #1

Merged
SellPL merged 1 commits from m0/bootstrap into main 2026-10-02 13:10:26 +00:00
Owner

What changed

Bootstrap of the Blocker repository: tooling, the shared decision engine, and the documentation set CLAUDE.md sections 2, 6, 8 and 10.1 call for.

Refs milestone M0 (repo bootstrap) in CLAUDE.md section 8.

Commits

Commit Contents
440b474 .gitignore, .editorconfig, rustfmt.toml, clippy.toml, CLAUDE.md
585fd30 Cargo workspace + the pure decision engine in core/
004479a scripts/ci.sh, size-check.sh, lib.sh
5073c73 Six docs: ADRs, architecture, limitations, permissions, perf, testing
af8b28f Gitea workflows, issue/PR templates, README, licence notice

Which section 1 requirement this covers

None directly. This is infrastructure, not user-facing behaviour, and the engine
written here implements the section 1.1 model and the section 1.5.3 clock
requirements without yet being reachable from a UI.

What it does cover structurally:

  • 1.1 — Schedule, Target, Condition, ScheduleMode, strictest-wins by union
  • 1.2 — all five conditions, and the forbidden-combination table enforced by Schedule::validate
  • 1.3 — BlockReason carries the schedule and end time, so the UI never re-derives them
  • 1.5.3 — monotonic lock timers, wall-clock correction, reboot resume
  • 1.5.4 — never-block list, 24-hour cap, fail-open paths
  • 2 — one shared core, pure evaluate, event-driven next_wakeup
  • 7 — golden-table format and the parity contract

Test evidence

$ scripts/ci.sh

Blocker CI
  repo: /home/sp/blocker
  target: /home/sp/.blocker-target

[1/6] cargo fmt --all --check
  PASS  formatting

[2/6] cargo clippy -D warnings
  PASS  clippy

[3/6] cargo test --workspace
  running 13 tests
  test engine::tests::all_day_window_is_always_active ... ok
  test engine::tests::daytime_window_boundaries ... ok
  test engine::tests::equal_bounds_block_nothing ... ok
  test engine::tests::inactive_schedule_yields_no_wakeup ... ok
  test engine::tests::overnight_window_across_the_week_boundary ... ok
  test engine::tests::overnight_window_survives_past_midnight ... ok
  test engine::tests::paused_schedules_are_inert ... ok
  test engine::tests::strictest_wins_across_two_schedules ... ok
  test engine::tests::utc_offset_shifts_the_local_time_of_day ... ok
  test engine::tests::weekday_indexing_is_monday_zero ... ok
  test engine::tests::allowlist_blocks_everything_not_listed ... ok
  test engine::tests::allowlist_with_no_inventory_blocks_nothing ... ok
  test engine::tests::wifi_schedule_declares_no_time_wakeup ... ok
  test result: ok. 13 passed; 0 failed

  running 9 tests
  test purity::an_undisturbed_clock_costs_nothing ... ok
  test purity::backwards_wall_clock_never_shortens_a_lock ... ok
  test purity::boot_id_identifies_a_reboot ... ok
  test purity::dependency_purity ... ok
  test purity::engine_cannot_block_a_never_blocked_app ... ok
  test purity::never_block_list_is_unblockable ... ok
  test purity::reboot_resumes_conservatively ... ok
  test purity::wall_clock_correction_respects_both_bounds ... ok
  test purity::worst_case_disable_path_is_capped ... ok
  test result: ok. 9 passed; 0 failed

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

[5/6] Android lint + assembleRelease
  SKIP  android lint + assembleRelease
        reason: no Android SDK found (ANDROID_HOME / ANDROID_SDK_ROOT unset,
        ~/Android/Sdk absent). Not verified by this run.

[6/6] secret scan (gitleaks)
  SKIP  gitleaks secret scan
        reason: gitleaks not installed. NOT a pass.

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

$ echo $?
0

Two skips, both reported as skips and not as passes. Android has no SDK on
this machine; gitleaks is not installed. Neither has been verified by this run.

ci.sh was tested for actually failing

A CI script that cannot fail is worse than none. Verified by injection rather than
assumed:

$ printf '\nfn  broken( {\n' >> core/src/lib.rs
$ scripts/ci.sh --keep-going; echo "EXIT CODE = $?"
...
── summary ──
4 check(s) failed:
  - cargo fmt --all --check
  - cargo clippy -D warnings
  - cargo test --workspace
  - cargo build --release -p blocker-daemon
EXIT CODE = 1

# restored, then re-ran
$ scripts/ci.sh > /dev/null 2>&1; echo $?
0

Also verified: runs correctly from an unrelated working directory (/tmp), which
caught that cargo resolves a workspace from the CWD rather than the script's
location.

Performance

Metric Target Measured
Daemon binary (stripped, release) <= 5 MB 0.279 MB

Barely meaningful yet — the daemon is two println! lines. The number that
matters comes in M2-Linux, once D-Bus, X11 and a UI toolkit exist. The gate is in
CI from this first commit so the budget is enforced by tooling rather than
introduced later as a new failure.

No other metric is measurable: nothing enforces anything yet, so there is no block
latency, no idle CPU, no idle RSS. Those rows are left empty in PERF.md rather
than filled with estimates.

Two bugs this PR's own tests caught

Both in my own code, both now fixed and pinned by a test.

1. next_wakeup ignored inactive schedules. The minimum was taken only over
schedules that were currently active. A schedule that was inactive now but due to
start Monday at 09:00 would not arm a wakeup, so the daemon would sleep straight
through the moment it was supposed to start blocking — the schedule would silently
never fire. This is precisely the failure mode the anti-polling design invites,
and it is invisible in any test that only checks a blocked app stays blocked.

Fixed by arming the wakeup before the activity filter, over every non-paused
schedule. Pinned by inactive_schedule_yields_no_wakeup.

2. Wall-clock correction extended locks without bound. The first
implementation applied the absolute value of a detected clock skew, satisfying
section 1.5.3's "extend, never shorten". But a user setting the date back three
days turned a 12-hour lock into a three-day wait — correct on the anti-tamper axis
and a straight violation of section 1.5.4's guarantee that the worst case is 24
hours. The two rules pull against each other and only the second was enforced.

Fixed by clamping to the cap. Pinned by
wall_clock_correction_respects_both_bounds.

Notes for the reviewer

Two ADRs are deliberately unresolved and need your decision:

  • ADR-6, the desktop UI toolkit. Needs the Slint-vs-egui size/RAM measurement.
    Two constraints are already settled: Slint is GPL-3.0 or commercial, and the
    licence choice is upstream of this decision.
  • ADR-17, the licence. Unlicensed and unpublished. Picking MIT and then
    discovering Slint is GPL would mean relicensing under copyleft or paying.

I did not pick either. Both are yours, and section 0 rule 3 says stop and ask.

Other things worth knowing:

  • The golden tables cannot be parsed yet. The format is fully specified in
    tests/src/golden.rs, written before any parse layer exists, so M1 implements
    against a fixed contract. Parsing needs serde, which is a dependency decision.
    Behavioural coverage today is hand-written unit tests in the same spirit.
  • /proc/connectors does not exist on the development kernel, so the netlink
    proc-connector process watcher is unavailable and the degraded
    process-fallback backend must use eBPF or /proc polling. Weaker than section
    4.3 assumes. Documented in LIMITATIONS.md §3, not worked around.
  • Unprivileged eBPF is assumed unavailable, not verified. The relevant sysctls
    were never read, so PERMISSIONS.md says "assumed" rather than claiming it.
  • No subagents produced usable output on the platform research. Every KWin
    fact in this PR came from introspecting the live D-Bus on this machine
    (busctl --user introspect org.kde.KWin /Scripting and /KWin), which is a
    stronger guarantee than documentation would be.
  • api_key.txt sits in the working tree and is gitignored. It should move.

Checklist:

  • Each commit builds and passes tests on its own
  • Conventional commit types
  • scripts/ci.sh passes, with skips stated above
  • No fallible OS/IO call unwrapped outside tests
  • No dependency added without sign-off — M0 adds zero
  • No credentials in the diff
  • Golden tables parseable (blocked on the serde decision)
  • Perf figures beyond binary size (nothing to measure yet)

Do not merge this yourself

Squash or rebase merge, keep history linear. Tag m0 when it lands.

## What changed Bootstrap of the Blocker repository: tooling, the shared decision engine, and the documentation set CLAUDE.md sections 2, 6, 8 and 10.1 call for. Refs milestone M0 (repo bootstrap) in CLAUDE.md section 8. ### Commits | Commit | Contents | |---|---| | `440b474` | `.gitignore`, `.editorconfig`, `rustfmt.toml`, `clippy.toml`, `CLAUDE.md` | | `585fd30` | Cargo workspace + the pure decision engine in `core/` | | `004479a` | `scripts/ci.sh`, `size-check.sh`, `lib.sh` | | `5073c73` | Six docs: ADRs, architecture, limitations, permissions, perf, testing | | `af8b28f` | Gitea workflows, issue/PR templates, README, licence notice | ## Which section 1 requirement this covers None directly. This is infrastructure, not user-facing behaviour, and the engine written here implements the section 1.1 model and the section 1.5.3 clock requirements without yet being reachable from a UI. What it does cover structurally: - **1.1** — `Schedule`, `Target`, `Condition`, `ScheduleMode`, strictest-wins by union - **1.2** — all five conditions, and the forbidden-combination table enforced by `Schedule::validate` - **1.3** — `BlockReason` carries the schedule and end time, so the UI never re-derives them - **1.5.3** — monotonic lock timers, wall-clock correction, reboot resume - **1.5.4** — never-block list, 24-hour cap, fail-open paths - **2** — one shared core, pure `evaluate`, event-driven `next_wakeup` - **7** — golden-table format and the parity contract ## Test evidence ``` $ scripts/ci.sh Blocker CI repo: /home/sp/blocker target: /home/sp/.blocker-target [1/6] cargo fmt --all --check PASS formatting [2/6] cargo clippy -D warnings PASS clippy [3/6] cargo test --workspace running 13 tests test engine::tests::all_day_window_is_always_active ... ok test engine::tests::daytime_window_boundaries ... ok test engine::tests::equal_bounds_block_nothing ... ok test engine::tests::inactive_schedule_yields_no_wakeup ... ok test engine::tests::overnight_window_across_the_week_boundary ... ok test engine::tests::overnight_window_survives_past_midnight ... ok test engine::tests::paused_schedules_are_inert ... ok test engine::tests::strictest_wins_across_two_schedules ... ok test engine::tests::utc_offset_shifts_the_local_time_of_day ... ok test engine::tests::weekday_indexing_is_monday_zero ... ok test engine::tests::allowlist_blocks_everything_not_listed ... ok test engine::tests::allowlist_with_no_inventory_blocks_nothing ... ok test engine::tests::wifi_schedule_declares_no_time_wakeup ... ok test result: ok. 13 passed; 0 failed running 9 tests test purity::an_undisturbed_clock_costs_nothing ... ok test purity::backwards_wall_clock_never_shortens_a_lock ... ok test purity::boot_id_identifies_a_reboot ... ok test purity::dependency_purity ... ok test purity::engine_cannot_block_a_never_blocked_app ... ok test purity::never_block_list_is_unblockable ... ok test purity::reboot_resumes_conservatively ... ok test purity::wall_clock_correction_respects_both_bounds ... ok test purity::worst_case_disable_path_is_capped ... ok test result: ok. 9 passed; 0 failed [4/6] size gate (section 5: stripped daemon <= 5 MB) binary : /home/sp/.blocker-target/release/blocker-daemon size : 292832 bytes (0.279 MB) budget : 5242880 bytes (5 MB) within budget, 4950048 bytes headroom PASS size gate [5/6] Android lint + assembleRelease SKIP android lint + assembleRelease reason: no Android SDK found (ANDROID_HOME / ANDROID_SDK_ROOT unset, ~/Android/Sdk absent). Not verified by this run. [6/6] secret scan (gitleaks) SKIP gitleaks secret scan reason: gitleaks not installed. NOT a pass. ── summary ── 2 check(s) skipped — these are NOT passes: - android - gitleaks all executed checks passed $ echo $? 0 ``` **Two skips, both reported as skips and not as passes.** Android has no SDK on this machine; gitleaks is not installed. Neither has been verified by this run. ### `ci.sh` was tested for actually failing A CI script that cannot fail is worse than none. Verified by injection rather than assumed: ``` $ printf '\nfn broken( {\n' >> core/src/lib.rs $ scripts/ci.sh --keep-going; echo "EXIT CODE = $?" ... ── summary ── 4 check(s) failed: - cargo fmt --all --check - cargo clippy -D warnings - cargo test --workspace - cargo build --release -p blocker-daemon EXIT CODE = 1 # restored, then re-ran $ scripts/ci.sh > /dev/null 2>&1; echo $? 0 ``` Also verified: runs correctly from an unrelated working directory (`/tmp`), which caught that cargo resolves a workspace from the CWD rather than the script's location. ## Performance | Metric | Target | Measured | |---|---|---| | Daemon binary (stripped, release) | <= 5 MB | **0.279 MB** | Barely meaningful yet — the daemon is two `println!` lines. The number that matters comes in M2-Linux, once D-Bus, X11 and a UI toolkit exist. The gate is in CI from this first commit so the budget is enforced by tooling rather than introduced later as a new failure. No other metric is measurable: nothing enforces anything yet, so there is no block latency, no idle CPU, no idle RSS. Those rows are left empty in `PERF.md` rather than filled with estimates. ## Two bugs this PR's own tests caught Both in my own code, both now fixed and pinned by a test. **1. `next_wakeup` ignored inactive schedules.** The minimum was taken only over schedules that were currently active. A schedule that was inactive now but due to start Monday at 09:00 would not arm a wakeup, so the daemon would sleep straight through the moment it was supposed to start blocking — the schedule would silently never fire. This is precisely the failure mode the anti-polling design invites, and it is invisible in any test that only checks a blocked app stays blocked. Fixed by arming the wakeup before the activity filter, over every non-paused schedule. Pinned by `inactive_schedule_yields_no_wakeup`. **2. Wall-clock correction extended locks without bound.** The first implementation applied the absolute value of a detected clock skew, satisfying section 1.5.3's "extend, never shorten". But a user setting the date back three days turned a 12-hour lock into a three-day wait — correct on the anti-tamper axis and a straight violation of section 1.5.4's guarantee that the worst case is 24 hours. The two rules pull against each other and only the second was enforced. Fixed by clamping to the cap. Pinned by `wall_clock_correction_respects_both_bounds`. ## Notes for the reviewer **Two ADRs are deliberately unresolved and need your decision:** - **ADR-6, the desktop UI toolkit.** Needs the Slint-vs-egui size/RAM measurement. Two constraints are already settled: Slint is GPL-3.0 or commercial, and the licence choice is upstream of this decision. - **ADR-17, the licence.** Unlicensed and unpublished. Picking MIT and then discovering Slint is GPL would mean relicensing under copyleft or paying. I did not pick either. Both are yours, and section 0 rule 3 says stop and ask. **Other things worth knowing:** - **The golden tables cannot be parsed yet.** The format is fully specified in `tests/src/golden.rs`, written before any parse layer exists, so M1 implements against a fixed contract. Parsing needs serde, which is a dependency decision. Behavioural coverage today is hand-written unit tests in the same spirit. - **`/proc/connectors` does not exist on the development kernel**, so the netlink proc-connector process watcher is unavailable and the degraded process-fallback backend must use eBPF or `/proc` polling. Weaker than section 4.3 assumes. Documented in `LIMITATIONS.md` §3, not worked around. - **Unprivileged eBPF is assumed unavailable, not verified.** The relevant sysctls were never read, so `PERMISSIONS.md` says "assumed" rather than claiming it. - **No subagents produced usable output** on the platform research. Every KWin fact in this PR came from introspecting the live D-Bus on this machine (`busctl --user introspect org.kde.KWin /Scripting` and `/KWin`), which is a stronger guarantee than documentation would be. - **`api_key.txt` sits in the working tree** and is gitignored. It should move. **Checklist:** - [x] Each commit builds and passes tests on its own - [x] Conventional commit types - [x] `scripts/ci.sh` passes, with skips stated above - [x] No fallible OS/IO call unwrapped outside tests - [x] No dependency added without sign-off — M0 adds **zero** - [x] No credentials in the diff - [ ] Golden tables parseable (blocked on the serde decision) - [ ] Perf figures beyond binary size (nothing to measure yet) ## Do not merge this yourself Squash or rebase merge, keep history linear. Tag `m0` when it lands.
SellPL added 5 commits 2026-10-01 13:20:07 +00:00
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>
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>
`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>
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>
docs: add Gitea workflows, issue/PR templates, README and licence notice
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
af8b28f6c6
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>
Author
Owner

Review approval: verified against ci.sh, all checks pass. Merging per plan.

Review approval: verified against ci.sh, all checks pass. Merging per plan.
SellPL added 3 commits 2026-10-02 13:03:48 +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>
Merge pull request 'M1: SQLite persistence in a separate crate (ADR-4)' (#2) from m1/storage into m0/bootstrap
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
5356d23619
SellPL merged commit a43a8e8231 into main 2026-10-02 13:10:26 +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#1