An agent with long-term memory is built to remember what people tell it. Privacy law and ordinary decency also require it to forget on request. Under the EU's GDPR, Article 17 gives people a right to erasure of their personal data in defined circumstances, and other regimes have similar rights. For a stateless chatbot, forgetting means deleting a transcript. For an agent built with the Agent Development Kit for Java, the same conversation has been copied into session events, session state, artifacts, a memory store with its embeddings and summaries, logs, traces and backups.

This article shows how to implement erasure that actually reaches all of those places. It starts with an inventory, then checks what ADK Java's interfaces can delete against the framework source. The central finding is that the memory service interface has no delete operation at all. We then build a memory wrapper that can forget, an ordered erasure orchestrator, a fix for the race where a late write brings deleted data back, and a verification step. This is engineering guidance, not legal advice; your obligations depend on your jurisdiction and lawful basis, so involve counsel. The memory tier itself is described in the ADK Java MemoryService architecture.

Advertisement

Where one sentence ends up

Suppose a user tells a support agent: my daughter has asthma, so send the replacement inhaler to my sister's address. Trace that sentence. The runner appends it to the session as an event, through whichever session service you configured. A tool might write the address into session state. If the user uploaded a prescription photo, it is an artifact. When the session closes, your code calls addSessionToMemory and the memory store keeps either the raw events or distilled facts, plus their embedding vectors. The model provider received it in every later request. Your logs and traces may hold the request body. Nightly backups hold all of the above.

So an erasure request is a fan-out problem. List every store, decide for each whether you delete, anonymise or shred, and make the process ordered, idempotent and verifiable. The best time to reduce the fan-out is before data is stored, by redacting what you do not need; see PII redaction in ADK Java. Erasure handles what you had a reason to keep.

One erasure request, every store: the orchestrator fences writes first, then deletes, then verifiesErasure requestapp, user, request idTombstone storeblock new writesErasure orchestratorordered, idempotentReceiptno personal dataArtifactsdeleteArtifactSessionsdeleteSessionMemory storeyour delete pathLogs, tracespurge jobBackupscrypto-shred keyForgettableMemoryServiceadd checks tombstones; search filters erased usersVerifierlist, search canaries, scan1 fence234567 verifyADK Java's BaseMemoryService has no delete method, so the memory store needs a wrapper you own.
The orchestrator fences new writes, deletes artifacts before their sessions, deletes memory through a wrapper, queues log and backup handling, and then verifies.

What ADK Java can delete, checked against the source

We read the interfaces in the adk-java repository's core module. The results decide the design.

InterfaceRelevant methodsCan it erase?
BaseSessionServicelistSessions(appName, userId), deleteSession(appName, userId, sessionId), both returning RxJava typesYes, per session. You enumerate a user's sessions, then delete each.
BaseArtifactServicelistArtifactKeys(appName, userId, sessionId) returning filenames, deleteArtifact(appName, userId, sessionId, filename)Yes, per file. The in-memory implementation removes every version of the file.
BaseMemoryServiceaddSessionToMemory(Session) and searchMemory(appName, userId, query) onlyNo. There is no delete method on the interface.
InMemoryMemoryServiceStores events keyed by app and user, matches by keywordsNo removal method; fine for tests, not a privacy story.

Two points follow. First, memory erasure is your responsibility. Whatever backs your memory, whether a vector database, a managed memory product or a table of distilled facts, you need a delete path, and you need to call it. If you adopt a managed memory service, check its own documentation for per-user deletion and confirm it removes embeddings and derived summaries, not just source records. Second, session deletion is per session, so your code must enumerate first. If your session service stores user-scoped or app-scoped state separately from sessions, as state keys with user: prefixes suggest, check whether deleting every session also removes that state; we did not confirm this for every implementation, so test it against yours. Artifacts have the same shape: in the in-memory implementation a filename prefixed user: lives in a user-wide bucket shared by all sessions, and listArtifactKeys returns those names alongside the session's own, so the per-session loop below reaches them only while at least one session exists. Artifacts are covered in ADK Java artifacts.

Advertisement

A memory service that can forget

Wrap your memory store in a class that implements BaseMemoryService and adds the operation the interface lacks. Two design rules make it work. Every memory row carries provenance: the app, the user and the source session id, including rows that are derived, such as summaries and extracted facts. Without provenance you cannot find derived data later; a summary that merged three users' tickets is the worst case. And every write passes a tombstone check, which is the subject of the next section.

import com.google.adk.memory.BaseMemoryService;
import com.google.adk.memory.SearchMemoryResponse;
import com.google.adk.sessions.Session;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Single;
import java.util.List;

