Skip to content

Tenant lifecycle

Every tenant moves through a small, explicit set of states. Stratum models the lifecycle as a strict state machine so each transition is a named operation with predictable behavior, both for the tenant itself and for its descendants.

State Meaning Reversible
pending Created with its own schema or database, which is not yet provisioned. Not usable. Becomes active via activateTenant
active Normal, fully usable tenant. n/a
suspended Access is blocked, but the tenant and its data are intact. Yes, via resumeTenant
archived Soft-deleted: hidden from listings, deleted_at set, data retained. Yes, via resumeTenant
(purged) Hard-deleted for GDPR erasure. The row and all related data are gone. No
createTenant
│
▼
suspendTenant ┌────────┐ archiveTenant
┌─────────│ active │─────────┐
▼ └────────┘ ▼
┌───────────┐ ▲ ┌──────────┐
│ suspended │ │ │ archived │
└───────────┘ resumeTenant └──────────┘
│ │ │
└──────────────┴──────────────┘
│
purgeTenant ──▶ (row gone, irreversible)
Operation From To Notes
createTenant none active, or pending for SCHEMA_PER_TENANT / DB_PER_TENANT Parent must be active.
activateTenant pending active Call once the tenant’s schema or database exists. Parent must be active.
suspendTenant active suspended Blocks access; reversible.
resumeTenant suspended, archived active Reverses suspend and archive; clears deleted_at.
archiveTenant active, suspended archived Soft delete; reversible.
purgeTenant any (gone) GDPR Article 17 hard delete; irreversible.

deleteTenant is retained as a deprecated alias of archiveTenant: soft deleting a tenant is archiving it.

A pending, suspended or archived tenant is excluded from listTenants, getChildren, and getDescendants, its API keys do not authenticate, and getTenant throws:

  • TenantPendingError (409) for a pending tenant
  • TenantSuspendedError (403) for a suspended tenant
  • TenantArchivedError (410) for an archived tenant

listTenants(pagination, { status: "pending" }) (or GET /api/v1/tenants?status=pending) lists tenants in another state.

Pass includeArchived: true to getTenant or getDescendants when you genuinely need the row regardless of state (for lifecycle or retention passes):

await stratum.suspendTenant(tenant.id);
await stratum.getTenant(tenant.id); // throws TenantSuspendedError
await stratum.getTenant(tenant.id, true); // returns the suspended row
await stratum.resumeTenant(tenant.id); // back to active

The interesting cases are transitions on a tenant that has a subtree. The rules keep one invariant true at all times: an active tenant’s parent is always active.

  • Downward transitions block, they do not cascade. suspendTenant and archiveTenant reject with TenantHasChildrenError (409) if the tenant has any active child. Work leaf-first: suspend or archive the children before the parent.
  • Upward transitions are top-down. resumeTenant, activateTenant, createTenant, batchCreateTenants and moveTenant require the (new) parent to be active. You cannot resume a tenant whose parent is still suspended or archived, nor create or move a child under a non-active parent. Resume the parent first.
  • Purge requires an empty subtree. purgeTenant rejects while the tenant has any child of any status. Purge from the leaves up.

A SCHEMA_PER_TENANT or DB_PER_TENANT tenant is created pending. The control plane then provisions its schema or database and activates it, so POST /api/v1/tenants returns an active tenant. When you use the library directly, provision the storage yourself and then call activateTenant.

If provisioning fails, the control plane returns TENANT_PROVISIONING_FAILED with the tenant id and leaves the tenant pending. To retry, purge that tenant and create it again. Purging a tenant that is still pending removes only its rows and never drops a schema or database, because its storage was never confirmed as its own. A tenant-scoped API key with the admin scope can purge a pending tenant in its own subtree. You do not need an operator key for it.

If provisioning succeeds but activation fails, the control plane drops the schema or database it just created. It then returns TENANT_PROVISIONING_FAILED with details.stage set to "activation", and leaves the tenant pending. Purge the tenant and create it again, as above. If details.storage_removed is false, the drop also failed: drop that schema or database by hand before you create the tenant again. For a failure during provisioning, details.stage is "provisioning".

The control plane drops the storage only when it can confirm that the tenant is still pending. If it cannot read the tenant after the activation error, it keeps the schema or database and returns the original error. In that case, get the tenant with GET /api/v1/tenants/:id. If the response is the tenant, it is active and ready to use. If the response is TENANT_PENDING, purge the tenant, drop its schema or database by hand, and create it again.

// Blocked: root still has an active child.
await stratum.suspendTenant(root.id); // throws TenantHasChildrenError
// Leaf-first works.
await stratum.suspendTenant(child.id);
await stratum.suspendTenant(root.id); // ok
// Resume is top-down.
await stratum.resumeTenant(child.id); // throws while root is suspended
await stratum.resumeTenant(root.id);
await stratum.resumeTenant(child.id); // ok

Each lifecycle transition emits an event you can subscribe a webhook to: tenant.activated, tenant.suspended, tenant.resumed, and tenant.archived (plus tenant.deleted from the deleteTenant alias). See Webhooks.

A purge emits no webhook event. The purge erases the event log of the tenant with the tenant itself. tenant.purged is deprecated: purgeTenant never emits it, and the next major version removes it.