crow.db corruption recovery
crow.db has corrupted twice the same way (2026-06-14, 2026-07-02): the cross_host_calls federation-audit table is an unbounded, append-only, high-write table, and a crash mid-write orphaned pages and spammed tens of thousands of disk image is malformed errors while federation degraded silently. Three hardening layers now exist; this doc covers the third — the one-command recovery — and points at the other two.
One command: npm run recover-db
Rebuilds a corrupt crow.db offline into a fresh, integrity-checked file, salvaging every readable base table.
# Stop everything that opens crow.db FIRST (gateway + pi-bots + any same-host
# instances), then:
npm run recover-db -- --dry-run # rehearse: build + verify, never swap
npm run recover-db # do it: build, verify, swapFlags:
| Flag | Meaning |
|---|---|
--db <path> | DB to recover (default ~/.crow/data/crow.db). |
--dry-run | Do everything except the swap. Leaves the rebuilt temp DB in place for inspection and never touches the original. |
--force | Bypass the liveness gate (you assert the gateway/pi-bots are stopped). |
What it does
- Liveness gate — refuses to run if
crow.db,crow.db-wal, orcrow.db-shmhave any open file handles (lsof/fuser). A TCP port check is not enough: pi-botsbot_jobsIPC, the WAL keeper, and other same-host gateway instances all opencrow.db. Override with--force. - Backup — copies the corrupt DB (+
-wal/-shm) to<db>.CORRUPT-<ts>. - Fresh schema — runs the in-repo
scripts/init-db.jsinto a temp file. - Salvage —
ATTACHes the corrupt backup andINSERT OR REPLACE-copies every readable base table (shared columns only, so schema drift is survivable). FTS virtual tables are rebuilt viaINSERT INTO fts(fts) VALUES('rebuild').crow_instancesis preserved whenever it is readable — it is the peer-auth trust anchor (validateInstanceTokenselects byauth_token_hash), so dropping it blacks out all federation until every peer re-enrolls. It is skipped only if its per-tableSELECTthrows a malformed error.cross_host_calls(expendable audit) andmcp_sessions(ephemeral) are always dropped.
- MCP token re-inject — re-writes
sha256(CROW_LOCAL_MCP_TOKEN)(from the environment or.env) so headless MCP clients keep working, and validates it before allowing the swap. - Two swap gates — the rebuilt DB is installed only if:
PRAGMA integrity_checkreturnsok, and- every readable source table's row count exactly matches the copied count (a partially-readable source can't yield an "ok" but lossy DB). Any shortfall prints a loud diff and aborts without swapping; the rejected rebuild is left on disk for inspection. A COPY-FAIL on a readable table (its
count(*)succeeds but a full row read throwsSQLITE_CORRUPTon a corrupt leaf/overflow page — the classic partial-corruption case) also aborts, sinceintegrity_checkon the fresh target would otherwise pass on a table that is simply missing its rows.
- Swap — atomically renames the rebuilt file over
<db>and removes the stale-wal/-shm. Prints a runbook, every row count, and — ifcrow_instanceshad to be dropped — a loud "federation peers must re-enroll" warning.
After a real recovery
- Restart the gateway and pi-bots so they reopen the fresh file (
sudo systemctl restart crow-gateway+ the pi-bots unit, etc.). - The corrupt original stays at
<db>.CORRUPT-<ts>; delete it once you trust the recovery. - If the warning fired, re-pair each federation peer (grackle sync, black-swan, …) — they will be rejected until they re-enroll.
The other two hardening layers
Recovery is the last resort. The root cause and the silent-failure mode are addressed upstream:
- Bounded retention + checkpoint —
pruneCrossHostAuditdeletescross_host_callsrows older than 14 days (must exceed the 7-dayintegrationsSignalreader) and runsPRAGMA wal_checkpoint(TRUNCATE), scheduled fromservers/gateway/boot/post-listen.js(5-min initial delay, 24h interval). The table now stays tiny — far less corruption surface, fast recovery. Seeservers/shared/cross-host-audit-retention.js. - Circuit breaker + loud alert — when
auditCrossHostCallsees a structural error (malformed/not a database/disk image/SQLITE_IOERR), it trips a per-process breaker that stops feeding the corruption and fires a DB-free ntfy + email alert (it does not route throughcreateNotification, which would try to write the corrupt DB first). Surfaced in the nest as thefederation-audithealth signal. Seeservers/shared/cross-host-auth.js.
The migration guard (A3)
Every production init-db run — auto-update after a pull, the gateway boot gate, scripts/install.sh upgrades, and scripts/crow-update.sh — goes through servers/shared/migration-guard.js when the run carries migration risk (a schema-generation crossing, or a pulled change to scripts/init-db.js). The guard:
- Takes a pre-migration backup (incremental SQLite backup, safe on a live WAL database) into
<data-dir>/backups/migrations/(keeps the last 3, plus any pinned by a finding for 30 days). - Runs init-db, then compares per-table row counts, columns, and schema objects against
servers/shared/migration-expectations.js(the manifest of expected drops/prunes/moves/rebuilds — kept honest by a static rot-guard test). - On high-confidence loss (an undeclared table vanished, a rebuild lost rows/columns, a large unexplained loss): restores the backup, keeps the damaged file as
crow.db.damaged-<ts>evidence, writes quarantine markers, fires a DB-free ntfy + email alert, and (on the auto-update path) rolls the code back and restarts. Anything less certain fails open: the migration stands, and the same loud alert channel explains what looked suspicious.
Quarantine: two marker files with one meaning — this migration damaged data here, don't re-run it:
<repo>/.crow-migration-quarantine.json(stops every updater sharing the checkout, including the manual "Check for updates now" button)<data-dir>/migration-quarantine.json(makes the boot gate skip init-db — the gateway boots on the intact old-schema data; features needing the new schema may error until the fix lands)
The quarantine clears itself when a new commit lands on main (up to 3 automatic retries per generation crossing). To override manually — after verifying the verdict was wrong or recovering by hand — delete both marker files. node scripts/guarded-init-db.mjs is the guarded manual entry point; npm run init-db remains the bare, unguarded seam for scratch/dev databases.