A tenant leaves your agent platform. Maybe the contract ended, maybe they moved to a competitor, maybe they were removed for non-payment. Whatever the reason, within a fixed number of days you owe them three things: their data back in a usable form, the removal of their data from every system you run, and proof that you did both. You also owe your remaining tenants a platform where the departed tenant's credentials, schedules and webhooks can no longer act.

This article treats offboarding as an engineering process with states, not as a ticket that ends in a delete query. It is written for ADK Java deployments, where the tenant's data sits in a session service, an artifact service, a memory store and a set of side systems you built around them. It covers the lifecycle, what the ADK interfaces can and cannot delete, a purge driver you can adapt, crypto-shredding for backups, a sized worked example, and the failure modes that turn a clean exit into an incident. Erasing a single person inside a tenant that stays is a different problem, covered in memory privacy and the right to forget.

Why offboarding is not a delete call

Three properties make tenant exit harder than it looks. First, it is wide: a tenant's footprint spans every user they ever had, every session and artifact those users created, derived memory, logs, metrics, billing records, caches, queues and backups. Second, it is irreversible: once you delete, a mistaken offboarding of the wrong tenant is a data-loss incident with no undo. Third, it is contractual: the timelines and the export format usually come from the customer agreement, and the deletion obligations may also come from law, as described in GDPR and DPDP compliance for ADK Java.

The irreversibility drives the design. You want a long, cheap, reversible phase in which the tenant is switched off but nothing is gone, followed by a short irreversible phase that runs only after explicit sign-off. You also want every step to be idempotent, because a purge over hundreds of thousands of objects will be interrupted, and the only safe recovery is to run it again.

The lifecycle as a state machine

Tenant offboarding as a state machine: every arrow is a recorded, auditable transitionActiveserving trafficSuspendedadmission closedExport windowtenant pulls dataDrainedno runs in flightShreddedtenant key destroyedCertifiedreceipt issuedVerifiedrelist = zeroPurgingdirectory-drivenpurge startsreactivate (allowed until Purging)Reversible: Active, Suspended, Export window, Drained. Irreversible: Purging onward.Backups are not purged row by row: they become unreadable when the tenant key is destroyed,and they age out on their normal retention schedule.
The offboarding lifecycle. Reactivation is cheap until purging starts; after that, the only way forward is completion.
StateEntry actionExit condition
SuspendedAdmission rejects new runs; API keys disabled (not deleted); schedules pausedNotice period elapsed or export requested
Export windowExport jobs produce an archive per data class; download links issuedTenant confirms receipt, or window expires
DrainedWait until no invocation for the tenant is running; stop queue consumersIn-flight count is zero for a full timeout period
PurgingDirectory-driven delete across sessions, artifacts, memory, side storesJournal shows every user done
VerifiedRe-list everything; canary searches; side-store countsAll counts are zero
CertifiedSigned receipt listing systems, counts and timestampsReceipt delivered
ShreddedDestroy the tenant data key; backups become unreadableKey destruction confirmed by the KMS

Store the state in your tenant directory as a single field with a transition log, and make every component read it. The admission layer refuses runs in any state other than Active. Schedulers skip suspended tenants. The purge job refuses to start unless the state is Drained and an approver other than the requester has signed. These checks cost nothing and are what stops a typo in a tenant id from deleting a live customer.

What ADK Java can delete, and what it cannot

Before writing a purge, check what the interfaces actually offer. We read the ADK Java core module on the date of this article; the table lists the relevant methods.

InterfaceMethods that matterWhat it means for offboarding
BaseSessionServicelistSessions(appName, userId), deleteSession(appName, userId, sessionId)Delete is per session and listing is per user. There is no method to list the users of an app.
BaseArtifactServicelistArtifactKeys(appName, userId, sessionId), deleteArtifact(appName, userId, sessionId, filename)Delete is per file, per session. List before you delete each session.
BaseMemoryServiceaddSessionToMemory(session), searchMemory(appName, userId, query)No delete at all. Tenant deletion must go to the store behind it.

The second column hides the most important fact: ADK cannot tell you who the tenant's users were. listSessions needs a user id. If the tenant used an app name of its own, as recommended in Agent Tenant Data, the app name scopes the data, but you still need the list of user ids to reach it through the interface. That list has to come from a system you own: the identity provider, the tenant directory, or a table that your admission layer appends to on every new user id it sees. If you have none of these today, add the append-only table now; you cannot reconstruct it later from the session service alone. If your session store is a database you control, you can also delete by app name directly, but keep the interface-driven path as the verification step, because it exercises the same code paths your agents read from.

