# Your chat log stores the RAG context you swore you never kept

> An agent that logs its assembled prompt stores every injected RAG block too. How a 'never stored' claim broke, the invariant, and a five-minute canary check.

- Author: Chad Priest
- Published: 2026-09-19
- Canonical URL: https://blog.vodou.ai/your-chat-log-stores-the-rag-context-you-swore-you-never-kept/
- Tags: ai, rag, sqlite, security

---

My commit message said page text was "never stored." That was wrong, and a live end-to-end test showed it.

The feature let you hand the assistant the page you were reading. The text went into the prompt wrapped in a fence (`⟦vodou:context page v1⟧ … ⟦/vodou:context⟧`), and my commit (`dd29f851`, 2026-08-09) promised that none of it was persisted. To check that, I ran a real WebSocket turn with a hostile canary planted in `pageContext`, then searched both databases for it.

memory.db had 0 hits, which is what I'd promised. gateway.db also had the canary, in full, sitting in `gateway_messages` under the conversation `workbench:surface:cli`.

## The CLI executor persisted `promptForLlm`, not the user's message

The web conversation stored the user's words. The CLI executor path stored `promptForLlm`, the fully assembled prompt the model receives. The page text wasn't the only thing in there, so I counted with one query: rows in that conversation stored with `role = 'user'` that contain the fence marker.

```sql
SELECT COUNT(*), MIN(created_at), MAX(created_at)
FROM gateway_messages
WHERE conversation_id = 'workbench:surface:cli'
  AND role = 'user'
  AND content LIKE '%vodou:context%';
-- 112 | 2026-07-04 08:17:17 | 2026-09-19 17:01:29
```

That's 112 out of 8,959 user-role rows in that conversation, about 1.3%. 109 of them were scheduled heartbeat runs, and each one stored its whole rendered directive (roughly 20 KB, injected blocks included) as if the user had typed it. The other 3 were ordinary turns between 2026-08-09 and 2026-08-30, which started the same day as the commit that said "never stored." That count only catches fenced text. Injected text that went in without the marker doesn't show up in it, so treat 112 as a floor.

The canary never reached long-term memory because the extractor strips the fence before it pulls facts. So the accurate claim was narrower than the one I shipped: *never stored in the web conversation, and never reaches memory.* The fenced text was sitting in a local, single-user chat DB, and the only thing protecting it was a string filter in one consumer.

It's also in the full-text index. `gateway_messages` feeds an external-content FTS5 table through `AFTER INSERT/UPDATE/DELETE` triggers, and a `MATCH` on the marker phrase returns 135 rows across the table. The tokenizer is the built-in `porter unicode61`, so nothing prevents cleanup. Delete or rewrite through the base table and let the triggers update the index. If you touch the index by hand, the log and the index stop agreeing.

The fix is one line of intent: the row attributed to the user gets the user's message, and the assembled prompt goes only to the model.

```ts
// before: the model's input became the user's history
saveMessage(convId, 'user', promptForLlm);

// after: history gets what the user sent; the prompt is never persisted
saveMessage(convId, 'user', userMessage);
await chat(convId, promptForLlm, onEvent);
```

That's the shape of the change, not a paste from the repo, and it hasn't shipped as of this post. Until it does, "never stored" isn't true on the CLI path, so I'm not claiming it.

## A delimiter protects exactly one reader

The standard advice for untrusted text is to mark its region in the prompt with XML tags or rotating markers, and to tell the model that whatever sits inside them is data. That's correct for what the model sees in one request. It doesn't cover what happens when the marked prompt gets saved. Once that happens, the marker is a promise that every future reader of that row will strip it: the summarizer, the extractor, the search index, the export, and next month's feature.

The class is this: **a system that persists its assembled prompt in place of the raw user input copies every retrieved or injected block into history, and whether that content stays out of downstream systems depends on a delimiter filter at every consumer.**

I haven't audited other frameworks for this, so the following are shapes to check, not bugs I've confirmed: chat memory that saves the formatted prompt instead of the input variable, thread APIs where your code appends retrieved chunks to the user turn before posting it, and MCP clients that log tool-augmented prompts into the same table their memory job reads. Any of them has the bug only if your code or its defaults store the assembled string. The canary below tells you whether yours does.

