Organization lifecycle

An invoice goes unpaid for a quarter, or a customer’s contract ends and they ask for their data to be erased. Either way the account has to stop, for everybody in it, without reaching the people in it who also work somewhere else. Your support desk does it through the control plane, and every step leaves a reason in the trail.

An organization can be suspended or deleted. The kit holds the state, enforces it at every door a member uses, records every transition, and tells the owners. It has the shape a person’s account lifecycle has, and one rule of its own about members.

RouteScopeWhat it does
POST {base}/admin/organizations/:organizationId/suspendorganization:accounts:suspendSuspend it, until until or a reinstatement. A second call restates the reason and the end, keeping the start
POST …/suspend/previeworganization:accounts:suspendWho the suspension’s cascade would reach, with nothing applied. The suspension’s body and refusals
POST …/reinstateorganization:accounts:suspendLift the suspension. No suspension in force is a 200 that changed nothing
DELETE {base}/admin/organizations/:organizationIdorganization:accounts:deleteDelete it. Soft by default; mode: "hard" purges now
POST …/delete/previeworganization:accounts:deleteWho the deletion’s cascade would reach, soft or hard, with nothing applied. The deletion’s body and refusals
POST …/restoreorganization:accounts:deleteRestore it inside its window, with every membership it had

Each is reached only by a registered connection holding the scope it names. The two scopes are separate from each other and from every read and write scope, so a client holding neither is refused 403 on all six. An unknown :organizationId is a 404 on every one, never a 403.

Two states

StateColumnsWhat it meansEnds
Suspendedsuspended_at, suspended_untilNobody can act in it. It is gone from every member’s listAt suspended_until, by itself, or when an operator reinstates it
Deleteddeleted_at, purge_afterThe same, and its data is due for erasure once the purge window closes. Restorable, memberships and all, until thenWhen it is restored inside the window, or when the purge erases it

Both live on pithy_organization_organizations, so the read that already joins a membership to its organization carries them. A deletion outranks a suspension the organization was already under. A suspension past its suspended_until is not a suspension, and nothing has to run to end it.

What a suspension means for a member

Nobody can act in a suspended organization. A member whose session acts in it is refused 403 organization/organization_suspended on the next request, and their selection of it is cleared in the same request. A route that names the organization refuses its members the same way, and so does choosing it. params.suspendedUntil says when it ends, when it does. A stranger still gets organization/not_found: the 403 only ever answers somebody already proved to be a member, so it tells nobody about an account they could not already see.

It leaves every member’s list. GET {base} lists what a person may act in, and a suspended or deleted organization is not that. Beside the list it answers suspended: how many of the caller’s own organizations are suspended. A member whose only organization is suspended has nothing to choose, and the count is what tells a chooser they are not somebody who belongs nowhere. A deleted organization is not counted.

A member of several organizations carries on in the others, unchanged. Suspending one organization writes nothing to anybody’s account. Their memberships elsewhere, their selection of another organization, their sessions and their account all stand. The suspended one simply stops being theirs until it is reinstated.

pithy ui add react ships an organization suspended screen, and pithy ui add react-native ships the same screen for a React Native app. The scaffolded chooser sends a member there when their list is empty and suspended is not zero, and when the organization they pick was suspended after the list was drawn. Your own screens do the same with organizationRefusal(code) === "suspended", from @pithy-sh/organization/src/index.

A deleted organization is refused as one that is not there: organization/not_found, to a member and a stranger alike, and every session’s selection of it is cleared when it is deleted.

It takes nobody in. An invitation to a suspended or deleted organization reads as organization/invitation_invalid, on the accept screen’s read and on the accept itself, so no membership is written into a shut account. The offer stays pending: once the organization is reinstated or restored, the link works again until it expires.

The member cascade

A suspension or a deletion reaches the people in the organization only on purpose: members: "sole" in the body. The default, and what leaving it out means, is members: "none", which touches nobody.

sole reaches only a member whose one and only organization this is. A member of any other organization is never modified, whatever state that other organization is in. “Suspend the account and its users” written naively locks a person out of every other tenant they work in; this cannot.

It runs through the user lifecycle, not around it. A reached account is suspended, or deleted the way the organization is, soft or hard, by auth’s own transitions, with the organization’s reason: the same columns, history row, auth/admin_user_* audit event, mail and hook an operator acting on that one person would produce. A hard cascade starts each reached account’s purge, or leaves it to auth’s daily pass. An account already suspended keeps its own suspension, its reason and its end. One already deleted is left as it is.

The response says who was reached, who was not, and why. members.reached lists each account suspended or deleted. members.skipped lists each member left alone, with a reason: member_elsewhere, already_suspended, account_deleted, no_account, or failed when the user transition refused, which the log explains. The audit event carries the counts, never the people.