/** A memory service that can forget. MemoryStore and Tombstones are your own interfaces. */
public final class ForgettableMemoryService implements BaseMemoryService {
  private final MemoryStore store;      // rows carry app, user AND source session id
  private final Tombstones tombstones;

  public ForgettableMemoryService(MemoryStore store, Tombstones tombstones) {
    this.store = store;
    this.tombstones = tombstones;
  }

  @Override
  public Completable addSessionToMemory(Session session) {
    // Refuse writes for a user mid-erasure, or for a session that was erased.
    return tombstones.blocks(session.appName(), session.userId(), session.id())
        .flatMapCompletable(blocked -> blocked
            ? Completable.complete()
            : store.upsertFromSession(session));
  }

  @Override
  public Single<SearchMemoryResponse> searchMemory(String appName, String userId, String query) {
    return tombstones.userErasing(appName, userId)
        .flatMap(erasing -> erasing
            ? Single.just(SearchMemoryResponse.builder().memories(List.of()).build())
            : store.search(appName, userId, query));
  }

  /** Not part of BaseMemoryService: deletes every row derived from this user's data. */
  public Completable forgetUser(String appName, String userId) {
    return store.deleteByUser(appName, userId);   // rows, embeddings, summaries
  }
}

Register the wrapper wherever you construct your runner's memory service, so the LoadMemoryTool and your session-close hook both go through it. Deleting embeddings matters as much as deleting text. A vector alone can leak information about its source, and an approximate-nearest-neighbour index may keep deleted vectors until compaction. Confirm how your store handles deletes; vector stores for semantic memory covers the options.

The re-ingestion race

The interface's own documentation says a session may be added to memory multiple times during its lifetime. That is reasonable for incremental ingestion, and it creates a race. Erasure deletes the user's memory at 10:00:00. A background job that read the session at 09:59:58 calls addSessionToMemory at 10:00:03 with a copy it already holds. The deleted facts are back, and nothing reports an error.

Fence before you delete. The orchestrator first writes a user-level tombstone meaning erasure in progress, and the wrapper refuses all writes for that user while it exists. It then records a session-level tombstone for each session it deletes, which permanently refuses that session id. When erasure completes, the user tombstone can be lifted so a returning user can build new memories, while the session tombstones keep old sessions out. Finally, schedule a second memory sweep after your longest ingestion delay, the time from a session update to its memory write, to catch any writer that slipped past the fence through a path that does not use the wrapper.

The orchestrator

Order matters. Fence first. Delete artifacts before their session, because the session id is your only key to them. Delete sessions before memory, so no session remains to be re-ingested. Then queue the stores you cannot delete synchronously. Make every step idempotent, because the job will be retried: deleting something already gone must succeed, not fail.

public final class ErasureOrchestrator {
  private final BaseSessionService sessions;
  private final BaseArtifactService artifacts;
  private final ForgettableMemoryService memory;
  private final Tombstones tombstones;
  private final PurgeQueue purges;          // logs, traces, analytics copies

  public Completable erase(String app, String user, String requestId) {
    return tombstones.beginUserErasure(app, user, requestId)          // 1. fence writes
        .andThen(sessions.listSessions(app, user))
        .flatMapCompletable(resp -> Flowable.fromIterable(resp.sessions())
            .concatMapCompletable(s -> tombstones.markSession(app, user, s.id())
                .andThen(deleteArtifacts(app, user, s.id()))           // 2. needs the id
                .andThen(sessions.deleteSession(app, user, s.id())))) // 3.
        .andThen(memory.forgetUser(app, user))                       // 4.
        .andThen(purges.enqueue(app, user, requestId))               // 5. and 6.
        .andThen(purges.scheduleSecondSweep(app, user, requestId));  // late writers
  }

  private Completable deleteArtifacts(String app, String user, String sessionId) {
    return artifacts.listArtifactKeys(app, user, sessionId)
        .flatMapCompletable(r -> Flowable.fromIterable(r.filenames())
            .concatMapCompletable(f -> artifacts.deleteArtifact(app, user, sessionId, f)));
  }
}

The code uses only interface methods confirmed in the source, plus your own Tombstones and PurgeQueue. concatMapCompletable deletes sessions one at a time, which is slower than parallel deletion but keeps load and failure handling simple. Persist the request and its progress, so a crash mid-erasure resumes rather than restarts from an unknown state, and write a receipt holding the request id, timestamps and per-store outcome, with no personal data in it.

Logs, model providers and backups

