Chinese version: zh-Hans/soft-delete.md
This document answers one question: after you call a DELETE endpoint in XID, what actually happens to the data, and can it be recovered. The reader is an integrator working against the Management API, and any contributor adding a new deletable resource.
The principle: identity, authorization and audit resources are always soft deleted and filtered out of ordinary read paths. Only one-time credential material and aggregate facts with a retention window are hard deleted. The complete list of hard deletes is in "Hard delete exceptions" at the end of this document.
| Resource | Fields | Delete semantics |
|---|---|---|
| users | deleted_at, status |
Sets deleted_at = now and status = deleted. Management API list, detail, update, delete, ban, unban, bulk metadata and export all filter on both status = deleted and deleted_at IS NOT NULL. POST /v1/users/:id/restore restores a deleted user to active and clears deleted_at, after checking that no active non-deleted user already holds the same username or external_id. Platform user search, stats totalUsers and tenants userCount count active non-deleted users only. The account portal /v1/me, /v1/me/profile and the RBAC metadata helper all require the user to be active with deleted_at IS NULL. Uniqueness of username and external_id constrains active non-deleted users only, so a soft deleted user releases its identifiers |
| organizations | deleted_at, status |
Sets deleted_at = now and status = deleted. Ordinary list and detail filter deleted. POST /v1/organizations/:id/restore restores active and clears deleted_at |
| api keys | revoked_at |
A revoked key can no longer authenticate; ordinary list and detail filter revoked. An API key is a secret credential, so there is no restore |
| sessions | status |
Sets status = revoked. Management API list and detail filter revoked. Reading a cookie session and issuing a session both require the linked user to be active with deleted_at IS NULL, and revoke_all rejects a deleted user |
| mfa factors | status |
A TOTP factor is set to status = revoked; a backup code factor marks every code in the same batch as used |
| trusted devices | revoked_at |
GET filters revoked by default |
| passkey credentials | revoked_at |
DELETE sets revoked_at; sign-in and listing filter revoked |
| user identities | revoked_at |
Deleting a social connection sets revoked_at; social sign-in, SSO JIT and listing filter revoked |
| memberships | status |
DELETE sets status = inactive; list and detail default to active. Creating or restoring a membership requires the target user to be active with deleted_at IS NULL. POST /v1/organizations/:orgId/memberships/:membershipId/restore restores active |
| invitations | status |
DELETE and revoke set status = revoked; list defaults to pending and detail returns pending only. An invitation token is one-time credential material, so there is no restore |
| applications | status |
Management API DELETE sets status = deleted; OIDC, DCR and token auth accept active only. POST /v1/applications/:id/restore restores active and does not return the client secret |
| SSO connections | status |
DELETE sets status = deleted; SAML, OIDC RP and HRD accept active only. POST /v1/connections/:id/restore restores active |
| directories | status, deleted_at |
DELETE sets status = deleted and sync_status = disabled, and clears the previous-token grace fields; SCIM tokens are accepted for active directories only. POST /v1/directories/:id/restore restores active, clears deleted_at, resets sync_status = idle and returns a new SCIM token exactly once |
| roles | status, deleted_at |
DELETE sets status = deleted; list and detail default to active. POST /v1/roles/:id/restore restores active and clears deleted_at |
| permissions | status, deleted_at |
DELETE sets status = deleted; list and detail default to active. POST /v1/permissions/:id/restore restores active and clears deleted_at |
| webhooks | status |
DELETE sets status = deleted; list and detail default to active. POST /v1/webhooks/:id/restore restores active and does not return the signing secret |
| organization domains | status, deleted_at |
DELETE sets status = deleted; HRD and ordinary listing filter deleted |
| SCIM users | status, deleted_at |
DELETE sets active = false, status = deleted and deleted_at = now; SCIM list and detail filter deleted |
| SCIM groups | status, deleted_at |
DELETE clears the group members, then sets status = deleted and deleted_at = now; SCIM list and detail filter deleted |
| project grants | revoked_at, status |
DELETE and revoke set status = revoked and revoked_at = now, and cascade user_grants.revoked_at for every user grant under the same grant. Ordinary list and detail filter revoked |
| expired sessions retention | expires_at |
The hourly cron hard deletes session rows with expires_at < now through hardDeleteExpiredSessions; active and revoked states are still expressed by sessions.status |
| usage monthly retention | year_month |
The daily cron hard deletes aggregate metering facts older than the 13-month retention window through hardDeleteOldMonthlyUsage; identity, audit and authorization resources never take this path |
- Ordinary users, ordinary organizations, the account portal, the platform console, the Management API and the RBAC token metadata helper never return or count resources with
deleted_at IS NOT NULL,revoked_at IS NOT NULLorstatus = deletedby default. - The
usernameandexternal_idduplicate checks in Management APIPOST /v1/usersandPATCH /v1/users/:idmatch active non-deleted users only. The D1 unique indexesusers_tenant_username_unqandusers_tenant_external_id_unqare active-only partial unique indexes, so a soft deleted user no longer occupies either identifier. - Every authentication entry point that issues a cookie session goes through
issueSession, which checks that the linked user is active withdeleted_at IS NULL. That covers password, Magic Link, OTP, passwordless, passkey, social OAuth, SAML/SSO and the automatic sign-in after a password reset. - When platform administration needs to see deleted or revoked resources, it must go through the platform management endpoints, gated by
instance_manager. - The tenant isolation predicate must still be injected by
createTenantDb. Soft delete filtering may only append a narrowing condition; it can never replace thetenant_idcondition. TenantScopedexposes no genericdelete. A hard delete requires an explicithardDeletecall. Deleting a business resource defaults to a status field or a timestamp field.- Every restore endpoint has a cross-tenant authorization test: a cross-tenant restore returns 404 and modifies no row of the victim tenant, secrets are not echoed back, a users restore that hits an active identifier conflict returns 409, and a membership restore rejects a deleted user. The tests live in
apps/server/worker/v1/__tests__/isolation.test.ts.
| Resource | Call site | Reason | Boundary |
|---|---|---|---|
| verification tokens | auth/otp.ts, auth/magic-link.ts, me-auth/email-verify-token.ts, me-auth/passwordless.ts, me-auth/mfa-challenge.ts |
One-time authentication tokens. Once verified, expired, or past the failure limit, no replayable material may be kept | Deletes only through the current tenant query layer and by token purpose |
| password reset tokens | me-auth/password-reset.ts |
A password reset token is one-time secret material: issuing a new token invalidates the old one, and verification kills it at once | Deletes only through the current tenant query layer and by token hash |
| backup codes | auth/backup-codes.ts |
Regenerating recovery codes invalidates the previous batch; recovery codes are not retained as audit resources, the audit event is written separately | Deletes only through the current tenant query layer and by user id |
| SCIM group members | scim/groups.ts, scim/shared.ts |
The membership join table expresses the current group member set, which must be synchronized on group delete or on PATCH replace/remove | The group and the directory user remain protected by soft delete and tenant-scoped queries |
| SCIM pending members | scim/users.ts, scim/shared.ts |
Unknown-member placeholders exist only for SCIM idempotency compensation and should be cleared once the member resolves or the group membership is recomputed | Clears only the placeholder for the matching group/ref in the current tenant |
| expired sessions | crons/hourly.ts hardDeleteExpiredSessions |
The sessions table has a retention window, and an expired row can no longer take part in authentication, revocation or restore | Deletes only expires_at IS NOT NULL AND expires_at < now |
| monthly usage retention | crons/daily.ts hardDeleteOldMonthlyUsage |
Aggregate metering facts keep a 13-month window only, and past that they are not identity audit resources | Deletes only usage_monthly rows with year_month < cutoff |
| Durable Object transient state | durable-objects/* |
OAuth state, PAR, device flow, challenge, rate limit and metering sets are short-lived coordination state | Never an entry point for restoring a D1 business resource |
- Only add migrations. Never rewrite an existing baseline.
- A new soft delete field is named
revoked_atordeleted_at, typed as a Unix millisecond timestamp. - After adding the field, update the schema, the route filters, the write endpoints and the tests together.