# RAG Outbox SQLite — Single-Writer Topology, Checkpoint Discipline & Integrity Watchdog (MC #106047)

# RAG Outbox SQLite — Single-Writer Topology, Checkpoint Discipline & Integrity Watchdog

**Status:** ACTIVE
**Created:** 2026-07-20
**Owner:** FlowForge
**Related:** MC #106047 (root cause + fix), MC #106066 (follow-up: fsevents debounce)

---

## Purpose

`~/system/state/ingest-queue.sqlite` is the durable outbox queue that feeds documents into LightRAG (BookStack pages, MC task outcomes, evidence files, specs, rules). It corrupted repeatedly (Apr 23, May 9, Jul 15→20-silent, Jul 20 2026) with the same `SQLITE_CORRUPT` / "database disk image is malformed" signature. This runbook documents the root cause, the fix, the writer topology, and the guard now in place so future recurrences are caught in minutes instead of days.

---

## Writer Topology (as of 2026-07-20)

All writers use the shared library `~/system/lib/rag-outbox.js` (`openOutbox()`), which owns WAL-mode pragmas and the SQLite connection lifecycle.

| Daemon (LaunchAgent) | Script | Trigger | Lifetime | Checkpoint mode |
|---|---|---|---|---|
| `com.alai.rag-drain-worker` | `tools/rag-drain-worker.js` | KeepAlive | Long-lived, continuous | **TRUNCATE** (explicit) — the single owner of periodic WAL truncation |
| `com.alai.rag-bookstack-adapter` | `tools/rag-bookstack-adapter.js` | StartInterval=300s | Fresh process per run | PASSIVE (default) |
| `com.alai.rag-mc-adapter` | `tools/rag-mc-adapter.js` | StartInterval=300s | Fresh process per run | PASSIVE (default) |
| `com.alai.rag-fsevents-adapter` | `tools/rag-fsevents-adapter.js` | WatchPaths (`evidence/`, `specs/`, `rules/`) | Fresh process per fs-event | PASSIVE (default) |
| `com.alai.lightrag-outbox-ingest` | `tools/lightrag-outbox-ingest.js` | StartInterval=21600s (6h) | Fresh process per run | PASSIVE (default) |

`com.john.outbox-processor` (`daemons/outbox-processor.js`) is **not** a writer to this DB — it polls unrelated outbox tables in `durable-runner.db`, `mission-control.db`, `drafts.db`.

## Drain Worker Rate Configuration (MC #101501, 2026-07-29)

`com.alai.rag-drain-worker` is deployed with:

| Setting | Value | Source |
|---|---:|---|
| `MAX_UPLOADS_PER_MINUTE` | `20` | `~/Library/LaunchAgents/com.alai.rag-drain-worker.plist` and `~/system/config/launchagents/com.alai.rag-drain-worker.plist` |
| `DRAIN_INTERVAL_MS` | `10000` | LaunchAgent environment |
| `BATCH_SIZE` | `5` | `~/system/tools/rag-drain-worker.js` default |
| `MAX_CONCURRENT` | `1` | LaunchAgent environment; serial uploads remain the design |

`rag-drain-worker.js` now treats `DRAIN_INTERVAL_MS` as the deployed alias for `DRAIN_SLEEP_MS`. Effective behavior: batch-5 every 10s can burst near 30 docs/min, while the token bucket caps steady-state uploads at 20 docs/min. This implements the MC #101501 increase from the previous deployed cap of 10 docs/min without adding concurrency.

Verification command after edits/restarts:

```bash
launchctl print "gui/$(id -u)/com.alai.rag-drain-worker" | grep -E 'state =|pid =|MAX_UPLOADS_PER_MINUTE|DRAIN_INTERVAL_MS'
tail -12 ~/system/logs/rag-drain-worker.log
```

Expected evidence lines:

```text
MAX_UPLOADS_PER_MINUTE => 20
DRAIN_INTERVAL_MS => 10000
[drain] Rate config: max_uploads_per_min=20 batch_size=5 drain_sleep_ms=10000
```

---

## Root Cause (MC #106047)

`rag-outbox.js`'s own header comment says *"Do NOT require() this from two processes simultaneously"* — the design assumed a single writer. The deployed topology has five. `openOutbox()` unconditionally ran `PRAGMA wal_checkpoint(TRUNCATE)` on every open, with **no `busy_timeout`** set anywhere in the file.

