Skip to content

docs: ADR-0021 — no blocking work on a runtime worker thread - #78

Merged
douglaz merged 1 commit into
masterfrom
docs/adr-0021-no-blocking-on-runtime-threads
Aug 3, 2026
Merged

docs: ADR-0021 — no blocking work on a runtime worker thread#78
douglaz merged 1 commit into
masterfrom
docs/adr-0021-no-blocking-on-runtime-threads

Conversation

@douglaz

@douglaz douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

Records the decision to keep blocking work off tokio's worker threads, the one sanctioned pattern for reaching sqlite, and seven beads that deliver it. No code changes — this is the decision record plus its work graph.

The achievable rule is narrower than "100% async", and the ADR says so up front: sqlite has no async implementation in Rust, so the rule is "once serving, no blocking I/O and no unbounded-duration work runs on a tokio worker", with in-memory mutex sections as a named exception carrying constraints rather than a duration guarantee.

The ADR opens with Status: DECIDED, NOT YET BUILT and names the delivering beads, because none of it is in the tree yet.

Two things worth reviewing closely

tokio-rusqlite was evaluated and rejected as a dependency while its design was adopted. Its implementation is the OS-thread-plus-channel actor proposed here. Rejected because it requires rusqlite ^0.37 against our 0.31 and libsqlite3-sys declares links = "sqlite3" — cargo forbids coexistence, so 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 converts a slow fsync into unbounded queue growth with no backpressure.

The obvious enforcement design was refuted with a build probe. Re-exporting Transaction while withholding Connection does not work — an associated-type projection reaches every constructor from a crate with no dependency on the sqlite crate at all:

type C = <store::Transaction<'static> as std::ops::Deref>::Target;
let _ = C::open("/tmp/x.db");            // compiles

Hence 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 OS thread; connection init on that thread; shutdown drain
lnrent-68f index actors (lnv2 + phoenixd), which today have no boundary at all
lnrent-njv clippy denylist + the blocking-call audit, with a proven failure path
lnrent-7dw crate boundary behind the non-Deref newtype
lnrent-hrm dependency upgrades not gated by fedimint's graph
lnrent-7w1 P1 — hkdf/sha2 sit under a funded key derivation
lnrent-73r does fedimint 0.12 unblock the crypto + reqwest upgrades?

