Tenant Offboarding & Data Retention Workflows

Offboarding is the moment a multi-tenant platform has to prove that a tenant boundary was real, and it sits inside the wider Multi-Tenant Compliance & Data Governance framework that governs how tenant data is held and released. Everything that was merely a filter during the customer's lifetime becomes a hard requirement the moment they leave: you must be able to enumerate their data, hand it back, and then destroy it โ€” completely, verifiably, and on a clock.

The failure mode is rarely a refusal to delete. It is incompleteness. A tenant is removed from the primary database while their rows survive in a search index, a warehouse extract, a message-queue dead-letter topic, an object-storage bucket, and eleven months of nightly backups. Each of those is a separate deletion surface with a different mechanism and a different achievable latency, and a defensible retention policy is one that states those latencies explicitly rather than pretending the delete was instantaneous everywhere.

This page defines the offboarding state machine, the final-export obligation, retention clocks per data class, staged destruction across heterogeneous stores, and the evidence artifact that closes the loop.

Prerequisites

Offboarding States & Reversibility

Offboarding is not a single delete; it is a state machine whose early states are deliberately reversible, because the most expensive incident in this area is destroying data for a customer who was in a billing dispute rather than a termination.

State Tenant access Data present Reversible Typical duration
Active Full All stores n/a Contract term
Suspended Blocked at auth All stores Yes, instantly 0โ€“30 days
Export ready Read-only export link All stores Yes 7โ€“30 days
Retention hold None Primary + archive only Yes, via archive restore Contractual, 30โ€“90 days
Purging None Being destroyed store by store No Hours to days
Destroyed None Nothing but the tombstone No Permanent

The single most valuable design decision is putting a mandatory dwell time between retention hold and purging. Suspension blocks access immediately, which satisfies the commercial need; destruction can wait out the contractual window and any dispute period. That separation is what lets you honour "delete my data" quickly in the sense the customer means โ€” no further processing โ€” without becoming irreversible before you are certain.

Step-by-Step Implementation

Step 1 โ€” Model lifecycle state on the tenant, and gate every code path on it

Offboarding state has to be enforced at the same choke point that resolves tenant identity, otherwise a background job keeps processing a suspended tenant's data long after the UI stopped showing it.

CREATE TYPE tenant_lifecycle AS ENUM
  ('active','suspended','export_ready','retention_hold','purging','destroyed');

ALTER TABLE tenant
  ADD COLUMN lifecycle       tenant_lifecycle NOT NULL DEFAULT 'active',
  ADD COLUMN lifecycle_at    timestamptz NOT NULL DEFAULT now(),
  ADD COLUMN purge_not_before timestamptz;

-- A tenant may only be purged after its contractual dwell time.
ALTER TABLE tenant ADD CONSTRAINT purge_window_required
  CHECK (lifecycle <> 'purging' OR purge_not_before <= now());

The CHECK constraint is deliberately crude: it makes premature destruction a database error rather than a policy someone has to remember. Combine it with the context-resolution middleware described in Tenant Context Injection Strategies so a non-active lifecycle short-circuits request handling before any query runs.

Step 2 โ€” Produce a signed, complete final export

The customer's right to their data outlives their subscription, and the export is also your own evidence of what existed at termination. Build it from the same per-entity registry that drives destruction, so export completeness and deletion completeness are guaranteed by construction.

// export-bundle.ts
import { createHash } from 'node:crypto';

interface EntitySpec { name: string; query: string; }

export async function buildExport(db: PoolClient, tenantId: string, specs: EntitySpec[]) {
  const manifest: Record<string, { rows: number; sha256: string }> = {};
  for (const spec of specs) {
    const { rows } = await db.query(spec.query, [tenantId]);
    const body = rows.map((r) => JSON.stringify(r)).join('\n');
    await putObject(`exports/${tenantId}/${spec.name}.jsonl`, body);
    manifest[spec.name] = {
      rows: rows.length,
      sha256: createHash('sha256').update(body).digest('hex'),
    };
  }
  const manifestBody = JSON.stringify({ tenantId, generatedAt: new Date().toISOString(), manifest }, null, 2);
  await putObject(`exports/${tenantId}/manifest.json`, manifestBody);
  return manifest;
}

Per-entity checksums are what let a customer verify they received everything, and let you answer "did you give us all of it?" months later without regenerating anything.

Step 3 โ€” Hold the retention clock in a durable timer, not a cron guess

A retention window measured in months cannot live in process memory or in a nightly job that silently stops running. Use a workflow engine with durable timers, and make the timer's expiry the only thing that can advance the state.

# offboarding_workflow.py โ€” Temporal-style durable workflow
from datetime import timedelta
from temporalio import workflow

