Your agent memory stores vendor metrics with no expiry date
A 12-month keyword average of 2,400 hid an August value of 8,100. Agent memory kept both as timeless facts. Here is the invariant and a five-minute SQL check.
Fact stores for agents know how to retire a fact once something contradicts it. What they don’t handle is a fact that goes stale when nothing contradicts it, because nobody measured again. A dated number from a third-party vendor gets saved as a plain fact, gets retrieved as a fact from then on, and nothing on the read path knows the number has a shelf life. On 2026-09-20 my memory store saved this row, tagged METRIC:
vodou.ai keyword volumes (US/mo, 12-month average, DataForSEO, 2026-09-20):
ai harness2,400 (Aug 2026: 8,100, difficulty 36);hermes agent alternative140 (Aug 2026: 590, difficulty 0) …
ai harness: 2,400 on average, 8,100 in August
I was wrong about what kind of row this was. I treated it like the SPF record for my domain, which stays true until someone edits DNS. In fact it measures a moving trend, and its headline number is a 12-month average that comes to less than a third of the latest month. That headline is the figure a summary quotes.
The ranker made the same mistake I did. It gave the keyword row no age penalty and no replaced-by penalty, so its score was pure similarity to the query, the same treatment it gave the SPF row.1 The embarrassing part is that my table already had the columns for this. It has an invalid_at and a review_after. On this row both were empty, because nothing on the write path fills them in for a measurement. Expiry was something the schema allowed and the writer never used.
What did it cost? I have not traced a wrong answer or a wrong decision to this row. What I can say is what it would have done: any question like “how much search volume does ai harness get” retrieves a row whose leading figure is 2,400, for a term that ran at 8,100 in the latest month in the same pull. Next month that row will be a month older and will score exactly the same.
Contradiction-based revocation needs someone to measure again
Two existing designs cover most of this, and I should have started from them. The Bi-Temporal Fact Validity pattern gives every fact valid_at and invalid_at, and closes the old fact when a new one supersedes it. TEPA is explicit about its trigger: “When fresh evidence conflicts with an active precedent under the same key, TEPA performs a local state transition that removes the stale precedent from ordinary retrieval.” It has no time-based expiry. Revocation waits for evidence.
Both approaches assume the fresh evidence arrives. In my case it never did. Nobody reran the keyword pull, so nothing ever contradicted the 2,400. It went out of date without anything recording that. That’s the narrow gap: expiry without contradiction.
I have not tested other memory frameworks for this, so I’m not going to hand you a list of who has it. Some ship validity columns. Having the column didn’t save me, so check whether yours is actually filled in.
Every stored value that came from an external measurement has an as-of date and an expiry, and the read path enforces the expiry without waiting for a newer write.
Grep your memory table for numbers that never expire
Step 1, the column. List your memory table’s columns. If none of them means “valid until”, “expires at”, “invalid at” or “review after”, you fail, and you can skip step 2. Nothing in your store can go stale, by construction.
-- Postgres; in SQLite use PRAGMA table_info(memories);
SELECT column_name FROM information_schema.columns
WHERE table_name = 'memories';
Step 2, the rows. If you do have an expiry column, look for rows that state a quantity but have no expiry set. The pattern targets quantities: digits with thousands separators, percentages, dollar amounts, k/M suffixes, and a number next to a unit word. It skips bare years, dates and IDs:
-- Postgres. Rename memories/content/created_at/valid_until to match your schema.
SELECT id, created_at, substr(content, 1, 100) AS text
FROM memories
WHERE valid_until IS NULL
AND content ~* '(\d{1,3}(,\d{3})+|\d+(\.\d+)?\s?(%|k\M|m\M|million|users|visits|searches|/mo)|\$\s?\d)'
ORDER BY created_at
LIMIT 50;
SQLite has no REGEXP by default. Pull the rows into Python and run the same pattern with re.search(..., re.I), using \b where Postgres uses \M.
If any row comes back, you fail.
I ran both patterns over the live rows in my own store. The draft version of this check, any three consecutive digits, matched about 23% of rows, mostly years and dates. The quantity pattern matched about 6%. I read 25 of those hits drawn at random. Roughly 15 were numbers that go stale: vendor prices, market-size estimates, competitor claims, community sizes, memory footprints. Roughly 10 were not: an RGB triple, a hard-coded character cap, a price decision, a quoted “100%”. So expect somewhere around a third of the hits to be noise, and read the list by eye. It’s a triage list, not a verdict.
Step 3, the retriever. Pick a quantity you have measured at least twice and ask your retriever about it. Whatever library you use, you need three fields per hit: the score, the date the value was measured (not just the date the row was written, if those differ), and the text. Two real examples:
# mem0
from mem0 import Memory
m = Memory()
res = m.search("how many active users do we have", user_id="me")
for h in res["results"]:
print(round(h["score"], 3), h.get("created_at"), h["memory"][:80])
# Chroma, raw
res = collection.query(
query_texts=["how many active users do we have"],
n_results=10,
include=["documents", "metadatas", "distances"],
)
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
print(round(dist, 3), (meta or {}).get("created_at"), doc[:80])
If created_at prints None in the Chroma version, you never stored a date. That fails too.
Failing: you get two or more different values for the same quantity, none of them flagged, sometimes with the older one ranked higher. That is the pattern I found in my own store.
Passing: you get one current value, and anything older is either dropped or shown to the model with its as-of date next to the number.
Stamp the expiry when you write the row
A better embedding won’t fix this. The fix is a rule at write time:
- When a value comes from an external measurement (a vendor API, a dashboard export, a report), the writer sets
as_ofto the measurement date the source gives, not the ingest time. - The writer sets
valid_until = as_of + TTL, and the TTL comes from how often the source republishes. Monthly search volumes get 30 days. Pricing pages get 90. If you can’t name the cadence, use 30 days and let an owner extend it. - The row doesn’t get written without both fields. A measurement with no
valid_untilis rejected, not defaulted to forever. - The read path never drops the date. A live row is shown with its as-of date. An expired row is either left out or shown marked expired. It is never shown as a bare number.
- When a source reports both an average and a latest value, store the latest value as the headline and the average as context.
With that rule, the line my model sees for this row on 2026-10-25 would be:
[METRIC · DataForSEO · as of 2026-09-20 · EXPIRED 2026-10-20] "ai harness" US search volume: latest month (Aug 2026) 8,100; 12-month average 2,400. Re-measure before quoting.
That line tells the model how old the number is, which figure is current, and that the value needs a fresh measurement before anyone quotes it. The row I actually had said none of that.
Footnotes
-
In my ranker’s score breakdown these showed up as
age_mult: 1.0andsuperseded_mult: 1.0, meaning no multiplier was applied for either. ↩