brain/knowledge/decisions/000026-delete-platform-is-a-cloud-owner-action-purged-by-one-cascading-job.md
The account-settings "Delete Your Account" control becomes Delete Platform and moves to Platform Admin, where the rest of the platform-wide destructive surface lives. It is confirmed by typing the platform name, not the owner's email, and it targets the currently-active platform — a Cloud owner's other platforms are untouched. It stays Cloud-only and owner-only (the existing platformToEditMustBeOwnedByCurrentUser check on DELETE /v1/platforms/:id).
The isCloudNonEnterprisePlan plan gate is replaced by a subscription gate: a platform with a live subscription is refused with "cancel your subscription first". Once there is no active subscription, any Cloud platform owner may delete — enterprise included. Activepieces never touches Stripe during teardown; the customer and its billing history are deliberately left intact, so stripeHelper.deleteCustomer drops out of the flow — already removed from the endpoint on feat/autumn-billing-integration, which this builds on.
Teardown is two-beat. Access is cut when the request returns, and that takes four separate actions, because user.status gates exactly one of the four ways work starts here. Every member's user row goes INACTIVE (interactive login); every flow is disabled through the normal CHANGE_STATUS path, so triggerSourceService.disable unregisters webhooks and drops cron and polling schedules out of BullMQ; and the platform's API keys are deleted, because an API key authenticates as a SERVICE principal that never reads user.status and could otherwise re-enable a flow inside the window. Runs already in the queue are not cancelled — they finish as they would for any flow a user disables. The purge job is scheduled before any of this, so a failure midway still ends in a purge rather than a tenant stranded deactivated with nobody able to authenticate and retry; the owner's confirmation email is best-effort and logged, never fatal. The platform row itself is left untouched and gains no soft-delete column. The owner gets a confirmation email; other members get nothing and simply lose access.
The data is then purged ~7 days later by a single HARD_DELETE_PLATFORM job, scheduled as a one-time BullMQ job with a 7-day delay. One migration, and it deletes rather than rewires: tag and piece_tag are dropped outright — they have had no entity, no service and no reader since piece tags were removed, so two of the four blockers stop existing rather than being worked around. project and signing_key keep their RESTRICT and the job deletes them in the order the schema forces. It repeats the disable-and-drain sweep (a no-op after cut-off), then deletes piece_metadata and app_connection, then every project, then signing_key, then the tables no foreign key reaches, then the platform row — letting the 14 existing cascades clear the rest — and only then the user rows and any user_identity no surviving user references. It is deliberately not one transaction: one statement per table, each safe to run twice, so the 25-attempt retry resumes mid-list instead of holding locks across a tenant-sized delete and losing all partial progress on the first timeout. Scope is Postgres only: S3 objects, ClickHouse run logs, and Cloudflare DNS for custom domains and embed subdomains are out of scope for v1. If the job exhausts its attempts it fails like any other system job; no bespoke alerting.
The button shipped as an account-deletion affordance for Cloud freemium, but it was always a platform delete wearing the wrong label — and it does not work. Reading the schema rather than the entity files is what makes the real failure visible.
A platformId column is not a foreign key. 32 entities carry the column; only 18 FKs actually reference platform(id). Of those, 14 already CASCADE. Exactly four block the delete: project and signing_key are RESTRICT, tag and piece_tag are NO ACTION (the latter two are dropped by this work, leaving two). So teardown fails for any tenant with a signing key or a piece tag — not, as the code reads, for any tenant with a second member. Nine tables carry platformId with no FK at all (user, file, app_connection, piece_metadata, project_role, project_member, user_invitation, mcp_oauth_token, mcp_oauth_authorization_code); they neither block nor follow, they orphan.
The endpoint compounds this by deactivating only the invoking user, leaving every other member with a working login into a tenant on its way out. And the platform job waits on remainingProjects === 0 counted withDeleted(), polling behind the per-project HARD_DELETE_PROJECT jobs on a 25×60s budget. When it runs out, the platform is left half-dead — owner deactivated, projects gone, Stripe customer already deleted, platform row still present — and nobody is told.
tag and piece_tag block a delete for a feature that no longer exists; a teardown that reaches them by raw SQL is working around dead weight, and the raw SQL then breaks on any schema built by synchronize rather than migration, because a table with no entity is simply absent there. Dropping them is the smaller diff and the permanent fix. For the two that remain:project and signing_key to ON DELETE CASCADE. Cascade would make correctness the schema's job, and the ordered list is one a human maintains forever — but buying that costs a destructive migration (DROP CONSTRAINT on project and signing_key, both hot tables) to change how a once-per-tenant Cloud-only job behaves. The job already has to enumerate the unconstrained tables by hand, so the migration removes four names from a list it does not remove — same failure mode, one fewer handful of names, plus destructive DDL. Revisit if the list drifts badly; note that review caught three missing names before this even merged.user.status is read by interactive authentication and by nothing else — not by the trigger scheduler, not by the queue, not by the SERVICE principal an API key produces. Deactivating members and calling it a cut-off leaves cron, polling, webhooks, queued runs and API keys live for a week against a tenant that has been told it is closed. Each has to be closed by name.CHANGE_STATUS, not UPDATE flow SET status. The row is not what holds the webhook registration or the BullMQ schedule; triggerSourceService.disable is, and the status path is the tested way in. Writing the column directly would leave every trigger armed.INACTIVE with no job scheduled and nobody able to authenticate to retry: stranded deactivated, never purged. Scheduling first makes the worst case "purged as promised, cut off untidily" instead.platform. Deactivating the members already cuts access, and every auth guard and platform resolver in the app would otherwise need to learn about a new deleted state. Fewer read paths to get wrong.The 7-day window is reversible in practice but never offered as reversible. The rows are still there, so support can restore a platform inside it; the owner sees "This action is irreversible" and gets no undo path. Do not let that support capability leak into product copy — the moment it reads as a soft delete, people click it to try it out.
Delete order is forced by the schema, not by taste. platform.ownerId → user is RESTRICT and project.ownerId → user is NO ACTION, so the platform row must go before its owner's user row and projects before any user. There is one more trap: piece_metadata.archiveId → file is RESTRICT, and file.projectId → project is CASCADE — so a custom piece archive blocks the file delete that the project cascade triggers. piece_metadata has to go before files.
Twelve tables carry platformId with no FK, not the nine the first schema read found: variable, concurrency_pool and tool_search_index were missed and had to be added in review. All need explicit deletes in the job. project_member is already reachable (it cascades from both project and user), and file and user_invitation are covered for their project-scoped rows only — platform assets (logos, favicons) and platform-scoped invitations are not. Everything else on that list is invisible to the cascade. tag and piece_tag are gone, so the job no longer touches them.
The delete list is hand-maintained, and nothing enforces it — this is not theoretical. Three tables were already missing from the first implementation. A new table carrying platformId is invisible to the job: it orphans rows, or blocks the delete outright if it ships a RESTRICT. Both a CI guard and the cascade migration that would have made this the schema's problem were weighed and not taken, so this is a review-time obligation. The pg_constraint query in the Context section is most of the test — run it when platform deletion starts failing for some tenants and not others.
A one-time BullMQ job delayed seven days is exposed for that whole week: a queue flush or Redis loss means the platform is never purged and nothing notices, leaving members deactivated forever with their data intact. A daily sweep over deactivated platforms would be the backstop; it was weighed and not taken.
Non-Postgres artifacts outlive the platform: uploaded files and platform assets in S3, run logs in ClickHouse, Cloudflare DNS for custom domains and embed subdomains. A deleted platform's embed subdomain in particular stays claimed after the tenant is gone. Accepted for v1, and the first thing to revisit if a data-deletion commitment needs to cover it.
Because teardown is one job, whatever the per-project path did for flow side effects — flowSideEffects.preDelete, unscheduling triggers, draining queued work — has to be done platform-wide inside it. A cascading DELETE reaches no BullMQ queue; rows vanishing while schedules survive is how you get triggers firing for a flow that no longer exists. The job repeats the sweep the request already did, so it is written to be a no-op on an already-disabled flow.
Revoking the API keys is a real behaviour change, not just teardown hygiene. Any integration authenticating against this platform with an API key breaks at the moment of the request rather than at the purge. That is the intent — a key that outlives the cut-off can re-enable everything the cut-off just stopped — but it is the one thing here a user could experience as sudden.
Owner and members are deleted together, but a user_identity shared with another platform survives. Deleting a platform must never sign someone out of an unrelated one.