@workflow.defn
class TenantOffboarding:
    def __init__(self) -> None:
        self._reinstated = False

    @workflow.run
    async def run(self, tenant_id: str, retention_days: int) -> str:
        await workflow.execute_activity(
            "suspend_tenant", tenant_id, start_to_close_timeout=timedelta(minutes=5)
        )
        await workflow.execute_activity(
            "build_export", tenant_id, start_to_close_timeout=timedelta(hours=6)
        )

        # Durable wait: survives deploys, restarts and quarter boundaries.
        reinstated = await workflow.wait_condition(
            lambda: self._reinstated, timeout=timedelta(days=retention_days)
        )
        if reinstated:
            await workflow.execute_activity(
                "reinstate_tenant", tenant_id, start_to_close_timeout=timedelta(minutes=5)
            )
            return "reinstated"

        await workflow.execute_activity(
            "purge_tenant", tenant_id, start_to_close_timeout=timedelta(hours=12)
        )
        return "destroyed"

    @workflow.signal
    def reinstate(self) -> None:
        self._reinstated = True

Step 4 โ€” Purge store by store, recording each completion independently

Destruction is a fan-out over heterogeneous systems with wildly different semantics. Drive it from an explicit registry so a newly added store cannot be silently omitted, and record a completion row per store so partial progress is visible.

// purge-registry.ts
type Purger = { store: string; run: (tenantId: string) => Promise<number> };

export const purgers: Purger[] = [
  { store: 'postgres',   run: purgePrimaryRows },      // ON DELETE CASCADE from tenant root
  { store: 'redis',      run: purgeSessionKeys },      // SCAN + UNLINK on sess:{tenant}:*
  { store: 'opensearch', run: purgeSearchIndex },      // delete_by_query on tenant_id
  { store: 's3',         run: purgeObjectsAndVersions }, // objects + non-current versions
  { store: 'warehouse',  run: purgeWarehousePartitions },
  { store: 'kafka-dlq',  run: purgeDeadLetterRecords },
];

export async function purgeAll(db: PoolClient, tenantId: string) {
  for (const p of purgers) {
    const deleted = await p.run(tenantId);
    await db.query(
      `INSERT INTO purge_receipt (tenant_id, store, deleted_count, completed_at)
       VALUES ($1, $2, $3, now())
       ON CONFLICT (tenant_id, store) DO UPDATE
         SET deleted_count = EXCLUDED.deleted_count, completed_at = EXCLUDED.completed_at`,
      [tenantId, p.store, deleted],
    );
  }
}

The relational half of this โ€” cascade design, referential ordering, and verifying that no orphan rows survive โ€” is covered in detail in per-tenant data deletion workflows.

Each store deletes at a different speed and by a different mechanism, and the registry is what makes that heterogeneity visible instead of surprising. The diagram below annotates the fan-out with the mechanism and the realistic completion latency per store.

Step 5 โ€” Handle backups by expiry, and say so in writing

You cannot surgically delete one tenant from an immutable backup without invalidating the backup. The honest, defensible approach is a documented maximum residency: backups age out on a fixed rotation, restores from a backup taken before destruction must re-apply the tombstone list, and the customer is told the number.

-- Tombstones outlive the data and are consulted after any restore.
CREATE TABLE tenant_tombstone (
  tenant_id    uuid PRIMARY KEY,
  destroyed_at timestamptz NOT NULL,
  reason       text NOT NULL,
  certificate_sha256 text NOT NULL
);

-- Run immediately after any restore from a pre-destruction backup.
DELETE FROM tenant t USING tenant_tombstone tt
WHERE t.id = tt.tenant_id;

Retention Clock Reference

Retention is per data class, not per tenant. Financial records must survive the customer's deletion request; behavioural telemetry generally must not.

Data class Typical retention after termination Legal basis Deletion latency target
Operational tenant records 0โ€“30 days (contractual dwell) Contract โ‰ค 24h after dwell
Final export bundle 30 days from generation Contract Lifecycle rule, automatic
Invoices & payment records 7โ€“10 years Tax/accounting statute Never โ€” retained by law
Security audit log 12โ€“24 months SOC 2 / ISO 27001 Pseudonymised, then aged out
Product telemetry & analytics 0 days Legitimate interest ends โ‰ค 7 days (partition drop)
Encrypted backups Rotation window (e.g. 35 days) Operational necessity Expiry only, documented
Support tickets & correspondence 12โ€“24 months Legitimate interest Redact tenant identifiers