Logs and traces are the stores teams forget. If request bodies are logged, either redact them at write time, which is far easier, or index logs by user id so a purge job can find and delete the lines. Keep log retention short enough that natural expiry meets your deadline even if the purge fails.

Model providers process the conversation too. Check your provider agreement for how long prompts are retained and whether they are used for training, and choose settings that minimise retention. You cannot recall data already sent, which is another argument for redacting before the model call.

Backups are usually immutable, and restoring each one to delete a user is impractical. The standard answer is crypto-shredding. Encrypt each user's records, or at least their memory and artifact blobs, with a per-user data key that is itself wrapped by a key-management key. Erasure destroys that user's data key, which makes every backup copy unreadable without touching the backups. The envelope pattern is described in KMS envelope encryption. Also make restores safe: a restore must replay the tombstone list before the restored data goes live, or an old backup brings erased users back.

Verifying that forgetting worked

An erasure job that reports success is not proof. Add a verifier that runs after the job and again after the second sweep. It calls listSessions for the user and expects an empty list. It runs searchMemory for the user with canary queries drawn from the deleted content, such as distinctive nouns from their sessions captured, hashed, at request time, and expects no results; run it against the underlying store or after the user tombstone is lifted, because the wrapper returns nothing while the tombstone exists and the check would pass vacuously. It queries the memory store directly by user id, bypassing the wrapper, and expects zero rows. It checks that the user's data key is destroyed. And it scans a sample of recent logs for the user's identifiers. Record each check's result in the receipt.

A worked example

On day 0 a user of a pharmacy support agent asks to be forgotten. The request is authenticated, logged with an id, and the orchestrator starts. It writes the user tombstone; from this moment memory searches for the user return nothing and writes are refused. It lists four sessions. For each, it records a session tombstone, lists and deletes two artifacts, then deletes the session. It calls forgetUser, which deletes 37 memory rows including a summary derived from two of those sessions, found through provenance. It queues a log purge and destroys the user's data key.

Three minutes later, a delayed ingestion worker calls addSessionToMemory for the third session; the wrapper finds the session tombstone and drops it. The verifier passes: no sessions, no hits for the canaries searched directly in the store, zero direct rows, key destroyed, no identifiers in the sampled logs. An hour later the second sweep finds nothing. The user tombstone is lifted, the receipt is stored, and the user is told erasure is complete well inside the response deadline, which under the GDPR is generally one month under Article 12 and extendable in some cases.

Failure modes

  • Memory never deleted. The team deletes sessions and assumes memory went with them. It does not; the interface has no delete method.
  • Derived data orphaned. Summaries and extracted facts lack provenance, so nobody can find them.
  • Re-ingestion. A late addSessionToMemory restores deleted facts.
  • Artifacts stranded. Sessions were deleted first, so the artifact keys can no longer be enumerated.
  • Restore resurrection. A backup restore skips the tombstone replay.
  • Non-idempotent steps. A retry fails on the first already-deleted item and leaves the rest in place.
  • Receipts that leak. The audit trail of the erasure itself stores the data that was erased.

Trade-offs

Hard deletion is simplest to reason about but cannot reach immutable backups. Crypto-shredding reaches them, at the cost of per-user key management and an encryption layer in every store. Anonymisation keeps aggregate analytics but is hard to do well for free text, where a single sentence can identify someone. Synchronous erasure gives the user a definite answer; asynchronous erasure with a receipt handles slow stores and retries. Legal holds can require keeping data despite a request, so the orchestrator needs a way to pause with a recorded reason. Most teams combine all of these: delete live stores, shred keys for backups, and redact at ingestion so there is less to erase.

What to do next

  1. Inventory every place a user's words can land: session events and state, artifacts, memory rows, embeddings, summaries, logs, traces, analytics and backups.
  2. Add provenance (app, user, source session id) to every memory row, including derived ones.
  3. Wrap your memory store in a BaseMemoryService implementation with a forgetUser method and tombstone checks on add and search.
  4. Build the ordered, idempotent orchestrator: fence, delete artifacts, delete sessions, forget memory, queue purges, sweep again.
  5. Introduce per-user data keys for stored blobs so erasure can shred backups, and replay tombstones on every restore.
  6. Write the verifier and a receipt with no personal data, and run the whole flow against a test user in CI.
Key takeaway: Forgetting is harder than remembering for an ADK Java agent because a conversation is copied into sessions, artifacts, memory, logs and backups, and the memory service interface offers no way to delete. Own the memory delete path through a wrapper with provenance and tombstones, run an ordered and idempotent orchestrator that fences writes before deleting anything, shred per-user keys to reach backups, verify the result independently, and redact early so there is less to forget.