building vodou.

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.

Chad Priest / / 4 min read

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:

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 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 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:

-- 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.