A year into a project, one agent's memory holds three instructions that should no longer win. It says to install packages with npm, and since June it also says to use pnpm; both are still there, and either can come back first. It holds a workaround for a library bug that was fixed in the spring, and nothing has needed it for months. It says to retry a failing integration test until it passes, and the last time the agent did that, the retries hid a real regression. Each one matches the question it answers exactly as well as on the day it was written.
The short answer: forgetting stale or incorrect instructions takes three operations, and decay is only one of them. Close an instruction when a newer one replaces it: keep it, mark the date it stopped applying and what replaced it, and make reads prefer open instructions. Decay the ones that go unused and unconfirmed for a long time, lowering their ranking with time so they stop competing at full strength. Push down an instruction that misled when the agent acted on it, with an explicit negative mark. None of the three deletes anything. Deletion stays for secrets, personal data on request, and noise nothing ever depended on.
Why a store never forgets on its own
A vector store ranks by closeness in embedding space, and age, deprecation and replacement are not dimensions of that space. Similarity has no age and no status: last year's convention matches today's query exactly as well as it ever did, and nothing in cosine distance says deprecated. The store is not broken. It answers "what is most similar?", while the agent needs "what is currently true?".
Diligence makes this worse: the more carefully agents write memory, the more retired instructions the store holds, and the higher the odds that a recall surfaces one.
Three operations, not one
Context decay lowers an entry's recall priority as time passes without it being used or confirmed, so it drifts down the ranking instead of competing at full strength. It changes ranking, not storage. Each of the three operations answers a different reason an instruction should stop winning:
| Operation | When it applies | What changes | What it must not do |
|---|---|---|---|
| Close | A newer instruction replaces it | Marked closed, with the date and its successor; reads prefer open entries | Overwrite or delete the old entry |
| Decay | Nobody uses, confirms or contradicts it for a long time | Its ranking weight falls with time since it was last used or confirmed | Delete silently |
| Push down | Acting on it failed | An explicit negative mark ranks it below the alternatives | Spread beyond the entry that misled |
| Delete | A secret, personal data on request, noise with no history | The entry is gone | Touch anything a past decision depended on |
Closing. When an instruction is replaced, do not overwrite it and do not delete it. Mark it closed, record when it stopped applying, and link its successor: "install with npm, true until 2026-06, replaced by pnpm". Reads prefer open instructions, but the closed one still exists, and that matters more than it seems: an agent asked why the January branch has a package-lock.json needs January's instruction to answer. Overwriting rewrites history; closing versions it.
Closing needs to know what replaced what, and that is known for certain only at the moment of the correction. So the correction should name the instruction it closes when it is written, rather than leave a later pass to guess from two similar texts.
Decay. Instructions that are never recalled, never confirmed and never contradicted should not keep competing at full strength forever. One way to build it on a store you control: keep a last-used and a last-confirmed date on each entry, both set when it is written. At read time, fetch more candidates than you need, multiply each retrieval score by a factor that shrinks with the time since the later of the two dates, and stop that factor at a floor above zero, so an old entry that matches a question closely can still come back. Decay should demote, not erase. Decay that quietly deletes cannot be told apart from data loss.
Pushing down. Some instructions are not old, just wrong, and you know they are wrong because acting on them failed. Those deserve an explicit negative mark that ranks them below the alternatives the next time the same question comes up. Push-down is targeted: it lands on the entry that misled. Decay is ambient: it touches everything that goes unused.
Deleting. Hard deletion keeps a narrow, real role. The test is simple: if nothing ever depended on the entry, delete it; if anything did, close it instead.
With plain files, and with a memory system
With plain files, all three are editorial habits: a status line on each entry (active, or closed, replaced by ...), a periodic review that produces a list for a person to approve instead of silent removals, and a rule never to rewrite an old entry in place. It works, and a person is the garbage collector.
With a memory system, the same three become properties to look for: statuses and validity windows on entries, decay that demotes rather than deletes, and a feedback channel for marking what misled.
Closing an instruction in code
The code uses mnemoverse 0.4.0 from PyPI, the Python SDK of Mnemoverse (the author's company), with setup on the Python SDK page. The example covers closing, on the write and on the read, plus a rating after the agent acts. The agent's own logic is left out.
from mnemoverse import MnemoClient
client = MnemoClient() # reads MNEMOVERSE_API_KEY
DOMAIN = "project:web"
# January: the instruction as it was given
old = client.write(
"Instruction (2026-01, repo: web): install packages with npm.",
concepts=["instruction", "package-manager", "npm"],
domain=DOMAIN,
)
if not old.stored:
raise SystemExit(f"Not stored: {old.reason}")
# June: the correction names the instruction it closes, in the same write
new = client.write(
"Instruction (2026-06, repo: web): install packages with pnpm, not npm. "
"Replaces the 2026-01 npm instruction.",
concepts=["instruction", "package-manager", "pnpm", "npm"],
domain=DOMAIN,
supersedes=[str(old.atom_id)],
)
if not new.stored:
raise SystemExit(f"Not stored: {new.reason}")
print(new.superseded) # the ids this write marked as replaced
# Before acting: read, and act only on instructions nothing has replaced
result = client.read("how do I install packages in the web repo", domain=DOMAIN)
current = [item for item in result.items if item.superseded_by is None]
for item in current:
print(item.content)
# After acting: rate what was used (1.0 helped, -1.0 misled)
if current:
client.feedback(
atom_ids=[item.atom_id for item in current],
outcome=1.0,
query_concepts=result.query_concepts,
domain=DOMAIN,
)
# Explaining the past: ask for replaced instructions too
history = client.read(
"which package manager did the web repo use in January",
domain=DOMAIN,
include_history=True,
)
for item in history.items:
print("replaced" if item.superseded_by else "current", item.content)
The June write names the January record in supersedes, and the API reference says what happens to a record named there: "A target is not deleted; it gains a superseded_by pointer to the new atom and stays fetchable by ID". Hiding replaced records from ordinary reads is an organisation setting, "Hide replaced memories" under Settings in the console, and it is off by default, so a read can return the January and the June instructions side by side. For that case the reference names the field to check: "while filtering is off, this field is how you tell a replaced revision from a live one". That is why the code filters on superseded_by itself, which gives the same result with the setting on or off. With it on, ordinary reads return current records only, and include_history=True makes replaced ones eligible again under the same ranking, as Reading history describes.
Ratings re-rank what comes back next. The overview states the limit of that: feedback "changes the order of the next read, not the stored text". The two stored checks are there for the write gate: a write that is too similar to the nearest memory in the same domain is not stored, and a refused write returns no id to pass to supersedes.
Check it with a control
- Write an instruction, then a correction that names it as replaced. Ask the agent how to do the task. It should follow the correction, not the original.
- Ask it to explain something that happened under the old instruction, such as the January lockfile. It should find the closed instruction and say it was replaced, which shows that closing kept the history a deletion would have lost.
- Control: ask about an instruction that was never replaced, such as how tests are run. It should come back as current. If it comes back marked as replaced, or the npm instruction comes back unmarked, the closing is not working, and the first answer proves nothing.
The library page has the same three operations in a shorter form, including what to look for in a memory system before you trust it with instructions. Which retired instruction is your agent still following?
Top comments (6)
Really good breakdown, the close/decay/push-down split is the right cut, most people collapse all three into a TTL and call it done…
We hit the same wall building cairn (a work-harness plugin for Claude Code and a handful of other agent CLIs) and ended up solving decay a little differently. Instead of decaying on the clock, every memory card has to cite its provenance, the file(s) and commit that back the claim. At recall time we run a git diff from that commit and if the cited code moved or the file is gone, the card comes back flagged stale: true with the reason attached, and the agent is told to re-verify before trusting it. So a card can sit untouched for a year and still be current, or go stale in an afternoon if someone refactors the thing it describes, which felt closer to how instructions actually rot in a codebase…
For your "push down" op we lean on a confidence grade (high/medium/low) that only a retro pass can change, never silently, and aged low-confidence cards get surfaced as compaction candidates rather than deleted. The one thing we don't have is your explicit supersedes link at write time, we've been relying on the stale flag plus the retro to catch replaced instructions, and reading this I'm not sure that's enough.
Code and docs if you want to poke at it:
Curious what you think, does code-change-based staleness hold up as a substitute for time decay in a repo-bound agent, or do you see cases where you'd still want the clock running underneath it? And is the supersedes link worth adding even when git history already tells you what replaced what?
For cards that cite code, a moved source is a better signal than age: the clock knows nothing about the file, and a diff knows when it changed. The gap is the cards no diff reaches. mem_card_create takes provenance as optional, checkCardStaleness over an empty list returns not stale, and the 90 day ageing in the memory audit counts only cards graded low. A card that no file backs and that is not graded low, such as a convention, a preference or a lesson from an incident, can neither go stale nor age. That is where a clock on last use or last confirmation is still the only signal.
The supersedes link covers a different window. Git says what replaced the code a card cites, not which card replaced which, and the two come apart between a decision and the change that carries it out. Say the team decides to move service calls from the REST client to gRPC. The card saying calls go through the REST client cites a file that will not change until the migration lands, so recall serves it as current through exactly the period when the agent writes new calls. A link written with the decision closes that window without waiting for the tree to move. It also orders two cards that contradict each other while both cite unchanged files, which no diff can do.
You read the tree closer than I did, all three of those are exactly where you said they are, and the honest answer to my own question is no, the diff alone doesn't hold up…
The part that stung is that the confirmation event you're asking for already exists and we throw the timestamp away. The retro pass re-reads every card in the phase's scope and asks whether that phase's events confirmed or contradicted it, confirmed moves the grade up a step, contradicted drops it to low and writes a corrected card. That's your "last confirmed" signal, firing on a real cadence, and the card never learns it happened, the frontmatter has created and nothing else date-shaped. So a card graded high and re-confirmed last week is indistinguishable from one graded high the day it was written and never looked at since. The fix I'm filing is a confirmed date on the card, set at write, bumped by any retro re-grade including "same grade, still true," and a second bucket in the memory audit: cards with no provenance whose last confirmation is older than the window, any grade, surfaced for the retro to re-confirm or retire. Age still only selects candidates and nothing leaves on a timer, which was the promise, but the cards your comment describes stop being invisible to it.
Last use I'm leaving out on purpose, and not because it's wrong, our recall isn't a ranked lookup. Every card in scope lands in the session banner as an index row and bodies get fetched by id, so "used" would just mean "a session happened," no signal in it. Which also surfaced a bug while I was checking: the banner tells the agent to fetch one body by id and the recall tool doesn't take an id, it takes scopes, so that call quietly returns every card in scope and the token estimate printed beside it is off by the card count. Your comment found that one by accident, it's going in the tracker today…
Your gRPC example settles the supersedes question. Same shape as the retro gap, actually: the contradicted-card path already writes the corrected card as a new card, at exactly the moment the correction knows which card it's closing, and we don't record the link. Issues in cairn have a supersedes edge and a lineage walk, cards don't. So: supersedes on card create, recall returns supersededBy on the old card the way it returns stale, the banner lists superseded cards out of the live table, and the compaction archive carries the lineage. And "drop it to low" stops being the proxy for "closed," which is your table, those were never the same operation.
For what it's worth, our own store is 57 cards and every one carries provenance, but only because the retro mints them from the ledger range and the ledger always has files and commits. The write paths with no ledger behind them, a remembered convention, a note from an incident, are where the unbacked cards would come from, and that's the population your comment is about. Thanks for doing the reading, that's two issues and a bug from one comment…
John, the retro path you describe will not reach the cards the second bucket is for. Step 3 of the retro verb re-grades with mem_card_recall scoped to the phase, and listCards keeps a card only when its scopePhase equals that phase, so a card written without one never comes back from it. The banner shows a phase card only while that phase is active and an issue card only while that issue is. A remembered convention or an incident note that should reach every session therefore has to be written with neither scope, and that same choice takes it out of every re-grade. Written through the remember verb, it takes its scope from context_get instead, so a convention remembered during a phase or an issue drops out of the banner once that scope is no longer active.
So the confirmed date will move for phase cards, in the retros that cover their phase, and stay at created for project-wide ones. One window after they are written, every unbacked project-wide card is in the second bucket, which is age again under another field name. For those cards the bucket is the only way to a confirmation. The evidence a retro gathers is the ledger of its scope, and a card with no provenance points nowhere into it, so nothing ties its new date to an event, and a yes to the approval question can move it on its own. Recording which of the two set the date would keep them apart, since a card confirmed by an event and a card someone approved again are not the same evidence.
Two for two, and the first one is embarrassing in the useful way…
I checked every repo on this machine that has a card store. Five of them, four commit every card, and the one that doesn't is cairn itself, the public one, with fifty-seven cards that have never been anywhere but this laptop, exactly as you read it off the gitignore. Every doc we ship says cards are git-committed and the remember verb even tells the user they ship with the repo, and nothing checks it, the only gitignore check we have is for the local tracker store. So the fix going in is that the server says it: card stats carry tracked, ignored and unpushed counts from git, the write result says "this card is gitignored here and will not leave this machine" when that's true, and the memory audit raises it. Whether I un-ignore cairn's own cards is a separate call, that's publishing fifty-seven files, but at least the tool will stop letting me not know.
Your reading of rule 3 as commit-and-push is already how the trust order works, I just hadn't said it that way. The handoff is a disposable cache in the home directory, and the resume path ranks tracker plus git log above the ledger above the handoff, so the thing that counts as written on another box is the tracker comment and the pushed commit, never the local file. What was missing is the server telling you when a card hasn't made that crossing yet, which is the same fix as above.
On the account key, I think you've got the split right, and it moves a decision in a phase I'm about to build. The axioms I pulled out of the six-article read went into my global Claude rules, which is to say one machine and one harness, and the Codex seat on this same box reviews code without them. The phase that writes cairn's axioms into the architecture doc now also renders them into the generated harness spine, the same AGENTS.md fragment every CLI already reads from the checkout, so they travel with the repo and a drift check keeps the copy honest. That's the project key doing the project's job. What stays in a home directory, my own preferences, voice, cross-project habits, is mine and the harness vendor's, and cairn has a decision record saying it is not a persona store, so I'd rather it never grew a second account-keyed layer, it would just be a worse copy of yours…
One question back, since your account is a team account in the example: when the 06:00 job writes a conclusion under project:support and a person at 10:00 disagrees with it, whose write wins, and does the store know the difference between what one connection established and what the team has accepted? That's the line I keep tripping over with a shared tracker, a comment from the agent and a comment from a human look identical until you check the author…
Context decay thực sự là bài toán thú vị — mình thấy nhiều team chỉ dùng TTL đơn giản (expire sau N ngày) nhưng bỏ qua yếu tố tần suất sử dụng. Một instruction được reference hàng ngày nên decay chậm hơn instruction chỉ dùng một lần cách đây 3 tháng.
Đã thử approach kết hợp: recency weight × access frequency × semantic relevance. Cụ thể, mỗi instruction có score =
e^(-λ·t) × log(1 + access_count) × similarity(current_task, instruction_context). Khi score dưới threshold thì move sang "archived" thay vì xóa hẳn — giúp rollback nếu instruction cũ bỗng trở nên relevant lại (ví dụ: feature flag rollback).Một gotcha hay gặp: instruction conflict. Instruction A nói "luôn dùng library X", instruction B (mới hơn) nói "migrate sang Y". Nếu chỉ decay theo thời gian, A vẫn có thể win trong một số context. Cần layer detection: khi thêm instruction mới, scan xem có contradict với existing active instructions không → nếu có, force decay instruction cũ nhanh hơn (tăng λ tạm thời).
Performance-wise: pre-compute scores batch mỗi giờ thay vì realtime, cache vào Redis sorted set keyed by agent_id. Query top-K instructions thành O(log N) thay vì scan toàn bộ.
Bạn có chia sẻ thêm về cách handle instruction versioning khi cùng một intent được update nhiều lần không? (vd: "dùng retry policy v1" → "v2" → "v3" — giữ bao nhiêu version, merge thế nào?) — found it via LabAgent, site: labagent .tech