`TRUNCATE` mode requires exclusive WAL access to zero out the WAL file. The four short-lived adapters each open a brand-new connection (and therefore force a new `TRUNCATE` checkpoint) on every invocation — for `rag-fsevents-adapter` this can mean several times per minute during write bursts (1,965+ file touches observed under `evidence/` alone since 2026-07-15). Meanwhile `rag-drain-worker` holds one long-lived WAL connection open continuously. Without `busy_timeout`, contending checkpoints don't wait each other out — and a `TRUNCATE` checkpoint that's interrupted or races another process's checkpoint on the same WAL/shm can leave the b-tree in an inconsistent state. This matches the observed corruption signature exactly (`tree2/page2` btree errors).

The Jul 20 event was **silent for 5 days** (corruption occurred ~Jul 15 12:57 per file mtime, discovered manually by John on Jul 20) — proof no integrity guard existed before this fix.

Confirmed *not* the cause: unclean shutdown/reboot (no reboot near either corruption window per `last reboot`), disk pressure (6% used, 198Gi free at time of investigation).

---

## Fix (applied 2026-07-20)

**File: `~/system/lib/rag-outbox.js`**
- Added `db.pragma('busy_timeout = 5000')` right after the `journal_mode`/`synchronous` pragmas, so a connection waits up to 5s for a lock instead of failing/racing immediately.
- `openOutbox(dbPath, opts)` now accepts `opts.checkpointMode` (`'PASSIVE'` default, or `'TRUNCATE'`). `PASSIVE` never blocks or forces exclusivity — it checkpoints whatever it safely can.

**File: `~/system/tools/rag-drain-worker.js`**
- Its `openOutbox(DB_PATH)` call now passes `{ checkpointMode: 'TRUNCATE' }` explicitly — it remains the single designated owner of periodic WAL truncation, per the original single-writer spec.

All four other callers (`rag-bookstack-adapter.js`, `rag-mc-adapter.js`, `rag-fsevents-adapter.js`, `lightrag-outbox-ingest.js`) pass no second argument and therefore get the new `PASSIVE` default automatically — no code changes needed in those files.

**Verification:** all 5 writer daemons were restarted via `launchctl kickstart -k gui/<uid>/<label>` (NOT `nohup` — orphaned `nohup` processes hold the SQLite lock and are a known trap on this system). Post-restart: `rag-drain-worker` confirmed alive and actively draining (LightRAG health OK, `pipeline_busy=true`); no new `SQLITE_CORRUPT`/malformed entries in any `.err` log; `node ~/system/tools/rag-ingest-integrity-watchdog.js --no-alert` returned `integrity=ok quick=ok`.

---

## Integrity Watchdog (guard)

`~/system/tools/rag-ingest-integrity-watchdog.js` runs `PRAGMA integrity_check` + `PRAGMA quick_check` against the live DB (read-only), writes Prometheus metrics to `~/system/metrics/rag-ingest-integrity.prom`, appends JSONL events to `~/system/logs/rag-ingest-integrity-events.jsonl`, and alerts Slack (`#alerts`) + HiveMind on failure with a 4h cooldown. It never mutates the queue DB.

LaunchAgent staged at `~/Library/LaunchAgents/com.alai.rag-ingest-integrity-watchdog.plist` (StartInterval=900s / 15 min, RunAtLoad, `plutil -lint` validated). **Not yet loaded** — persistent daemon registration is an outward-facing action; loading it requires CEO go-ahead, tracked separately by John.

Manual check any time:
```bash
node ~/system/tools/rag-ingest-integrity-watchdog.js            # alerts on failure
node ~/system/tools/rag-ingest-integrity-watchdog.js --no-alert # dry-run, no Slack/HiveMind
node ~/system/tools/rag-ingest-integrity-watchdog.js --json     # machine-readable
```

---

## Housekeeping (deferred)

Stale corruption/backup snapshots from Apr 23 and May 9 (4 files, ~46M, in `~/system/state/`) are superseded by two later recovery cycles and are deletion candidates, but were **not deleted** — disk is at 6% (no pressure) and John did not create these files, so deletion is held pending explicit CEO/John confirmation. Today's pair (`.corrupt-20260720-0825`, `.pre-recover-20260720-0825`, ~91M) is kept as forensic evidence until MC #106047 closes.

---

## Follow-up

MC #106066 — `rag-fsevents-adapter.js` has no debounce on `WatchPaths` bursts; every single fs event spawns a fresh process + fresh `openOutbox()` call. Even with `PASSIVE` checkpoint + `busy_timeout` landed, this remains unnecessary connection churn on the shared outbox DB during evidence-writing storms. Recommend a 2-5s debounce/coalesce window before triggering an enqueue pass.