Facts Should Expire: A Database That Tells an AI Agent When Its Knowledge Has Gone Stale
An agent keeps a memory table, and two rows matter for today’s task: a supplier’s headquarters city, written eleven months ago, and a freight quote, written three hours ago. A SELECT returns both in the same shape. The headquarters is probably still right. The quote may already be wrong. Nothing in either row says so, and the agent repeats both in the same flat tone.
A row has no idea how old its truth is. It might carry a created_at, and a careful person can compute an age from it, but the decay rate belongs to the kind of fact, not to the table. A three-week-old headquarters address is fine. A three-week-old shipping rate is fiction. Server health goes bad in half a minute. No timestamp column holds that knowledge, and nothing forces a query to read it anyway.
The tool worth building is a fact store where each type of fact declares how long it stays fresh, and every read returns the value with its age and a state: fresh, stale or expired. Nothing gets deleted on expiry. An expired fact is still evidence, only weaker, and sometimes the best you have: “the supplier was in Austin as of March” beats “unknown.” The agent decides whether to re-check, and the store can queue the check for it.
What Caches Already Know
Key-value stores have TTLs, but a TTL deletes. Redis EXPIRE and Cassandra’s TTL both remove the data when time runs out, which is right for a cache and wrong for knowledge, because you lose the answer along with the record that you ever had one.
HTTP sorted this out for caches long ago. In RFC 9111, max-age sets how long a response is fresh. The stale-while-revalidate and stale-if-error extensions from RFC 5861 add a middle state, stale but usable, with rules for when it may be used: while a refresh runs in the background, or while the origin is down. Conditional requests make the refresh cheap, since an If-None-Match with the stored ETag gets a 304 Not Modified if nothing changed. So HTTP has three states (fresh, stale but usable, gone) and a cheap way back from stale to fresh. Caching API responses in SQLite borrows that for one API. The idea here is to apply it to facts instead of responses.
The 2026-07-28 MCP revision added cache hints, ttlMs and cacheScope, to the list calls and to resources/read, but not to tools/call. That’s defensible, since a call can have side effects. It also leaves a gap: when an agent saves a tool result into memory, the result arrives with no age information at all, so the memory store is where freshness has to be assigned.
Temporal databases cover another half. XTDB is bitemporal, SQL:2011 system-versioned tables in MariaDB and SQL Server track when rows changed, and Datomic can answer what it knew as of a point in time. They separate when something was true from when you learned it, which matters here, but none of them has a notion of how long a fact deserves trust. Agent memory systems such as Zep’s temporal knowledge graph track when facts held, which is closer. A recency bonus in retrieval ranking is the usual shortcut, though ranking only sorts. It never says “too old to use.”
Policies, States and a Re-check Queue
A fact row carries a type, an entity, a key, a value, observed_at, checked_at and a source. observed_at comes from the source: when the thing was seen, not when a pipeline loaded it. checked_at is the last time anyone confirmed the value still held, and it makes re-checking cheap. Confirming an unchanged fact moves checked_at forward and writes no new version, the 304 of fact storage. Provenance on every value pays off here too, because the source field tells the store which checker to call.
Policies live in a file beside the data, one block per type:
types:
company.headquarters:
fresh_for: 365d
expire_after: 730d
shipping.rate:
fresh_for: 24h
expire_after: 72h
server.health:
fresh_for: 30s
expire_after: 5m
Between fresh_for and expire_after a fact is stale. State is computed at read time, a CASE expression in a view over the current clock, so there’s no sweeper process and nothing runs at 3 a.m. A query looks like this (the syntax is a sketch):
> SELECT key, value, age, state FROM facts_now WHERE entity = 'supplier:acme';
key value age state
headquarters Austin 212d fresh
rate_to_rotterdam 1,840 USD 31h stale
server.health degraded 9m expired
What the agent sees matters as much as the query. Metadata the model has to go looking for gets ignored, so the read tool puts the state inside the sentence: “Rate to Rotterdam: 1,840 USD (observed 31 hours ago, STALE, re-check before quoting).” An expired fact comes back as “no current value; last known: degraded, 9 minutes ago,” and the plain value only when the caller asks for it.
Reading a stale fact can also queue a re-check, which is stale-while-revalidate for facts: return the flagged value now, refresh in the background. Each type registers a checker (a URL, a tool call, a script), and a checker that finds the same value just bumps checked_at. Derived facts inherit the weakest state among their inputs. A landed-cost estimate built from a fresh tariff, a stale rate and a fresh exchange rate is stale, and should say which input made it so. A store that tracks derived values and their dependencies gets that from its dependency graph for free.
Choosing the Decay Rules Is the Hard Part
The numbers come from whoever knows the domain, and they’ll be wrong at first. Better to let behavior set them. Every re-check is an experiment: the value changed or it didn’t. A field checked twenty times that never moved earns a longer life, and one that changes on every second check gets a shorter one. HTTP caches already do a rough version, since RFC 9111 lets a cache guess a lifetime from how long ago a resource last changed. A half-life gives the same idea a smooth number, but the number looks more precise than anyone has earned, so for a first version thresholds win. People can read “stale,” and agents can follow it.
Observation time against ingestion time is the next trap. A scraper that loads a page today may be loading a sentence written last spring, so age counts from the source’s own date. When the source gives none, the fetch time is the fallback, and it flatters the fact, which is at least that old. Store the basis next to the timestamp so the store can say “at least 3 hours old” instead of “3 hours old.”
Clocks matter more here than in most databases. A 30-second window dies to a 60-second skew between the machine that saw the fact and the machine that reads it. Stamp ingestion on the store’s own clock, compute every age on that clock, and clamp source timestamps from the future instead of trusting them.
Then the agent. A model handed a stale value with a warning will still sometimes use it, especially when the warning sits in a metadata field. Rendering state into the sentence helps. A strict mode helps more: a type marked strict never returns its value once expired, only the instruction to re-check. Absence decays fastest of all. A record that a check found nothing at 14:00 is nearly worthless at 15:00 for anything that moves. Freshness also belongs in what gets packed into a prompt, so a context database filling a token budget should drop expired facts first and label the stale ones.
Scope for a First Version
Version 0.1 is a SQLite file and a policy YAML: the facts table, a facts_now view that computes state, a recheck_queue table with at most one open row per fact (a thousand reads of one stale fact queue one check, not a thousand), and a read function that renders state into sentences. Checkers are plain commands the store shells out to.
It refuses learned decay rates until there’s re-check history to learn from. No vector search, no scheduler of its own, no probabilities. It doesn’t decide what’s true. It reports how old what you believe is.
Anyone running an agent against prices, schedules or inventory has this problem today, and most solve it with a last_updated column and hope.
Every fact has an age. Make the database say it.