The rows that cause arguments are invoices and audit logs, because they conflict with a plain reading of a deletion request. The resolution is to keep the record and strip the identifiability: an invoice needs a legal entity, an amount, and a date, not a list of end-user email addresses. The key-management angle โ€” destroying the tenant's data encryption key so ciphertext in long-lived stores becomes unrecoverable โ€” is developed in Per-Tenant Encryption & Key Management, and it is by far the most practical answer for archives you cannot rewrite.

Security Enforcement & Access Control

Offboarding concentrates two dangerous capabilities in one workflow: bulk export of a tenant's entire dataset, and irreversible destruction. Both need controls stronger than ordinary application authorization.

Control Implementation Boundary guarantee
Purge authorization Requires two distinct staff approvers plus a ticket reference No single operator can destroy a tenant
Export link scope Pre-signed URL, tenant-bound, โ‰ค 7 day expiry, single audience Bundle cannot be replayed or shared broadly
Export at rest Encrypted with the tenant's own data key, deleted by lifecycle rule Stale bundles are not a standing breach surface
Purge dry-run Mandatory counting pass logged before any destructive pass Wrong-tenant purges are caught before execution
Tenant id binding Every purger takes tenant_id as a bound parameter, never interpolated No pattern can widen to a second tenant
Certificate signing Signed with an offline key, hash recorded in the audit stream Deletion evidence cannot be forged or altered later
Tombstone enforcement Post-restore job deletes tombstoned tenants automatically A backup restore cannot resurrect destroyed data

The dry-run is the control that earns its keep. Running the full purger set in counting mode produces a per-store row count that a human compares against the export manifest; a mismatch means either the export was incomplete or the purge is about to over-reach, and both are far cheaper to discover before the destructive pass.

Operational Overhead & Scaling Metrics

Offboarding is low-volume and high-consequence, so the metrics that matter are about completeness and latency, not throughput.

Metric Threshold Mitigation
Stores with a purge receipt / stores in registry Must equal 1.0 Block certificate issuance; a missing receipt is a failed deletion
Time from purge start to last receipt > 24h Parallelise independent stores; investigate slow delete_by_query
Export manifest rows vs purge dry-run rows Delta > 0.5% Entity registry drift โ€” a table is exported but not purged, or vice versa
Orphan scan hits after destruction > 0 Missing cascade or a store outside the registry; fix before closing
Reinstatements after retention hold > 5% of offboardings Dwell time is too short for your dispute cycle
Age of oldest export_ready bundle > 30 days Lifecycle rule not firing; bundles are an uncontrolled copy
Backup residency vs published figure Actual > published Update the published figure or shorten rotation โ€” never the other way round

The first row is the one to alert on. A certificate of deletion issued while a store has no receipt is a false statement to a customer, which is a materially worse outcome than a late deletion. Wire certificate issuance so it reads the registry and refuses to sign unless every store has reported.

Pitfalls & Anti-Patterns

Frequently Asked Questions

How fast does a tenant's data actually have to disappear? Regulators care about the end of processing far more than the millisecond of physical destruction. Blocking access and stopping all processing on the day of the request satisfies the substance of GDPR Article 17; the physical purge can then follow your documented schedule, provided that schedule is short, published, and actually honoured. What is not defensible is an unbounded window or one you cannot evidence.

Can we ever delete a single tenant from an encrypted backup? Not surgically โ€” a backup is a consistent point-in-time image, and rewriting it destroys its integrity. The workable answer is crypto-shredding: encrypt each tenant's data with a per-tenant key, destroy the key at offboarding, and the ciphertext in every archive becomes unrecoverable without touching the archive. Otherwise, document the rotation window and re-apply tombstones after any restore.

What belongs in a certificate of deletion? The tenant identifier, the destruction timestamp, the list of stores with a per-store completion time and record count, the export manifest hash so the customer can tie it to what they received, the classes of data deliberately retained with their legal basis, and the backup residency end date. Sign it with a key that is not the application's, and record the certificate hash in the audit stream.

Should offboarding differ for a customer who churned versus one who invoked erasure rights? The mechanics are identical; the clock and the retained set differ. A churned customer usually gets the contractual dwell time and keeps their invoice history intact. An erasure request under GDPR compresses the dwell time toward zero for everything not covered by a legal retention basis, and it applies to personal data inside otherwise-retained records โ€” which is why redaction paths matter as much as deletion paths. The request-handling machinery for the latter is covered under GDPR Data Subject Requests.

How do we prove afterwards that deletion was complete? Run an independent orphan scan rather than trusting the purgers' own return values: for each store, query by the destroyed tenant identifier and assert zero results, executed by a job that does not share code with the deletion path. Store its output alongside the certificate. Self-reported success from the component that did the work is the weakest possible evidence, and it is exactly what fails under audit sampling.