Ledger connection cost + consumer query surface (spec for a delegate run)¶
Three deliverables, in this order. Part A is a diagnosis, not a fix — do not skip it, and do not start Part B's design until A has produced the actual SQL and the actual cost split. Part D is hygiene, gated on a verified backup.
Repo doctrine applies in full: CLAUDE.md §0 (GROUND BEFORE YOU ACT), §0b (match the unit of review to the
unit of the defect), §anti-amnesia (every lesson ends as a test/lint/assert, never prose), and the spend
rules (run under the gate, estimate before spend).
The observation¶
On 2026-10-17, honestreview-mcp --http --port 4912 — a warm daemon that imports the gate — was caught
burning CPU repeatedly. Four separate daemon instances were observed in a few hours (PIDs 6602, 8471,
13857, 21127), each with the same shape: burn hard on startup, then subside.
Worst instance: 79 CPU-minutes over 75 wall-minutes — ~250% of a core, six threads at ~39% each.
Measured during the burns:
- Every busy thread inside SQLite.
samplecaught 3,064 samples on one identical stack:thread_run → context_run → gen_iternext → builtin_next → pysqlite_connection_execute → _pysqlite_query_execute → sqlite3_step. Generator-driven cursor iteration, CPU-bound (STAT R). - 43 open SQLite connections in the one process (
lsof, PID 8471 at 3 minutes old, 154% CPU):
- ~3 ledger rows written per minute.
SELECT COUNT(*) FROM callsmoved 704,613 → 704,614 in 20 s. The rows were correct and correctly routed (honestreview:*intents on the codex$0lane). - Zero inbound connections on :4912 during the worst burn — this was not request traffic.
250% of a core to write three rows a minute is the defect. Nothing in the tuning posture explains it.
What has already been ruled out — do not re-derive these¶
| hypothesis | verdict | evidence |
|---|---|---|
the consumer queries spend.db directly in its daemon |
NO | honestreview's only direct openers are evals/precision_recall.py:39, src/honestreview/refute.py:549, scripts/honestreview_guard_ledger.py; none runs in the warm daemon |
| the consumer's 10 s source re-hash | NO | measured: 155 files, 2.1 MB, 6 ms/pass = 0.1% of one core |
| WAL runaway / failed checkpoints | NO | -wal sits at exactly 67,108,864 B = exactly the journal_size_limit this repo sets. The cap is working (it was 1.3 GB on 2026-10-08 before the cap) |
starved cache_size / tiny journal_size_limit |
NO — measurement error | those were read from a separate sqlite3 CLI connection. Both pragmas are per-connection; tune_ledger_connection sets cache_size=-64000, mmap_size=256MB, journal_size_limit=64MB |
| SQLite being unsuitable | NO | it is behaving exactly as configured; the tuning posture is sound and its docstring explains each pragma |
The structural fact that IS established¶
PRAGMA page_count × page_size = 4.4 GB file. dbstat by table:
~3.5 GB of the file is free pages. Never VACUUMed. Real and file-level — but not by itself an explanation for the CPU. Say so if you cite it.
Part A — attribute the CPU (diagnosis; do this first)¶
py-spy is not installed anywhere on this machine, and sample only reaches the C stack, which is why
the previous investigation could not get Python frames. Fix that first. py-spy needs sudo to attach on
macOS — if the sandbox denies it, say so verbatim rather than substituting a weaker method.
- Install
py-spyinto a scratch venv (not a project venv). - Reproduce the burn. It is bursty and self-resolving, so it must be profiled inside a burst — two
earlier instances exited mid-investigation. Drive a consumer review through a freshly started warm
daemon and confirm the shape first (
ps -Mshowing threadsRinsqlite3_step). py-spy dump --pid <pid>for every thread, andpy-spy recordfor a flame graph over ≥60 s.
Then answer with evidence, not reasoning — the central question is a cost split:
What fraction of the CPU is connection setup (connect + 7 PRAGMAs +
_ensure_schema) versus actual query execution?
The 43 connections and the burst-on-startup shape both point at setup, and so does an asymmetry worth
explaining: 36 spend.db fds but only 2 -shm. A WAL-mode connection maps the shared-memory index
when it actually reads, so most of those connections may never have done any work at all — pure setup.
Confirm or refute that; do not assume it.
Also quantify:
- How many connections are created per unit of work, and how long does each live?
- Does
_LEDGER_POOL(config.py, thread-local per(thread, subsystem)) multiply combinatorially with a fan-out thread pool? With ~8 subsystems that is ~8 connections per worker thread. - Each of the 36 asks for
cache_size=-64000(64 MB) andmmap_size=256MB— a budget of ~2.3 GB page cache and ~9 GB mmap address space for one daemon. Measured footprint was 456 MB (peak 917 MB), so the caches were not full. What is the real resident cost per connection? - How often does each new
(thread, subsystem)connection re-runPRAGMA table_info/PRAGMA index_list? - Is anything re-reading rows it already read — a poll loop, a freshness check, an un-indexed predicate, or
a
SELECT *pulling the large payload columns (4.4 GB / 704 K rows ≈ 6 KB/row)?
Deliverable: a written finding naming the mechanism, with the profile attached and a reproduction. If the burn cannot be reproduced, say so plainly and stop — do not proceed on a guess.
Part B — bound the connection pool¶
Independent of what Part A finds, 43 connections in one process is not defensible and should be fixed on its own merits.
Why it matters more than it looks: a consumer that cold-starts once a week would never notice this. But
the observed consumer was being actively developed, and its own staleness check exits the daemon on any
source edit — 13 edits in 2 hours, a mean daemon lifetime of roughly 9 minutes. Every restart repaid the
full 43-connection warmup. spendguard does not control the restart frequency, but it does control what a
restart costs, and that is the half that belongs here. (The consumer is fixing its half separately; see
honestreview/docs/design/warm_daemon_ledger_consumer_migration.md.)
Ground first, per §0 — read _LEDGER_POOL and the _X_db() openers before changing anything, and
understand why it is keyed per subsystem (the docstring is explicit: so a nested op across subsystems
cannot commit or roll back another's in-flight transaction). That constraint is real. Do not collapse to
one global connection and reintroduce the bug the keying was added to prevent.
Requirements:
- A hard, configurable ceiling on live connections per process, with the current count observable.
- Setup paid once, not per connection —
_ensure_schemashould not re-runPRAGMA table_info/index_listfor a subsystem that has already been verified in this process. - The per-connection memory budget must be coherent with the ceiling.
cache_size=-64000andmmap_size=256MBare reasonable for a handful of reused connections and incoherent at 43. - A loud failure, not silent growth, when the ceiling is hit — per the repo rule that a control which is not real at the moment it matters is worse than no control.
Part C: sanctioned ledger consumer surface¶
This surface lets ledger consumers use spendguard's read contracts without resolving database locations or opening SQLite themselves. Part B concerns connection and warmup cost and is outside this document's scope.
All operations are read-only and make no model calls. CLI output is JSON by default; --json is accepted explicitly. MCP uses the existing spendguard_ledger_read tool with an operation argument. The CLI and MCP share one handler, which delegates to the library without rounding money.
| Need | Library (spendguard.ledger_read) |
CLI (spendguard ledger-read) |
MCP operation | Success |
|---|---|---|---|---|
| N1 raw recorded call costs | read_recorded_call_total(path=None, *, time_budget=DEFAULT_LEDGER_READ_TIME_BUDGET_S) |
recorded-call-total [--path PATH] --json |
recorded-call-total (path optional) |
Library float; transport {"recorded_call_total": value} |
| N2 conversation spend since | read_conversation_spend_since(since, conv, *, deadline_s=config.LEDGER_QUERY_DEADLINE_S) |
conversation-spend-since --since DATE --conv SESSION [--deadline-s SECONDS] --json |
conversation-spend-since (since, conv, optional deadline_s) |
Library float; transport {"conversation_spend_since": value} |
| N3 read observability | ledger_read_metrics() |
metrics --json |
metrics |
Same metrics object |
N1: raw corpus total, explicit unknown¶
N1 computes the complete SUM(calls.cost) (zero for a successfully read empty table). This is the raw recorded-call corpus total, not countable billed dollars. It can include subscription-covered estimated value and other observations; it is not the billed accounting total from receipt --by-intent and must never be presented as money out the door.
The bounded read-only reader raises a LedgerReadError subclass when it cannot measure: missing or unreadable database, lock contention, corruption, missing calls schema, or timeout. Unknown is never silently converted to zero.
The two honestreview consumer policies can therefore share one library call:
from spendguard.store.spend_search.ledger_read import LedgerReadError, read_recorded_call_total
try:
recorded_total = read_recorded_call_total()
except LedgerReadError:
recorded_total = 0.0 # Only a caller explicitly tolerating unknown as zero.
A fail-closed consumer instead lets the typed error propagate, or catches LedgerReadError and assigns None. It must refuse a budget decision when measurement is unknown.
N2: existing accounting, one caller deadline¶
The alias calls the existing public entrypoint:
Thus honestreview's budget.spent_since(today, conv=session, deadline_s=seconds) is supported directly. The alias forwards since as day, the conversation, and an explicit deadline. Omission uses config.LEDGER_QUERY_DEADLINE_S; no transport invents another default.
config.LEDGER_QUERY_DEADLINE_S defaults to None. For conversation reads, None selects the bounded reader's config.LEDGER_READ_TIME_BUDGET_S fallback (currently five seconds), rather than disabling the deadline. SPENDGUARD_LEDGER_READ_TIME_BUDGET_S configures that fallback; an explicit caller deadline takes precedence.
The conversation path uses the bounded read-only connection and existing aggregate SQL. Its deadline includes connection setup, lock waiting, and aggregate execution. Expiry raises LedgerQueryDeadline, a LedgerReadError subclass; there is no partial aggregate. Interrupted read handles are closed; subsequent queries open fresh read handles. Reads do not interrupt or change the forensic writer connection or its pending writes. CLI and MCP forward deadline_s unchanged. This operation uses the configured ledger (SPENDGUARD_HOME), and rejects path rather than silently ignoring it.
Failure contract across transports¶
Library calls preserve typed exceptions. CLI failures exit nonzero and return {"error": "ExceptionClass: message", "error_type": "ExceptionClass"} without a success value. MCP direct handlers preserve exceptions; the MCP protocol reports the same object with isError: true, including LedgerQueryDeadline. Consumers must check the exit status or MCP isError before using a value. A timeout or unreadable ledger must resolve to refusal/over-budget, never permission to spend.
N3: process-local metrics¶
ledger_read_metrics() returns open_connections, read_calls_issued, and cumulative_read_seconds. The first composes config.ledger_connection_count() rather than counting descriptors. The issued counter and cumulative monotonic elapsed seconds instrument bounded read attempts, including failed attempts; updates are thread-safe. Metrics reads do not themselves issue a ledger read.
Counters belong to the serving process. A warm MCP daemon accumulates its own activity; a separate CLI invocation reports that CLI process's activity. Shapes and semantics match, while independent process histories naturally produce different numbers. Within one process, CLI/MCP/library snapshots agree when no intervening read occurs.
Consumer goal state¶
After adoption, honestreview can delete every sqlite3.connect against a spendguard database and every db_path()/lane_queue_db_path() resolution. Honestreview should add a guard that fails if any file directly opens a ~/.spendguard path. That migration and its consumer guard are outside this spendguard-only change; this document does not claim they have happened.
Part D — ledger hygiene (gated on a verified backup)¶
- Back up first, and verify the backup before touching the original.
~/.spendguard/spend.dbis 4.4 GB with a live 64 MB WAL. House rule: never copy a livedb+wal. The B2 ledger job runs 03:45 — sequence against it, and use the backup API or a checkpointed copy. wal_checkpoint(TRUNCATE)thenVACUUM. Expect to reclaim ~3.5 GB. Measure the file before and after, and measure whether it changes the Part A profile at all — report that number even if it turns out to be zero. Nobody currently knows that VACUUM will help the CPU, and an assumed win is worse than a measured null.- The
tune_ledger_connectiondocstring says "a periodicwal_checkpoint(TRUNCATE)drains it". Determine whether that periodic drain actually runs. If it doesn't, that is a finding in itself. - Decide whether
auto_vacuum=INCREMENTALis right going forward, and whether~/.spendguardshould be excluded from Spotlight —mds_storeswas observed at 90% CPU alongside this churn, and a 4.4 GB db with a constantly-rewritten 64 MB WAL is exactly what makes the indexer loop.
Guards (§anti-amnesia — prose does not count)¶
Nothing here is done until it cannot regress:
- A test that fails if any consumer outside spendguard's internals opens
config.db_path()directly. - A test that fails if live connections per process exceed the Part B ceiling.
- A test that fails if a ledger query is issued without a row cap and a time budget.
- A test asserting no
SELECT *againstcalls/spend_eventsin the query surface. - A regression guard for whatever Part A actually finds — a bounded assertion on the real mechanism (statements per recorded call, schema-ensures per process), not a comment.
- A bloat check reporting free-page ratio, so 3.5 GB of dead pages becomes visible instead of being discovered by a CPU investigation months later.
Verify green¶
Run every command and paste full output, no piping or truncation:
- the repo's offline suite
ruff(the suite's lint gate)spendguard doctor— must printENFORCING HERE: YES- the Part A reproduction before and after, with the profile and the setup-vs-query cost split
lsof -p <pid> | grep -c '\.spendguard/.*\.db$'before and after Part B — the 43 must come downls -l ~/.spendguard/spend.db ~/.spendguard/spend.db-walbefore and after Part D
If a sandbox denies ~/.spendguard writes, a local port bind, or sudo for py-spy, say so verbatim —
the host re-runs it.