Artifacts have one subtlety. Filenames prefixed user: live in a user-wide scope and may be returned by listArtifactKeys for every session of that user. Deleting artifacts before deleting each session, as the driver below does, reaches them while a session still exists to list them. Confirm the behaviour for your artifact implementation with a test, because the interface does not promise it.

A purge driver in code

The driver below purges one tenant. It reads user ids from your directory, skips users already finished according to a durable journal, deletes artifacts then sessions for each user with bounded concurrency, deletes derived memory through your own store, and finally re-lists to verify. TenantDirectory, PurgeJournal and TenantMemoryStore are your interfaces, not ADK classes.

import com.google.adk.artifacts.BaseArtifactService;
import com.google.adk.sessions.BaseSessionService;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;

/** Deletes one tenant's ADK data. Safe to re-run after any interruption. */
public final class TenantPurgeDriver {
  private final BaseSessionService sessions;
  private final BaseArtifactService artifacts;
  private final TenantMemoryStore memory;   // BaseMemoryService has no delete
  private final TenantDirectory directory;  // every user id the tenant ever had
  private final PurgeJournal journal;       // durable per-user progress
  private final int concurrency;

  public TenantPurgeDriver(BaseSessionService s, BaseArtifactService a, TenantMemoryStore m,
                           TenantDirectory d, PurgeJournal j, int concurrency) {
    this.sessions = s; this.artifacts = a; this.memory = m;
    this.directory = d; this.journal = j; this.concurrency = concurrency;
  }

  public Completable purge(String tenantId, String appName) {
    directory.requireState(tenantId, TenantState.PURGING);  // fail closed on any other state
    return Flowable.fromIterable(directory.userIdsEverSeen(tenantId))
        .filter(userId -> !journal.isDone(tenantId, userId))
        .flatMapCompletable(userId -> purgeUser(appName, userId)
                .andThen(Completable.fromAction(() -> journal.markDone(tenantId, userId))),
            false, concurrency)
        .andThen(memory.deleteTenant(tenantId))   // rows, embeddings, summaries
        .andThen(verifyEmpty(tenantId, appName));
  }

  private Completable purgeUser(String appName, String userId) {
    return sessions.listSessions(appName, userId)
        .flatMapPublisher(r -> Flowable.fromIterable(r.sessions()))
        .concatMapCompletable(s -> purgeArtifacts(appName, userId, s.id())
            .andThen(sessions.deleteSession(appName, userId, s.id())));
  }

  private Completable purgeArtifacts(String appName, String userId, String sessionId) {
    return artifacts.listArtifactKeys(appName, userId, sessionId)
        .flatMapPublisher(r -> Flowable.fromIterable(r.filenames()))
        .concatMapCompletable(f -> artifacts.deleteArtifact(appName, userId, sessionId, f));
  }

  private Completable verifyEmpty(String tenantId, String appName) {
    return Flowable.fromIterable(directory.userIdsEverSeen(tenantId))
        .flatMapSingle(u -> sessions.listSessions(appName, u).map(r -> r.sessions().size()))
        .reduce(0, Integer::sum)
        .flatMapCompletable(n -> n == 0 ? Completable.complete()
            : Completable.error(new IllegalStateException(n + " sessions survived the purge")));
  }
}

Design notes. The journal is written only after a user's last delete succeeds, so a crash re-processes at most the users that were in flight, and re-deleting is harmless if your implementations treat a missing object as success; check that they do, and wrap them if not. The concurrency bound protects your storage backend, which other tenants still depend on. Memory goes last because the memory store is often the slowest and the least idempotent; a tenant-wide delete by tenant id is far cheaper than per-user calls, which is one more reason every memory row should carry its tenant id.

Everything outside ADK, and backups