Trade-offs accepted

  • The shutdown mechanism is deliberately unspecified. Three drafts were written and all three were refuted during review (bare join(); timeout(spawn_blocking(|| join())), which can't be abandoned and which runtime teardown then waits on). The ADR states three obligations and records the failed drafts; lnrent-skk owns choosing and testing one.
  • The blocking-call audit is not in the ADR. Four review rounds each found another live path an "exhaustive" classification had missed. A decision record that also claims a finished inventory has a rotting half — lnrent-njv owns the audit, derived rather than recalled.
  • lnrent-73r also owns a doc repair it uncovered: daemon/Cargo.toml:98-99 and ADR-0018 both state that no fork commit touches a compiled crate. cargo tree -i fedimint-tpe shows otherwise. The defensible claim is narrower (the changed function is uncalled), and this does not weaken the standing decision to keep the fork.

Test plan

  • nix develop evaluates with the cargo-outdated addition; cargo outdated runs
  • Every file:line citation in the ADR and beads verified against the tree
  • The Deref bypass claim proven by a three-crate build, not asserted
  • clippy::disallowed_methods and await_holding_lock confirmed warn-by-default on this toolchain
  • 11-pass codex + fable review loop (5 P1, 33 P2, 6 P3 fixed; 3 simplified, 2 cut)
  • CI green
  • Codex bot review

No code changed, so the money-path suites are unaffected; CI runs them regardless.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq

Summary by CodeRabbit

  • Documentation
    • Added guidance to keep blocking operations off runtime threads and protect application responsiveness.
    • Documented safer database processing, bounded queues, shutdown behavior, and operational safeguards.
    • Clarified that decision records are numbered sequentially.
    • Corrected documentation describing functional equivalence versus identical builds.
  • Chores
    • Added tooling to support dependency monitoring and hygiene.
    • Recorded follow-up work for database reliability, cryptographic compatibility, dependency reviews, security policy, and related improvements.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds ADR 0021 for blocking-I/O rules and dedicated-thread SQLite actors. It records follow-up issues, updates ADR navigation, and adds cargo-outdated to the Nix development shell.

Changes

Blocking I/O policy and SQLite actor roadmap

Layer / File(s) Summary
Runtime rules and SQLite actor design
docs/adr/0021-no-blocking-work-on-runtime-threads.md
ADR 0021 defines serving-phase blocking rules, SQLite actor behavior, bounded queues, initialization reporting, serialized execution, shutdown handling, and rejected alternatives.
SQLite and blocking-I/O enforcement
docs/adr/0021-no-blocking-work-on-runtime-threads.md
The ADR specifies a separate SQLite crate, a non-Deref transaction type, a synchronous backup boundary, Clippy checks, SQL audit guidance, and enforcement limits.
Follow-up issues and tooling
.beads/issues.jsonl, README.md, flake.nix
Issues track cleanup automation, CSP delivery, e2e failure handling, dependency upgrades, cryptographic compatibility, Fedimint evaluation, and fork documentation. README ADR navigation and development-shell tooling are updated.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • douglaz/lnrent#27: Related to the dedicated SQLite actor architecture and database error-handling boundaries.
  • douglaz/lnrent#76: Related to the README/ADR documentation and web buyer CSP and e2e assertion issues.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the addition of ADR-0021 and its primary decision about blocking work on runtime worker threads.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/adr-0021-no-blocking-on-runtime-threads

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 088dcaeea5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +29 to +30
constraints rather than a guarantee — no I/O, no unbounded iteration, and nothing that grows
with request volume, inside the critical section. Sections that outgrow those constraints stop

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align the mutex exception with the existing maps

When recurring alerts have distinct subjects, AlertDispatcher::dispatch inserts them into the unbounded last_sent HashMap while holding its standard mutex (daemon/src/alerts.rs:247-250); the Nostr abort tracker likewise retains and extends a request-sized vector under its lock (daemon/src/nostr_engine.rs:1838-1841). These are among the sections expressly endorsed just above, but they already violate the stated prohibitions on allocation and request-volume growth, and none of the four delivery beads changes them. Closing those beads would therefore leave the ADR's contract false; either narrow these constraints or include bounding/converting these sections in the tracked work.

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
docs/adr/0021-no-blocking-work-on-runtime-threads.md (1)

74-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the dependency facts used by this decision.

The ADR makes version- and implementation-specific claims about tokio-rusqlite, deadpool-sqlite, and sqlx. Record the evaluated versions and the source or lockfile used for the channel and dependency-graph conclusions. Recheck this section when those versions change.

Also applies to: 130-140

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md` around lines 74 - 77,
Update the ADR’s dependency-facts section to record the evaluated versions of
tokio-rusqlite, deadpool-sqlite, and sqlx, along with the source or lockfile
used to verify their channel-based implementations and dependency graphs. State
that these claims must be rechecked whenever the referenced versions change,
including the related discussion at the additional marked section.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.beads/issues.jsonl:
- Line 154: The shutdown requirement in issue lnrent-68f uses the superseded
“bounded, off-worker shutdown join” design. Replace that wording with a
requirement for a completion signal that the shutdown path can await and
abandon, or explicitly require following the final shutdown mechanism
implemented by lnrent-skk; remove the specific join reference while preserving
bounded, non-blocking worker shutdown behavior for both index actors.

In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md`:
- Around line 212-214: Update the SQL-site inventory command in the ADR to
detect unqualified rusqlite imports and constructors, including patterns such as
use rusqlite::Connection and Connection::open alongside the existing reference
and transaction searches. Ensure the inventory covers cases like
daemon/src/backup.rs without replacing the documented scope with unrelated
searches.
- Around line 105-107: Update obligation 1 in the ADR to define the guarantee
from successful completion of Store::run’s self.tx.send(job).await, rather than
from invocation. Explicitly distinguish cancellation before send completion,
where no job delivery is required, from cancellation after send completion,
where the queued job must still be processed.

---

Nitpick comments:
In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md`:
- Around line 74-77: Update the ADR’s dependency-facts section to record the
evaluated versions of tokio-rusqlite, deadpool-sqlite, and sqlx, along with the
source or lockfile used to verify their channel-based implementations and
dependency graphs. State that these claims must be rechecked whenever the
referenced versions change, including the related discussion at the additional
marked section.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 77e5e8a6-76bc-426a-9f61-0d800f2e3f97

📥 Commits

Reviewing files that changed from the base of the PR and between fcfca26 and 088dcae.

📒 Files selected for processing (4)
  • .beads/issues.jsonl
  • README.md
  • docs/adr/0021-no-blocking-work-on-runtime-threads.md
  • flake.nix

Comment thread .beads/issues.jsonl Outdated
Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md Outdated
Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md
douglaz added a commit that referenced this pull request Aug 2, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from 088dcae to f253445 Compare August 2, 2026 14:50
@douglaz

douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Round 1 addressed — all four findings verified against the tree before fixing, none rejected. Head is now f253445.

codex P2 (mutex exception vs. the existing maps) — correct, and the sharper version of it is that the ADR named alerts.rs and nostr_engine.rs as endorsed examples in the same breath as constraints they violate. Confirmed: alerts.rs:248 inserts into an unbounded last_sent map under the lock, nostr_engine.rs:1839 does a retain() scan over a request-sized vector, and relay_status.rs:77 clones the whole vector. Rather than narrow the constraints until the counterexamples fit, the ADR now lists all three explicitly as grandfathered, not conforming, notes none is a live hazard at current scale, and hands assessment to lnrent-njv. An exception list that quietly contains its own counterexamples is a false contract, which is the failure mode this ADR exists to prevent.

CodeRabbit, Major (enqueue boundary) — correct. Obligation 1 said a cancelled caller "leaves the job queued", which only holds after tx.send(job).await returns Ok. The guarantee now explicitly starts at successful enqueue, and says a caller cancelled before that is owed nothing. That boundary is what makes the obligation testable.

CodeRabbit, Major (inventory command undercounts) — correct, and backup.rs is the proof: my rusqlite::-anchored pattern matched only the use line and missed Connection::open at :619. Command fixed to catch unqualified uses, and now labelled a lower bound for sizing, not proof of coverage — four separate derivation commands in this ADR's review history were each wrong in a different way, so claiming completeness for a text pattern is not credible.

CodeRabbit, Minor (stale join cross-reference) — correct. lnrent-68f cited "the same bounded, off-worker shutdown join skk specifies", but skk's round-11 revision superseded every join-based draft with a completion signal, and its acceptance criteria no longer mention a join. 68f now tracks whatever mechanism skk lands on rather than naming one, keeping the obligations as the stable part.

Context: this diff already went through an 11-pass codex + Claude Fable review loop before the PR opened (5 P1 / 33 P2 / 6 P3 fixed, 3 simplified, 2 cut). The loop was stopped deliberately on a fractal-tail signal rather than a clean pass — the remaining findings were about my corrections rather than the decision — so a fresh set of eyes finding four real items is the expected and useful outcome, not a surprise.

@douglaz

douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f25344506c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +115 to +120
1. **Enqueued work is not silently dropped.** 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 already in the queue that must still run. A
caller cancelled *before* the send completes has no such job and is owed nothing — that
boundary is deliberate, and stating it is what makes the obligation testable. Closing the
senders lets `blocking_recv` return `None` once the queue drains.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile the accepted-job guarantee with bounded shutdown

When one database job stalls and another job has already been successfully enqueued behind it, the bounded shutdown described on lines 121-135 exits after logging the abandoned queue, so the queued job cannot “still run” as required here. The delivery beads inherit both obligations, leaving an implementation unable to satisfy the stated contract; scope the run-to-completion guarantee to a normal drain and explicitly define the timeout path as an acknowledged abandonment, including a test for a successfully enqueued job behind the stalled one.

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

douglaz added a commit that referenced this pull request Aug 2, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from f253445 to b4a8d4b Compare August 2, 2026 17:01
@douglaz

douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Round 2 — head is now b4a8d4b. One finding fixed, one rejected with evidence.

codex P2 (accepted-job guarantee vs. bounded shutdown) — FIXED. Correct, and it's a contradiction I introduced in round 1: tightening obligation 1 to "must still run" put it in direct conflict with obligation 2's bounded exit, since a job enqueued behind a stalled one cannot satisfy both.

Rather than weaken either obligation, the ADR now states the resolution explicitly: bounded exit wins, obligation 1 describes the normal drain only, and the timeout path is an acknowledged data-loss event that must log the abandoned queue depth so the loss is announced rather than inferred. lnrent-skk's test requirement is sharpened to match — a healthy job enqueued behind a stalled one, asserting both that the process still exits within the bound and that the abandoned depth is logged. A test that wedges only a single job never exercises the collision, which is exactly the kind of check that passes without proving anything.

CodeRabbit Major (SQL inventory undercounts) — REJECTED, already fixed in round 1. The comment re-anchored to b4a8d4b but still cites "Lines [212]-[214]", the pre-fix location, and quotes the old rusqlite::-anchored pattern. The command in the tree now reads:

rg -n '\b(Connection|Transaction|OpenFlags)\b|params!|\.query_row\(|\.prepare\(' daemon/src --stats

Run against the file the finding names, it catches both lines the old pattern missed:

$ rg -n '\b(Connection|Transaction|OpenFlags)\b|params!|\.query_row\(|\.prepare\(' daemon/src/backup.rs
66:use rusqlite::Connection;
619:        Connection::open(src_db).with_context(...)

backup.rs:619 is the exact case the finding calls out, and it matches. The ADR also now labels the command a lower bound for sizing rather than proof of coverage, and points at an AST-based inventory if completeness matters — which is the finding's own fallback suggestion.

I verified this against the tree rather than assuming staleness, because "the bot re-posted an old finding" is a convenient conclusion and worth being suspicious of.

@douglaz

douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4a8d4ba4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 — a single
bounded `unlink` on a path already committed to shutting down, where there is no latency left

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Offload the shutdown unlink instead of treating it as bounded

When the socket resides on a stalled FUSE/network filesystem or the underlying filesystem is wedged, std::fs::remove_file can block indefinitely; because ipc.rs:471 executes it synchronously on a Tokio worker, the task cannot observe cancellation and runtime teardown can hang despite SHUTDOWN_DRAIN. Calling the unlink bounded therefore contradicts the ADR's bounded-shutdown obligation—offload it behind a cancellable wait, or explicitly acknowledge that shutdown is unbounded on this path.

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.beads/issues.jsonl:
- Line 155: Remove the earlier statement in the “Sanctioned boundaries” guidance
that permits allowing load_operator_recipe in place. Keep the later directive
requiring load_operator_recipe and its Recipe::load_all work to be offloaded, so
the bead contains no contradictory exemption.

In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md`:
- Around line 128-130: Qualify the SQLite recovery statement in this ADR by
limiting WAL plus synchronous=FULL to SQLite’s documented commit durability and
recovery guarantees under supported filesystem assumptions. Reword the
corresponding claims at the referenced passages so they do not imply protection
against I/O, filesystem, or storage failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: da7a3705-d205-4b55-85ec-063ac32430cf

📥 Commits

Reviewing files that changed from the base of the PR and between f253445 and b4a8d4b.

📒 Files selected for processing (4)
  • .beads/issues.jsonl
  • README.md
  • docs/adr/0021-no-blocking-work-on-runtime-threads.md
  • flake.nix
🚧 Files skipped from review as they are similar to previous changes (2)
  • flake.nix
  • README.md

Comment thread .beads/issues.jsonl Outdated
Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md Outdated
douglaz added a commit that referenced this pull request Aug 2, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from b4a8d4b to 88eedaa Compare August 2, 2026 19:58
@douglaz

douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Round 3 — head 88eedaa. Three fixed, one rejected with evidence (third posting of the same stale finding).

codex P2 (shutdown unlink is not bounded) — FIXED, and it's the best catch of the three rounds. Correct: std::fs::remove_file on a wedged or networked filesystem can block indefinitely, and running synchronously at ipc.rs:471 it cannot observe cancellation, so this path can outlast SHUTDOWN_GRACE. Calling it "a single bounded unlink" was exactly the kind of unchecked bound this ADR exists to prevent — I wrote it in the same document that argues against claiming bounds nobody verified.

Resolved by dropping the false label rather than adding machinery: the residual is now accepted and stated, with the reasoning (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 a clean exit for an unrecoverable process isn't worth an offload). lnrent-njv's exemption text was updated to match — its #[allow] must cite the residual, not claim a bound.

CodeRabbit Minor (contradictory load_operator_recipe guidance) — FIXED, and this is a pattern I'd already named. lnrent-njv said both "may be allowed in place" and "offload it rather than allowing it", because I appended a round-9 correction without deleting the sentence it superseded. My own pass-7 notes on this branch say: "a fix that adds a correction elsewhere without deleting the original claim leaves the document holding both." Did it anyway. The superseded sentence is now gone, not contradicted — both mentions say offload.

CodeRabbit Major (WAL durability overstated) — FIXED. Correct. "WAL plus synchronous=FULL guarantees the database is not corrupt" overstates it: those settings give SQLite's documented commit durability under its filesystem assumptions, and say nothing about I/O, filesystem or storage failure. Both occurrences now qualify the claim and point at lnrent-y4m.3's degraded-mode latch, which is what actually handles storage failure in this daemon.

CodeRabbit Major (SQL inventory undercounts) — REJECTED, third posting, unchanged. The comment cites "Lines [212]-[214]" and quotes the rusqlite::-anchored pattern. There is exactly one rg command in the ADR, at line 237, and it is the fixed one:

$ grep -n 'rg -n' docs/adr/0021-no-blocking-work-on-runtime-threads.md
237:rg -n '\b(Connection|Transaction|OpenFlags)\b|params!|\.query_row\(|\.prepare\(' daemon/src --stats

Against the file the finding names, it matches both lines the old pattern missed — backup.rs:66 (the import) and :619 (Connection::open). I re-verified from scratch rather than repeating round 2's dismissal, since a finding that returns deserves fresh eyes; the conclusion is unchanged.

Two of these three rounds have turned up defects introduced by my own previous fix, which is the expected shape this late in a review — but each has been real, and an ADR that contradicts itself would hand the implementer impossible obligations. Worth the rounds.

@douglaz

douglaz commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88eedaa4bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +185 to +186
daemon's filesystem work is genuinely pre-runtime (`config.rs`), offline (`backup.rs`, which
refuses to run against a live daemon), or one-shot startup, and converting those buys

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Label config filesystem work as pre-serving

When implementing lnrent-njv, this rationale can cause config.rs blocking calls to be exempted as if no runtime existed, but only load_raw_config precedes build_runtime (main.rs:243-254); prepare_data_dir and bootstrap_headless_with_store execute inside async run_daemon (main.rs:410-415). The ADR itself calls this distinction load-bearing, so describe this category as pre-serving/one-shot startup, or restrict the config.rs parenthetical to load_raw_config, to keep the exemption rationale consistent with the enforcement audit.

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md`:
- Around line 184-187: Update the rejection rationale around the configuration
filesystem work to say “pre-serving” instead of “pre-runtime,” accurately
distinguishing config::load_raw_config from config::prepare_data_dir and
config::bootstrap_headless_with_store, which run inside run_daemon on runtime
workers.
- Around line 92-95: Update ADR 0021’s evaluation notes or add an appendix
documenting the exact evaluated versions of tokio-rusqlite, rusqlite,
libsqlite3-sys, deadpool-sqlite, and sqlx, including dependency-graph evidence
from Cargo.toml and Cargo.lock so the SQLite runtime claims remain reproducible.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ec43069a-a10c-43a3-bf56-188e6a88a73c

📥 Commits

Reviewing files that changed from the base of the PR and between b4a8d4b and 88eedaa.

📒 Files selected for processing (4)
  • .beads/issues.jsonl
  • README.md
  • docs/adr/0021-no-blocking-work-on-runtime-threads.md
  • flake.nix
🚧 Files skipped from review as they are similar to previous changes (3)
  • flake.nix
  • README.md
  • .beads/issues.jsonl

Comment on lines +92 to +95
*"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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

while IFS= read -r file; do
  rg -n -C 2 'tokio-rusqlite|rusqlite|libsqlite3-sys|deadpool-sqlite|sqlx' "$file" || true
done < <(fd -a -t f '^(Cargo\.toml|Cargo\.lock)$' .)

if command -v cargo >/dev/null 2>&1; then
  cargo tree --workspace --edges normal,build |
    rg -n 'tokio-rusqlite|rusqlite|libsqlite3-sys|deadpool-sqlite|sqlx' || true
fi

Repository: douglaz/lnrent

Length of output: 144


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ADR relevant lines =="
sed -n '80,105p' docs/adr/0021-no-blocking-work-on-runtime-threads.md 2>/dev/null || true
sed -n '150,180p' docs/adr/0021-no-blocking-work-on-runtime-threads.md 2>/dev/null || true

echo
echo "== Locate manifests =="
fd -a -t f '^(Cargo\.toml|Cargo\.lock)$' . || true

echo
echo "== Dependency mentions in manifests =="
while IFS= read -r file; do
  echo "--- $file ---"
  rg -n -C 2 'tokio-rusqlite|rusqlite|libsqlite3-sys|deadpool-sqlite|sqlx' "$file" || true
done < <(fd -a -t f '^(Cargo\.toml|Cargo\.lock)$' .)

echo
echo "== Rust tooling availability =="
command -v cargo || true
cargo --version || true
rustc --version || true

echo
echo "== Targeted lockfile entries (if Cargo.lock exists) =="
file="$(fd '^Cargo\.lock$' . | head -n 1)"
if [ -n "${file:-}" ]; then
  awk '
    /^\[\[package\]\]$/ { name=""; version=""; source=""; deps="" }
    /^name = / { gsub(/^name = "/,""); gsub(/"$/,""); name=$0 }
    /^version = / { gsub(/^version = "/,""); gsub(/"$/,""); version=$0 }
    /^source = / { gsub(/^source = "/,""); gsub(/"$/,""); source=$0 }
    /^name = "rusqlite|name = "tokio-rusqlite|name = "libsqlite3-sys|name = "deadpool-sqlite|name = "sqlx/ {
      p=1; printf "\npackage: %s version: %s\n", name, version; if (source) printf "source: %s\n", source
    }
    p && /^dependencies = \[/ { depblock=1 }
    p && depblock && /^\[/ { depblock=0; print }
    p && depblock { print }
    p && /^name = / { p=0 }
    END { if (p) print "package:" name " version:" version }
  ' "$file"
fi

Repository: douglaz/lnrent

Length of output: 144


Include the exact dependency versions evaluated for this ADR.

The ADR makes SQLite runtime claims that depend on tokio-rusqlite, rusqlite, libsqlite3-sys, deadpool-sqlite, and sqlx versions. Add the evaluated versions and dependency graph evidence from Cargo.toml/Cargo.lock, e.g. in an appendix or evaluation notes, so future maintainers can repeat these checks against the same dependency set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md` around lines 92 - 95,
Update ADR 0021’s evaluation notes or add an appendix documenting the exact
evaluated versions of tokio-rusqlite, rusqlite, libsqlite3-sys, deadpool-sqlite,
and sqlx, including dependency-graph evidence from Cargo.toml and Cargo.lock so
the SQLite runtime claims remain reproducible.

Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md Outdated
douglaz added a commit that referenced this pull request Aug 3, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from 88eedaa to 9a02105 Compare August 3, 2026 03:52
@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Round 4 — head 9a02105. One fixed (flagged by both bots), one cut, one rejected.

codex P2 + CodeRabbit Major — same finding, and the first time the two bots have agreed on this PR. FIXED. Line 185 still called config.rs "genuinely pre-runtime" while line 49 — 136 lines earlier in the same document — says the exempt phase is pre-serving, "not 'before the runtime'", and that only one call site is genuinely pre-runtime. Two independent reads landing on the same contradiction is the strongest signal available, and they were right.

Fixed at the source and then swept: grep -n 'pre-runtime|before the runtime' across the ADR and all four delivery beads now returns only correct uses. Fixing the cited line alone is what produced three of the last four rounds' findings, so the sweep is the actual remedy.

CodeRabbit Minor (add an appendix of evaluated dependency versions) — CUT, over-specification. The ADR already cites every version its claims rest on, inline where the claim is made: rusqlite 0.31, tokio-rusqlite's ^0.37 requirement, libsqlite3-sys's links = "sqlite3", fedimint v0.11.1. An appendix restating them creates a second copy that rots independently of Cargo.toml/Cargo.lock, which are the actual source of truth — and AGENTS.md:171-173 explicitly forbids hand-maintaining what the repo can derive. This ADR has already had four separate hand-derived lists proven wrong during review; adding a fifth to make version claims "repeatable" would be the same mistake wearing a different hat. Skipping it leaves no real hole: a maintainer checking these claims reads the lockfile, not an appendix.

CodeRabbit Major (SQL inventory undercounts) — REJECTED, fourth identical posting. Same text, same stale "Lines [212]-[214]" citation, same already-replaced pattern. Current state, unchanged since round 2:

$ grep -n 'rg -n' docs/adr/0021-no-blocking-work-on-runtime-threads.md
237:rg -n '\b(Connection|Transaction|OpenFlags)\b|params!|\.query_row\(|\.prepare\(' daemon/src --stats

It matches both backup.rs:66 and :619 — the exact case the finding names.

Round-by-round this is converging: 4 real → 1 real + 1 stale → 3 real + 1 stale → 1 real + 1 cut + 1 stale. The single real finding this round was a wording inconsistency, not a defect in the decision, which has been stable since well before the PR opened.

@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a021059b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +69 to +70
not allowed. The genuinely exempt set is the two `config.rs` calls plus `prepare_data_dir`:
fixed, single-shot work that does not grow with anything.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Define a consistent pre-serving exemption

When lnrent-njv applies the denylist, this declares the config entry points exempt as fixed work, but bootstrap_headless_with_store reaches read_secret_file_bytes, whose read_to_end is unbounded (config.rs:1707-1747). That is the same data-scaled blocking work that lines 62-68 use to put Recipe::load_all in scope, so the implementer cannot consistently decide which startup calls must be offloaded. Either exempt all pre-serving work regardless of size or require these unbounded config reads to be bounded/offloaded too.

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
.beads/issues.jsonl (2)

158-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add current/target versions for the web-only dependency row.

Every other row in lnrent-hrm's upgrade table states a current and target version (for example rusqlite 0.31 -> 0.40.1), but the last row only names the crates:

  getrandom/gloo-net/gloo-timers          (web buyer only)

Without version numbers, whoever picks up this bead cannot tell what the target state is for these three crates, and the acceptance criterion "Each bump is a reviewable step" is ambiguous for this row specifically. State the current and target versions, matching the format of the other rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.beads/issues.jsonl at line 158, Update the web-only dependency row in the
lnrent-hrm upgrade table to include current and target versions for getrandom,
gloo-net, and gloo-timers, using the same current-to-target format as the other
dependency rows. Keep the web buyer-only scope and crate grouping unchanged.

154-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate lnrent-skk's shutdown acceptance criteria into one final list.

The shutdown-mechanism guidance for lnrent-skk goes through four layered corrections: the round-8 addendum ("joins each actor thread"), the round-10 correction ("supersedes the round-8 addendum," proposes spawn_blocking + timeout), the round-11 correction ("superseding BOTH addenda above," refutes round 10's own proposal, proposes a completion signal instead), and a final PR #78 round-2 refinement that adds the "healthy job enqueued behind a stalled one" requirement without an explicit "Acceptance:" header.

Each correction is internally consistent when read in sequence, but a reader who stops at the round-11 "Acceptance (supersedes both earlier shutdown lines)" block will miss the PR #78 refinement that the stalled-job test must also enqueue a healthy job behind the stalled one and assert the logged abandoned-queue-depth. Add a single, final "Acceptance criteria (shutdown)" block near the end of the description that states the current, complete set of requirements, so an implementer does not need to trace four rounds of "supersedes" language to find the true bar.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.beads/issues.jsonl at line 154, Consolidate the shutdown requirements in
the issue description into one final “Acceptance criteria (shutdown)” block near
the end, covering the current completion-signal mechanism, bounded shutdown
without unbounded Tokio-worker blocking, preservation of enqueued jobs, and the
PR `#78` refinement requiring the stalled-job test to enqueue a healthy job behind
the stalled one and assert the logged abandoned-queue depth. Remove or supersede
the layered earlier shutdown guidance so readers are not required to reconcile
conflicting rounds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/adr/0021-no-blocking-work-on-runtime-threads.md`:
- Around line 99-102: Update the SQLite connection architecture statement to
limit the async-only contract to serving/runtime paths, explicitly excluding the
synchronous offline backup entry point in backup.rs. Preserve the
dedicated-thread and bounded-channel requirements for the state DB, lnv2 index,
and phoenixd index.
- Around line 65-70: Clarify the exempt call sites in the ADR by explicitly
naming config::load_raw_config, config::prepare_data_dir, and
config::bootstrap_headless_with_store. State that only config::load_raw_config
runs pre-runtime, while config::prepare_data_dir and
config::bootstrap_headless_with_store run pre-serving, and remove the ambiguous
“two config.rs calls plus prepare_data_dir” phrasing.
- Around line 263-265: Update the ADR text describing the denylist near the
“std::thread::sleep” examples to replace “future blocking primitives” with
wording that limits coverage to explicitly listed blocking primitives,
accurately reflecting the configured clippy.toml denylist.

---

Nitpick comments:
In @.beads/issues.jsonl:
- Line 158: Update the web-only dependency row in the lnrent-hrm upgrade table
to include current and target versions for getrandom, gloo-net, and gloo-timers,
using the same current-to-target format as the other dependency rows. Keep the
web buyer-only scope and crate grouping unchanged.
- Line 154: Consolidate the shutdown requirements in the issue description into
one final “Acceptance criteria (shutdown)” block near the end, covering the
current completion-signal mechanism, bounded shutdown without unbounded
Tokio-worker blocking, preservation of enqueued jobs, and the PR `#78` refinement
requiring the stalled-job test to enqueue a healthy job behind the stalled one
and assert the logged abandoned-queue depth. Remove or supersede the layered
earlier shutdown guidance so readers are not required to reconcile conflicting
rounds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 88bc29ad-46a8-4e4b-911c-6598e0f67ae3

📥 Commits

Reviewing files that changed from the base of the PR and between 88eedaa and 9a02105.

📒 Files selected for processing (4)
  • .beads/issues.jsonl
  • README.md
  • docs/adr/0021-no-blocking-work-on-runtime-threads.md
  • flake.nix
🚧 Files skipped from review as they are similar to previous changes (2)
  • flake.nix
  • README.md

Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md Outdated
Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md Outdated
Comment thread docs/adr/0021-no-blocking-work-on-runtime-threads.md
douglaz added a commit that referenced this pull request Aug 3, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from 9a02105 to 23f36cf Compare August 3, 2026 19:25
@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Round 5 — head 23f36cf. Four fixed, two rejected as repeats.

codex P2 + CodeRabbit Major, both on line 70 — FIXED by cutting, not patching. codex is right: bootstrap_headless_with_store reaches read_secret_file_bytes, whose file.read_to_end(&mut bytes) (config.rs:1746) is unbounded — so calling the config entries "fixed, single-shot work that does not grow with anything" was inconsistent with putting Recipe::load_all in scope for exactly that property. CodeRabbit's companion finding (the phrase "the two config.rs calls plus prepare_data_dir" is ambiguous, since prepare_data_dir is a config.rs call) is the same sentence failing a second way.

The fix is structural rather than another qualifier. The ADR now states the criterion and stops classifying sites: fixed-cost + pre-serving is exemptible; recurring, data-scaled, or reachable-once-serving must be offloaded. Site-by-site classification moves to lnrent-njv, which derives it.

The reason is on the record in the ADR itself: four review rounds produced four different wrong classifications from that section — load_operator_recipe filed as bounded, the IPC bind filed as startup when supervisor.rs:927 rebinds live, and now the unbounded secret read filed as fixed-cost. A decision record cannot hold an accurate site-by-site taxonomy of a moving codebase, and the honest response to being wrong four times in the same way is to stop making that kind of claim — the same call already made for the filesystem audit and the shutdown mechanism.

CodeRabbit Major (async-only contract vs. the backup door) — FIXED. Correct contradiction: the pattern section said "the only public surface is async" while Enforcement requires a synchronous offline door for backup.rs. Now reads "the serving surface is async only", naming the one deliberate exception.

CodeRabbit Minor (denylist coverage overclaim) — FIXED. "future blocking primitives" implied coverage the mechanism cannot give. Now "any other explicitly listed blocking primitive".

Rejected, both repeats: the SQL-inventory finding (fifth identical posting, still citing pre-fix "Lines [212]-[214]"; the one rg command in the file matches both backup.rs:66 and :619), and the dependency-version appendix (second posting; cut in round 4 as over-specification — the ADR already cites every version inline, and AGENTS.md:171-173 forbids hand-maintaining what Cargo.lock derives).

Round tally: 4 real → 1 → 3 → 1 → 4. The count went back up, and every one traced to the same block, which is why it's gone rather than fixed. If the next round is clean on that section I'd suggest landing: what's left is mechanism that only code and tests can settle, and it's already delegated to lnrent-skk and lnrent-njv.

@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23f36cf9e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +173 to +174
unbounded, which for a `synchronous=FULL` money DB converts a slow fsync into unbounded
queue growth with no backpressure signal, where `mpsc::channel(64)` makes the caller wait.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate the actor bound to IPC admission

When the actor queue fills behind a slow fsync, this does not actually bound pending memory at the current IPC ingress: serve_with_shutdown accepts every same-UID connection and immediately adds a handler to conns (ipc.rs:424-449) without a semaphore, so each additional mutating request can retain its Job while awaiting tx.send. The 64-slot channel therefore moves excess work into an unbounded set of waiting tasks rather than providing the claimed protection against unbounded growth; carry the bound through request admission, cap in-flight IPC handlers, or narrow this rationale to actor-local queue growth.

AGENTS.md reference: AGENTS.md:L168-L170

Useful? React with 👍 / 👎.

douglaz added a commit that referenced this pull request Aug 3, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from 23f36cf to b799234 Compare August 3, 2026 19:43
@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Round 6 — head b799234. One new finding, fixed. Two repeats rejected. One of CodeRabbit's own comments self-marked ✅ Addressed.

codex P2 (the bound doesn't reach IPC admission) — FIXED, and it correctly punctured a claim I'd made twice. Verified: serve_with_shutdown does listener.accept()conns.spawn(...) with zero Semaphore anywhere in ipc.rs, so the 64-slot channel bounds the actor's queue but relocates excess work into an unbounded set of parked tasks, each holding its Job. "Protection against unbounded growth" was wrong.

Both occurrences narrowed to what's actually true — the bound makes pressure visible at the call site and bounds the actor's queue, rather than bounding total pending memory. This matters beyond wording because that claim was one of three reasons given for rejecting tokio-rusqlite; the rejection still holds (the forced rusqlite ^0.37 bump via links = "sqlite3" is decisive on its own, and awaiting tx.send is still meaningfully different from an unbounded channel), but it now rests on a claim that survives inspection.

Capping IPC admission is deliberately not opened here, and the ADR says why rather than leaving it implied: the socket is owner-only and SO_PEERCRED-gated, so anyone who can flood it already has the operator's privileges. That makes it a robustness limit worth stating, not a security boundary worth building — and filing a bead for a same-UID local resource-exhaustion path would be mechanism no real requirement needs.

Rejected: the SQL-inventory finding (sixth identical posting, unchanged text, still citing pre-fix "Lines [212]-[214]") and the dependency-version appendix (third posting; cut in round 4 — the ADR cites every version inline and AGENTS.md:171-173 forbids hand-maintaining what Cargo.lock derives). CodeRabbit's third comment this round marks itself ✅ Addressed in commit 23f36cf, which it was.

The section I cut last round produced zero findings this round — that was the convergence signal I was watching for. Round tally: 4 → 1 → 3 → 1 → 4 → 1.

This is the point to land, and I'd rather say so than keep spending rounds. Six rounds have moved the document from wrong to narrow; what remains is mechanism — the shutdown drain, the denylist's real coverage, the site classification — which is delegated to lnrent-skk and lnrent-njv with explicit, failure-path-proving test requirements. Those are settled by code and tests, not by more prose review. CI has been green on all six rounds.

@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7992341df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +6 to +8
still depends on `rusqlite` directly, and there is no `clippy.toml`. The four beads that
deliver it are `lnrent-skk` (store actor), `lnrent-68f` (index actors), `lnrent-njv` (lint
denylist) and `lnrent-7dw` (crate boundary). Read every "does" and "is" below as "will" until

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point to the bead graph instead of freezing four IDs

When ADR-0021 gains, splits, or replaces a delivery bead, this hand-maintained count and list can remain unchanged and incorrectly report the decision as built once only these four beads close. The delivery set is already derivable from .beads by querying references to ADR-0021, so name that source of truth rather than duplicating its current membership here.

AGENTS.md reference: AGENTS.md:L171-L173

Useful? React with 👍 / 👎.

douglaz added a commit that referenced this pull request Aug 3, 2026
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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Round 7 — head d147dff. Fixed.

codex P2 (frozen delivery-bead list) — FIXED. Correct, and consistent with every count finding on this PR: the Status header hand-maintained "the four beads that deliver it are skk / 68f / njv / 7dw", which silently rots the moment a bead splits or another is added — and would then report the decision as built once only those four close. AGENTS.md:171-173 forbids exactly this.

Replaced with the derivation, verified to return all four before committing:

br list --limit 0 --json -a | jq -r '.[] | select(.status!="closed")
  | select((.description // "") + (.title // "") | test("ADR-0021")) | "\(.id) \(.title)"'

I checked that lnrent-7dw is still matched despite its title not carrying the tag — its description references ADR-0021 twice, so the query covers it.

Swept the rest of the ADR for the same defect: the remaining lnrent-* mentions are ownership pointers ("njv owns the classification", "skk must test the stalled-job path"), not an enumeration claiming completeness, so they stay.

Rejected, all repeats: the SQL-inventory finding (seventh posting, unchanged, still citing pre-fix line numbers), the dependency-version appendix (fourth; cut in round 4 as over-specification), and the denylist-coverage wording CodeRabbit itself marked ✅ Addressed last round.

Round tally: 4 → 1 → 3 → 1 → 4 → 1 → 1. Seven rounds, CI green on every one.

@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from b799234 to d147dff Compare August 3, 2026 21:40
@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d147dffd0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

- `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.

None of the four delivery beads changes them, and none of the three is a live hazard at current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the remaining delivery-bead count

Although lines 7–13 now derive the outstanding delivery set, fresh evidence after the prior comment is that this separate hard-coded count remains. If an ADR-0021 delivery bead is split or added, “four” can silently become false and misstate whether the grandfathered mutex sections are covered; refer to the derived set without freezing its size.

AGENTS.md reference: AGENTS.md:L171-L173

Useful? React with 👍 / 👎.

Comment thread .beads/issues.jsonl Outdated
{"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 `<pre>`, 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::<Job>(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<Mutex<Connection>>` 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<Connection>` 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 all four ADR-0021 beads 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-02T14:50:26.194647583Z","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)' <rusqlite>/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 none of the four delivery beads 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: if an unbounded read is tolerated at startup, say\nwhy in the `#[allow]`; do not exempt one unbounded startup read while offloading another.","status":"open","priority":2,"issue_type":"task","created_at":"2026-08-01T18:32:54.615703187Z","created_by":"master","updated_at":"2026-08-03T19:25:15.160470784Z","source_repo":"lnrent","source_repo_path":"/home/master/projects/lnrent","compaction_level":0,"original_size":0}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the unbounded secret read to be bounded or offloaded

When implementing lnrent-njv, the final amendment permits an unbounded startup read to remain on the runtime worker if its #[allow] explains why, contradicting both the immediately preceding requirement to bound or offload this read_to_end and ADR-0021's fixed-cost-plus-pre-serving exemption criterion. Because amendments are the guidance implementers are told to prioritize, this can let the bead close while data-scaled blocking work still violates the decision; remove the tolerance alternative and require bounding or offloading.

AGENTS.md reference: AGENTS.md:L63-L65

Useful? React with 👍 / 👎.

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 = <store::Transaction<'static> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzBbjDBkhCjbEefQrHWdGq
@douglaz
douglaz force-pushed the docs/adr-0021-no-blocking-on-runtime-threads branch from d147dff to 34a0b0d Compare August 3, 2026 21:45
@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Round 8 — head 34a0b0d. Both fixed.

codex P2 (frozen delivery-bead count) — FIXED, and the sweep found two more than were reported. Correct: round 7 removed the frozen list from the Status header and left a frozen count thirty lines below ("None of the four delivery beads changes them"). Half-fixing is the recurring failure on this PR, so I swept instead of patching the cited line — the same frozen count also sat in lnrent-njv and lnrent-68f. All three now say "no delivery bead" / "every ADR-0021 delivery bead", and grep across the ADR and all seven beads confirms none remains.

codex P2 (njv permitted the unbounded read to stay) — FIXED, and this one could have shipped a real violation. lnrent-njv said "bound that read or offload the call" and then, two sentences later, "if an unbounded read is tolerated at startup, say why in the #[allow]" — which permits exactly what the preceding sentence forbids, and what ADR-0021's criterion forbids. Since bead amendments are what implementers are told to prioritise, the bead could have closed with data-scaled blocking work still on a runtime worker.

The tolerance alternative is gone: "tolerated with a reason" is now explicitly not an option for an unbounded read, because the criterion exempts fixed-cost pre-serving work only. A read that scales with file size gets bounded or offloaded, never #[allow]-ed — and the bead names the inconsistency to avoid: don't offload Recipe::load_all while exempting read_secret_file_bytes for the identical property.

Worth noting these two are the same defect from opposite directions: one left a stale number after fixing the list, the other left a permissive escape hatch after adding a requirement. Both are corrections that didn't delete what they superseded — which is now the single most repeated mistake across this PR's eight rounds, and the reason I sweep rather than patch the cited line.

Round tally: 4 → 1 → 3 → 1 → 4 → 1 → 1 → 2. CI green on every round so far.

@douglaz

douglaz commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 34a0b0d4ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@douglaz
douglaz merged commit 190ff38 into master Aug 3, 2026
3 checks passed
@douglaz
douglaz deleted the docs/adr-0021-no-blocking-on-runtime-threads branch August 3, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant