# PRAGMA quick_check reported FTS5 corruption on a healthy file

> A long-lived SQLite handle running quick_check reports FTS5 corruption while writers are active, even when the file is healthy. How to separate that from real WAL damage.

- Author: Chad Priest
- Published: 2026-09-15
- Canonical URL: https://blog.vodou.ai/sqlite-fts5-quick-check-false-corruption/
- Tags: memory, retrieval, observability, architecture

---

If your agent keeps its memory in SQLite, you probably have a messages table, an FTS5 index beside it, sync triggers between the two, and a health check that runs `PRAGMA quick_check` on a timer. Mine did. Starting 2026-08-04 that check kept printing `fts5: corruption found reading blob N`, and I believed it for a month. Some of it was real. Most of it was the check tripping over its own snapshot.

The real part: the database was damaged four times on my dev machine. On 09-04 the base table was intact, all 78,017 rows readable, while the indexes and FTS5 shadow tables showed 57 "2nd reference to page" errors. The hard part was that the loud signal and the real damage came from two different mechanisms.

## `fts-audit.ts` logs the statement, not the afternoon

What I shipped is small. In [Vodou](https://vodou.ai/register?utm_source=blog&utm_medium=feature&utm_campaign=di-2-instrument-the-fts-delete-update-path-and-record-what-n), the web gateway stores chat in a `gateway_messages` table with an external-content FTS5 index. `MCP-servers/Vodou-Console/src/fts-audit.ts` adds one call at every site that updates or deletes those rows. Trimmed:

```ts
export function auditFtsMutation(site: string, op: 'update' | 'delete', changes: number, db?: DbLike): void {
  if (!Number.isFinite(changes) || changes <= 0) return; // matched nothing, sent no 'delete'
  console.error(`[${stamp()}] [fts-audit] ${op} site=${site} rows=${changes}`);
  if (!checkAfterEachMutation || !db) return;            // opt-in, off by default
  db.prepare("INSERT INTO gateway_messages_fts(gateway_messages_fts) VALUES('integrity-check')").run();
  // on throw: "INDEX WENT BAD IMMEDIATELY AFTER: site=... op=... rows=..."
}
```

It logs the row count because that count is the exposure. An external-content index requires that the `'delete'` command be handed the exact bytes that were indexed for that rowid, and one UPDATE over 3,000 rows sends 3,000 of those. With the opt-in check on, FTS5's own `integrity-check` runs after every mutation, so "the index broke sometime today" becomes "this statement broke it." That costs a full index scan per mutation, so it is only for reproduction runs.

**Diagram: One mutation, one named site**

Each UPDATE or DELETE on the messages table fires the FTS5 sync trigger, then the audit logs site and row count and optionally runs FTS5 integrity-check

```text
  [UPDATE / DELETE] --> [FTS5 sync trigger] --changes > 0--> [auditFtsMutation] --repro runs--> [integrity-check (fixed)]

  notes:
    UPDATE / DELETE: one of six call sites
    FTS5 sync trigger: one 'delete' per row
    auditFtsMutation: site + rows logged
    integrity-check: opt-in

  If the index breaks, the log names the statement that did it.
```

Wiring it in exposed something I had missed. The update trigger fires on every UPDATE, including ones that only flip `excluded_from_context` or `dedupe_key`, and one function runs two unbounded UPDATEs that can touch thousands of rows at once, some older than the index.

## Node 22 took the blame, then Node 24 still failed every ten minutes

My first diagnosis was the runtime. The gateway was running Node 22.22.3 although `package.json` required 24. The timeline ruled that out: two months of FTS5 on Node 22 with no corruption, then the first DELETE path on 07-27, the first UPDATE on 07-31, and the first corruption on 08-04. That made delete/update traffic the prime suspect.

On 09-04 at 19:15 I added a guard that allows only Node 24, and then nobody checked for twenty hours. When I looked: no file corruption, a clean full `integrity_check`, and 22 new handle-local failures, bad on the gateway's connection and clean on a fresh one. Both Nodes bundle SQLite 3.51.3, so a version difference couldn't explain it.

The failures came in bursts. From 01:49Z they were exactly ten minutes apart, which is the `quick_check` interval, so every tick failed. Then came 70 clean ticks in a row, then more failures. `pmset` ruled out sleep. Docker Desktop showed up in `lsof` holding the database, so I blamed Docker. I quit it, and failure #22 fired three minutes later. I took that one back.

**Diagram (timeline)**

From first corruption through wrong diagnoses to the quick_check artifact reproducing

```text
  08-04     ->  first corruption, 4 days after first UPDATE path (problem)
  09-04     ->  blamed Node 22; guard to Node 24 (problem)
  09-05 AM  ->  Node 24: no damage, 22 transients; Docker blamed, withdrawn (problem)
  09-05     ->  audit wired at every mutation site
  09-05 PM  ->  4,441 mutations clean; transient reproduced as a check artifact (fixed)
```

## 4,441 mutations, and the prime suspect came back clean

Next I tried to reproduce it on throwaway databases, never the real file. I used the shipped schema and triggers, `node:sqlite` and WAL, and seeded rows *before* creating and rebuilding the FTS table, because that matches the real history. The traffic covered the six audited sites plus the `ON DELETE CASCADE` from the conversations table, which fires the delete trigger in bulk and wasn't on my list.

That was 40 rounds and 4,441 mutations on each Node. `quick_check`, full `integrity_check` and FTS5 `integrity-check` came back ok every round, with 7,169 MATCH hits at the end on both. The two runs were identical line for line.

Then I forced the desync: drop the update trigger, rewrite every 7th row, restore the trigger, rerun. FTS5's `integrity-check` went bad at once and stayed bad. `quick_check` stayed ok, and **a fresh connection saw the damage too**. That is the opposite of my symptom. A real desync is on disk, where every connection sees it. Mine existed on one handle.

## 47 of 57 ticks failed on a file with nothing wrong

The run that explained it used one long-lived connection running `quick_check` every 150ms while three separate processes wrote to the file. 47 of 57 ticks failed with the exact live message, across 33 distinct blob ids, bursts included. On that same handle, FTS5's `integrity-check` and `MATCH` never failed once, and afterwards a fresh connection passed every check. The control ran the same traffic without an FTS5 table: 184 ticks, zero failures.

**Diagram: quick_check failures, same writers**

quick_check failures on a long-lived handle: 47 with an FTS5 table present across 57 ticks, 0 without one across 184 ticks

```text
  with FTS5 (57 ticks) : 47 failed  ################################ (problem)
  no FTS5 (184 ticks)  :  0 failed  # (fixed)
```

`quick_check` on a long-lived handle walks the FTS5 shadow b-trees while other connections are rewriting them. It reads a blob id its snapshot no longer describes and reports corruption on a healthy file. The problem is in the check, not the file, and the bursts line up with the hours when writers are busy.

## The real damage came from two processes that exited cleanly

The file damage had another cause, one I had measured on 09-04 and never written down. Three live processes held `-wal`/`-shm` inodes 146979904/146979905, but the files on disk were 150851813/150851814. In WAL mode those sidecar files are only removed when the last connection closes, and none of those connections had closed. Something deleted and recreated the files underneath them. The live handle counted 803 freelist pages against the file's 742: two separate WALs over one main file.

At that point the file was still clean. Fifteen minutes later, two of those processes exited normally and checkpointed their orphaned WAL into a main file that had moved on. `integrity_check` went from ok to 100 problem lines in that one step. The clean shutdown is what did the damage. A stranded process has to be killed with `kill -9`.

## Three checks: the trigger, the sidecar inode, the second connection

Each of these is a property that is either true or false of your code. First: **an external-content FTS5 update trigger fires only when an indexed column changed.**

```sql
SELECT name, sql FROM sqlite_master
WHERE type = 'trigger' AND sql LIKE '%''delete''%';
```

Passing looks like `AFTER UPDATE OF content ON messages`, or a `WHEN old.content IS NOT new.content` clause. Failing looks like a bare `AFTER UPDATE ON messages`, which re-deletes every row your metadata jobs touch.

Second: **every process that holds the `-wal` holds the inode that is on disk.**

```bash
DB=/path/to/app.db
echo "on disk: $(stat -f %i "$DB-wal" 2>/dev/null || stat -c %i "$DB-wal")"
lsof -nP 2>/dev/null | awk -v w="$DB-wal" 'index($0, w) {print $1, "pid", $2, "inode", $8}'
```

Passing means every inode matches the one on disk. Failing means a process holds a different inode, or one marked `(deleted)` on Linux. Kill that process with `-9`. Do not send SIGTERM.

Third: **a corruption report from a long-lived handle doesn't count until a fresh connection agrees.**

```python
import sqlite3

def confirm(db_path, live_result):
    if live_result == "ok":
        return "ok"
    fresh = sqlite3.connect(db_path).execute("PRAGMA quick_check").fetchone()[0]
    return "corrupt" if fresh != "ok" else "handle-local (log it, do not alarm)"
```

If your health check pages someone on the first `fts5: corruption found reading blob`, it fails this check.

## Agent memory write-ups assume the checker is honest

SQLite with WAL and FTS5 is a common local store for agent memory. The [TencentDB agent memory design](https://github.com/neoneye/agent-memory-atlas/blob/main/content/systems/tencentdb-agent-memory.md) pairs it with `sqlite-vec`. Published FTS failure reports focus on write atomicity: [Hermes Agent issue #72716](https://github.com/NousResearch/hermes-agent/issues/72716) lost search permanently because `executescript()` issued an implicit COMMIT partway through a demote. [mnema](https://github.com/Ray0907/mnema) uses a contentless-delete FTS5 table, which avoids the byte-for-byte delete contract altogether.

None of that covers an instrument that is wrong, or sidecars pulled out from under a live connection. [The New Stack](https://thenewstack.io/retrieval-ai-agent-architecture/) notes that a hallucination often means the source never reached the context. When a keyword index silently loses postings, users see a model making things up, not an index problem.

## Still open: what deletes `-wal` under a live connection

I don't know what deleted those sidecars. The four historical corruptions happened before any instrumentation existed. And 4,441 clean mutations is absence of evidence, not proof the pattern is safe at 78,000 rows over months.

## Your memory is a SQLite file on your machine, so its health check has to be honest

I tracked this bug so closely because the database it hit is where Vodou's memory lives. Vodou is local-first: facts pulled from your chats and coding sessions go into a database on your own disk, and nothing leaves until you send it. That design also makes you the one who finds out when the file breaks. So after this incident, the gateway shows a banner that says outright when the database reports damage and new messages may not be saved. Before it, lost messages simply weren't there. Handle-local artifacts are counted apart from real damage, so the warning still means something.

Keyword search is half of retrieval here. Vodou fuses vector and FTS results, runs a cross-encoder reranker over them, and applies a precision floor that injects nothing rather than noise. If postings quietly go missing, that pipeline gives your assistant less than you actually told it. That's why this index gets a named-statement audit instead of a guess.

You never re-explain yourself, wherever you work. The same memory reaches Claude Code, Cursor and VS Code through MCP and hooks, and ChatGPT, Claude and Gemini in the browser through the Vodou Bridge extension. Proactive loops flag it when extraction or capture goes quiet, so a silent failure like this one reaches you before you notice the missing context. The MCP servers, skills and extension are open source, including `fts-audit.ts`, so you can read exactly what the instrument does and change it.

It also runs work while you're away. This post went through that path. The notes behind it were mined from the memory of the build, then the draft was written, graded against a rubric, checked by a redaction gate so nothing private or proprietary gets out, and deployed on Vodou's own scheduler. The investigation followed the same pattern: the next corruption, if one comes, will leave a named statement in a log instead of a month of guesses.

Vodou is for engineers who want their assistant's memory in a file they own, with the failure modes visible. It runs on your machine, and every skill, server and schedule is yours to extend.

If you're standing up a local memory store and want the index, the WAL and the health check already checked against each other, [start at vodou.ai](https://vodou.ai/register?utm_source=blog&utm_medium=feature&utm_campaign=di-2-instrument-the-fts-delete-update-path-and-record-what-n).

---

Source: [PRAGMA quick_check reported FTS5 corruption on a healthy file](https://blog.vodou.ai/sqlite-fts5-quick-check-false-corruption/) by Chad Priest, from Building Vodou in Public.
