Agent Memory Expiry Is a State, Not a Delete
Most agent-memory systems age notes out with a TTL delete. I shipped that exact bug, then read my own fix as a code review: four defects in twelve lines, and the design that replaced deletion with a state an event log can fold over.
I build a working-memory system for AI coding agents: notes go in with one tool call, come back out with another, and the whole point of the product is that a note survives things a conversation does not, context compaction, a new session, a model swap. So it should embarrass me more than it does to admit that for a while, my own tool had an opt-in setting that would silently delete your standing rules once they got old enough.
Not archive them. Not flag them. DELETE FROM notes WHERE workspace = ? AND created_at < ?. If you set VECTR_NOTES_TTL_DAYS and left it running, a directive note, the kind reserved for a rule the user told the agent explicitly, "always run tests in the venv," "never touch the config files directly," would get purged on exactly the same schedule as a scratch finding from a task that finished months ago. The row was gone. The event log that recorded it was gone with it.
That bug is the whole subject of this post, because the fix for it is not really about TTL tuning. It's about a category error that almost every memory system I've read about makes, including, for a while, mine: treating a note's age as if it were evidence about whether the note is still true.
It isn't. Age is not evidence. A note from a year ago telling you the deploy target for a specific service, or the reason a migration was written the way it was, can be the single most relevant thing you own today. A note from an hour ago can already be wrong. The only things that tell you a memory has gone stale are things that actually happened: someone said it's wrong and why, something replaced it, the artifact it was anchored to changed underneath it. Elapsed time on its own tells you none of that. It just tells you time elapsed.
Why Memory Is Not a Cache
TTL expiry is the correct default for a huge class of systems, which is exactly why it's tempting to reach for here too. It's correct for a cache because a cache's whole contract is: this value is a convenience copy of a source of truth that still exists somewhere else. If the copy goes stale, you re-fetch, and the world is unharmed. The TTL is a bet about how fast the upstream truth moves, and if you bet wrong, the fallback is one round trip to the real answer.
A memory is not a convenience copy of anything. It's the record. When an agent writes down "the staging DB migration for this table needs to run with --allow-dangerous, learned that the hard way," there is no upstream source of truth sitting somewhere ready to be re-fetched if the note expires. The note is the artifact. Delete it on a clock and you haven't invalidated a cache, you've destroyed the only copy of something somebody paid, in time or in a production incident, to learn.
Cache invalidation and memory expiry look like the same problem because both involve a timestamp and a cutoff, but they solve opposite failure modes. A cache is wrong when it's too old relative to a re-derivable truth. A memory is wrong when it's been contradicted, superseded, or drifted from what it was anchored to, none of which age measures directly. Reach for TTL on a cache. Reach for something else on a memory.
I didn't invent that distinction while fixing this bug, I'd actually written it up months earlier in a research pass on staleness handling for exactly this kind of note (build quirks, environment facts, decisions with no file to hash). That research laid out five deterministic-first options, ranked by how much real evidence each one carries:
| Option | Mechanism | Evidence quality |
|---|---|---|
| A | Kind-scoped decay/TTL | Weakest: pure arithmetic on created_at |
| B | Event-driven proxy anchors (hash a lockfile or CI config, flag on mismatch) | Strong: a real environment change |
| C | Usage-outcome feedback (a later tool failure contradicts an injected note) | Strong: an observed contradiction |
| D | Always-on "last confirmed: <date>" framing |
Not a decay mechanism, a hedge on every note regardless of state |
| E | Explicit revert state machine plus anti-memory injection, append-only, nothing ever deleted | Strongest: an explicit, reasoned human or agent judgment |
The doc's own verdict was blunt about where each one ranks: "A is the residual fallback for notes with neither anchor nor revocation history, bounds worst-case exposure, mirroring the TTL-baseline/event-driven hybrid from cache-invalidation practice." Option E was the one flagged as the centerpiece, "the most differentiated capability relative to every surveyed system," because none of the production systems it surveyed (mem0, Zep/Graphiti, Letta, LangMem, ChatGPT memory, Anthropic's memory tool) re-inject a past revocation as content an agent has to reckon with. They log it, if they log it at all, and move on.
E shipped first, as an append-only event log over each note: created, then optionally superseded, revoked(reason, actor, ts), stale_flagged, or reinstated. Current state is a fold over that log. Nothing is ever mutated or deleted by any of those transitions, so undoing a revocation is just one more event, not a special case.
A, the cheapest and weakest option on the list, the one explicitly flagged as a fallback for notes that have no anchor and no revocation history, was the one that shipped incorrectly. And what shipped didn't just implement it poorly, it violated E's central invariant outright, by deleting rows.
The Four Defects, Read as a Code Review
The decay logic lived in agent/working_context_store/_store.py as a function called decay_old_notes(). Here's what a grep for its callers turned up before the fix: three call sites, all three inside the same test file, and nothing else. No production code path called it. It computed a real number, wrote it to a real column, and nothing downstream ever looked at that column to make a decision. Dead code that has its own passing unit tests is worse than dead code that's obviously unused, because it reads as live. You'd see the tests green and assume the feature does something.
That was defect one. The other three were in the function body itself:
Defect two: it wasn't kind-scoped
The signature took a single flat half_life_days=14.0 and applied it uniformly:
reconstructed, pre-fix# reconstructed from the pre-fix behavior described in the fix commit UPDATE notes SET decay_score = decay_score * ... WHERE workspace = ?
No kind column in the WHERE clause, no branch on note type. A standing directive, the exact case the original research doc had called out by name, "directive facts don't decay by default", decayed on the identical clock as a throwaway finding. The one kind the design explicitly said must never lose rank to age was never exempted from anything.
Defect three: it deleted rows
Once a note's decay_score crossed a threshold, the function ran:
pre-fix, decay_old_notes()DELETE FROM notes WHERE workspace = ? AND decay_score < 0.1
This is the same category of bug as the VECTR_NOTES_TTL_DAYS purge, just reached through a different door: ranking machinery reaching in and destroying data. It also orphaned every row in note_events, the append-only table that is the audit trail for that note. Delete the note and you don't just lose the content, you lose the record that it was ever created, revoked, or reinstated. The event-sourcing invariant the whole rest of the system was built around, current state is a fold over an immutable log, cannot survive a DELETE reaching into the thing it's supposed to be folding over.
Defect four: the math wasn't idempotent
The actual decay computation was:
pre-fix formuladecay_score = decay_score * pow(0.5, (now - created_at) / half_life_s)
Read that closely and the bug is in the first token. It multiplies the existing decay_score by a fresh decay factor, every time it's called, rather than computing a decay score from scratch off created_at. Call it twice at the same wall-clock instant and you get two different answers, because the second call's input already includes the first call's output. A test that pinned this down seeded a prior decay_score of 0.3, ran one half-life's worth of elapsed time through the function, and got 0.14999896382860867 back, when the correct answer, one clean half-life applied once, is 0.5. That number is 0.3 x 0.5 to within the microseconds of real time that elapsed inside the test: the old value compounding with a fresh decay factor instead of being replaced by one. A note that happened to get touched by two decay passes close together, or by the same pass run twice at startup, would silently rot faster than its configured half-life implied, for no reason connected to its actual age.
Put together: dead code, kind-blind, destructive, and non-idempotent. Any one of those alone is a bug. All four in the same twelve-line function is what happens when a fallback mechanism gets bolted on without anyone re-reading the invariant it's supposed to respect.
The Live Path Was Worse, Because It Was Wired Up
decay_old_notes() being broken was bad but inert, nothing called it. The sibling function, purge_expired_notes(), had the opposite problem: it was correct in the sense that its code did exactly what it looked like it did, and what it did was wrong. It ran behind an environment-variable opt-in, VECTR_NOTES_TTL_DAYS, wired from app/service.py's startup path, and its body was one flat statement:
pre-fix, purge_expired_notes()DELETE FROM notes WHERE workspace = ? AND created_at < ?
No kind check at all. Turn the setting on, and every note across every kind ages out on the same clock, directives and revoked-note deterrents included.
That second case matters as much as the first: a revoked note isn't dead weight, its entire job is to keep rendering as a warning, "previously believed X, don't re-derive this without checking," specifically so the agent doesn't wander back into a mistake it already made once. A TTL purge that can't tell a revoked deterrent from an ordinary stale note will happily delete the thing whose sole purpose is to persist.
The call site had its own smaller smell, a pattern I've caught in my own code more than once: it parsed the environment variable as a float inside except (ValueError, Exception). ValueError is already a subclass of Exception, so the clause is redundant, and worse, it swallows every other kind of failure the same way it swallows a bad float. The fix split it in two: a ValueError guard around the parse, so a malformed value logs a clear warning and does nothing, and a separate except Exception around the actual purge, so an unrelated failure gets its own trace instead of being folded into "not a valid float."
Two Tables, One Reason Per Number
The fix adds a memory_decay section to agent/config.yaml with two per-kind tables, one for the ranking signal, one for the visibility signal:
agent/config.yaml, memory_decayhalf_life_days_by_kind: directive: null operational: 14 task: 21 gotcha: 30 finding: 30 reference: 45 decision: 60 ttl_days_by_kind: directive: null operational: 60 task: 90 gotcha: 120 finding: 120 reference: 180 decision: 240
null means exempt from that mechanism at any age, full stop. Today exactly one kind is marked null in both tables: directive. And the exemption is written to win even over an explicit operator override. If you set VECTR_NOTES_TTL_DAYS=1 to aggressively prune a noisy workspace, a directive still doesn't expire, because the null baseline for that kind is checked before the override is applied, not after. An operator can tighten every kind's retention window from the outside, but they cannot accidentally tighten the one kind whose entire purpose is to never silently disappear.
The ordering of the other six numbers isn't arbitrary; it encodes a real claim about what each kind of note is for:
operationaldecays fastest (14-day half-life, 60-day TTL): a fact about the environment, "the build needs JDK 21," is exactly the thing most likely to have moved out from under it. Closest of any kind to what a cache TTL is actually good at.taskis next (21 / 90): a checkpoint on work in flight. Work finishes or gets abandoned, and either way the checkpoint stops being the current picture.gotchaandfindingsit together (30 / 120): a longer shelf life than a checkpoint, but still operational-grade rather than a deliberate decision.reference(45 / 180) outlives the ranked kinds: a pointer either still resolves or it doesn't, closer to option B's territory than option A's.decisiongets the longest ranked runway (60 / 240), closest to an architectural record, meant to read as a long timeline.directiveis exempt from both: a rule a user stated once is not less true for being old.
Think of the two tables the way a library sets loan periods, not late fees. A library doesn't put every book on the same fourteen-day clock: a bestseller often gets a shorter loan because someone else is waiting on it, a reference volume gets a whole semester because it's meant to sit on a desk and get consulted for months, and the library's own catalog rules aren't due back on any date at all. operational is the bestseller, checked out and returned fast because the thing it describes moves fast. decision is the reference volume, meant to still be useful long after it was shelved. directive was never checked out on a due date in the first place, it's the standing policy the library runs on.
There's a second, quieter piece of design in how these tables get loaded that I like more than the numbers themselves: every kind in VALID_KINDS must have an entry in both tables, and the lookup is a direct dictionary subscript, MEMORY_DECAY_TTL_DAYS_BY_KIND[kind], not a .get(kind, some_default). Add a new note kind to the system without adding it to both tables in config.yaml, and the import fails with a KeyError at process startup, not a silent fallback to some default half-life nobody chose on purpose. Retention policy for a new memory kind isn't optional homework you can forget to do. It's a load-bearing decision the system refuses to start without.
Two Passes, Doing Genuinely Different Jobs
The reason the fix ships as two functions instead of one is that decay-for-ranking and decay-for-visibility are not the same operation wearing different clothes. They have different blast radii, and the fix treats them accordingly.
decay_old_notes() runs unconditionally, every time the service starts up, no environment flag required. It's safe to run unconditionally because it's non-destructive: it only ever recomputes a decay_score column that feeds a tie-break in ranking. Worst case, a note sorts a little later among equally-trusted peers. It can never make a note disappear.
purge_expired_notes() stays behind the VECTR_NOTES_TTL_DAYS opt-in, unset by default. It's the one with an observable effect on what a default recall() or fire() call actually returns, so it stays something an operator has to turn on deliberately, rather than a behavior baked into every install. Out of the box, nothing changes visibility on age alone. You have to ask for that.
The clean function bodies, comments trimmed for space, show the shape of the fix directly:
agent/working_context_store/_store.py, fixeddef purge_expired_notes(self, workspace, ttl_days=None, now=None): from agent.config import MEMORY_DECAY_TTL_DAYS_BY_KIND now = now or time.time() candidate_ids = [] for r in rows: # SELECT note_id, kind, created_at FROM notes WHERE workspace = ? baseline = MEMORY_DECAY_TTL_DAYS_BY_KIND[r["kind"] or DEFAULT_KIND] if baseline is None: continue # exempt at any age, e.g. directive effective_ttl = ttl_days if ttl_days is not None else baseline if r["created_at"] < now - effective_ttl * 86400: candidate_ids.append(r["note_id"]) states = self._note_event_states_by_ids(workspace, candidate_ids) expired_count = 0 for note_id in candidate_ids: if states.get(note_id, {}).get("state", "active") in ("expired", "revoked"): continue # already expired or revoked: no-op, not a duplicate event _append_event(conn, workspace, note_id, "expired", actor="system", reason="ttl exceeded", ts=now) expired_count += 1 return expired_count # a transition count, never a delete count
No DELETE anywhere in it. The function's job is to decide which notes cross the line and append one expired event each, skipping anything already expired or revoked so repeat calls are true no-ops rather than duplicate log entries.
decay_old_notes() fixes the compounding bug the same way, by computing fresh instead of multiplying:
agent/working_context_store/_store.py, fixeddef decay_old_notes(self, workspace, half_life_days=None, now=None): from agent.config import MEMORY_DECAY_HALF_LIFE_DAYS_BY_KIND now = now or time.time() for r in rows: baseline = MEMORY_DECAY_HALF_LIFE_DAYS_BY_KIND[r["kind"] or DEFAULT_KIND] if baseline is None: continue # exempt at any age, e.g. directive effective_half_life = half_life_days if half_life_days is not None else baseline half_life_s = effective_half_life * 86400 elapsed_s = now - r["created_at"] score = pow(0.5, elapsed_s / half_life_s) if elapsed_s > 0 else 1.0 conn.execute("UPDATE notes SET decay_score = ? WHERE workspace = ? AND note_id = ?", (score, workspace, r["note_id"]))
This is the same bug as computing compound interest by repeatedly multiplying whatever the balance currently reads, instead of computing it fresh from the principal and the number of periods elapsed. balance = balance * 1.05 run three times in a loop is not the same number as balance = principal * pow(1.05, 3) computed once, because the loop's second call already has the first call's rounding and timing baked into its input. Recomputing from a fixed starting point, principal in the interest case, created_at here, is what makes the answer depend only on how much time has actually elapsed, not on how many times the function happened to run in between.
score depends only on now and the note's unchanging created_at. Call this twice at the same clock reading and you get the same number twice, the idempotence property the original version lacked. One extra guard is worth noticing, elapsed_s > 0 else 1.0: without it, a note whose created_at is at or after now (clock skew, a note created mid-call) would compute pow(0.5, negative number), which is greater than 1.0, and rank a skewed note above a genuinely fresh one.
The Sort Key, and the Multiplying Trap
Here's the design point in this fix I think is easiest to get wrong even after you've internalized everything above: how decay_score actually enters ranking. It would be natural to multiply it against a note's semantic similarity score, treating decay as a discount factor on relevance. That's the trap. What shipped does something structurally different:
_sort_keydef _sort_key(note: WorkingNote) -> tuple: trust = 1.0 if note.kind == "task" else note.author_trust_score decay = 1.0 if note.kind == "task" else note.decay_score return (trust, decay, note.created_at, note.note_id)
The first element of that tuple, author_trust_score, is a separate axis this post doesn't get into, provenance rather than age, covered in full in the companion post linked at the end. What matters here is the position it occupies: decay_score is element two of a tuple, not a coefficient on similarity. That's a lexicographic sort key, meaning it only breaks ties within a pool that's already been assembled by relevance. A note has to clear semantic retrieval first. Only once two or more notes are roughly tied on relevance does decay_score get consulted at all, to decide which one sorts first among equals.
Multiply similarity by a decay factor instead, and a sufficiently old note becomes mathematically unreachable no matter how well it matches the query. A decision note from eight months ago that is a perfect, dead-on match for what the agent needs right now would get its score dragged toward zero by pure elapsed time, and something worse but fresher would outrank it. That's precisely the failure mode this design refuses to allow. Decay can reorder an already-retrieved pool. It can never suppress a relevant note out of that pool, and it can never pull an irrelevant note above a relevant one.
Notice too that task notes are pinned to a flat 1.0 on both axes regardless of their actual decay_score or trust value. That's deliberate and consistent with the retention table above: a task note is current-work state, and current-work state should sort first among task notes by recency, not get quietly buried by a decay computation that was never meant to apply to it in the first place. It's the same "kind changes the rule" principle as the two tables, just expressed in the ranking tie-break instead of the retention window.
Expiry as a State, Not a Hole
The word expired was added as a new entry in the note event vocabulary, alongside the six already there: created, superseded, revoked, stale_flagged, reinstated, and promoted. That's the real shape of the fix: expiry became one more transition an event-sourced log can record, not an operation that reaches outside the log and deletes something.
The practical consequence is that an expired note's row survives, its full note_events history survives, and it keeps rendering as a deterrent instead of silently vanishing:
rendered, explicit-expand path[142] [EXPIRED] [deploy, staging] Expired by age (recorded 2026-02-11, expired 2026-06-11, reason: ttl exceeded): "old operational fact". Row and event history retained — use vectr_reinstate to bring it back into active recall.
Filtering the note out of default results happens at read time, through one small helper, _exclude_expired(), called from exactly four places: recall(), the semantic recall path, path-scoped recall, and the trigger-firing path that powers automatic injection. Two other paths deliberately skip it: get_note() and format_notes_for_llm(), because those are the explicit-expand paths, and an expired note's deterrent has to stay reachable there or the whole mechanism is pointless.
I think this is the generalizable lesson of the whole fix, more than any specific number in the config tables: filter at read time, don't destroy at write time. The four call sites are a short, fixed list, easy to audit, easy to reason about. Every one of them applies the same rule, "drop anything whose folded state is expired," and every one of them is trivially wrong-able if a fifth call site gets added later and someone forgets to wire it in, which is exactly the kind of mistake a code review or a test suite can catch. Compare that to a DELETE buried in a decay pass: there's no reviewing your way out of a row that's already gone.
And because nothing is ever mutated or deleted, reversing any of this is free. vectr_reinstate on an expired note appends a reinstated event, and the fold over the log just produces a different current state on the next read. A revert of a revert costs one row in an append-only table and nothing else. That property, an undo that never has to special-case "well, what if the thing I'm undoing was itself an undo," is the actual payoff of building this on an event log in the first place rather than a mutable status column.
Fold the Event Log, Then Pick a Read Path
Add events to a note's history one at a time and watch how the folded current state changes. Then flip between the four read paths that filter expired notes by default and the two that don't, to see why the same row can be invisible on one call and fully rendered, tombstone and all, on another.
expired state gets dropped by _exclude_expired(). Every other folded state renders identically across all six read paths, because this design filters exactly one thing at exactly one boundary.What This Fix Had to Prove
The acceptance bar for the fix, stated as three concrete checks, doubles as a decent closing checklist for anyone building the same kind of mechanism:
- A
directivenote survives both the decay pass and the TTL purge at any age, including under an aggressive operator override. - An expired note stops appearing in default
recall()/fire()output, butget_note()still resolves its row, its event log still shows theexpiredtransition, andformat_notes_for_llm()still renders it as a deterrent. - Calling
decay_old_notes()twice at the same clock reading produces the identicaldecay_scoreboth times.
Recompute vs. Compound
Pick a note kind, then press "run decay pass" a few times in a row without moving the elapsed-time slider. Watch which formula holds still and which one keeps sliding, the same idempotence failure the seeded test in defect four caught.
score = pow(0.5, elapsed_days / half_life_days), recomputed from created_at every call. Buggy: decay_score = decay_score * pow(0.5, elapsed_days / half_life_days), multiplying its own prior output. Both start from a seeded decay_score of 0.3, matching the test that caught the bug.All three are about constraining what the mechanism is allowed to do, not about making it smarter. And that's deliberate, because I don't want to oversell what kind-scoped decay actually is. It's option A from that original taxonomy, the weakest signal on the list, explicitly scoped in the research as a fallback for notes that have neither a real anchor nor a revocation history. It's arithmetic on a timestamp. It doesn't know that a note is wrong. It doesn't know that the environment changed. It doesn't know that someone already corrected it. All it knows is how much time has passed, which, per the thesis this whole post opened with, isn't evidence of anything.
The stronger signals in that same taxonomy, proxy-anchor drift (hash a lockfile or a CI config, flag the note when the hash changes), explicit contradiction (contradicts=note_id, revoke with a stated reason), supersession (a newer note explicitly replaces an older one), all carry actual information about whether a note is still true. Kind-scoped decay is what you fall back to when a note has none of those signals attached to it, not a replacement for building them. The research recommendation was blunt about the ordering: proxy anchors are "the primary deterministic trigger," decay is "the residual fallback... bounds worst-case exposure." I'd rather ship the honest version of a weak signal, one that can only ever reorder a ranked pool and never delete a row, than a strong-looking signal that quietly encodes a wrong belief about what age means.
The thesis holds up under its own fix, in other words. Age still isn't evidence. What changed is that the system finally stopped pretending, four bugs deep, that it was.
Sources
- vectr source:
agent/working_context_store/_store.py(decay_old_notes,purge_expired_notes,_exclude_expired,_sort_key),agent/config.pyandagent/config.yaml(memory_decaysection),agent/working_context_store/_events.py(NOTE_EVENT_KINDS,NOTE_LIFECYCLE_STATES),app/service.py(start_background_index). - vectr commit
a97d862, "UPG-MEMORY-DECAY-KIND-SCOPED: kind-scoped, append-only memory decay/expiry." - An internal research pass that surveyed five options (A through E) for how a stored note should age out, the source of the option labels used throughout, including the prior-art comparison against mem0, Zep/Graphiti, Letta, LangMem, ChatGPT memory, and Anthropic's memory tool.