AI Agent Memory: Catch Contradictions on Write, in One SQL Query
Objective
An agent's memory fills up from many writers: the chat, a CRM sync, a scraper, another agent. Sooner or later two of them disagree. The office is in Denver and in Chicago. The plan has a 14-day return window, and also "does not allow returns". Most memory layers store both facts, and recall returns whichever one is most similar to the question. The agent now holds two contradicting beliefs and doesn't know it.
This recipe checks a batch of incoming facts before they're written. A single SQL statement does three things that usually need three systems:
EMBED+COSINE_SIMILARITYpairs each incoming fact with the existing memories on the same topic, and only those.NLI_VERIFY(a local NLI model running inside the engine) checks the incoming fact against those memories.- SQL sorts the batch into
CONFLICT(with the memory it conflicts with),ALREADY_KNOWNandNEW. Only safe facts are stored. Conflicts go to a review queue.
No LLM judge or external API is involved, and the check runs where the memory
lives. Self-Consistent Agent Memory
covers the single-fact version on MEMORY_STORE. This recipe does a whole batch
and adds the similarity gate, which a batch needs (Step 4 shows why).
Before you start: NLI is off by default
The NLI functions need a model installed and enabled. Until you enable it, every NLI call fails with:
Inference error: NLI is disabled; install a model and enable NLI
Install and enable the curated model (DeBERTa-v3-small, Apache-2.0, English, ~577 MB download), then restart:
synapcores --config /etc/synapcores/gateway.toml pull nli-default
synapcores --config /etc/synapcores/gateway.toml models enable-nli nli-default
For Docker or environment-driven installs, set AIDB_NLI_ENABLED=1 and
AIDB_NLI_MODEL=nli-default at startup. Check that GET /v1/ai/nli/models
reports the model as ready. See
Semantic Claim Verification
for air-gapped installs.
Step 1: What the agent already believes
Memories are rows with an embedding, so you can join on them.
CREATE TABLE IF NOT EXISTS recipe_memcheck_memories (
id INT PRIMARY KEY,
fact TEXT,
embedding VECTOR(384)
);
INSERT INTO recipe_memcheck_memories (id, fact, embedding) VALUES
(1, 'The customer prefers to be contacted by email, never by phone.',
EMBED('The customer prefers to be contacted by email, never by phone.')),
(2, 'The customer is on the Aurora plan, which includes a 14-day return window.',
EMBED('The customer is on the Aurora plan, which includes a 14-day return window.')),
(3, 'The customer manages procurement for a mid-size logistics company.',
EMBED('The customer manages procurement for a mid-size logistics company.')),
(4, 'The customer''s office is in Denver.',
EMBED('The customer''s office is in Denver.'));
Step 2: A batch of incoming facts from different writers
CREATE TABLE IF NOT EXISTS recipe_memcheck_incoming (
id INT PRIMARY KEY,
source TEXT,
fact TEXT,
embedding VECTOR(384)
);
INSERT INTO recipe_memcheck_incoming (id, source, fact, embedding) VALUES
(101, 'chat', 'The customer asked us to call them by phone from now on.',
EMBED('The customer asked us to call them by phone from now on.')),
(102, 'crm-sync', 'The customer works in logistics procurement.',
EMBED('The customer works in logistics procurement.')),
(103, 'webinar-tool', 'The customer attended the October product webinar.',
EMBED('The customer attended the October product webinar.')),
(104, 'scraper', 'The customer''s office is in Chicago.',
EMBED('The customer''s office is in Chicago.')),
(105, 'agent-b', 'The customer''s plan does not allow returns.',
EMBED('The customer''s plan does not allow returns.')),
(106, 'chat', 'The customer is learning to play the cello.',
EMBED('The customer is learning to play the cello.'));
Facts 101, 104 and 105 conflict with an existing memory. Fact 102 restates one. Facts 103 and 106 are new.
Step 3: Look at the numbers before choosing thresholds
List every incoming/memory pair that the NLI model scores as a contradiction (above 0.6), with its topic similarity:
SELECT i.id AS incoming, m.id AS memory,
ROUND(COSINE_SIMILARITY(i.embedding, m.embedding), 3) AS similarity,
ROUND(CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', m.fact, i.fact),
'$.scores.contradiction') AS DOUBLE), 3) AS contradiction
FROM recipe_memcheck_incoming i
CROSS JOIN recipe_memcheck_memories m
WHERE CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', m.fact, i.fact),
'$.scores.contradiction') AS DOUBLE) > 0.6
ORDER BY similarity DESC;
Observed on 2.3.1 with nli-default:
| incoming | memory | similarity | contradiction | reality |
|---|---|---|---|---|
| 104 | 4 | 0.740 | 0.999 | real conflict (office city) |
| 105 | 2 | 0.524 | 1.000 | real conflict (returns) |
| 101 | 1 | 0.502 | 0.999 | real conflict (contact channel) |
| 104 | 2 | 0.408 | 0.760 | false: office city vs plan |
| 103 | 3 | 0.366 | 0.888 | false: webinar vs procurement job |
| 106 | 4 | 0.235 | 0.997 | false: cello vs office |
| 106 | 3 | 0.228 | 1.000 | false: cello vs procurement job |
| 106 | 1 | 0.169 | 0.999 | false: cello vs contact channel |
| 106 | 2 | 0.132 | 0.996 | false: cello vs plan |
Nine pairs score as contradictions, and only three of them are real. The NLI model scores pairs of sentences. Give it two sentences about different things and it often still returns a confident "contradiction": the cello fact "contradicts" all four memories at 0.996 or higher. Contradiction scores can't separate real from false here, but similarity can. Real conflicts sit at 0.50–0.74, and false ones at 0.41 or less. This recipe sets the floor at 0.45, inside that gap. It's a value read from this data, not a universal constant, so measure your own memories the same way.
Step 4: The trap: verify against every memory
NLI_ANCHORS collects a group's memories into an evidence array, and
NLI_VERIFY checks the incoming fact against them. Without a similarity gate,
each incoming fact is checked against all memories:
SELECT i.id, i.source,
JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', i.fact, NLI_ANCHORS(m.fact)),
'$.verdict')) AS verdict,
JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', i.fact, NLI_ANCHORS(m.fact)),
'$.violated_anchor')) AS conflicts_with
FROM recipe_memcheck_incoming i
CROSS JOIN recipe_memcheck_memories m
GROUP BY i.id, i.source, i.fact
ORDER BY i.id;
Observed:
| id | source | verdict | conflicts_with |
|---|---|---|---|
| 101 | chat | LIE | The customer prefers to be contacted by email, never by phone. |
| 102 | crm-sync | TRUTH | |
| 103 | webinar-tool | LIE | The customer manages procurement for a mid-size logistics company. |
| 104 | scraper | LIE | The customer's office is in Denver. |
| 105 | agent-b | LIE | The customer is on the Aurora plan, which includes a 14-day return window. |
| 106 | chat | LIE | The customer manages procurement for a mid-size logistics company. |
Five of six facts are rejected, including the webinar and the cello, which
conflict with nothing. NLI_VERIFY gives contradiction precedence, so one
unrelated memory scored as a "contradiction" vetoes the fact. A memory
that rejects everything new can't learn.
Step 5: The fix: the whole batch in one gated query
Add the similarity floor and a LEFT JOIN, so that a fact with no related
memory comes back as NEW instead of disappearing:
SELECT i.id, i.source,
CASE g.verdict
WHEN 'LIE' THEN 'CONFLICT'
WHEN 'TRUTH' THEN 'ALREADY_KNOWN'
ELSE 'NEW'
END AS status,
g.conflicts_with
FROM recipe_memcheck_incoming i
LEFT JOIN (
SELECT c.id AS cand_id,
JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
'$.verdict')) AS verdict,
JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
'$.violated_anchor')) AS conflicts_with
FROM recipe_memcheck_incoming c
CROSS JOIN recipe_memcheck_memories m
WHERE COSINE_SIMILARITY(c.embedding, m.embedding) > 0.45
GROUP BY c.id, c.fact
) g ON g.cand_id = i.id
ORDER BY i.id;
Observed:
| id | source | status | conflicts_with |
|---|---|---|---|
| 101 | chat | CONFLICT | The customer prefers to be contacted by email, never by phone. |
| 102 | crm-sync | ALREADY_KNOWN | |
| 103 | webinar-tool | NEW | |
| 104 | scraper | CONFLICT | The customer's office is in Denver. |
| 105 | agent-b | CONFLICT | The customer is on the Aurora plan, which includes a 14-day return window. |
| 106 | chat | NEW |
All six are sorted correctly, and each conflict names the memory it collides
with. Fact 103 gets an UNVERIFIED_PLAUSIBLE verdict from the one memory that
cleared the floor (office in Denver, similarity 0.487, contradiction 0.488,
below the 0.6 threshold). Fact 106 cleared no memory at all, so the LEFT JOIN
returns a NULL verdict. Both become NEW.
On 2.3.1, put the similarity condition in WHERE over a CROSS JOIN, as
shown. The same condition in JOIN … ON COSINE_SIMILARITY(...) > 0.45 returned
zero rows on that build.
Step 6: Route the batch: store what's safe, queue what conflicts
Save the triage once, so the NLI work isn't repeated:
CREATE TABLE IF NOT EXISTS recipe_memcheck_triage (
id INT PRIMARY KEY,
source TEXT,
fact TEXT,
status TEXT,
conflicts_with TEXT
);
INSERT INTO recipe_memcheck_triage (id, source, fact, status, conflicts_with)
SELECT i.id, i.source, i.fact,
CASE g.verdict
WHEN 'LIE' THEN 'CONFLICT'
WHEN 'TRUTH' THEN 'ALREADY_KNOWN'
ELSE 'NEW'
END,
g.conflicts_with
FROM recipe_memcheck_incoming i
LEFT JOIN (
SELECT c.id AS cand_id,
JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
'$.verdict')) AS verdict,
JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default', c.fact, NLI_ANCHORS(m.fact)),
'$.violated_anchor')) AS conflicts_with
FROM recipe_memcheck_incoming c
CROSS JOIN recipe_memcheck_memories m
WHERE COSINE_SIMILARITY(c.embedding, m.embedding) > 0.45
GROUP BY c.id, c.fact
) g ON g.cand_id = i.id;
Only NEW facts become memories. ALREADY_KNOWN is skipped, because it
would only add a duplicate:
INSERT INTO recipe_memcheck_memories (id, fact, embedding)
SELECT i.id, i.fact, i.embedding
FROM recipe_memcheck_incoming i
JOIN recipe_memcheck_triage t ON t.id = i.id
WHERE t.status = 'NEW';
Expected: rows_affected = 2 (the webinar and the cello).
The review queue is the conflicts, each paired with the belief it collides with:
SELECT id, source, fact, conflicts_with
FROM recipe_memcheck_triage
WHERE status = 'CONFLICT'
ORDER BY id;
Expected: 3 rows (101 chat, 104 scraper, 105 agent-b).
Step 7: A conflict isn't always an error
Look at the queue again. Fact 101 ("asked us to call them by phone from now on") is probably a real preference change, not a mistake. Fact 104 comes from a scraper and contradicts your CRM. The model can't tell those apart. All it can say is that the two statements can't both be true. What happens next is your policy:
| Conflict source | Typical policy |
|---|---|
| The user, first-hand ("from now on…") | Supersede: update the old memory, keep a history row |
| A lower-trust writer (scraper, another agent) | Reject and keep the existing memory |
| Two equally trusted sources | Hold for a human, and serve neither as fact |
The model's job is to make sure no contradiction is stored without anyone seeing it. Whether the new fact or the old one wins depends on who wrote it, and that decision stays yours.
Step 8: Same check on the built-in MEMORY_* API
If the agent uses MEMORY_STORE / MEMORY_RECALL instead of a table, apply
the same floor to recall's similarity column. On the same embedding model,
recall gave the same similarity values as Step 3 (0.502 for fact 101 vs
memory 1).
SELECT MEMORY_STORE('recipe_memcheck_ns', fact) AS memory_id
FROM recipe_memcheck_memories
WHERE id <= 4;
SELECT JSON_UNQUOTE(JSON_EXTRACT(NLI_VERIFY('nli-default',
'The customer asked us to call them by phone from now on.',
NLI_ANCHORS(content)), '$.verdict')) AS verdict
FROM MEMORY_RECALL('recipe_memcheck_ns',
'The customer asked us to call them by phone from now on.', 5)
WHERE similarity > 0.45;
Expected: LIE, which means a conflict, so don't call MEMORY_STORE.
Count before you verify. NLI_VERIFY over an empty anchor set raises an error
rather than guessing:
SELECT COUNT(*) AS related
FROM MEMORY_RECALL('recipe_memcheck_ns',
'The customer is learning to play the cello.', 5)
WHERE similarity > 0.45;
Expected: 0. Nothing related is stored, so treat the cello fact as NEW
and store it. MEMORY_RECALL takes its query as a
string literal, so this path checks one fact per statement. For a batch in one
query, use the table form in Step 5.
Cleanup (Optional)
DROP TABLE IF EXISTS recipe_memcheck_memories;
DROP TABLE IF EXISTS recipe_memcheck_incoming;
DROP TABLE IF EXISTS recipe_memcheck_triage;
SELECT MEMORY_FORGET('recipe_memcheck_ns', id) AS forgotten
FROM MEMORY_RECALL('recipe_memcheck_ns', 'customer', 10);
Honest boundaries
- The model checks consistency, not truth.
CONFLICTmeans "these can't both be true". It doesn't say which one is right. If the stored memory was wrong to begin with, the correct new fact is the one that gets flagged. - Choose thresholds from your own numbers. In this data the gap between real and false conflicts sat between similarity 0.41 and 0.50. That margin is narrow. Re-run Step 3 on a sample of your memories, and raise or lower the floor until unrelated pairs stop appearing.
- Short, single-claim memories work best. A memory that packs three facts into one sentence gives the model three ways to read it as contradicted.
- Not a numeric or date engine. "Renewal is on March 1" vs "renewal is on March 3" should be compared exactly as typed values, not with NLI.
- English model. NLI is off by default, so enable it as described above.
Key Concepts Learned
- Memory poisoning is easiest to stop when the fact is written. Recall ranks by similarity, not consistency, so once a contradiction is stored it's served alongside the facts.
- A pairwise NLI model returns confident "contradictions" for unrelated
sentences. An
EMBEDsimilarity floor makes sure it only compares memories about the same thing. NLI_ANCHORS+GROUP BYchecks every incoming fact against its related memories in one statement, and aLEFT JOINkeeps facts with no related memory asNEW.- The model only reports the conflict. Whether to supersede, reject or hold is a trust policy you set.