**Invariant: every row a conversation store attributes to the user contains only bytes the user submitted, never bytes the system added.** Attachments, pasted files and voice transcripts count as submitted. Retrieved chunks, memory packs, tool output and system directives count as added. A given codebase either satisfies that or it doesn't.

## Check yours in five minutes

Put a unique token, `CANARY9D41E`, in a document your retriever will return. Ask a question that pulls that document in. Then look for the token everywhere, not just in the table you think of as the chat log. A leak lands in whichever table you forgot about: a prompt or trace log, a backup copy made during a migration, the FTS index.

SQLite, every column of every table:

```sh
db=app.db
for t in $(sqlite3 "$db" "SELECT name FROM sqlite_master WHERE type='table'"); do
  for c in $(sqlite3 "$db" "SELECT name FROM pragma_table_info('$t')"); do
    n=$(sqlite3 "$db" "SELECT COUNT(*) FROM \"$t\" WHERE CAST(\"$c\" AS TEXT) LIKE '%CANARY9D41E%'")
    [ "$n" -gt 0 ] && echo "$t.$c: $n"
  done
done
```

FTS5 shadow tables (`*_data`) hold the index as binary blobs, so `LIKE` won't find anything there. Query each FTS table directly: `SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'CANARY9D41E';`

Postgres, the same sweep in one query:

```sql
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
  AND data_type IN ('text', 'character varying', 'json', 'jsonb')
  AND (xpath('/row/n/text()', query_to_xml(format(
        'SELECT COUNT(*) AS n FROM %I.%I WHERE %I::text LIKE %L',
        table_schema, table_name, column_name, '%CANARY9D41E%'),
      false, true, '')))[1]::text::int > 0;
```

**Pass:** no output, or output only from a table you've explicitly declared as a prompt audit log, one that nothing downstream reads. **Fail:** any hit in a row stored with `role = 'user'`. The worst fail is a hit in whatever table your memory or summarization job reads from.

Then run the test that matters most. Break the fence on purpose, for example by emitting `<context` with no closing bracket or dropping the closing tag, and repeat the question. Wait for your extraction or summarization job to run, then query its output:

```sql
SELECT id, created_at, content
FROM memories          -- or facts, summaries: whatever your extractor writes
WHERE content LIKE '%CANARY9D41E%';
```

**Expected: 0 rows.** Any row means your delimiter filter was the only thing between retrieved text and permanent storage, and a malformed tag walked straight past it. That text will now be retrieved as a "memory," injected into the next prompt, and possibly extracted again.

## What Vodou does with the bytes you didn't type

This bug is why Vodou's extraction treats fences as load-bearing and is tested with a hostile canary instead of a friendly one. That test is what found this leak. It showed 0 hits in memory, which held, and a full copy in the CLI chat log, which the fix above addresses and which hasn't shipped yet.

Here's what Vodou is. Your memory lives in a SQLite database on your own machine. Facts get extracted from your conversations automatically, and extraction is deliberately limited to what you said, not what the system injected. That matters because if your own retrieved context flows back into memory, it gets retrieved again, extracted again, and stored as if it were new.

Retrieval uses hybrid vector and keyword search with a cross-encoder reranker. If no candidate clears the reranker's cutoff, nothing gets injected, which beats filling the prompt with noise. The same memory goes into ChatGPT, Claude and Gemini in the browser through the Vodou Bridge extension, and into Claude Code, Cursor and VS Code through MCP and hooks. The client side (MCP servers, skills, scripts, the extension) is open source, so you can read the gateway code this post is about, watch this fix land, and add your own skills and servers.

It's built for engineers who are tired of re-explaining their project to every new chat and who want to own the database that holds those explanations.

Before you trust a "never stored" claim, grep your whole schema for a canary. That includes mine. If you'd rather use a memory system that publishes what the test found, start at [vodou.ai](https://vodou.ai).

---

Source: [Your chat log stores the RAG context you swore you never kept](https://blog.vodou.ai/your-chat-log-stores-the-rag-context-you-swore-you-never-kept/) by Chad Priest, from Building Vodou in Public.