Preview it before it runs. POST {base}/admin/organizations/:organizationId/suspend/preview and …/delete/preview take the write’s own body, under the write’s own scope, and answer { organizationId, members }: the same reached and skipped, computed by the function the write calls, with nothing applied. members absent or none answers an empty cascade. A body the write would refuse — no reason, an end in the past, a deleted organization’s suspension, a sole cascade on a Worker with no auth composed — is refused with the same error, so a client cannot preview an act it could not then take. A preview writes nothing: no column, no history row, no mail, no hook, no audit event; the write that follows is the one on the trail. Who belongs to no other organization is a disclosure, which is why it sits behind the write’s scope. It is a snapshot: a membership added or removed between the preview and the write changes what the write does, and the write’s own response is the record of what happened. A preview never says failed, which only a write that ran can report.

Nothing undoes a cascade silently. Reinstating the organization lifts nobody’s own suspension, and restoring it restores nobody’s account. Both responses list membersStillShut: every member whose own account is still suspended or deleted. Reinstate or restore each through auth’s own routes.

Every one takes a reason

reason is required in the JSON body of all six, a DELETE included: at most 500 characters, with no email address. Each write records an organization/admin_organization_* event with the operator, the connection, the reason and the tenant; the two previews record none. A deleted organization cannot be suspended or reinstated: the refusal is 409 core/conflict, naming the restore route, and a suspension it was under is still there to lift once it is restored.

Delete, and restore

Soft, the default: shut now, gone from every list, every selection of it cleared, its memberships kept. purge_after is now plus the purge window: retention.purgeWindows.organization from the Worker’s retention block in pithy.config.ts when it names one, otherwise organization’s own retention.purgeAfterDays, 30 unless you set it. The response says purge: "scheduled".

Hard, mode: "hard": the window closes now, nothing can restore it, and the purge starts in the same call. purge is started, or pending when this Worker binds no purge Workflow, and the daily pass starts it then. A hard deletion of one already deleted softly brings its purge forward; outcome says deleted, brought_forward or unchanged.

Restore clears both columns inside the window. Its memberships were never removed, so every member sees it again. A suspension it was under is still in force, and the response says state: "suspended". Once purge_after has passed, a restore is 409 core/conflict naming the date. After the purge there is no organization: a 404, whose action says the data is gone.

GET {base}/admin/organizations says where each tenant stands: state, suspendedUntil and purgeAfter. It narrows on it with ?state=active, suspended or deleted, judged as every door judges it, and pages on nextCursor.

The record

pithy_organization_lifecycle holds every transition: the action, the actor, the reason, the time. The columns say what is true now; the table says what happened and who did it, and the two are written in one transaction. It has no foreign key to the organization, because the purge erases that row and this record has to outlive it. A transition that finds the organization already where it was asked to put it writes no row and fires no hook.

The purge

A purge erases one deleted organization from every composed capability that declares what erasing it means. It runs as a Workflow, one step per composed purge contribution for the organization subject, named for its capability, so a capability that fails retries alone and the steps that finished are not run again. A contribution that retains its rows is a step too: it records purge_retained, with the basis it gave, and erases nothing. Organization’s own step runs last, because the others may still read a membership: it deletes the organization, its memberships, invitations, ownership offer and selections, and your onDelete rows in the same batch. Then the purged record, organization/purged, and onOrganizationPurged, once.

Every organization purge can be proved, the way a person’s is. Every step also records organization/purge_step in the organization’s own audit chain, with the step’s name and whether it erased or retained, and a purge that runs out of retries records a purge_failed row and organization/purge_failed, naming the step, before the run fails. The deleted row keeps the deletion’s mode and when its purge comes due. The erasure attestation reads them: GET /control-plane/erasure/organization/:organizationId, under erasure:read, and the period read counts organization purges beside people’s.

A legal hold stops it. The daily pass starts no purge for an organization a hold stands on. A purge already running asks before each step whether one stands, and a hold found ends the run as held with nothing more erased; once the hold is released, the next daily pass starts it again and its finished steps find nothing left to erase. Holds are placed over the control plane: retention and legal holds.

Three kit capabilities declare one. Media deletes every record whose organizationId is the organization’s, with its bytes in R2, Cloudflare Images or Stream, and its dedup hashes. Storage deletes every file whose organizationId is the organization’s, with its bytes in R2 and every share link to it. Each deletes the bytes before the row, so a step that fails keeps the rows that say where the rest is, and retries. Payments declares none: an organization’s purchases and entitlements are billing records, and the purge leaves them.

A person’s purge is a different subject. Organization declares a user contribution too: it erases the person’s memberships, the organization each of their sessions acted for, every ownership offer made to or by them, and every invitation addressed to them, and exports them first, never an invitation’s token digest. An invitation they sent and the organization’s lifecycle history keep their id, which names nobody once the account is gone. An organization they were the last owner of is left without one, and an operator gives it an owner again. What each capability does with a person’s rows has the rest of the kit.

A capability that declares no contribution is not touched by the purge, whatever rows it holds. Declare one for your own tenanted tables on your app capability, beside its user contribution:

purge: [
  { subject: "organization", summary: "Deletes the organization's uploads.", erase: async ({ subject, env }) => { /* … */ } },
],

A daily cron at 02:00 UTC, the one auth’s purge uses, starts the purge for every organization past its purge_after. The instance is named for the deletion and the UTC day, so a hard deletion and the daily pass start one purge, not two.

The Workflows are yours to export, for the reason auth’s are: only the Worker that composes the capabilities has their contributions.

// apps/api/src/index.ts
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
import { runOrganizationPurge, runOrganizationPurgeDue } from "@pithy-sh/organization/src/purge/workflow";

export class OrganizationPurgeWorkflow extends WorkflowEntrypoint<Record<string, unknown>> {
  override run(event: WorkflowEvent<unknown>, step: WorkflowStep) {
    return runOrganizationPurge(this.env, event, step, NonRetryableError);
  }
}

export class OrganizationPurgeDueWorkflow extends WorkflowEntrypoint<Record<string, unknown>> {
  override run(event: WorkflowEvent<unknown>, step: WorkflowStep) {
    return runOrganizationPurgeDue(this.env, event, step, NonRetryableError);
  }
}
// apps/api/wrangler.jsonc, in each environment's stanza
"workflows": [
  { "binding": "ORGANIZATION_PURGE", "name": "acme-prod-organization-purge", "class_name": "OrganizationPurgeWorkflow" },
  { "binding": "ORGANIZATION_PURGE_DUE", "name": "acme-prod-organization-purge-due", "class_name": "OrganizationPurgeDueWorkflow" }
],
"triggers": { "crons": ["0 2 * * *"] }

Until they are bound, a deletion still shuts the organization, a hard deletion reports its purge as pending, and nothing is erased.

Who is told

The owners: the members holding the role your ownership pair confers or, with no pair, the members whose role holds organization:delete. Three templates in email, all transactional, in each owner’s own language:

  • organizationSuspended, with the reason and any end. Unlike an operator’s membership notices, this one carries the reason, because the suspended screen tells every member the owners were told why. An owner a sole cascade reached is sent their own account’s suspension notice instead, because this one says their account is unaffected.
  • organizationReinstated.
  • organizationDeletionScheduled, with the date the data is erased.

A hard deletion sends none. With no email composed nothing is sent and nothing is refused, and the audit event records mailed: null.

Config and hooks

organization({
  roles,
  // How long a deleted organization waits before the purge erases it. 30 when nothing says otherwise.
  retention: { purgeAfterDays: 30 },
  // All three send by default. Turn one off by setting it to false.
  lifecycleEmails: { organizationSuspended: true, organizationReinstated: true, organizationDeletionScheduled: true },
  onOrganizationSuspended: async (event, d1) => {},
  onOrganizationReinstated: async (event, d1) => {},
  onOrganizationDeleted: async (event, d1) => {},
  onOrganizationPurged: async (event, d1) => {},
}),

Each hook fires once per transition that happened, after it is written. A hook that throws is logged and swallowed, and the transition stands.

Two membership hooks are passed the same way, and fire on a member rather than on the organization: after a role change or the end of a membership is written and recorded, once per member it changed, through every route that makes one — a member’s own, an ownership transfer accepted or forced, and an operator’s.

organization({
  roles,
  // A role changed. `powersLost` is what the old role held and the new one does not, from the catalog's nesting; empty for a promotion.
  onMemberRoleChanged: async ({ organizationId, membershipId, userId, previousRole, role, powersLost, actor, at }, d1) => {},
  // A membership ended: removed, left (`left: true`), removed by an operator, or removed by a forced transfer.
  onMemberRemoved: async ({ organizationId, membershipId, userId, role, left, actor, at }, d1) => {},
}),

actor is { type: "member", userId } or { type: "control-plane", connectionId }. A hook that throws is logged and swallowed, and the change stands. They exist so a management client can re-check what a demoted member’s agents were granted, and revoke what a departed one authorized.

What it does not do

Delete from inside the account softly. DELETE {base}/current, a member holding organization:delete, still ends the account at once, as Using Organization describes. The window and the restore are an operator’s.

ESC