User deactivation and deletion
When someone leaves, you usually want one of two things: take their access away, or permanently delete their account and private data. TOW treats these as separate actions because they have very different consequences. Access removal is reversible; account deletion is not. The API and configuration call permanent deletion "erasure": both words mean the same thing on this page.
In this release the deletion and lifecycle controls are not shown in the web
app. All operations described here run through the permission-protected admin
API (see /api/openapi.json). The one exception is the public Cancel account
deletion page, which a user can open from an emailed cancellation link.
Choose the right action
| Action | What it does | Reversible? |
|---|---|---|
| Suspend a membership | Blocks access to one organisation. Data and grants stay. | Yes; reactivate the membership. |
| Remove a membership | Ends access to one organisation and clears its grants. Shared history stays. | A new invitation is required. |
| Deactivate the account | Blocks sign-in everywhere and revokes sessions and tokens. All data stays. | Yes; reactivate the account. |
| Delete the account | Blocks sign-in immediately, then permanently deletes private data. Shared work stays, shown as Former user. | Only before processing starts. |
Use suspension or deactivation for offboarding, temporary leave, or any case where the data must remain. Use deletion only when your organisation has decided the data should be permanently removed, typically for a privacy ("right to erasure") request.
Delete organisation is a different feature: it deletes an organisation, not a user account.
Member lists keep showing lifecycle badges (Suspended, Account deactivated, Deletion pending). A deleted account appears as Former user under the Removed filter.
How deletion works
A deletion request moves through four stages:
- Locked. The moment a request is accepted, the account can no longer sign in. Sessions, tokens, and pending logins are revoked, running agent jobs are stopped, and anything the person owned operationally (assigned tickets, project lead, goal ownership) is transferred.
- Cancellation window. Self-service requests run 14 days later and the user gets a one-time cancellation link by email. An admin-created request can be scheduled immediately or for any date. An admin can also cancel any request that hasn't started processing. Cancelling restores the account, but not old sessions, tokens, or completed ownership transfers.
- Deletion. TOW deletes private data, scrubs copied identity data, verifies each step, and marks the account erased. Shared work remains, attributed to Former user.
- Backup expiry. The request stays open until every managed backup that could contain the old data has a verified destruction record. Only then is it completed.
What is deleted
- Profile, credentials, sessions, tokens, and local identity links.
- All private data: chats, drafts, private docs and knowledge, memories, preferences, snapshots, embeddings, and derived data.
- Private and profile file uploads.
- The user's entries in search indexes (verified against Meilisearch).
- Unsent emails to the user, and the user's data in managed export files.
What is kept
- Shared work, including tickets, documents, comments, and history, is attributed to a Former user tombstone. The stable internal ID is kept so shared records don't break; personal fields on them are cleared.
- Files still referenced by shared work (their names and attribution are scrubbed).
- Minimal, PII-free evidence that the deletion happened, for auditing.
What TOW cannot delete
Plan a separate process for copies outside TOW's control:
- Personal data someone typed into shared text (TOW never rewrites prose).
- Email already delivered to recipients, and exports already downloaded.
- Infrastructure logs, Git history, screenshots, and backups TOW doesn't manage.
- Corporate identity-provider accounts (LDAP, SAML, upstream OIDC).
- Portal customers and public-form submitters, who are not TOW users.
These show up in the request's result as recorded exceptions; the workflow
finishes as completed_with_exceptions rather than pretending the copies are
gone.
Request statuses
| Status | Meaning |
|---|---|
draft | Recorded by an admin; nothing has changed yet. |
scheduled | Account locked; processing starts at the scheduled time. |
processing | Deletion is running. |
blocked | Something needs an operator (see Monitoring). |
failed | A step failed; it retries automatically, or an admin retries it. |
awaiting_backup_expiry | Live data is gone; waiting for backups to expire. |
completed | Everything verified, including backup destruction. |
completed_with_exceptions | Done, but recorded copies remain (shared work, delivered email, provider retention). |
cancelled | Cancelled before processing began. |
Each request records live_erased_at (live systems verified),
backup_erasure_due_at (deadline for backup destruction), and
fully_erased_at (backups verified destroyed).
Set up deletion
Deletion is off by default (privacy.full_erasure_enabled: false) and stays
inert until a readiness check passes.
On a standard deploy-kit installation, one command asks the required questions and does the whole setup:
scripts/setup-erasure.sh
It records your retention-policy reference and backup-coverage attestation, declares email-relay and AI-provider retention, optionally turns on managed-authentik account deletion, applies Authentik's GDPR cleanup and enforces the selected event-retention maximum without lengthening a stricter existing window, runs the privacy backfill, checks readiness, and enables deletion only once readiness passes. It needs backups set up first, and the installer offers to run it at the end of a production install. Re-running it later updates your answers.
The numbered steps below are the manual path. Use them for non-standard setups (external backup systems, bare metal) or to understand exactly what the script writes; do them in order and enable deletion last.
-
Name your retention policy. Set
privacy.policy_referenceto the name or version of your organisation's data-retention policy (for exampledata-retention-2026). TOW stamps this reference into deletion evidence so every record points at the policy that governed it. Use an identifier, not the policy text itself. -
Generate two secrets. Deletion evidence is authenticated with two independent HMAC secrets (32+ characters each):
openssl rand -base64 48 # PRIVACY_IDENTITY_HMAC_SECRETopenssl rand -base64 48 # PRIVACY_LEDGER_HMAC_SECRETwarningBack both secrets up outside the application data. Losing or changing them after deletions exist makes identity and readiness checks fail.
-
Set up encrypted backups and the deletion ledger. Follow Back up and restore: generate an
agekey pair (the public key becomesPRIVACY_BACKUP_AGE_RECIPIENT; keep the private key off the server), pick a host directory for the ledger such asTOW_PRIVACY_LEDGER_DIR=/var/lib/tow-privacy, initialise the ledger, and install the shipped daily backup and pruning timers. The ledger records completed deletions so a backup restore can never silently resurrect deleted data; never delete or recreate it. -
Add the configuration. In
tow.yaml(secrets go in the environment or your secret manager, not in this file):privacy:full_erasure_enabled: false # switched on in step 7policy_reference: data-retention-2026email:relay_retention_mode: bounded_retention # for SMTP relaysrelay_retention_days: 30 # your relay's retention windowprocessors:registry:openai: bounded_retention # or zdr if you have a zero-retention contractbounded_retention_days: 30backups:coverage_mode: bundled_onlyThe
processors.registryentry declares how long your AI provider keeps request data; remove it if no AI route is configured.bundled_onlydeclares the bundled encrypted backup as your only backup system; if you run other backups, see external backup systems. -
Backfill and test. Upgrade to the latest migration, then index historical data:
docker compose run --rm backend python -m app.scripts.privacy_backfill --dry-rundocker compose run --rm backend python -m app.scripts.privacy_backfillAlso set
runtime.public_app_urlto your browser URL and send a test email from Server Settings → Email; cancellation links are delivered by email. Run one backup, prune, and restore drill. -
Check readiness. As a server admin, call
GET /api/admin/privacy/readiness, or run the same check from the deployment directory:docker compose run --rm backend python -m app.scripts.privacy_readinessFix each reported blocker until it returns
ready: true. -
Enable it. Set
privacy.full_erasure_enabled: true, restart the backend and workers, and check readiness again; a few checks (such as the ledger host path) only run once deletion is enabled. Confirmenabled: trueandready: true. -
Do a dry run. Create a self-service deletion request for a dedicated test account (non-admin, from a browser session), open the emailed cancellation link, cancel, and sign back in. This proves the email path and the cancellation window work before a real user needs them.
Turning full_erasure_enabled off later stops new requests but does not
abandon accepted ones; they continue until they finish or need an operator.
TOW gives you the deletion mechanics and the evidence trail. Your organisation still owns its retention policy, response deadlines, and systems outside TOW. The UK ICO guidance on the right to erasure is a good plain-language reference.
Configuration reference
Non-secret settings live in tow.yaml; environment values override YAML. See
Runtime configuration.
Core
tow.yaml setting | Environment override | Default | Purpose |
|---|---|---|---|
privacy.full_erasure_enabled | PRIVACY_FULL_ERASURE_ENABLED | false | Master switch for accepting deletion requests. |
privacy.policy_reference | PRIVACY_POLICY_REFERENCE | Unset | Name/version of your data-retention policy, stamped into deletion evidence. Required. |
security.privacy_identity_hmac_secret | PRIVACY_IDENTITY_HMAC_SECRET | Unset | Secret (32+ chars) authenticating identity evidence. Required; keep stable. |
privacy.backups.ledger_hmac_secret | PRIVACY_LEDGER_HMAC_SECRET | Unset | Different secret (32+ chars) authenticating the deletion ledger. Required; keep stable. |
privacy.backups.age_recipient | PRIVACY_BACKUP_AGE_RECIPIENT | Unset | Public age key for backup encryption. Keep the private key off the server. |
runtime.public_app_url | PUBLIC_APP_URL | Unset | Browser base URL used to build absolute cancellation links. |
Email retention
| Setting | Default | Purpose |
|---|---|---|
privacy.email.relay_retention_mode | unknown | Use bounded_retention for SMTP relays. unknown blocks readiness when SMTP is configured. |
privacy.email.relay_retention_days | Unset | Your SMTP relay's retention window. Required with SMTP. |
privacy.email.content_retention_days | 30 | How long local email bodies and spool files are kept at most. |
privacy.email.metadata_retention_days | 365 | How long delivery-audit rows are kept. Must be ≥ content retention. |
The console, file, and disabled email transports skip the SMTP check, but
users then get no cancellation email; an admin can always cancel a scheduled
request instead. Bare-metal deployments must run the email worker for local
retention to be enforced.
AI providers
privacy.processors.registry (PRIVACY_PROCESSOR_REGISTRY, JSON object) maps
each configured AI route to a retention mode. Route keys: openai,
openrouter, or custom:<hostname>.
| Mode | Meaning |
|---|---|
zdr | You have a contractual zero-data-retention agreement with the provider. TOW records your declaration; it cannot verify the contract. |
bounded_retention | The provider keeps data for a declared window. Requires privacy.processors.bounded_retention_days. |
delete_api | TOW should delete provider-side data itself. Not supported in this build; it blocks readiness. |
These modes are operator attestations. They can reflect provider-account
guardrails, an organisation contract, or other controls that are not repeated
in each API request. TOW requires a declaration for every active or historical
route and records it in deletion evidence, but does not second-guess a declared
zdr or bounded_retention mode based on the endpoint hostname. Keep the route
key accurate: for example, declare openrouter, not openai, when
ai.openai_base_url points to OpenRouter.
Managed Authentik
By default TOW only removes its own link to the identity provider; the provider account itself is your identity administrator's job. If TOW manages the Authentik instance, it can delete the Authentik account too:
| Setting | Default | Purpose |
|---|---|---|
privacy.identity.managed_authentik_user_deletion_enabled | false | Opt in to deleting Authentik accounts in the managed boundary. |
privacy.identity.managed_authentik_issuers | [] | Exact issuer URLs TOW is allowed to manage. |
privacy.identity.authentik_event_retention_days | Unset | Required maximum Authentik event retention (1–30 days). The standard erasure setup applies this value to the bundled Authentik instance. |
When enabled, also set AUTHENTIK_PUBLIC_URL and AUTHENTIK_BOOTSTRAP_TOKEN
plus AUTHENTIK_INTERNAL_URL for a directly reachable API route. The standard
Compose kit sets the internal route to http://authentik-server:9000. TOW
verifies Authentik's GDPR cleanup setting before accepting requests. Corporate
LDAP, SAML, and upstream OIDC accounts are never touched.
Backup coverage and the deletion ledger
Deletion can only complete when TOW knows what backups exist:
| Setting | Default | Purpose |
|---|---|---|
privacy.backups.coverage_mode | undeclared | bundled_only (the bundled scripts are your only backups) or external_contract (you registered external systems). undeclared blocks readiness. |
privacy.backups.restore_data_roots | [/app/data] | Every path replaced during a data restore. The ledger must live outside all of them. |
privacy.backups.ledger_path | /app/privacy-ledger/erasure.jsonl | Container path of the deletion ledger. |
privacy.backups.ledger_host_path | Unset | Host path declaration; Compose derives it from TOW_PRIVACY_LEDGER_DIR. |
privacy.backups.registry_head_path | Beside the ledger | Protected file preventing backup history from moving backwards. |
privacy.backups.external_systems | [] | External system declarations for external_contract mode. |
Bundled backup retention is a script argument (--retention-days or
PRIVACY_BACKUP_RETENTION_DAYS, 1–30 days, default 30); use the same value
for backup and pruning. Host backup scripts read their configuration,
including the ledger secret, from the deployment .env; see the
backup configuration reference.
external_contract is for deployments with their own backup controller. Each
declared system needs an id, contract_version, retention_days (1–30),
and true for encrypted, journal_replay, and destruction_receipts, and
must keep current backup and destruction records registered in TOW:
privacy:
backups:
coverage_mode: external_contract
external_systems:
- id: offsite-postgres
contract_version: offsite-backup-v1
retention_days: 14
encrypted: true
journal_replay: true
destruction_receipts: true
These declarations are your attestation, not something TOW can probe. Don't
use this mode without a reviewed integration runbook and a successful backup,
prune, and offline-restore drill. Bare-metal deployments need an
external_contract-grade backup system; don't copy container /app/...
paths into a bare-metal config.
Monitoring and recovery
Re-check GET /api/admin/privacy/readiness after configuration changes,
provider or worker incidents, backup failures, and restore drills.
The privacy runner writes content-free privacy_operational_alert log
records (most include key=<key> and count=<n>). Route them into your
monitoring:
| Alert key | What to do |
|---|---|
request_approaching_30_days | A request is nearing your response deadline; clear its blocker. |
blocked_request_stale, blocked_action_stale | Check readiness and the request's residual summary, fix the dependency, retry. |
action_retry_exhausted | Automatic retries stopped; treat as an incident. |
managed_backup_overdue, backup_intent_stale | Check whether backup bytes exist, then prune or reconcile records. |
journal_checkpoint_divergence | Stop all writers and restore the canonical ledger or key. Use offline restore replay only when a valid ledger is ahead of PostgreSQL. |
backup_registry_head_not_ready, backup_coverage_not_ready | Reconcile the backup-history head, coverage declaration, and destruction records. |
privacy_backfill_not_ready | Run the privacy backfill (setup step 5). |
search_erasure_verification_failed | Restore Meilisearch and its worker, then retry. |
processor_*_not_ready | Fix the AI route declaration; don't weaken it to force readiness. |
processor_retention_expired | Verify provider-side expiry through your own review; the alert is evidence, not a task. |
authentik_event_privacy_not_ready | Restore Authentik API access or fix its cleanup/retention settings. |
readiness_refresh_failed, evaluation_failed | Investigate the runner exception. |
When something goes wrong:
- A request is
blockedorfailed: fix the reported cause, then use the admin retry endpoint. Never mark a step successful directly in PostgreSQL. - Waiting on backup expiry: run the supported prune command. Never record a backup as destroyed while its bytes still exist.
- Ledger or its key missing/corrupt: keep every writer (app, search, backup, restore) offline and restore the protected copy. If none exists, contact support; never initialise a replacement ledger.
- A valid ledger is ahead of PostgreSQL: run the supported offline restore flow so deletions are replayed before services start.
- Cancellation email didn't arrive: an admin can cancel any request that hasn't started processing.
If Meilisearch is configured but down, deletion waits instead of reporting success. Forced completion, editing Former user records, or replacing the ledger are incident actions, not shortcuts.