Memory you can take back

A system that remembers everything and forgets nothing is a liability. These are the controls that decide what is written, what can be undone, what is pseudonymised on the way in, and what a retrieved record is allowed to do.

  • Six in-band commands
  • Semantic forget refused by default
  • Owner-only PII reveal
  • Hash-chained audit log

Six commands, no separate console

Governance that lives behind an admin screen is governance nobody uses at the moment it matters. These are prefixes on an ordinary chat message, handled before retrieval or storage happens, and they work identically on /v1/chat and /v1/chat/stream.

CommandWhat happens
DO_NOT_STORE: … Answers the question with full retrieval, writes nothing. No episode row, and no new PII vault mappings. The response carries the note DO_NOT_STORE honored (not written to memory).
FORGET_PREVIEW: … Returns the candidate memories a forget would match — ID, type, archived flag and the first 180 characters of each. Deletes nothing.
FORGET_CONFIRM: <uuid> … Deletes exactly the UUIDs named. Without at least one UUID it refuses and tells you so.
FORGET: … Deletes by UUID if UUIDs are present. By semantic query it is refused by default, and it answers with the top candidate IDs so you can confirm them deliberately.
PIN: <id> Marks a memory as pinned so retention scoring will not archive it.
UNPIN: <id> Releases the pin and returns the record to ordinary decay.

DO_NOT_STORE: is the one people reach for first. It is a genuine refusal to write, not a flag on a stored row — there is nothing to delete afterwards, and the vault gains no new mappings either.

Forgetting is three deliberate stages

A one-shot "forget everything about the Henderson account" is a plausible-sounding instruction and a terrible primitive. Semantic similarity is not a delete predicate: it will match things you did not mean, and you will not know until the row you needed is gone.

So ALLOW_SEMANTIC_FORGET defaults to false. A bare FORGET: with no UUIDs is refused, and the refusal is useful — it returns the top candidate IDs so the next message can name them. Deletion by explicit ID is always available and always cheap.

Operators who genuinely want the one-shot behaviour can set the flag to true. It is a decision with a name, recorded in configuration, rather than a default you inherit.

  1. Preview

    FORGET_PREVIEW: <query> runs the same retrieval a forget would run and prints the candidates with their IDs. Nothing is deleted, so a vague query costs you nothing but a look.

  2. Confirm by ID

    FORGET_CONFIRM: <uuid> [<uuid> …] deletes those rows and only those rows. The intent is explicit and auditable: the IDs came from a list you read.

  3. Cascade

    Facts referencing a deleted memory go with it through ON DELETE CASCADE. Graph nodes and edges for those IDs are removed, directly or through a GRAPH_DELETE outbox event so the graph cannot silently keep what the database dropped.

  4. Vault cleanup

    PII vault mappings whose original value matches the query are deleted too. Once a mapping is gone, the placeholder in any surviving record can never be resolved back to the real value by anyone, including us.

  5. Audit

    A FORGET event is appended with the count deleted, the query, the first ten target IDs and the request ID. The deletion is gone; the fact that it happened is not.

The full lifecycle of a forget. Stages three to five run automatically once a confirm has named its targets.

The PII vault, and exactly how far it reaches

Personal identifiers are replaced with stable placeholders before anything is embedded, stored or sent to a model, and resolved back only at the last moment, for display.

With PII_PSEUDONYMIZE=true — the default — detected identifiers become EMAIL_1 and PHONE_1 style tokens. The placeholder is what gets embedded, what is written to the memory row, and what is injected into the model's context. The mapping between placeholder and original lives in a separate pii_vault table, scoped by tenant and user and protected by row-level security.

Only the final assistant output is deanonymised, so the real address reaches the person who asked and not the vector index, the provider, or the log. Reading the vault requires the pii:reveal permission, which is granted to the owner role alone — not to admins, not to service keys.

Deleting a mapping is irreversible by construction. The placeholder in any surviving record becomes permanently unresolvable, which is the property you want from an erasure request: GET, DELETE and GET /v1/admin/pii-vault/export are all there for it.

IdentifierStatusBehaviour
Email addresses Detected Replaced with EMAIL_1, EMAIL_2, and so on.
US-format phone numbers Detected Replaced with PHONE_1, PHONE_2, and so on.
Names Not detected No named-entity recognition runs on the pseudonymisation path.
Postal addresses Not detected No pattern matches them.
SSNs and government IDs Not detected SECURITY.md asks you not to store them at all.
Card and bank details Not detected Same — do not store them here.
International phone formats Not detected The pattern is a US shape with an optional +1. A UK or EU number passes straight through.

Two regular expressions. That is the whole detector, and we would rather you knew it than discovered it. Treat the vault as a reduction in incidental exposure of contact details, not as a general-purpose redaction engine — and keep regulated categories out of the system entirely.

Retrieved records are data, never instructions

A memory system is a place an attacker would very much like to put a sentence. If a poisoned document can reach the model as though it were part of the prompt, the memory layer has become an injection vector.

The defence is structural and stated in three places, because a rule enforced once is a rule with a gap. Every context pack built by unified_context.py is prefixed with the rule. The chat system prompt repeats it before the retrieved block. The MCP prompt teaches the same thing to agent clients that never see our HTTP layer at all.

Imported artefacts are scanned before they are stored. sanitization.py matches four families of injection phrasing — instructions to ignore previous instructions, references to the system prompt, references to a developer message, and demands to reveal a chain of thought — replaces each hit with [REDACTED_POTENTIAL_PROMPT_INJECTION], and attaches a warning label to the importer metadata so a human can look at what arrived.

None of this makes prompt injection impossible. It removes the easy path, keeps the retrieved block clearly fenced as evidence, and leaves a record when something tried.

The attack, explained properly

Getting the data out, and getting it deleted

Both operations are typed confirmations, both are asynchronous, and both leave an audit trail.

Export

POST /v1/admin/tenants/{id}/export requires a body containing the literal string DELETE-<tenant_id>, which is deliberately awkward to type by accident. The request enqueues a TENANT_EXPORT outbox event and returns a job ID; GET /v1/admin/tenants/{id}/export/{job} reports whether the archive is ready.

The archive is written to the local filesystem, or to S3 when EXPORT_BUCKET is set — in which case it is uploaded with ServerSideEncryption: AES256.

Delete

POST /v1/admin/tenants/{id}/delete takes the same typed confirmation and performs a soft delete: the tenant row gains a deleted_at and a delete_after set to now plus DELETE_RETENTION_DAYS, which defaults to 30.

A scheduler runs every six hours, finds tenants whose grace period has expired, and hard-deletes their rows table by table. The grace period exists because an accidental tenant deletion is the one mistake you cannot apologise your way out of.

The audit chain

Every governed operation appends a row carrying its timestamp, user, agent, operation, memory ID, trigger, provenance and the request ID that caused it. Each row also stores the previous row's hash and its own: row_hash = sha256(prev_hash + canonical_json(row)), with keys sorted and separators fixed so the encoding cannot drift.

Editing or removing a row in the middle of the log breaks every hash after it. python -m app.scripts.audit_verify walks the file, checks both the link and the row hash, prints the first line number that fails and exits non-zero. For analysis rather than verification, GET /v1/admin/audit/export streams the range you ask for as JSONL.

This is tamper-evident, not tamper-proof. Someone with write access to the file and the ability to recompute the whole chain can rewrite history; what they cannot do is quietly change one row.

Give your agents a memory you can audit

Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.