# Your memory extractor tags boat repair as a codebase gotcha

> A memory pipeline tagged a ChatGPT thread about a stuck throttle as an engineering GOTCHA and ranked it first. Root cause, the class, and a 5-minute SQL check.

- Author: Chad Priest
- Published: 2026-09-02
- Canonical URL: https://blog.vodou.ai/your-memory-extractor-tags-boat-repair-as-a-codebase-gotcha/
- Tags: ai, rag, sqlite, debugging

---

The top-ranked engineering gotcha in my memory store yesterday was about a boat.

```
[GOTCHA] Don't force the stuck throttle handle, and don't take the boat on
the water until the shift control is fixed
scope: capture:web:chatgpt   project_id: NULL   reranker_logit: 0.9991
```

I asked ChatGPT about a sticking Mercury shift control on Sunday. The browser extension captured the thread, the nightly extraction pass read it, and it wrote a durable row tagged `GOTCHA`, the same tag it uses for "running gateway caches old compiled dist until restart." On the next retrieval that touched the phrase "don't force", the boat came back first at a final score of 1.48. Every real engineering hit sat at 0.06.

## The tag came from the sentence shape, not the subject

I assumed for about a day that the bug was in ranking. It was not. Ranking did exactly what it was told: cosine 0.84 against the fact, a key hit on the extracted question "should I force the throttle lever?", the cross-encoder fired and said yes. The row was a perfect answer to the query. The query just should never have had that row in its candidate set.

The extraction prompt decides tags from cues. An imperative with a warning ("don't X, it causes Y") is the textbook shape of a gotcha, so it gets the gotcha tag, and gotchas carry a +0.06 tag bias at retrieval. Nothing in that path asks whether X is in my domain. Here is what that produced across the whole browser-capture lane:

```sql
SELECT COUNT(*), SUM(project_id IS NULL)
FROM memory_chunks
WHERE chunk_tag='GOTCHA' AND scope LIKE 'capture:web:%' AND archived=0;
-- 35 | 35
```

Fourteen of those thirty-five are not about software. Three are marine grease and throttle bolts. Nine are from a thread where I was checking whether a crypto-comp recruiter was a scam ("Don't trust a link just because the page looks like ARK"). Two are corporate bylaw traps. All thirty-five have a null project, which means they are global, which means per-project scoping (the fix that [memnode's context-bleed piece](https://memnode.dev/articles/per-project-memory-isolation-context-bleed) argues for, correctly) cannot touch them. Scoping only works when the writer assigns a scope, and a browser capture of a consumer chat has no project to assign.

[MemGuard](https://arxiv.org/html/2605.28009) names a neighboring failure: facts, events and rules collapsed into one space and retrieved as interchangeable evidence. That is real and I have that bug too. But the boat row is not a type error. It is a rule, correctly typed as a rule. It is the wrong domain wearing the right type, and a type-aware boundary passes it straight through.

## The class: a classifier that reads the imperative and never the noun

Any pipeline that extracts facts from a mixed personal-and-technical stream and labels them by surface form will do this. Mem0 or Zep sitting under a coding agent while the same user also asks about their taxes. A LangChain summarizer over a Slack export where #eng and #random share a workspace. An MCP memory server that ingests everything the user says to any model. The cue that says "this is a lesson" ("never", "don't", "must", "always") is domain-free, and the tag that cue produces is treated downstream as domain-specific.

**An extracted fact's category label must not be computable from its syntax alone; if the classifier can tag a row without any token that names the target domain, that tag is a sentence-shape score and cannot be used as a domain filter or a domain boost.**

Run this on your own store. Pull every stored row that carries your "lesson" or "gotcha" or "rule" label, then count how many contain a token that names anything in your stack:

```sql
-- adapt table/column names; the shape is what matters
WITH lessons AS (
  SELECT id, text FROM memories WHERE category IN ('gotcha','rule','lesson')
),
technical AS (
  SELECT id FROM lessons
  WHERE text GLOB '*[a-z_]*.[a-z][a-z]*'      -- a filename or extension
     OR text LIKE '%restart%' OR text LIKE '%config%' OR text LIKE '%api%'
     OR text LIKE '%build%'   OR text LIKE '%cache%'  OR text LIKE '%deploy%'
)
SELECT (SELECT COUNT(*) FROM lessons) AS total,
       (SELECT COUNT(*) FROM technical) AS names_the_stack;
```

Passing looks like `total` and `names_the_stack` within a few rows of each other, and the difference reads like edge cases when you print it. Failing looks like mine: a gap of a dozen or more, and the printed remainder is boats, recruiters and bylaws. Then run the second half: feed one of those off-domain rows' own question ("should I force the lever?") through your retrieval and see whether it outranks a real technical row. If a personal note can take the top slot under a technical tag, your tag is a boost for grammar, not for relevance.

## Two rules: no project means no boost, no domain subject means `NOTE`

The fix on my side is two rules, not a smarter model. Rows from consumer-chat capture with no project get tagged but do not get the tag's retrieval boost, and the extractor is told that a gotcha needs a subject in the domain of the store or it is a `NOTE`. That drops the boat to 0.06 with the rest of the tail, where it belongs and where I can still find it if I ask about the boat.

The rule for a codebase that shares nothing with mine: a category assigned from sentence shape is metadata about the sentence, not about the fact. Never let it decide what gets injected into a prompt until something has checked the nouns.

---

Source: [Your memory extractor tags boat repair as a codebase gotcha](https://blog.vodou.ai/your-memory-extractor-tags-boat-repair-as-a-codebase-gotcha/) by Chad Priest, from Building Vodou in Public.
