From 34a0b0d4ac5f41687fe916df038ddd78915da424 Mon Sep 17 00:00:00 2001 From: Allan Douglas Date: Sun, 2 Aug 2026 04:33:05 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-0021=20=E2=80=94=20no=20blocking=20?= =?UTF-8?q?work=20on=20a=20runtime=20worker=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator directive: keep everything async, because mixing sync and async is what lets a later edit silently park a runtime worker. This records the rule, the sanctioned pattern, and seven beads that deliver it. No code changes. The achievable rule is narrower than "100% async" and the ADR says so: sqlite has no async implementation in Rust — every crate advertising one (tokio-rusqlite, deadpool-sqlite, sqlx) is a thread-and-channel wrapper over the same blocking calls. So the rule is "once serving, no blocking I/O and no unbounded-duration work on a tokio worker", with in-memory mutex sections a named exception carrying constraints rather than a duration guarantee. tokio-rusqlite was evaluated and REJECTED as a dependency while its design was adopted: its implementation is exactly the OS-thread-plus-channel actor proposed here. It requires rusqlite ^0.37 against our 0.31, and libsqlite3-sys declares links = "sqlite3", so cargo forbids coexistence — adopting it forces a rusqlite major bump as a side effect of a concurrency fix. Its channel is also unbounded, which for a synchronous=FULL money DB turns a slow fsync into unbounded queue growth with no backpressure. Enforcement is two mechanisms because neither suffices. The obvious one — re-export Transaction, withhold Connection — was REFUTED with a build probe: an associated-type projection reaches every constructor from a crate with no dependency on the sqlite crate at all: type C = as std::ops::Deref>::Target; let _ = C::open("/tmp/x.db"); // compiles So the boundary is a Txn newtype that does not Deref to Connection, plus a clippy.toml denylist for what no crate boundary can catch. The ADR states plainly what that does NOT close: disallowed-methods matches listed paths only. Beads: lnrent-skk (store actor onto its own thread), lnrent-68f (index actors), lnrent-njv (denylist + the blocking-call audit), lnrent-7dw (crate boundary), lnrent-hrm (dependency upgrades), lnrent-7w1 (hkdf/sha2 under a funded key), lnrent-73r (what fedimint v0.11.1's graph pins). flake.nix gains cargo-outdated; README's ADR range stops being hand-maintained. Addresses PR #78 bot review, round 1 — all four findings verified before fixing: - codex P2: the mutex exception named alerts.rs / nostr_engine.rs as endorsed while stating constraints they violate (alerts.rs:248 inserts into an unbounded map under the lock; nostr_engine.rs:1839 retains over a request-sized vector). The ADR now lists all three non-conforming sections explicitly as grandfathered rather than conforming, and lnrent-njv owns assessing them. An exception list that quietly contains its own counterexamples is a false contract. - CodeRabbit (Major): obligation 1's delivery guarantee did not state where it begins. It starts at SUCCESSFUL enqueue — a caller cancelled before tx.send().await returns is owed nothing. - CodeRabbit (Major): the SQL-site inventory command was anchored on `rusqlite::` and so missed unqualified use after `use rusqlite::Connection` — backup.rs:619 is exactly that. Fixed, and marked a lower bound for sizing rather than proof of coverage, since four derivation commands in this ADR's history were each wrong. - CodeRabbit (Minor): lnrent-68f still cited skk's "bounded, off-worker join", which skk's own round-11 revision superseded with a completion signal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq --- .beads/issues.jsonl | 7 + README.md | 2 +- ...021-no-blocking-work-on-runtime-threads.md | 307 ++++++++++++++++++ flake.nix | 4 + 4 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0021-no-blocking-work-on-runtime-threads.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9d3ca24..edba645 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -150,3 +150,10 @@ {"id":"lnrent-hyb","title":"Nightly CronJob: schedule, start-of-run sweep reaper over ALL tagged droplets, and a fate-independent failure signal","description":"## Outcome\nThe nightly CronJob itself: scheduling, the start-of-run droplet reaper, and a failure signal that\nreaches the operator without anyone watching.\n\n## Reaper — fix the real gap first\nToday's teardown guard is a `Drop` impl in `e2e_money_path.rs` that destroys `.droplets[0].id` — ONE\ndroplet — and only if the process unwinds. Both properties are wrong for this:\n- the run holds SEVERAL concurrent droplets, so one-shot reaping leaks the rest\n- a SIGKILLed or OOM-killed job never runs `Drop` at all\nSweep EVERY droplet carrying the test tag at job START, so the next run cleans up after a killed one\nrather than trusting the dying process to clean up after itself.\n\n## Failure signal (ADR-0020)\n- PRIMARY: Nostr DM to the operator through the real `OperatorAlert` path (`daemon/src/alerts.rs`).\n Deliberate: it dogfoods the alert delivery that compressed-only testing otherwise leaves unproven.\n- BACKSTOP: a cluster-level signal on job exit code that does NOT depend on the daemon. A daemon\n broken enough to fail the run may be too broken to DM, and a watchdog sharing fate with the watched\n reports nothing exactly when it matters.\n\n## Scope\n- CronJob manifest via the GitOps repo (ArgoCD). NEVER `kubectl apply` on production.\n- DO token as a k8s secret, never in the image or a manifest.\n- Concurrency policy: never two runs at once — they would share the test tag and reap each other.\n\n## Depends on\nThe image (nothing to run without it) and the lifecycle driver (nothing to schedule without it).\n\n## Acceptance\n- Runs nightly unattended; a deliberately broken daemon produces BOTH signals.\n- A killed mid-run job leaves no droplet alive after the next run starts.\n- Two runs cannot overlap.","status":"open","priority":1,"issue_type":"feature","created_at":"2026-07-31T02:22:22.592173330Z","created_by":"master","updated_at":"2026-07-31T02:22:28.293556915Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"lnrent-hyb","depends_on_id":"lnrent-jnw","type":"blocks","created_at":"2026-07-31T02:22:28.293349065Z","created_by":"master","metadata":"{}","thread_id":""},{"issue_id":"lnrent-hyb","depends_on_id":"lnrent-m7g","type":"blocks","created_at":"2026-07-31T02:22:28.216756919Z","created_by":"master","metadata":"{}","thread_id":""}]} {"id":"lnrent-3ma","title":"web buyer SPA ships no Content-Security-Policy, though its own spec requires one","description":"## The gap\n`docs/specs/web-wasm-buyer.md` requires a restrictive Content-Security-Policy for the buyer SPA\n(spec Security section). Nothing ships one: `clients/web/static/index.html` has no CSP meta tag, and\n`clients/web/README.md` gives operators no CSP guidance for serving the bundle.\n\nFound 2026-07-31 by a codex review of the docs-archive proposal, which correctly refused to treat\nweb-wasm-buyer.md as fully delivered. Its header says \"**Status:** Implemented\"; the WebLN\nexplicit-click P1 and the CI e2e did land, but this requirement did not.\n\n## Why it matters\nThe SPA holds buyer key material in session scope and talks to a configured Nostr relay. Its threat\nmodel is XSS: a single injected script can exfiltrate a key or redirect a payment. A CSP is the\ncontrol the spec chose for that, and it is the one part of the web security contract with no code\nbehind it.\n\n## Also: the security contract lives in ONE place\nSession-only key storage, no raw `localStorage`, credential non-persistence, configured-relay-only\negress, the XSS constraints and the CSP guidance exist together only in web-wasm-buyer.md. The web\nREADME is build/serve instructions. Until this closes, that spec must NOT be archived — the operator\ndecision of 2026-07-31 keeps it live for exactly this reason.\n\n## Scope\n- A restrictive CSP shipped with the bundle (meta tag and/or documented server header), tight enough\n to be worth having: no inline script beyond what the SPA genuinely needs, `connect-src` limited to\n the configured relay, no wildcard `script-src`.\n- Operator guidance in `clients/web/README.md` for serving it with the right headers, since a static\n host is the deployment path.\n- Verify the SPA still works under it — a CSP that breaks WASM instantiation or the relay websocket\n is worse than none, because it will be turned off.\n\n## Acceptance\n- `index.html` (or documented headers) carries a CSP; `clients/web/README.md` explains it.\n- The existing headless e2e (`clients/web/e2e/run.sh`) still passes under the policy.\n- web-wasm-buyer.md's status is corrected to say what actually shipped.","status":"open","priority":2,"issue_type":"bug","created_at":"2026-07-31T17:34:55.497264193Z","created_by":"master","updated_at":"2026-07-31T17:34:55.497264193Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0} {"id":"lnrent-3zt","title":"web buyer e2e: the request-kind op assertion cannot fail the run (try/catch then exit 0)","description":"## The gap\nThe web buyer e2e's request-kind op step cannot fail the run. `clients/web/e2e/web-buyer-e2e.mjs`\nwraps the whole op sequence in a `try`, sets `opOk = false` in the `catch`, logs\n\"request-kind op step skipped/soft-failed\", and then reaches `process.exit(0)` regardless. The only\nhard failure in the script is a page exception.\n\nSo if `op.request` -> `op.result` regresses in the SPA — ops never listed, the click does nothing,\nthe result never rendered — CI stays green and the PASS line simply omits the op clause.\n\n## Why it matters\n`docs/specs/web-wasm-buyer.md` makes this an acceptance requirement: the e2e must assert that a\nrequest-kind operation runs and displays `op.result.data`. A check that logs and exits 0 does not\nassert anything. It is worse than no check, because the PASS line reads as coverage.\n\nFound 2026-07-31 by a codex review of the docs cleanup, which correctly refused the claim that the\nmissing CSP (lnrent-3ma) was that spec's ONLY outstanding requirement.\n\n## Scope\n- Make the op step fatal: on failure, exit non-zero with which sub-step failed.\n- Decide deliberately whether the step may be SKIPPED when `#ops-section` is absent (a recipe with\n no declared operations is legitimate) versus soft-failing when the section exists but the flow\n breaks. Those are different conditions and only the second should be fatal.\n- Keep the failure message specific enough to diagnose from CI logs alone.\n\n## Acceptance\n- A deliberately broken op flow makes the e2e exit non-zero in CI.\n- A recipe with no operations still passes, by an explicit skip path, not by swallowing an error.\n- web-wasm-buyer.md's status is updated once this and lnrent-3ma are both closed.\n\n## The predicate is ALSO vacuous — making the catch fatal is NOT enough\nCaught by a second codex pass, 2026-07-31. Even with the `try`/`catch` removed, the wait that\nclaims to prove the op returned cannot fail:\n\n```\nawait waitFor(ws, `(document.getElementById('ops-list')?.textContent||'').length > 0 && ...`,\n 20000, 'op.result shown');\n```\n\n`#ops-list` is the element holding the operation BUTTONS, so its `textContent` is already non-empty\nthe moment the list renders — before any operation is invoked. The predicate is therefore true on\nentry and the 'op.result shown' label is unearned. The actual result is written elsewhere: the\nhandler sets `resultEl.textContent = JSON.stringify(result.data, null, 2)`\n(`clients/web/static/app.js`), a sibling `
`, and clears it on error.\n\nSo the fix has TWO parts, and shipping only the first leaves the check just as green on regression:\n1. make the failure fatal (exit non-zero, naming the failed sub-step), AND\n2. replace the predicate with an assertion on the RENDERED RESULT element — that it becomes\n   non-empty and parses as the op's `data` — not on the list that was already populated.\n\nA regression test for the test itself is worth it here: break the op path deliberately and confirm\nthe e2e goes red. This bead exists because a check that logs and exits 0 read as coverage for\nhowever long it has been in CI.","status":"open","priority":2,"issue_type":"bug","created_at":"2026-07-31T18:35:17.259726659Z","created_by":"master","updated_at":"2026-07-31T20:22:41.427423490Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0}
+{"id":"lnrent-skk","title":"store actor runs blocking sqlite on a tokio worker thread (ADR-0021)","description":"The store actor (ADR-0001's sole writer) runs SYNCHRONOUS rusqlite inside a plain\n`tokio::spawn` task (daemon/src/store.rs:743). While a job executes, it occupies a tokio\nworker thread for the duration of the SQL. ADR-0021 fixes the rule: once the daemon is\nserving, no blocking I/O and no unbounded-duration work runs on a runtime worker. (NOT \"no blocking call\" -- ADR-0021 explicitly rejects that phrasing and permits short in-memory std::sync::Mutex sections.)\n\nOwn an OS thread instead of a runtime task:\n\n    std::thread::spawn(move || {\n        let mut conn = conn;\n        while let Some(job) = rx.blocking_recv() { job(&mut conn); }\n    });\n\nKEEP the bounded `mpsc::channel::(64)`. The bound is load-bearing: with\n`synchronous=FULL`, an unbounded queue turns a slow fsync into unbounded memory growth and\nunbounded latency with no signal, whereas `tx.send(job).await` makes the caller wait. (One of\nthree reasons ADR-0021 rejected adopting tokio-rusqlite, whose channel is\n`crossbeam_channel::unbounded`.)\n\nMOVING THE LOOP IS NOT ENOUGH -- caught in review. `Store::open_spawn` (store.rs:770)\nevaluates `open(path)?` BEFORE spawning, and `open()` does `Connection::open`, the PRAGMA\nbatch, `quick_check` and migrations (store.rs:600-640). That work runs on the CALLER's\nthread; the production call site is config.rs:1009. So the connection must be created and\ninitialized INSIDE the actor thread, with its initialization result returned to the caller\nasynchronously (a oneshot carrying `Result`), or the bead ships with the blocking part\nuntouched and only the cheap part fixed.\n\nVerified safe to move off the runtime: the job closure is\n`Box::new(move |conn| { let _ = rtx.send(f(conn)); })` -- pure rusqlite plus a\n`oneshot::Sender::send`, neither of which needs a tokio context. Only `rx.recv().await` did,\nand `blocking_recv()` is its off-runtime equivalent.\n\nAcceptance:\n- Store::spawn owns a std::thread; no SQL executes on a tokio worker.\n- Connection OPEN + PRAGMAs + quick_check + migrations also happen on that thread, and their\n  failure still surfaces to the caller as an error (the y4m.3 empty-file and quick_check\n  gates must keep failing startup loudly).\n- The 64-slot bound is unchanged and a test proves backpressure still applies.\n- Existing store tests pass unchanged, including under #[tokio::test] (current_thread).\n- Panic behaviour is unchanged: a panicking job still ends the actor and subsequent sends\n  fail with \"store actor stopped\".\n\nKEEP THE JoinHandle AND DRAIN ON SHUTDOWN (review, round 8). The naive\n`std::thread::spawn(...)` discards its handle, and a detached thread does NOT keep the process\nalive -- so daemon exit can terminate the actor mid-job while work is still queued. The window\nis real: `Store::run` (store.rs:1069-1078) sends the job and then awaits the oneshot, so a\ncancelled caller leaves its job queued behind it.\n\nRequired shape: retain the `JoinHandle`, and on shutdown close the sender, let the actor drain\nthe queue to completion, then join the thread before the process exits. Do this for all three\nactors (state DB + both indexes, see lnrent-68f).\n\nThis is not a regression the bead introduces -- today's `tokio::spawn` actor dies with the\nruntime just as abruptly -- but the bead is where the thread's lifetime becomes explicit, so\nit is where the drain belongs. sqlite's WAL + `synchronous=FULL` means an interrupted job\ncannot corrupt the DB; what it can do is silently drop queued work a caller was told to expect.\n\nAcceptance addendum:\n- Shutdown closes the channel, drains queued jobs, and joins each actor thread.\n- A test proves a job sent immediately before shutdown still commits.\n\nTHE JOIN MUST BE BOUNDED AND OFF-WORKER (review, round 10 -- correcting round 8's addendum\nabove, which said \"join the thread\" without either qualifier). A bare `handle.join()` awaited\nfrom async shutdown blocks a tokio worker for an unbounded time (forbidden by this very ADR)\nand contradicts `SHUTDOWN_GRACE` at daemon/src/supervisor.rs:95, whose doc comment says\noutright that \"a stuck loop must not hang process exit\". A stalled fsync would hang the daemon\nforever -- the round-8 addendum traded one defect for two.\n\nRequired: drop the senders, then await the join inside `spawn_blocking` under a\n`tokio::time::timeout` aligned with SHUTDOWN_GRACE. On timeout, log the abandoned queue depth\nand exit anyway; WAL + `synchronous=FULL` makes the DB crash-safe, and bounded exit outranks a\ndrained queue.\n\nAcceptance (supersedes the round-8 line \"joins each actor thread\"):\n- Shutdown closes the channel, drains, and joins OFF-WORKER under a bound.\n- A stalled-job test proves exit still happens within the bound.\n\nSHUTDOWN: THIS BEAD OWNS THE MECHANISM (review, round 11 -- superseding BOTH addenda above).\nThree drafts were specified in the ADR and all three were refuted:\n  1. bare `handle.join()` -- unbounded, and blocks a tokio worker.\n  2. `timeout(spawn_blocking(|| handle.join()))` -- a timed-out blocking task cannot be\n     abandoned, and dropping the runtime at main.rs:258 waits for the blocking pool, so the\n     process still hangs.\n  3. (whatever is chosen) -- must be TESTED, not argued.\n\nThe obligations, which do not change:\n  a. Queued jobs are not silently dropped: closing the senders drains the queue.\n  b. Shutdown stays bounded -- SHUTDOWN_GRACE (supervisor.rs:95) says a stuck loop must not\n     hang process exit.\n  c. The wait must not block a tokio worker for an unbounded time.\n\nA completion SIGNAL the shutdown path can await and abandon (rather than any join) looks like\nthe shape that satisfies all three, since a detached std::thread does not hold up process exit\n-- but verify it, including that runtime teardown does not wait on anything left behind.\n\nAcceptance (supersedes both earlier shutdown lines):\n- A stalled-job test: with a job wedged, the process still exits within the bound.\n- A drain test: a job sent immediately before shutdown still commits.\n\nTHE TWO OBLIGATIONS COLLIDE -- TEST THE COLLISION (codex review, PR #78 round 2). Obligation\n(a) says enqueued jobs run to completion; obligation (b) says shutdown exits on the bound. When\none job stalls, a healthy job enqueued BEHIND it cannot satisfy both. ADR-0021 resolves it:\nbounded exit WINS, and the timeout path is an acknowledged data-loss event -- log the abandoned\nqueue depth so it is visible rather than inferred.\n\nThe test that matters is therefore not just \"a stalled job still lets us exit\". It is:\n- enqueue a healthy job BEHIND a stalled one,\n- confirm the process still exits within the bound,\n- confirm the abandoned queue depth is logged (>= 1), so the loss is announced.\nA test that only wedges a single job passes without ever exercising the collision.","status":"open","priority":2,"issue_type":"task","created_at":"2026-08-01T18:32:54.493796994Z","created_by":"master","updated_at":"2026-08-02T17:01:17.439986189Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0}
+{"id":"lnrent-68f","title":"payment-backend index sqlite is called directly from async fns, with no boundary (ADR-0021)","description":"ADR-0021 requires every blocking resource to sit behind an async API. The two payment\nbackend indexes do NOT: `Arc>` is called directly from async fns via ~20\nfree functions (`idx_get_by_external`, `idx_insert`, `idx_mark_canceled`, ... in\ndaemon/src/lnv2_backend.rs:1463+ and daemon/src/phoenixd_backend.rs:1686+).\n\nThese are a WORSE violation than the store was. The store at least had a boundary -- a\nchannel in, a oneshot out, an async-only surface -- and was merely mounted on the wrong kind\nof thread. The index helpers have no boundary at all: a raw blocking sqlite call inside an\nasync fn, which is precisely the surface where a later edit adds an `.await` and something\nsubtle breaks.\n\nApply the same sanctioned pattern: a dedicated-thread actor per connection (they stay THREE\nseparate connections -- lnv2_index.db lives inside the federation data-dir and the two indexes are backed up by\nDIFFERENT mechanisms -- backup.rs:253 `vacuum_if_present` captures ONLY the phoenixd index,\nwhile the lnv2 index rides inside the opaque fedimint subtree byte copy at backup.rs:237. Two capture mechanisms is exactly why folding them into the state DB would entangle backup/restore layout with a concurrency fix).\n\nDepends on the store actor bead so the pattern exists to reuse.\n\nAcceptance:\n- No `Mutex` remains; idx_* are async and reach sqlite only via the actor.\n- The comments asserting \"the lock never crosses an `.await`\" are deleted along with the\n  locks they describe.\n- The existing index GC `spawn_blocking` (lnv2_backend.rs:588) is folded into the actor --\n  it is the one long index operation and no longer needs a special case.\n- Money-path behaviour is unchanged: create-once, the pay maps, and recovery all keep their\n  current semantics. This is a transport change, not a semantics change.\n\nDO NOT collapse the chunked GC into a single actor job (review, round 4). The current reaper\ntakes and RELEASES the index mutex per 512-row batch on purpose -- lnv2_backend.rs:1763 says\nso verbatim (\"Chunking releases the sole index mutex between batches on a flooded DB\"), with\nthe lock acquired inside the loop. Folding both GC loops into one job would hold every queued\ninvoice/pay operation behind the entire backlog on a flooded DB, which is a money-path\nLIVENESS change, not the transport-only change this bead claims. Each batch must remain a\nseparate actor job (or otherwise yield between batches) so ordinary money operations keep\ninterleaving.\n\nINITIALISE INSIDE THE ACTOR THREAD, exactly as lnrent-skk requires for the state DB (review,\nround 10). Both index constructors do blocking sqlite work on the `run_daemon` worker today:\n`Connection::open(paths.index_db)` + `execute_batch(INDEX_SCHEMA)` at lnv2_backend.rs:1924-1926,\nand the same plus `prepare_private_file` at phoenixd_backend.rs:786-790. Moving only the query\nloop into an actor would leave these untouched, and every ADR-0021 delivery bead could close with\nblocking startup SQL still on a worker.\n\nShutdown: track WHATEVER MECHANISM lnrent-skk lands on -- do not name one here. An earlier\n\nAcceptance addendum:\n- Index open + schema init happen on the actor thread; failures still surface to the caller.\ndraft of this line said \"the same bounded, off-worker shutdown join skk specifies\", which skk\nitself walked back: its round-11 revision supersedes every join-based draft in favour of a\ncompletion signal the shutdown path can await and abandon, and its acceptance criteria no\nlonger mention a join at all. An implementer reading this bead in isolation would have copied\nthe design skk discarded for blocking a tokio worker unboundedly.\n\nThe obligations are the stable part: enqueued jobs are not silently dropped, shutdown stays\nbounded, and the wait never blocks a tokio worker unboundedly. Both index actors inherit\nskk's final mechanism, whatever it turns out to be.","status":"open","priority":2,"issue_type":"task","created_at":"2026-08-01T18:32:54.567435752Z","created_by":"master","updated_at":"2026-08-03T21:45:33.454748728Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"lnrent-68f","depends_on_id":"lnrent-skk","type":"blocks","created_at":"2026-08-01T18:32:54.861342548Z","created_by":"master","metadata":"{}","thread_id":""}]}
+{"id":"lnrent-njv","title":"clippy.toml denylist for blocking primitives, with a proven failure path (ADR-0021)","description":"ADR-0021's rule is only real if a violation fails a build. Add `clippy.toml` with\n`disallowed-methods` covering the blocking primitives, denied by CI's existing `-D warnings`.\n\nMEASURED, not assumed: `clippy::disallowed_methods` is warn-by-default once configured\n(verified on rustc 1.96.0 with a throwaway crate -- it fires on the call and is silenced by an\nexplicit `#[allow(clippy::disallowed_methods)]`). No lint-level plumbing beyond the config\nfile.\n\nKNOW WHAT THIS DOES NOT DO. `disallowed-methods` matches the exact paths listed and nothing\nelse: an unlisted blocking call (`std::net::TcpStream::connect`,\n`std::sync::mpsc::Receiver::recv`, a future crate's blocking API) still compiles silently.\nDemonstrating one listed violation proves the lint is LIVE, not that every violation fails the\nbuild. Do not describe this bead's output as closing the category -- it closes an audited\nlist. Say so in the PR.\n\nCandidate denylist: EVERY `rusqlite::Connection` constructor. Do NOT enumerate them from\nmemory -- an earlier draft of this bead said \"four\" and rusqlite 0.31 has SIX (`open`,\n`open_in_memory`, `open_with_flags`, `open_with_flags_and_vfs`, `open_in_memory_with_flags`,\n`open_in_memory_with_flags_and_vfs`). Derive the list with `grep -E \"pub fn open\" src/lib.rs\nin the vendored rusqlite source, and re-derive it after the 0.40 bump. Plus `std::thread::sleep` and the `std::fs` entry points. Keep the list scoped: an `#[allow]` that becomes routine stops being a signal.\n\nSanctioned boundaries needing an explicit `#[allow]`, each a deliberate marker: the\nstore/index actors; `config.rs` -- but ONLY `load_raw_config` (main.rs:243) is pre-runtime; `prepare_data_dir` (:410) and `bootstrap_headless_with_store` (:415) run INSIDE run_daemon and are exempt as PRE-SERVING, not as pre-runtime. Copy that reason correctly into the `#[allow]`;\n`backup.rs` (offline CLI); and path\nsetup at startup. The IPC SOCKET BIND is NOT in this group -- supervisor.rs:927-932 restarts\nserve_with_shutdown in a backoff loop, so bind_owner_only recurs on a live daemon and must be\nOFFLOADED, not allowed. `load_operator_recipe` (main.rs:451) must ALSO be offloaded, not allowed -- see the unbounded-scan note below. The only sites that may be allowed in place are the fixed, single-shot ones: config.rs's three entry points and the offline backup CLI.\n\nCRITICAL -- this is exactly the class from `checks-that-pass-without-proving`: PROVE the lint\nfails. Add a deliberate violation, confirm CI goes red, then remove it. A denylist that\nsilently matches nothing is worse than none, because it reads as enforcement.\n\nAcceptance:\n- clippy.toml exists; `cargo clippy --workspace --all-targets -- -D warnings` is green.\n- The failure path is demonstrated in the PR, not asserted.\n- Every `#[allow(clippy::disallowed_methods)]` carries a one-line reason.\n\nTHIS BEAD OWNS THE AUDIT. ADR-0021 deliberately stopped enumerating exempt sites after four\nreview rounds each found another live blocking path its \"exhaustive\" list had missed. Derive\nthe inventory here; do not inherit a list from the ADR.\n\nThe audit must cover at least these, all confirmed live-while-serving:\n- `std::fs::*` entry points (the obvious set).\n- `std::path::Path` METADATA methods -- `exists`, `metadata`, `is_file`, `is_dir`,\n  `read_dir`, `canonicalize`. These are blocking syscalls that match NEITHER an `fs::` grep\n  NOR a `std::fs` denylist. Confirmed live: `hook.exists()` at daemon/src/preflight.rs:650,\n  reached by a normal `Request::Preflight` (served at ipc.rs:816).\n- The supervised IPC rebind. `supervisor.rs:927` is a restart loop that re-invokes the task\n  factory at :932, so `bind_owner_only` (ipc.rs:406) and its filesystem work RECUR during\n  recovery while Nostr and maintenance stay live -- it is not a one-shot startup cost, and\n  an earlier draft of the ADR wrongly classified it as one.\n- `ipc.rs:471`'s shutdown unlink, which runs before in-flight handlers drain at :487.\n- NOT this bead: the store crate's synchronous backup entry point. lnrent-7dw CREATES that\n  helper, so 7dw OWNS adding its own exact path to this denylist. Deliberately not a blocker\n  on this bead -- a cheap, valuable lint should not wait behind a crate split.\n\nACCEPTANCE IS NOT SYMMETRIC (review, round 6). \"OFFLOAD or EXEMPT-WITH-REASON\" for every\nsite would let an implementer `#[allow]` the three LIVE paths above and close this bead with ADR-0021's rule left false. So: sites reachable while serving MUST be offloaded, not exempted -- `hook.exists()` becomes an async existence check, and the supervised rebind runs off-worker. Only `ipc.rs:471`'s shutdown unlink may be exempted, and NOT because it is bounded -- it is not: `remove_file` on a wedged or networked FS can block indefinitely and cannot observe cancellation. It is exempted as an ACCEPTED RESIDUAL (a filesystem wedged enough to hang it has already made the daemon unable to commit), and its `#[allow]` must say that rather than claiming a bound. Startup-only sites may be exempted with a reason. An audit that silently omits a category is the same defect\nas a lint that matches nothing, and an acceptance criterion satisfiable by annotation alone is the same defect one level up.\n\nDENY THE OUTER BACKUP API, NOT JUST THE INNER HELPER (review, round 8). `pub mod backup`\n(daemon/src/lib.rs:8) exposes `pub fn backup(..)` (daemon/src/backup.rs:183) and its restore\ncounterpart. Denying only the store crate's sync helper does nothing at that layer: the\nhelper call sits INSIDE backup.rs behind its `#[allow]`, so async code calling\n`lnrentd::backup::backup(..)` performs `VACUUM INTO` plus synchronous filesystem work on a\nworker with no lint firing. The public wrapper launders the disallowed work.\n\nEither put the outer `backup::backup` / `backup::restore` paths on the denylist and `#[allow]`\nonly the synchronous CLI edge that legitimately calls them, or reduce their visibility to\n`pub(crate)` plus a single sanctioned entry point. Whichever is chosen, the deliberate door\nmust be ONE named place, not a public module surface.\n\nTHE CONSTRUCTOR LIST CANNOT BE PROVEN COMPLETE -- SAY SO (review, round 9). This bead has now\nsupplied a wrong enumeration THREE times: first \"four\" constructors (there are six `open*`),\nthen a derivation `grep -E \"pub fn open\"` that finds those six and MISSES two more --\n`Connection::from_handle` and `Connection::from_handle_owned` are `pub unsafe fn` and do not\nstart with `open` (rusqlite 0.31 src/lib.rs:947, :979). Eight total.\n\nCorrect derivation for 0.31, and RE-RUN it after the 0.40 bump:\n\n    grep -nE '^\\s*pub (unsafe )?fn (open|from_handle)' /src/lib.rs\n\nBut do not write \"every constructor is covered\" in the PR. A path-matching denylist is closed\nover the paths you listed and nothing else; a future rusqlite can add a ninth and the lint\nstays green. State the covered set explicitly and note that completeness rests on re-deriving\nat each version bump -- claiming more is the exact defect this bead exists to prevent, and\nthree wrong enumerations in one bead is the evidence.\n\nAlso offload `load_operator_recipe` (main.rs:451) rather than allowing it: `Recipe::load_all`\n(recipe.rs:153-166) is unbounded in both directory count and manifest size, so it is\ndata-scaled work, not the fixed single-shot startup cost the exemption is for.\n\nWRAPPER LAUNDERING IS A GENERAL HOLE, NOT A LIST (review, round 11). Once a primitive inside a\nsanctioned helper carries `#[allow]`, ANY public wrapper around it is callable from async code\nwith no disallowed method at the call site: `lnrentd::backup::backup`, `Recipe::load`,\n`Recipe::load_all`, `config::prepare_data_dir` are the ones found so far, and enumerating them\nhere would repeat this bead's three-strikes history with the rusqlite constructors.\n\nState the principle in the PR instead: an `#[allow]` on a primitive is only safe if the\nenclosing function is not reachable from a serving path. So for each `#[allow]` added, either\n(a) the enclosing function is private / crate-visible and provably called only pre-serving or\noffline, or (b) the enclosing PUBLIC function goes on the denylist too, allowed at its single\nsanctioned edge. Reducing visibility is usually cheaper than another denylist entry, and unlike\na path list it cannot silently miss a wrapper someone adds later.\n\nALSO ASSESS THE THREE GRANDFATHERED MUTEX SECTIONS (codex review, PR #78). ADR-0021 permits\nshort in-memory `std::sync::Mutex` sections under constraints -- no I/O, no unbounded\niteration, nothing growing with request volume -- and names three existing sections that do\nNOT meet them:\n- relay_status.rs:77 -- clones the whole relay vector under the lock.\n- alerts.rs:248 -- inserts into an unbounded `last_sent` map keyed by (kind, subject).\n- nostr_engine.rs:1839 -- `retain()` scan over a vector sized by in-flight requests.\nNone is a live hazard at current scale, and no delivery bead changes them. For\neach: bound it, or record why it is tolerated with the scale at which it stops being tolerable.\nLeaving them unassessed would let these beads close with the ADR's own exception list\ncontaining its counterexamples.\n\nTHIS BEAD OWNS THE CLASSIFICATION, NOT JUST THE LINT (PR #78 round 5). ADR-0021 now states only\nthe CRITERION -- fixed-cost + pre-serving is exemptible; recurring, data-scaled, or\nreachable-once-serving must be offloaded -- because four review rounds each produced a different\nwrong site classification from the ADR. Derive the sites here; do not inherit a list.\n\nKnown misfilings from those rounds, as INPUTS not a complete set:\n- `load_operator_recipe` (main.rs:451) was filed as bounded; `Recipe::load_all`\n  (recipe.rs:153-166) walks read_dir with no count limit and read_to_strings every manifest.\n- The IPC bind (ipc.rs:406) was filed as one-shot startup; supervisor.rs:927-932 restarts\n  serve_with_shutdown in a backoff loop, so it rebinds on a LIVE daemon.\n- `bootstrap_headless_with_store` (main.rs:415) was filed as fixed-cost; it reaches\n  `read_secret_file_bytes`, whose `file.read_to_end(&mut bytes)` (config.rs:1746) is unbounded.\n  Either bound that read (a secret file has a sane max size) or offload the call.\nThe exemption must be applied consistently, and \"tolerated with a reason\" is NOT an option for\nan UNBOUNDED read: ADR-0021's criterion exempts fixed-cost pre-serving work only, so a read that scales with file size must be bounded or offloaded, never `#[allow]`-ed. Do not offload `Recipe::load_all` while exempting `read_secret_file_bytes` for the same property.","status":"open","priority":2,"issue_type":"task","created_at":"2026-08-01T18:32:54.615703187Z","created_by":"master","updated_at":"2026-08-03T21:45:33.360282558Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0}
+{"id":"lnrent-7dw","title":"split the sqlite layer into its own crate behind a non-Deref Txn newtype","description":"ADR-0021 enforcement, structural half. Move the sqlite layer into its own workspace crate\nexposing only async APIs, and drop `rusqlite` from lnrentd's manifest.\n\nTHE OBVIOUS DESIGN DOES NOT WORK, and the bead exists partly to stop it being re-proposed.\nRe-exporting `Transaction`/`Row`/`params!` while withholding `Connection` is BYPASSABLE --\nmeasured on rustc 1.96.0 in a three-crate build where the consumer had no dependency on the\nsqlite crate at all:\n\n    type C =  as std::ops::Deref>::Target;\n    let _ = C::open(\"/tmp/x.db\");                          // compiles\n    let _ = C::open_in_memory_with_flags(Default::default()); // compiles\n\n`Transaction: Deref` hands out the TYPE, not just method access, so every\nConnection constructor is reachable by projection.\n\nThe boundary must therefore be a type that does NOT Deref to Connection: expose a `Txn`\nnewtype wrapping `rusqlite::Transaction` privately and forwarding the query methods callers\nactually use (execute / query_row / prepare / last_insert_rowid / ...). Audit the rest of the\npublic surface for the same hazard before declaring it closed -- any re-exported type whose\nDeref target reaches Connection reopens it.\n\nExplicitly NOT in scope: a typed repository API (one method per query). The newtype already\ngets to enforcement; a repository's benefit is a single audited SQL surface, which is a\nseparate decision on separate merits.\n\nSequenced AFTER the rusqlite 0.31 -> 0.40 bump: both touch the same manifests and call sites,\nand whichever lands second pays the rebase.\n\nAcceptance:\n- lnrentd's Cargo.toml has no `rusqlite` dependency (a dev-dependency for integration tests is\n  fine and expected).\n- No type in the crate's public API Derefs, directly or transitively, to `Connection`.\n- BOTH bypass attempts above FAIL TO COMPILE from daemon code, demonstrated in the PR. A\n  compile-fail test is the deliverable; \"we withheld the re-export\" is not evidence.\n\nBACKUP IS A STRUCTURAL EXCEPTION, caught in review. `daemon/src/backup.rs:66` imports\n`rusqlite::Connection` and `:619` calls `Connection::open(src_db)` for `VACUUM INTO`. That is\nPRODUCTION code in the same binary, so the moment lnrentd's manifest drops `rusqlite` it stops\ncompiling -- and a dev-dependency does not help a normal build. The store crate must therefore\nexpose a SYNCHRONOUS offline entry point for backup alongside its async surface. This does not\nviolate ADR-0021 (backup refuses to run against a live daemon, so it never touches a runtime\nworker), but it means \"the crate exposes only async APIs\" is false as a blanket statement --\nit exposes exactly one deliberate sync door, and that door needs a name and a doc comment\nsaying why it exists.\n\nAcceptance addendum:\n- `cargo build --workspace` succeeds with no `rusqlite` in lnrentd's [dependencies].\n- The backup path uses the store crate's named sync entry point, not a raw Connection.\n\nThe sync backup door needs its OWN denylist entry (review, round 4). Once the store crate\nexposes a synchronous backup helper, any lnrentd module can call it: the blocking\n`Connection::open` inside it sits behind an `#[allow]`, and callers never name a denylisted\nrusqlite method, so nothing fires. Put the helper's exact path in `clippy.toml`\n`disallowed-methods` and `#[allow]` only the offline backup call site -- otherwise the\nstructural-closure claim leaks through the one door we deliberately left open. Coordinate with\nlnrent-njv, which owns the denylist.\n\nFIXTURE HAZARD (review, round 5). Use `Default::default()` for the flags, NOT `0`. rusqlite\ntakes `OpenFlags`, so `open_in_memory_with_flags(0)` fails to compile against a FULLY EXPOSED\n`Connection` -- a fixture copying that form reports success while proving nothing about the\nboundary. Assert on the compiler's REASON (the type is not nameable), not merely on failure.\nThe probe that discovered the bypass used a stand-in crate with a `u32` flags argument, which\nis exactly how this slipped through.","status":"open","priority":2,"issue_type":"task","created_at":"2026-08-01T18:32:54.664077644Z","created_by":"master","updated_at":"2026-08-01T21:59:02.070727797Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"lnrent-7dw","depends_on_id":"lnrent-68f","type":"blocks","created_at":"2026-08-01T18:32:55.003456652Z","created_by":"master","metadata":"{}","thread_id":""},{"issue_id":"lnrent-7dw","depends_on_id":"lnrent-hrm","type":"blocks","created_at":"2026-08-01T18:32:54.932391409Z","created_by":"master","metadata":"{}","thread_id":""},{"issue_id":"lnrent-7dw","depends_on_id":"lnrent-njv","type":"blocks","created_at":"2026-08-01T21:59:02.069939396Z","created_by":"master","metadata":"{}","thread_id":""}]}
+{"id":"lnrent-hrm","title":"upgrade the dependencies fedimint v0.11.1's graph does not pin","description":"Upgrade the crates that are free to move. MEASURED with `cargo outdated --workspace\n--root-deps-only` (now in the devshell via flake.nix):\n\n  age                0.11  -> 0.12.1     (backup encryption, y4m.6)\n  rusqlite           0.31  -> 0.40.1     (state DB + both indexes)\n  toml               0.8   -> 1.1.4      (config parsing; `deny_unknown_fields` back-compat\n                                          is load-bearing -- see o4k's inert-knob decision)\n  thiserror          1     -> 2.0.19\n  getrandom/gloo-net/gloo-timers          (web buyer only)\n\nCORRECTION (review, round 5): an earlier draft read `cargo outdated`'s `Compat` column being\n`---` as \"no semver-compatible update available\". That is WRONG -- it means unresolved, and routine updates ARE pending (`cargo update -p anyhow --precise 1.0.104 --dry-run` and the same for tokio-util 0.7.18 -> 0.7.19 both succeed). Run a plain `cargo update` for the compatible tier as part of this bead; do not assume the lockfile is current.\n\nCORRECTED after review -- `reqwest` 0.12 -> 0.13 was originally listed here and does NOT\nbelong. `reqwest v0.12.28` is required transitively across the pinned fedimint graph (derive the set\nwith `cargo tree --workspace -i reqwest --depth 1`), so bumping lnrentd's direct dependency resolves BOTH\n0.12 and 0.13 in the default configuration. reqwest is in the fedimint-pinned group; it moved\nto the tracking bead.\n\nAlso NOT here: `bitcoin`, `lightning-invoice`, `bech32`, `secp256k1` (same fedimint pin), and\nthe hkdf/sha2 pair (separate bead -- it sits under a funded key derivation).\n\nAcceptance:\n- Each bump is a reviewable step, not one multi-crate commit.\n- `cargo tree` shows no newly duplicated major versions. This is satisfiable ONLY because\n  reqwest was removed from this bead; if a bump turns out to force an unavoidable duplicate,\n  carve it out to the tracking bead rather than weakening this criterion.\n- Full gate green in BOTH feature configurations (with fedimint and --no-default-features).\n\nTHIS BEAD OWNS RE-DERIVING THE DENYLIST AFTER THE rusqlite BUMP (review, round 10). lnrent-njv\nlands its `clippy.toml` against rusqlite 0.31; this bead moves to 0.40, where the constructor\nsurface can differ. Deliberately NOT modelled as njv blocking on this bead -- a cheap, valuable\nlint should ship now rather than wait behind a dependency upgrade -- so the obligation lives\nhere instead:\n- Re-derive with `grep -nE '^\\s*pub (unsafe )?fn (open|from_handle)' /src/lib.rs`\n  and update `clippy.toml` in the same PR as the bump.\n- Re-prove the failure path: add a deliberate violation, confirm CI goes red, remove it. A\n  denylist whose paths have drifted is green and worthless.","status":"open","priority":2,"issue_type":"task","created_at":"2026-08-01T18:32:54.712043653Z","created_by":"master","updated_at":"2026-08-02T04:26:55.800249043Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"lnrent-hrm","depends_on_id":"lnrent-njv","type":"blocks","created_at":"2026-08-02T04:26:55.799825317Z","created_by":"master","metadata":"{}","thread_id":""}]}
+{"id":"lnrent-7w1","title":"hkdf+sha2 bump sits under the FUNDED fedimint root secret: prove the pinned vector first","description":"`sha2` 0.10 -> 0.11 and `hkdf` 0.12 -> 0.13 must move TOGETHER (hkdf 0.13 requires sha2\n0.11). Carved out of the main dependency-upgrade bead because they sit under a FUNDED key\nderivation.\n\nCORRECTED after review: the production hkdf/sha2 use is `derive_fedimint_root_secret`,\nHKDF-SHA256 with info `lnrent:fedimint:v1` (daemon/src/identity.rs:45, :159) -- the FEDIMINT\nroot secret, which is funded. It is NOT a phoenixd derivation: `lnrent:phoenixd:v1` appears\nin zero Rust source files today. That domain is ADR-0018's DESIGN for a helper that is still\nopen work in lnrent-kr1, so this bead must not be written as though it exists.\n\nThe API changes; the algorithm should not. But \"should not\" is exactly the assumption that\nloses a wallet: if the derived secret changes by one byte, the ecash position derived from it\nis a different wallet.\n\nA pinned vector ALREADY EXISTS -- `EXPECTED_FEDIMINT_SECRET_HEX` (identity.rs:184), asserted\nat identity.rs:213. So the precondition is not \"write a vector\", it is \"prove the vector we\nalready have actually catches this\":\n1. Perturb the derivation (change the info string), confirm the existing assertion goes RED,\n   revert. If it does not fail, the vector is decorative and fixing THAT is the bead.\n2. Only then bump, and require the vector to pass byte-identical.\n\nWhen lnrent-kr1 lands the phoenixd seed helper, it owns pinning its own vector; do not couple\nthis bead to it.\n\nNote sha2 0.11.0 is ALREADY in Cargo.lock alongside 0.10.9, so this consolidates onto a\nversion the tree already builds.\n\nAcceptance:\n- The existing fedimint vector is demonstrated to FAIL under a deliberate derivation change,\n  in the PR, before the bump.\n- The derived secret is byte-identical before and after.","status":"open","priority":1,"issue_type":"task","created_at":"2026-08-01T18:32:54.760010413Z","created_by":"master","updated_at":"2026-08-01T20:53:26.502743525Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"lnrent-7w1","depends_on_id":"lnrent-hrm","type":"blocks","created_at":"2026-08-01T18:32:55.073968615Z","created_by":"master","metadata":"{}","thread_id":""}]}
+{"id":"lnrent-73r","title":"does fedimint 0.12 unblock the crypto + reqwest upgrades? (evaluate 0.12.0-beta.2)","description":"Determine whether a fedimint version bump unblocks the crypto + reqwest upgrades, and if so\nwhat it costs.\n\nACTIONABLE NOW: `fedimint-client 0.12.0-beta.2` was published 2026-07-28 (max_stable is still\n0.11.1). The work is to check whether 0.12's dependency graph moves `bitcoin` and `reqwest`\nforward, and to size the lnv2 API-compat risk of adopting it. Output is a decision, not\nnecessarily a bump.\n\nWHAT IS PINNED, AND BY WHAT. The gate is fedimint's VERSION (v0.11.1), NOT the douglaz fork:\n- CRYPTO: `bitcoin` resolves to ONE version shared with lightning / lightning-invoice /\n  lightning-types / miniscript / esplora-client. Bumping lnrent's side alone puts two majors\n  in the graph and a `PublicKey` from 0.33 stops unifying with fedimint's 0.32 -- a compile\n  error, not a silent bug. Behind: lightning-invoice 0.33.3 -> 0.34.1, bech32 0.11.1 ->\n  0.12.0. Derive: `cargo tree --workspace -i bitcoin --depth 1`.\n- REQWEST: 0.12.28 is required transitively across the fedimint graph plus esplora-client,\n  iroh (0.35 and 0.90) and pkarr. Bumping lnrentd's direct dep to 0.13 ADDS 0.13 alongside\n  0.12 rather than replacing it. Derive: `cargo tree --workspace -i reqwest --depth 1`.\n  NOT a second TLS stack: both take `hyper-rustls ^0.27.0`, `rustls ^0.23.4`,\n  `tokio-rustls ^0.26`, which cargo unifies; only the certificate source differs.\n\nDO NOT PROPOSE LEAVING THE FORK. Two earlier drafts of this bead did, and both were wrong:\n1. The fork is not the constraint. It is upstream v0.11.1 plus two commits, so returning to\n   the plain tag would change NO dependency version.\n2. Keeping the fork is a standing operator decision (lnrent-8ym, 2026-07-22), recorded in\n   ADR-0018's Consequences and at daemon/Cargo.toml:95-101, which explicitly rejected\n   returning to the tag as \"churn for zero functional change\". Superseding it needs a new\n   operator decision, not a dependency-hygiene bead.\n\nCORRECTION (review, round 7): an earlier draft claimed \"NEITHER fork commit touches a crate\nlnrent still compiles\". FALSE -- `cargo tree --workspace -i fedimint-tpe` shows\n`fedimint-tpe -> fedimint-lnv2-client -> lnrentd`, so the tpe crate IS built. ADR-0018's own\nwording is narrower and correct: the CHANGED FUNCTION is only called by\n`fedimint-gwv2-client`, which lnrent does not depend on. The claim is functional equivalence,\nnot crate absence -- do not restate it the loose way.\n\nRelated: lnrent-hrm (the upgrades this gates), ADR-0018 (why the fork stays), lnrent-e96.\n\nTHIS BEAD OWNS REPAIRING THE FALSE CLAIM IT FOUND (review, round 8). The refuted wording is\nnot only in this bead's history -- it is live in two places a reader will land on:\n- daemon/Cargo.toml:98-99: \"NEITHER touches any crate lnrent still compiles, so the built lnv2\n  output is byte-identical to the upstream tag\"\n- ADR-0018 Consequences (~:57-59): the same sentence.\n`fedimint-tpe` IS compiled (`cargo tree --workspace -i fedimint-tpe` -> fedimint-lnv2-client\nand fedimint-lnv2-common -> lnrentd), so both the crate-absence claim and the byte-identical\ncorollary are false as written. The DEFENSIBLE claim is narrower and is the one the\nparenthetical already makes: the changed tpe function is only called by\n`fedimint-gwv2-client`, which lnrent does not depend on -- functional equivalence, not crate\nabsence, and not a byte-identical build.\n\nRepair both sites in the same PR as this bead's investigation, and re-verify rather than\nassuming: if the tpe fix does change compiled code, say so plainly. Note this does NOT weaken\nthe standing decision to keep the fork (lnrent-8ym); it corrects the stated reason.","status":"open","priority":3,"issue_type":"task","created_at":"2026-08-01T18:32:54.807818505Z","created_by":"master","updated_at":"2026-08-02T03:31:14.524921612Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0}
diff --git a/README.md b/README.md
index 200fd78..82dd78e 100644
--- a/README.md
+++ b/README.md
@@ -133,7 +133,7 @@ $B order wait               # -> access credentials (host/port/user)
 
 - Current plan of record: the phased programme in [SPEC.md](./SPEC.md) §15 · delivered go-live roadmap (historical): [docs/specs/production-readiness.md](./docs/specs/production-readiness.md) · operator runbook: [docs/go-live.md](./docs/go-live.md)
 - Spec: [SPEC.md](./SPEC.md) (draft v0.29) · glossary: [CONTEXT.md](./CONTEXT.md)
-- Decisions: [docs/adr/](./docs/adr/) (0001-0020) · change specs: [docs/specs/](./docs/specs/)
+- Decisions: [docs/adr/](./docs/adr/) (numbered, sequential) · change specs: [docs/specs/](./docs/specs/)
 - Security/deployment notes: [docs/security/](./docs/security/)
 
 ## License
diff --git a/docs/adr/0021-no-blocking-work-on-runtime-threads.md b/docs/adr/0021-no-blocking-work-on-runtime-threads.md
new file mode 100644
index 0000000..49f981c
--- /dev/null
+++ b/docs/adr/0021-no-blocking-work-on-runtime-threads.md
@@ -0,0 +1,307 @@
+# 0021 — No blocking work on a runtime worker thread
+
+**Status: DECIDED, NOT YET BUILT.** Nothing described here is in the tree at the time of
+writing. `Store::spawn` still mounts the actor with `tokio::spawn` (`daemon/src/store.rs`),
+both backend indexes are still raw `Arc>` called from async fns, `lnrentd`
+still depends on `rusqlite` directly, and there is no `clippy.toml`. Read every "does" and
+"is" below as "will" until the delivery beads close. That set is derivable rather than listed
+here, so it cannot go stale as beads split or get added:
+
+```bash
+br list --limit 0 --json -a | jq -r '.[] | select(.status!="closed")
+  | select((.description // "") + (.title // "") | test("ADR-0021")) | "\(.id) \(.title)"'
+```
+
+`lnrentd` is a tokio application whose two most important dependencies are synchronous:
+`rusqlite` (a C binding, with no async form) and the filesystem. This ADR fixes how those
+are reached, because the alternative is the failure mode where a later edit adds a
+blocking call to an async path and nothing catches it.
+
+**The rule: once the daemon is serving, no blocking I/O and no unbounded-duration work runs
+on a tokio worker thread. Blocking I/O resources are reachable only through an async API.**
+
+Earlier drafts said "no blocking call", which review correctly rejected three times as a
+promise this ADR does not keep. What matters is work whose duration is unbounded or scales
+with data: sqlite queries, file I/O, sleeps, network calls.
+
+**In-memory `std::sync::Mutex` sections are a named exception, not an instance of the rule.**
+Holding one is the recommended tokio practice for short critical sections, and swapping
+`alerts.rs`, `relay_status.rs` and the Nostr engine's trackers for `tokio::sync::Mutex` would
+be slower and buy nothing. But do not claim a duration bound for them: a `std::sync::Mutex` has
+none when contended or when its holder is descheduled.
+
+The exception carries constraints rather than a guarantee: inside the critical section, no I/O,
+no unbounded iteration, and nothing that grows with request volume. **Those constraints bind new
+code; three sections in the tree today do not meet them**, and saying so is the point — an
+exception list that quietly includes its own counterexamples is a false contract:
+
+- `relay_status.rs:77` clones the whole relay vector under the lock.
+- `alerts.rs:248` inserts into an unbounded `last_sent` map keyed by `(kind, subject)`.
+- `nostr_engine.rs:1839` does a `retain()` scan over a vector sized by in-flight requests.
+
+No delivery bead changes them, and none of the three is a live hazard at current
+scale. `lnrent-njv`'s audit assesses each and either bounds it or records why it is tolerated;
+until then they are grandfathered, not conforming. Sections that outgrow the constraints stop
+being exceptions and come back into scope.
+
+The genuinely dangerous mutex case — a guard held across an `.await` — is separately and
+already denied by `clippy::await_holding_lock` under CI's `-D warnings`.
+
+Two scope words in the rule are load-bearing, because an absolute phrasing would be a claim
+this ADR does not deliver.
+
+*"Once serving"* — the exempt phase is **pre-serving**, not "before the runtime". Only
+`config::load_raw_config` (`main.rs:243`) precedes `build_runtime` at `:254`; everything else
+called "startup" — `config::prepare_data_dir` (`main.rs:410`),
+`config::bootstrap_headless_with_store` (`:415`), `load_operator_recipe` (`:451`) — runs inside
+async `run_daemon`, on a worker, after the store actor already exists. What makes them exempt is
+not the absence of a runtime but that **no request has been accepted yet**, so blocking there
+delays only startup.
+
+**This ADR gives the criterion, not the classification.** Two things decide a site:
+
+- *Fixed-cost and pre-serving* → exemptible with a reason.
+- *Recurs, scales with data, or can run once requests arrive* → in scope, offload it.
+
+Four review rounds each produced a different wrong classification from earlier drafts of this
+section — `load_operator_recipe` filed as bounded when `Recipe::load_all` (`recipe.rs:153-166`)
+walks `read_dir` unbounded; the IPC bind filed as startup when `supervisor.rs:927` rebinds on a
+live daemon; `bootstrap_headless_with_store` filed as fixed-cost when it reaches
+`read_secret_file_bytes`'s unbounded `read_to_end` (`config.rs:1746`). The pattern is the point:
+a decision record cannot hold an accurate site-by-site taxonomy of a moving codebase.
+**`lnrent-njv` owns the classification**, applying the criterion above, deriving the sites rather
+than inheriting a list from here. The three misfilings above are recorded there as known
+inputs — not as a complete set.
+
+**The IPC socket bind deserves a specific warning**, because it looks like startup and is not.
+`ipc.rs:406`'s
+`bind_owner_only` is reached through `serve_with_shutdown`, which `supervisor.rs:927-932`
+restarts in a backoff loop — so a listener error rebinds while Nostr and maintenance stay
+live. It is recurring work on a live daemon and belongs in the offload set. An earlier draft
+of this ADR filed it under startup and was wrong.
+
+One narrow exception is deliberate and named here so the rule and the beads cannot disagree:
+**the graceful-shutdown unlink.** `ipc.rs:471` removes the socket before in-flight handlers
+drain at `:487`, so it does run while requests are in flight. It stays blocking, on a path
+already committed to shutting down, where there is no latency left to protect.
+
+Do not call it *bounded*, though — an earlier draft did, and that was wrong. `std::fs::
+remove_file` on a wedged or networked filesystem can block indefinitely, and running
+synchronously on a worker it cannot observe cancellation, so this path can outlast
+`SHUTDOWN_GRACE`. That residual is **accepted, not eliminated**: the socket lives in the data
+dir, so a filesystem wedged enough to hang this unlink has already made the daemon unable to
+commit anything, and adding an offload here buys a clean exit for a process that is
+unrecoverable regardless. Stated rather than papered over — the whole point of this ADR is
+that a claimed bound nobody checked is worse than an acknowledged gap.
+
+*"Blocking"* — this is not "all code is async", which is unachievable: sqlite has no async
+implementation in Rust. Every crate advertising one (`tokio-rusqlite`, `deadpool-sqlite`,
+`sqlx`'s sqlite driver) is a thread-and-channel wrapper around the same blocking calls. The
+achievable rule is about *where* blocking runs, not whether it exists.
+
+## The sanctioned pattern
+
+One shape, to be used for all three sqlite connections (the state DB, the lnv2 index, the
+phoenixd index): a dedicated OS thread owns the `Connection` and receives closures over a
+**bounded** channel; the **serving** surface is `async` only. Not *every* surface — the crate
+also exposes one deliberate synchronous door for the offline backup CLI (see Enforcement).
+Today only the state DB has anything resembling this, and it is mounted wrongly; the indexes
+have no boundary at all.
+
+```rust
+// Shape only. The connection is OPENED ON THIS THREAD, not handed in:
+// Connection::open + PRAGMAs + quick_check + migrations are blocking too.
+let (tx, mut rx) = mpsc::channel::(64);
+std::thread::spawn(move || {
+    let mut conn = match open(&path) { Ok(c) => c, Err(e) => { let _ = ready.send(Err(e)); return } };
+    let _ = ready.send(Ok(()));            // initialisation result, back to the caller
+    while let Some(job) = rx.blocking_recv() {
+        job(&mut conn);
+    }
+});
+```
+
+### Shutdown is a requirement here, not a mechanism
+
+Graceful shutdown must satisfy three things at once, and this ADR states them as obligations
+because **three successive drafts specified a mechanism and all three were wrong** — the record
+is worth more than another guess:
+
+1. **On a normal drain, enqueued work runs to completion.** The guarantee starts at *successful
+   enqueue*: `Store::run` returns from `tx.send(job).await` with `Ok`, then awaits a oneshot, so
+   a caller cancelled at that second await leaves a job in the queue that must still run. A
+   caller cancelled *before* the send completes has no such job and is owed nothing. Closing the
+   senders lets `blocking_recv` return `None` once the queue drains.
+2. **Shutdown stays bounded, and outranks obligation 1 when they conflict.** `SHUTDOWN_GRACE`
+   (`supervisor.rs:95`) exists so "a stuck loop must not hang process exit". A stalled fsync
+   must not outrank that.
+3. **The wait itself obeys this ADR.** It cannot block a tokio worker for an unbounded time.
+
+Obligations 1 and 2 *do* collide, and the resolution is stated rather than left to the
+implementer: when one job stalls, everything enqueued behind it is **abandoned, deliberately and
+loudly** — obligation 1 describes the normal drain, not the timeout path. Exiting on the bound
+is the correct behaviour, and the timeout is an acknowledged data-loss event: log the abandoned
+queue depth so it is visible in the operator's logs rather than inferred. WAL plus
+`synchronous=FULL` gives SQLite's documented commit durability *under its filesystem
+assumptions* — an interrupted job leaves the DB consistent; it says nothing about the queue
+having been empty, and nothing about I/O, filesystem or storage failure (that is what
+`lnrent-y4m.3`'s degraded-mode latch exists for). `lnrent-skk` must test exactly this shape — a
+healthy job enqueued *behind* a stalled one — because that is where the two obligations meet.
+
+Rejected drafts, each defeated by the next review round: a bare `handle.join()` (unbounded, and
+blocks a worker — breaks 2 and 3); `timeout(spawn_blocking(|| handle.join()))` (a timed-out
+blocking task cannot be abandoned, and dropping the runtime at `main.rs:258` then waits for it,
+so the process still hangs — breaks 2). A completion **signal** the shutdown path can await and
+abandon looks more promising than any join, since a detached thread does not hold up process
+exit — but `lnrent-skk` owns choosing and, above all, **testing** it. The stalled-job path is
+the test that matters; without it this is three guesses and a fourth.
+
+WAL plus `synchronous=FULL` means an interrupted job leaves the database consistent, within
+SQLite's documented filesystem assumptions — not that storage cannot fail under it. What an
+abandoned queue does do is drop work a caller was told had been accepted, which is why
+obligation 1 exists for the normal path and why the timeout path has to announce itself rather
+than pass quietly.
+
+`std::thread::spawn`, not `tokio::spawn` — the distinction is the whole point. The actor
+already exists in this shape and is mounted on a tokio task, which means SQL occupies a
+runtime worker for its duration.
+
+## Considered options
+
+- **`tokio-rusqlite`.** Its implementation (`src/lib.rs:376`) is
+  `crossbeam_channel::unbounded` + `thread::spawn` + a closure event loop — the same design
+  adopted above, which is the strongest argument that the design is right. Rejected as a
+  *dependency* for three reasons. It requires `rusqlite ^0.37` against our `0.31`, and
+  `libsqlite3-sys` declares `links = "sqlite3"`, so cargo forbids coexistence: adopting the
+  crate *forces* a rusqlite major bump as a side effect of a concurrency fix. Its channel is
+  unbounded, where `mpsc::channel(64)` makes the caller await — so on a `synchronous=FULL` money
+  DB a slow fsync becomes visible pressure at the call site instead of a silently growing queue.
+  (That bounds the ACTOR's queue and propagates pressure to the caller; it does not bound total
+  in-flight memory — see the consequences.)
+  And `transaction()`, the degraded-latch (ADR-0001, lnrent-y4m.3) and the sole-writer
+  contract are built on top either way, so the code it replaces is the dozen lines that
+  spawn the thread. **Take the design, not the dependency.**
+- **`spawn_blocking` per job.** Moves work off the workers without a new thread. It does
+  **not** dissolve serialization — an actor loop that awaits each job before receiving the
+  next stays strictly sequential — so it is rejected on cost, not on correctness: `Connection`
+  is not `Sync`, so each job must move the connection into the closure and recover it from the
+  `JoinHandle`, paying that shuffle plus a blocking-pool dispatch on every single query. One
+  long-lived thread does the same work with neither.
+- **A read pool alongside the writer.** WAL permits concurrent readers, so this is a real
+  throughput gain — rejected because it introduces a second concurrency model into the money
+  core to solve a performance problem nobody has measured.
+- **Converting the filesystem calls to `tokio::fs` wholesale.** Rejected: the bulk of the
+  daemon's filesystem work is **pre-serving** — of `config.rs`'s three entry points only
+  `load_raw_config` is genuinely pre-runtime, while `prepare_data_dir` and
+  `bootstrap_headless_with_store` run inside `run_daemon` before any request is accepted (see
+  the table above) — or offline (`backup.rs`, which refuses to run against a live daemon).
+  Converting those buys nothing.
+
+  **This ADR deliberately does not carry the audit of which sites are exempt.** Four review
+  rounds each found another live blocking path an earlier draft's "exhaustive" classification
+  had missed — the shutdown unlink at `ipc.rs:471`, the supervised IPC rebind
+  (`supervisor.rs:927` loops and re-invokes the factory, so `bind_owner_only` recurs during
+  recovery, not just at startup), and `hook.exists()` on a live `Preflight` request
+  (`preflight.rs:650`), which is blocking metadata I/O that matches neither an `fs::` grep nor
+  a `std::fs` denylist. A decision record that also claims a finished inventory is a record
+  with a rotting half. **The audit is `lnrent-njv`'s deliverable**, derived rather than
+  recalled; this section records only the decision not to convert wholesale.
+
+## Enforcement
+
+Prose does not hold a rule like this; the rule is only real if a violation fails a build.
+Two mechanisms, because neither is sufficient alone:
+
+- **A crate boundary.** The sqlite layer moves into its own crate and `lnrentd`'s manifest
+  drops `rusqlite` (`lnrent-7dw`). "Only async APIs" is nearly true and the exception is
+  structural: `backup.rs` opens the state DB directly for `VACUUM INTO` (`backup.rs:619`, via
+  the `rusqlite::Connection` import at `:66`) and is an offline CLI that refuses to run
+  against a live daemon, so it needs a
+  *synchronous* offline entry point from the same crate. That is compatible with the rule —
+  it never runs while serving — but the crate exposes one deliberate sync door, and pretending
+  otherwise would leave the binary uncompilable the moment the dependency is dropped.
+- **A `clippy.toml` denylist.** `disallowed-methods` covering `rusqlite::Connection::open`
+  and the blocking primitives, denied by CI's `-D warnings`.
+
+**Withholding `Connection` from the re-export is NOT sufficient, and the reason is worth
+recording so nobody re-proposes it.** The obvious design — re-export `Transaction`, `Row` and
+`params!`, withhold `Connection`, and rely on `Connection::open` being an associated function
+that must be named — is bypassable through an associated-type projection. Measured on rustc
+1.96.0 in a three-crate build where the consuming crate had **no dependency on the sqlite
+crate at all**:
+
+```rust
+type C =  as std::ops::Deref>::Target;
+let _ = C::open("/tmp/x.db");
+let _ = C::open_in_memory_with_flags(Default::default());
+```
+
+`Transaction`'s `Deref` (rusqlite 0.31 `src/transaction.rs:232`) hands
+out the type itself, not merely method access, so every `Connection` constructor is reachable
+without naming or depending on `rusqlite`.
+
+The probe that established this used a stand-in crate whose flags argument was a `u32`; real
+rusqlite takes `OpenFlags`, hence `Default::default()` above. That detail is not pedantry —
+it is the whole hazard of the compile-fail fixture `lnrent-7dw` requires. A fixture that fails
+to compile because the *argument types* are wrong proves nothing about the boundary while
+happily reporting success. **The fixture must assert on the compiler's reason** (an
+unresolved/sealed type), not merely on failure.
+
+The boundary therefore has to be a type that does **not** `Deref` to `Connection`: the store
+crate exposes its own `Txn` newtype wrapping `rusqlite::Transaction` privately and forwarding
+the query methods callers actually use. No projection path, no bypass. Every SQL site in the
+daemon keeps its closures and business logic and simply calls the same methods on a different
+receiver. To size that before starting, derive the set rather than trusting a number written
+here:
+
+```bash
+# Qualified paths AND the unqualified uses that follow `use rusqlite::…`, which a
+# `rusqlite::`-anchored pattern misses entirely (backup.rs:619 is exactly that case).
+rg -n '\b(Connection|Transaction|OpenFlags)\b|params!|\.query_row\(|\.prepare\(' daemon/src --stats
+```
+
+Treat that as a **lower bound for sizing, not proof of coverage** — a text pattern cannot be
+shown complete, and four separate derivation commands in this ADR's review history were each
+wrong in a different way. If completeness matters, use an AST-based inventory.
+
+A typed repository API (one method per query, owning every site) is still **not** required
+for enforcement — the newtype gets there. That refactor has its own merit, a single audited
+SQL surface, and should be decided on that merit rather than smuggled in as the price of this
+rule.
+
+The denylist then covers what no crate boundary can: `std::thread::sleep`, blocking
+filesystem entry points, and any other explicitly listed blocking primitive from crates the daemon legitimately
+depends on. An explicit `#[allow(clippy::disallowed_methods)]` is the marker of a deliberate,
+sanctioned boundary — it should be rare and greppable.
+
+**What this enforcement does and does not buy.** `disallowed-methods` matches the exact paths
+listed in `clippy.toml` and nothing else: an unlisted blocking call (`std::net::TcpStream::
+connect`, `std::sync::mpsc::Receiver::recv`) still compiles silently. Demonstrating one listed
+violation proves the lint is *live*, not that every violation fails the build. So the honest
+contract is: the sqlite path is closed structurally, an audited set of named primitives is
+closed by lint, and everything else rests on review. Claiming more than that would be the same
+error as the bypass above — asserting a barrier without testing what walks through it.
+
+## Consequences
+
+- Blocking sqlite runs on threads outside the runtime's control, so a slow fsync delays the
+  store's queue rather than starving unrelated tasks.
+- The bounded channel makes store backpressure observable and bounds the actor's own queue: a
+  caller awaits `tx.send` instead of enqueueing into a queue that only grows. It does **not**
+  bound total pending memory, and saying it did would be the kind of unchecked claim this ADR
+  exists to prevent — `serve_with_shutdown` accepts every same-UID connection and spawns a
+  handler with no semaphore (`ipc.rs:424-449`), so excess work parks in an unbounded set of
+  waiting tasks, each holding its `Job`. Capping IPC admission is a separate question and
+  deliberately not opened here: the socket is owner-only and `SO_PEERCRED`-gated, so anyone who
+  can flood it already has the operator's privileges, which makes this a robustness limit worth
+  stating rather than a security boundary worth building.
+- Total serialization through one actor is unchanged. The money path depends on it
+  (lnrent-26b preserved money-path serialization by *sharing* the actor), and this ADR does
+  not relax it.
+- `clippy::await_holding_lock` is warn-by-default and already denied by CI's `-D warnings`,
+  so the narrowest and most dangerous version of this mistake — a `MutexGuard` held across
+  an `.await` — was already a hard gate before this ADR.
+- New blocking **sqlite** work needs either an actor or an explicit `#[allow]`. New blocking
+  work of other kinds needs review to catch it unless its exact path is on the denylist — see
+  the enforcement section. This ADR closes one class structurally and an audited list by lint;
+  it does not close the category.
diff --git a/flake.nix b/flake.nix
index b08941b..87fca46 100644
--- a/flake.nix
+++ b/flake.nix
@@ -43,6 +43,10 @@
 
             # ops convenience
             sqlite
+
+            # dependency hygiene: `cargo outdated` reports deps behind their latest
+            # release, INCLUDING semver-major bumps that `cargo update` will never make.
+            cargo-outdated
           ];
 
           # bindgen (librocksdb-sys, secp256k1) needs to find libclang