ADK data is the visible part. The rest of the footprint lives in systems you wrote, and each needs an owner and a delete path in the same runbook:

  • Credentials: API keys, OAuth clients and webhook secrets are disabled at suspension and deleted at purge. Revoke the tenant's outbound credentials too, such as tokens your tools hold for the tenant's own systems.
  • Schedules and queues: cancel recurring jobs and drain or dead-letter queue messages addressed to the tenant, or a delayed job will recreate a session after the purge finished.
  • Logs and traces: if prompts or tool payloads are logged, either the logs carry a tenant attribute you can delete by, or retention must be short enough to state in the receipt.
  • Caches: prompt caches, embedding caches and response caches keyed by content may hold tenant text; flush by tenant prefix or document the TTL.
  • Billing: close the final period first. Usage records and invoices are usually kept for tax reasons, so retain them with personal fields removed; see Agent Tenant Billing.

Backups are the hard case. Deleting individual rows from immutable snapshots is impractical, so encrypt each tenant's data at rest under a tenant-specific data key held in a key management service, and destroy that key as the final step. This is crypto-shredding: the backup bytes still exist until normal retention removes them, but nobody can read them. It only works if the per-tenant key was in place from the start; retrofitting it means re-encrypting live data, which is a migration in its own right, similar in shape to Agent Tenant Migration.

Worked example: sizing a purge

Assume a tenant with 1,200 user ids in the directory, an average of 40 sessions per user, and an average of 3 artifacts per session. That is 48,000 session deletes and 144,000 artifact deletes, plus 48,000 artifact list calls. Suppose each call takes about 25 ms against your backend and the driver runs 16 users at a time. The work is about 240,000 calls, or 6,000 seconds of call time, which at 16-way concurrency is roughly 375 seconds of wall time. These are assumptions to replace with your own measurements, but the shape is general: purge time is dominated by artifact count, and it scales with concurrency until your storage backend pushes back.

Now the schedule. A common contract shape is a 30-day notice period, a 14-day export window and deletion within 30 days after that; take the real numbers from your agreement. Plan the purge for a low-traffic window anyway, rate-limit it below the point where other tenants' latency moves, and size the export the same way, since producing the archive reads every object once.

Failure modes

  • Wrong tenant: an operator pastes the wrong id. Mitigation: state checks, a second approver, and a dry run that prints counts and the tenant's display name before anything is deleted.
  • Incomplete user list: users created by a path that bypassed the directory are never purged, and the verifier cannot see them because it reads the same list. Mitigation: append user ids at admission, and run a store-level count by app name as an independent check.
  • Resurrection: a scheduled job, a retry queue or a late webhook creates new sessions after the purge. Mitigation: the admission layer rejects every request for a tenant not in Active, including internal callers.
  • Memory left behind: derived summaries and embeddings outlive the sessions they came from, because BaseMemoryService has no delete. Mitigation: tenant id on every memory row, and a canary search after the purge.
  • Export that leaks: archive links that never expire or are guessable. Mitigation: short-lived signed links, and delete the archive when the window closes.
  • Key destroyed too early: shredding before the purge is verified makes the live data unreadable while still present, which hides failures. Shred last.

Trade-offs

ChoiceGainCost
Interface-driven purgeWorks with any session and artifact backendNeeds a complete user list; many small calls
Store-level delete by app nameFast and complete for one backendBypasses ADK code paths; one per backend
Crypto-shreddingBackups handled without rewriting themPer-tenant keys from day one; KMS dependency
Long reversible phaseMistakes and win-back deals are cheapData held longer; must be stated in the contract

What to do next

  1. Add a tenant state field with a transition log, and make admission refuse runs for any state except Active.
  2. Start an append-only table of user ids per tenant at admission today, even if offboarding is months away.
  3. Implement the purge driver against your own session and artifact services, and test that deleting a missing object succeeds.
  4. Add a tenant id to every memory row and implement a tenant-wide delete in your memory store.
  5. List every side system (credentials, schedules, queues, logs, caches, billing) with an owner and a delete path.
  6. Put tenant data under per-tenant keys so backups can be crypto-shredded.
  7. Write the receipt template: systems, object counts before and after, timestamps, approver names.
  8. Rehearse the whole lifecycle on a synthetic tenant each quarter and time each phase.
Key takeaway: Offboarding is a lifecycle: suspend, export, drain, then purge, verify, certify and shred. Keep the reversible part long and the irreversible part gated. ADK Java deletes per session and per artifact and cannot list users or delete memory, so drive the purge from your own user directory and your own memory store, verify by re-listing, and handle backups with per-tenant keys.