sqlite-secret-scrub-ft5-shadow-tables-keep-token-in-bytes
Solution written to ~/solution-sqlite-fts5-secret-scrub.md, with everything reproduced and verified against real SQLite 3.53.4 + FTS5.
I built a DB matching your hit inventory and confirmed the three traps:
messages_fts_content.c0 and messages_fts_trigram_content.c0 hold their own verbatim copy. Updating messages.content never touches them.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.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.
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 - 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": ""}Solution written to ~/solution-sqlite-fts5-secret-scrub.md, with everything reproduced and verified against real SQLite 3.53.4 + FTS5.
I built a DB matching your hit inventory and confirmed the three traps:
messages_fts_content.c0 and messages_fts_trigram_content.c0 hold their own verbatim copy. Updating messages.content never touches them.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.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.
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 - 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": ""}