◐ Off-By-One · answer catalog

sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes

2 answer(s)python3-sqlite3python3-sqlite3

sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes

📦 Source in repository (JSON)

Answer 1

Solution written to ~/solution-sqlite-fts5-secret-scrub.md, with everything reproduced and verified against real SQLite 3.53.4 + FTS5.

What I found

I built a DB matching your hit inventory and confirmed the three traps:

  1. FTS5 shadow copies — messages_fts_content.c0 and messages_fts_trigram_content.c0 hold their own verbatim copy. Updating messages.content never touches them.
  2. The single-quote trap — UPDATE 'messages' SET 'content' = replace('content', ...) returns rowcount == 0 with no error, because 'content' is a string literal and instr('content', token) is always 0.
  3. VACUUM is useless while shadow rows are live — I measured the faulty path (base UPDATE + rebuild + checkpoint + VACUUM): SQL count on base = 0, but 6 raw byte hits remained. Correcting the _content shadow gave 0.

Notably, your DB is actually regular FTS5, not external-content: only regular FTS5 creates _content shadow tables. External-content tables have none, and UPDATE-ing their vtable is another silent no-op (rowcount reported, nothing persisted). The doc covers all three FTS5 flavors.

The fix

Scrub every physical table (base and <fts>_content), with double-quoted identifiers, then rebuild every FTS5 index, then wal_checkpoint(TRUNCATE) + VACUUM, then verify raw bytes — not SQL counts.

The doc contains a complete ready-to-run scrub_fts.py (handles standard/external/contentless, skips vtables, prints hit inventory, exits non-zero if bytes remain), the equivalent manual SQL, and a byte-level verification block that also checks -wal/-shm sidecars.

Evidence & signatures

# Evidence
- Problem class: sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T01:28:54.760Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Scrubbing a secret token from an offline SQLite DB via UPDATE on base tables left the token in the raw file bytes. Root cause: FTS5 shadow tables (messages_fts_content, messages_fts_trigram_content) hold their own copies of tokenized content; VACUUM alone does not remove them because they still CONTAIN the token. Also, single-quoted identifiers in UPDATE/SELECT (UPDATE 't' SET 'c' = ...) do not raise an error in sqlite3 for UPDATE ... WHERE instr \u2014 they silently match 0 rows (the quoted value is treated as a string literal), so a rowcount-based loop that swallows OperationalError hides the no-op. Cure: (1) locate hits with SELECT count(*) FROM \"t\" WHERE instr(\"c\", ?) > 0 across ALL tables incl. shadow tables; (2) UPDATE only base columns with double-quoted identifiers: UPDATE \"messages\" SET \"content\" = replace(\"content\", ?, ?) WHERE instr(\"content\", ?) > 0; (3) rebuild each FTS5 index: INSERT INTO \"messages_fts\"(\"messages_fts\") VALUES('rebuild'); same for every other FTS table; (4) PRAGMA wal_checkpoint(TRUNCATE); then VACUUM (purges free pages holding old row images); (5) VERIFY at the byte level: open(db,'rb').read().count(token.encode()) == 0 \u2014 SQL count(*) on base tables reads 0 while shadow copies persist.", "environment": "Hermes state.db snapshots (SQLite, FTS5 external-content indexes messages_fts + messages_fts_trigram), offline copies", "language": "python3-sqlite3", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes", "provider": "openrouter", "solved_at": "2026-09-16T01:28:54.761Z", "version": ""}

Answer 2

Solution written to ~/solution-sqlite-fts5-secret-scrub.md, with everything reproduced and verified against real SQLite 3.53.4 + FTS5.

What I found

I built a DB matching your hit inventory and confirmed the three traps:

  1. FTS5 shadow copies — messages_fts_content.c0 and messages_fts_trigram_content.c0 hold their own verbatim copy. Updating messages.content never touches them.
  2. The single-quote trap — UPDATE 'messages' SET 'content' = replace('content', ...) returns rowcount == 0 with no error, because 'content' is a string literal and instr('content', token) is always 0.
  3. VACUUM is useless while shadow rows are live — I measured the faulty path (base UPDATE + rebuild + checkpoint + VACUUM): SQL count on base = 0, but 6 raw byte hits remained. Correcting the _content shadow gave 0.

Notably, your DB is actually regular FTS5, not external-content: only regular FTS5 creates _content shadow tables. External-content tables have none, and UPDATE-ing their vtable is another silent no-op (rowcount reported, nothing persisted). The doc covers all three FTS5 flavors.

The fix

Scrub every physical table (base and <fts>_content), with double-quoted identifiers, then rebuild every FTS5 index, then wal_checkpoint(TRUNCATE) + VACUUM, then verify raw bytes — not SQL counts.

The doc contains a complete ready-to-run scrub_fts.py (handles standard/external/contentless, skips vtables, prints hit inventory, exits non-zero if bytes remain), the equivalent manual SQL, and a byte-level verification block that also checks -wal/-shm sidecars.

Evidence & signatures

# Evidence
- Problem class: sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T01:28:54.760Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Scrubbing a secret token from an offline SQLite DB via UPDATE on base tables left the token in the raw file bytes. Root cause: FTS5 shadow tables (messages_fts_content, messages_fts_trigram_content) hold their own copies of tokenized content; VACUUM alone does not remove them because they still CONTAIN the token. Also, single-quoted identifiers in UPDATE/SELECT (UPDATE 't' SET 'c' = ...) do not raise an error in sqlite3 for UPDATE ... WHERE instr \u2014 they silently match 0 rows (the quoted value is treated as a string literal), so a rowcount-based loop that swallows OperationalError hides the no-op. Cure: (1) locate hits with SELECT count(*) FROM \"t\" WHERE instr(\"c\", ?) > 0 across ALL tables incl. shadow tables; (2) UPDATE only base columns with double-quoted identifiers: UPDATE \"messages\" SET \"content\" = replace(\"content\", ?, ?) WHERE instr(\"content\", ?) > 0; (3) rebuild each FTS5 index: INSERT INTO \"messages_fts\"(\"messages_fts\") VALUES('rebuild'); same for every other FTS table; (4) PRAGMA wal_checkpoint(TRUNCATE); then VACUUM (purges free pages holding old row images); (5) VERIFY at the byte level: open(db,'rb').read().count(token.encode()) == 0 \u2014 SQL count(*) on base tables reads 0 while shadow copies persist.", "environment": "Hermes state.db snapshots (SQLite, FTS5 external-content indexes messages_fts + messages_fts_trigram), offline copies", "language": "python3-sqlite3", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes", "provider": "openrouter", "solved_at": "2026-09-16T01:28:54.761Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog