Skip to content
Like what we’re building? Star on GitHub

Flow — org and team lifecycle: suspend, soft-delete, purge ​

An org and the teams under it share one status field written by different callers, and only an org can be scheduled for deletion and then swept away with everything its teams own.

Drawn from the code at commit f564846d3, except the write story of the create paths — the compensation of CreateOrgFor landed later, at cca8ed4a0, and the personal space's own plus the best-effort landing stamp later still, with #2655. How this page is kept true: README.

The status machine — one type, two accepted sets ​

identity.TeamStatus types both Team.Status and Org.Status. A team takes active, suspended or read_only; an org takes those three plus pending_deletion, the soft-delete state that carries a purge instant. An empty value is what every create path leaves behind — the field is omitempty and no creator writes it — and reads as active through EffectiveStatus; no reader compares Status directly.

  • The two lanes are independent writers of the same type. A team's status gates that team; the org's status gates every team under it, because the launch gate asks Team.CanLaunch and then Org.CanLaunch on the same request. Those two calls plus the webhook middleware's are the whole enforcement: Org.Suspended and Team.Suspended are defined and read by nothing, so a suspended or soft-deleted row refuses run launches and no other surface.
  • Entering suspended stamps the trio, and every other transition clears it. On a team that is one $set in PatchTeam, so a concurrent rename cannot resurrect a resumed team's SuspendedAt; the org writers are whole-document replaces, so two concurrent org edits can each carry back what the other wrote.
  • pending_deletion is the one state with a second field, PurgeAfter: MarkOrgForDeletion sets both, RestoreOrg clears both. The admin status route also accepts it — ValidTeamStatus admits the fourth value its own 400 message omits — and writes no PurgeAfter, so what that write means depends on what the row already carried. PurgeAfter nil and ListOrgsPendingPurge matches it never; a PurgeAfter left over from an earlier soft-delete and the next sweep purges the org with no grace at all, PurgeOrg's three re-asserted terms all passing. The route never clears PurgeAfter either, so leaving pending_deletion through it keeps the instant on an active row — which is where the stale one comes from; only RestoreOrg clears it.
  • A team row can also disappear with no status transition at all: DELETE /api/teams/{id} removes an empty one, and the org purge removes all of them.

Who may move it, and what refuses ​

The two enforcement points read the same predicate and disagree on a degraded store read: the launch gate fails open and says so in the log, the webhook middleware answers 503 to a machine that will redeliver. A team the store answers is GONE is not that case on either path — 403 no_workspace, and 403 tenant no longer exists. They also differ at the top: only the launch gate carries a bypass, and the synthetic identity a delivery is stamped with is never a super-admin, so the webhook lane always reads the team.

Steps ​

ActorActionCode
super-admincreate an org: the org row, the owner's org membership, then a seeded default team and its owner grant — four writes, no transaction, all four compensatedhandleAdminCreateOrg, auth.Service.CreateOrgFor, createTeamIn, provisionUndo
org adminadd a team to an org they administer, or to the JWT's active orghandleCreateOrgTeam, handleCreateTeam, CreateTeamFor
a new useropen signup, a first OIDC login granted no team, or an admin-created user: an org and a team of its own, with the owner memberships — the only writer of Personal: true, the flag the team delete answers 422; its four writes are one compensated unit, so a signup that cannot finish leaves no personal org or team holding the slugsregisterOpen, CreateUserAndPersonalTeam, createPersonalTeam, createPersonalSpaceIn, createPersonalOrg
operatorbackfill a pre-org deployment: an org per team, then relink the team — the one write that moves a team under another org's status, and the reverse unlinks itcli.MigrateTeamsToOrgs, createOrgUniqueSlug, linkTeam, ReverseTeamsToOrgs, identity.TeamPatch
team or org adminrename a team through a patch, so a rename cannot carry back the status it readhandleUpdateTeam, identity.MongoStore.PatchTeam
org adminset a team's status, with the suspension trio in the same writehandleSetTeamStatus, identity.TeamPatch
super-adminset an org's status with a whole-document replacehandleAdminSetOrgStatus, identity.MongoStore.UpdateOrg
super-adminsoft-delete an org: pending_deletion plus a PurgeAfter 24 h outhandleAdminDeleteOrg, orgDeletionGrace, MarkOrgForDeletion
super-admincancel a scheduled deletion; a no-op 200 on an org that is not pendinghandleAdminRestoreOrg, auth.Service.RestoreOrg
sweepernightly at 02:00 UTC, plus one catch-up at each lease term's start, list the orgs whose grace elapsedorgsweep.Sweeper.Run, Sweep, nextRun, ListOrgsPendingPurge
sweeperre-assert eligibility, then delete each team's live runs one by one through the run store — a terminal run only (store.RunDeletable), eligibility re-read before each — with the six S3 key spaces a run owns, then its side tables; and only once every blob is gone and the org is still eligible, each team's collections, the org-scoped ones and the reserved scopes. A failed or refused run delete or an exhausted run batch (attempts, failures included) stops the pass before any collection sweep and leaves the org pending for the next; a restore stops it with ErrNoLongerEligible; ForceSweep lets a named org's sweep go past the runs it cannot deleteorgsweep.Purger.PurgeOrg, purgeRuns, stillEligible, RunStore, ListRunIDsForPurge, RunBatch, ForceSweep, ErrPurgeDeferred, ErrNoLongerEligible, teamScopedCollections, orgScopedCollections, secrets.OrgOwnerKey, secrets.OrgTierTenantID
sweeperthe identity rows last: per team the memberships and pending invitations, then the team, then the org memberships and the orgauth.Service.DeleteOrgCascade
serverrun the sweeper on the one replica holding the org-purge-sweeper lease, campaigning on every other — the purge is idempotent, its S3 cost is notrunElectedOrgPurge, orgPurgeLeaseSpec, leaseOrgPurgeSweeper, cfg.OrgPurgeSweeper
org admindelete an empty team: probe the residue, revoke the memberships, drop the rowhandleDeleteTeam, teamResidue
engineat every launch, read the team fresh — or reuse the one webhookAuth stashed on the request — and ask both levels, unless the caller is a super-admingateLaunch, teamFromContext, orgForTeam, Team.CanLaunch, Org.CanLaunch, denyOrgSuspended
serveron an inbound delivery, check the tenant before stamping the synthetic identitywebhookAuth
studioAdmin → Organisations → status section and danger zoneOrgStatusSection.tsx, OrgDangerZone.tsx
operator CLIiterion remote teams status / delete, iterion remote admin orgs status / delete / restore — the status word is forwarded verbatim, so pending_deletion reaches the org route from a shellcmd/iterion/remote_team_lifecycle.go, cmd/iterion/remote_admin.go

Neither create path has a transaction, so each one COMPENSATES instead: a write pushes its undo onto a provisionUndo once it has answered, and a later failure runs them newest-first (compensate, bounded and detached from the caller's cancellation, every step keyed to an id that call minted). CreateOrgFor owns the undo of the org, its owner membership, the seeded team and that team's owner grant — createTeamIn is its read-free write phase, which pushes and lets the owner compensate; createPersonalTeam owns the same four for a personal space (createPersonalSpaceIn, createPersonalOrg), for all three of its callers. So no org and no team of a provisioning that could not finish holds the slug a retry asks for — unless the compensation itself fails, which the error names row by row, because that row is what the retry collides with.

What a compensation does NOT undo is the ACCOUNT row a signup wrote before its space: the identity store has no account delete, so the email stays taken and the retry answers 409 email already taken — and the account itself still SIGNS IN with that password, team-less and org-less, since no path provisions a space for an account that already exists, so an operator has to invite it to a team (an SSO first login is luckier only in that its link was written first, so its retry signs in and provisions nothing either). The landing defaults that point an account at its space, and the last-login stamp beside them, are written best-effort and logged (patchBestEffort): refusing them would answer 500 over an account that is complete and signs in, and the next sign-in resolves the active team from the membership anyway.

States ​

StatusAccepted onWritten byWhat it refusesLeaves by
""both — the value of every row nobody has set a status onno creator — every create path omits the field, so a fresh row and a pre-field row look alikenothing: EffectiveStatus reads it as activethe first status write
activeteam and orgboth status routes; RestoreOrgnothingany other status
suspendedteam and orgboth status routes, which stamp the triorun launches by a non-super-admin caller, at both enforcement points, with org_suspendedany other status; the trio is cleared
read_onlyteam and orgboth status routesthe same run launches only — no middleware blocks a mutating route, so reads and edits keep workingany other status
pending_deletionorg only — the team route 400s it, a team having no sweeperMarkOrgForDeletion with PurgeAfter; also the org status route, which writes none and keeps whatever the row heldthe same run launches, through Org.CanLaunch at its two sites and nothing elseRestoreOrg, the status route, or the purge, which deletes the row

Guards that refuse ​

  • requireSuperAdmin — 403 super-admin only on every /api/admin/orgs… route: create, update, status, delete, restore. /api/admin/ is a tenant-free prefix, so a team-less super-admin still reaches it.
  • orgAdminOfTeam or IsSuperAdmin — 403 on the team status route. A team admin suspending their own team is harmless; one resuming a suspension their org imposed would undo governance from inside the thing governed.
  • canManageOrg on the team's parent org — 403 on DELETE /api/teams/{id}; the same condition refuses a team whose OrgID is empty for every caller, super-admin included.
  • ValidTeamStatus, plus the explicit pending_deletion exclusion — 400 invalid status at the team route. The org route runs the first half only.
  • Team.Personal — 422 naming the org delete, because a personal team is deleted with its org.
  • The caller's own tenant — 409 on the active team, 409 on the active org: no self-lockout, switch first.
  • teamResidue — 409 listing what survives: repo integrations, forge connections, active runs, API keys, inbound webhooks, named secrets, the team forfait and team-authored bots. Credentials count with their USER-SCOPED rows included (CountByTeam, ListAllInTeam): the per-user list views read only team-wide rows, and a team holding only personal credentials deleted as empty would strand them in a tenant nothing can reach. A refusal list, not a cascade; inert history is deliberately not counted.
  • ghostTeamMutatingRefusal — 404 on a mutating /api/teams/{id}/… whose id resolves to no team and whose caller would otherwise pass, so no row is keyed by a spelling no reader resolves.
  • gateLaunch — 403 org_suspended when either level cannot launch, 403 no_workspace when the team is gone. Its first statement is a bypass: a super-admin, and local or filesystem mode where there is no auth store, return before any status is read, so that launch is neither suspend-checked nor metered. A degraded team read fails open and logs launching UNGATED; an org that orgForTeam cannot resolve skips the org arm with no log at all.
  • webhookAuth — 403 org suspended, 403 tenant no longer exists, 503 tenant lookup unavailable.
  • PurgeOrg — re-asserts PendingDeletion and an elapsed PurgeAfter before an irreversible delete, and logs no longer eligible instead; the same predicate is re-read before every run delete and before the first collection sweep, so a restore accepted mid-purge stops it there (RestoreOrg itself refuses nothing once PurgeAfter has passed: the runs a pass already deleted are not restored). A run that is not terminal is refused by store.RunDeletable, like every delete authority. A per-collection delete error is logged and counted zero: this sweep can under-purge, never cross a tenant.

Collections touched ​

  • Written by the lifecycle itself: orgs, teams, memberships, org_memberships, invitations, and audit_events — platform rows for the org actions, and team.status_changed written twice, tenant scope and org scope. users is read for the named owner.
  • Deleted per run by the purge, through store.DeleteRun under the team's tenant scope, before any collection is swept: the six S3 key spaces keyed by the run id — artifacts/<run>/, attachments/<run>/, tools/<run>/, ir/<run>.json with the bundle snapshots ir/<run>-bundle-<digest>.json, sessions/<run>/, runfiles/<run>/ — then the run's side tables. The key spaces name no tenant, so a run row deleted ahead of its objects would leave them orphaned for good; the order exists for that.
  • Deleted per team by the purge: the list is teamScopedCollections in sweep.go, held complete by purge_completeness_test.go — every collection the tree declares is classified there or the test fails by name. The groups: the run store and its side tables (runs, run_seq, events, interactions, user_messages, run_gitmeta, run_plans, run_notes, run_turns, run_logs, run_tags, run_route_decisions, retry_circuits), workspace memory, forge, secrets, org SSO, triggers, webhooks, the native board, schedules, config shares, notifications, plugin and bot sources, the assistant dock, model preferences, the credential pool and its usage, personal access tokens, connector connections — and the audit_events rows whose tenant_id is the team id (every auditTenant write: the team.status_changed tenant copy, member.added with the member's email), the one collection written under both scopes and listed in both tables.
  • Deleted by org id: the audit_events rows whose org_id or tenant_id is the org itself, marketplace, and the rows no tenant field reaches — oauth_credentials under each team's org-owner key and the org-tier owner key, api_keys under the org-tier tenant, llm_routing_policies by composite id, org_usage by its org| and forkauthor| id prefixes, schedule_launch_intents by the embedded schedule's tenant. A personal forfait is deliberately kept: it belongs to a user, who outlives the org.
  • Left on purpose, each with its reason in notPurged: users and oidc_links — a user outlives their orgs and may be re-invited, and the identity cascade never deleted them (purgedByCascade is held to the real DeleteOrgCascade by cascade_truth_test.go); erasing an account is an explicit gesture, not a side effect of an org purge. Also a user's sessions, session_revocations and password_resets, the TTL-reaped in-flight tickets and states, the deployment-wide leases, usage_windows, platform_settings, sent_notifications, and staged_uploads (reaped bytes-then-record by the staging sweep).
  • Named residue, bounded only by a 400-day TTL: an org_usage row whose second id segment is a TEAM id — written when the gate meters a team with no resolvable org. And the runs of a team deleted through the team door before its org was purged: teamResidue refuses a team only for what still fires, holds a credential or is an ACTIVE run, so a team with finished runs is dropped with its run rows and objects in place; once the team row is gone ListTeamsByOrg no longer names it, and the org purge, which enumerates the runs team by team, never reaches them. Those objects stay in the bucket for good. Delete the runs (DELETE /api/runs/{id}) before the team, or delete the org rather than the team, when a team has a past worth removing.
  • Without Mongo there is no org lifecycle: identity.MemoryStore mirrors PatchTeam and ListOrgsPendingPurge for tests, and the sweeper is wired only where the cloud database is.