I Taught My Agents to Remember. The Hard Part Was Keeping Deleted Memories from Coming Back.

I Taught My Agents to Remember. The Hard Part Was Keeping Deleted Memories from Coming Back.

By Yuriy Zhar 5 min read
Tombstones, append-only memories, physical deletion and an erasure-fence regression test for concurrent writes in Spectre Mnemonic.

One of the things I cared about most while building Spectre Mnemonic was giving agents a memory that wasn't just an endless list of messages.

I wanted to connect information, retrieve context, consolidate what was worth keeping, and let the rest fade away.

That's why Mnemonic uses ETS for active memory, a graph of associations, and persistent storage for durable records.

But the more representations you add, the harder a seemingly trivial operation becomes: deleting information.

Deleting one row is no longer enough.

The problem comes from the architecture that makes memory useful

One piece of information can produce a moment, associations, a summary, and perhaps an embedding.

When I ask the memory engine to retrieve something, it doesn't necessarily search only for the original text. It may reach that information through graph links or semantic search.

That's exactly the behavior I wanted.

But what if the information must no longer exist?

Removing the active moment isn't enough. It may already be in durable storage. Derived representations may survive. A restart might rebuild some of the state by replaying the persistent record.

I realized I needed two different operations that sound identical from the user's point of view: stop using a memory, and physically erase the data that represents it.

For the first, I use logical forgetting.

opts = [
  scope: {:project, "checkout"},
  namespace: "my_app_memory"
]

{:ok, %{moment: moment}} =
  SpectreMnemonic.signal(
    "Checkout uses provider A",
    opts ++ [persist?: true]
  )

{:ok, count} =
  SpectreMnemonic.forget(moment.id, opts)

The memory becomes logically unavailable. In append-only storage, however, the bytes of older records can remain until compaction.

A tombstone records that earlier content must no longer be treated as valid. It lets me invalidate data logically without rewriting the entire history for every change.

I like the approach. It solves only half of the problem, though.

The test I care about most doesn't start with a deletion

There's a concurrency case that particularly bothered me.

One process is erasing an account's memory. At the same time, another process is trying to append a new piece of information to that same partition.

Both operations can be legitimate from their callers' perspectives.

But their order matters enormously.

Process A               Process B

starts erasing
                         prepares a write
deletes old data
finishes
                         saves the old event

If I allow that sequence, the information I just deleted can immediately come back.

Adding another DELETE at the end isn't a solution. More delayed writes may still be on their way.

I needed a stronger boundary: after a partition has been erased, concurrent operations must not be able to recreate it accidentally.

Mnemonic exposes an explicit partition-erasure operation:

{:ok, report} =
  SpectreMnemonic.erase_partition(
    namespace: "my_app_memory",
    scope: {:subject, "account-42"},
    sealed: true
  )

With sealed: true, the partition is closed to future writes as well.

Physical erasure requires the configured storage backends to perform and verify the operation. If they can't give that guarantee, the API shouldn't pretend they did.

Trying to break the system at the worst possible moment

There's a regression test in the repository built specifically for this case.

I use a failure-injection hook to pause erasure while it's in progress. Then I launch another Task that attempts to write to the same partition.

Only then do I allow erasure to finish.

The behavior I'm testing is that the waiting write is rejected:

assert {:ok, report} =
         Task.await(erasure, 10_000)

assert report.compaction == :erased

assert {:error, :partition_erased} =
         Task.await(stale_write, 5_000)

erasure and stale_write are the two tasks prepared by the test.

I wanted to verify not only that existing data disappeared, but that a concurrent write couldn't put it back.

Replaying the storage must respect that outcome too: erased records don't reappear, and the sealed-partition marker continues to block new operations.

To me, this test is far more valuable than a simple delete → get == nil assertion.

That assertion proves I deleted something at one instant. It doesn't prove the system remains correct while other processes are still working.

One more question: when does a memory become outdated?

Not everything needs an explicit delete request.

Some information simply stops being true.

Mnemonic supports valid_from and valid_until, but even there I had to decide precisely what a date means.

If a memory is valid through August 25, should it expire at the beginning of that day or remain valid through the end?

I chose time intervals with an exclusive upper bound. When valid_until is an Elixir Date, it is normalized to 00:00 UTC at the beginning of the following day, not midnight in the user's local time zone.

So:

valid_until: ~D[2026-08-25]

This means the information remains valid on August 25 at 23:59:59 UTC, but not from 00:00 UTC on August 26. Applications using another time zone need to handle the conversion explicitly.

It's a small choice, but it makes the behavior predictable and testable.

And what about GDPR?

This is where I don't want to pretend there's a magic switch.

Erasing a partition inside Mnemonic doesn't automatically delete copies that may have ended up in external backups, application logs, exports, or remote AI providers.

The library can provide verifiable technical controls over its own storage. The host application still has to take care of everything outside that boundary.

What struck me while working on Mnemonic was how easy it is to design memory around storing and retrieving information.

Adding memory is relatively straightforward. Managing its full lifecycle is much more delicate.

And on a concurrent runtime like BEAM, deleting information isn't enough: no process should be able to put it back where it no longer belongs.


Spectre for Elixir
Recommended Tool

Spectre for Elixir

Build one Elixir agent. Give it only the powers it needs

Share this article:
Yuriy Zhar

Yuriy Zhar

github.com

Passionate web developer. Love Elixir/Erlang, Go, TypeScript, Svelte. Interested in ML, LLM, astronomy, philosophy. Enjoy traveling and napping.

Get in Touch

Have a question or want to work together? Drop a message below.

Book a Call

Stay updated

Subscribe to our newsletter and get the latest articles delivered to your inbox.

Stay curious.

Good ideas start with one more question.

Tap the rain. Make a little ripple.

Back to top