docs: app deletion design — trash, 30-day cooloff, purge
Approved in brainstorm 2026-08-04: additive deleted.json marker, restore window with hard name reservation, production flag = built-in deletion prevention (ratchet off-switch is the only off-ramp), console-only Delete now with account-deletion-grade gates, jittered auto-purge with re-read guards, and MaterializeAccounts reaping tombstoned rows (the Kass gap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+269
−0
docs/superpowers/specs/2026-08-04-deletion-policy-design.md+269 −0
diff --git a/docs/superpowers/specs/2026-08-04-deletion-policy-design.md b/docs/superpowers/specs/2026-08-04-deletion-policy-design.md| new file mode 100644 |
| index 000000000..9859a8131 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-deletion-policy-design.md |
| @@ -0,0 +1,269 @@ |
| +# App deletion — trash, cooloff, purge |
| + |
| +2026-08-04. Extends the multi-account spec (accounts already tombstone; |
| +`AccountManifest.DeletedAt`, console `handleDeleteAccount`) and the bake |
| +controls spec (2026-08-04, the production flag). Touches `internal/domains` |
| +(reconcile.go, a new trash.go), `internal/release` (purge), `internal/console` |
| +(server.go, store.go, materialize.go, templates), and `cli.go`. |
| + |
| +## 1. Problem |
| + |
| +Apps cannot be deleted at all. There is no verb, no handler, no marker — an |
| +app, once shipped, holds its name, its registry routes, its console row and |
| +its bucket keys forever. Account deletion exists and deliberately refuses any |
| +account that still holds apps, so today that refusal is a dead end: the app |
| +blocking the deletion cannot itself be removed. |
| + |
| +The gap has a second half, exposed by the Kass incident (2026-08-04, 64 junk |
| +accounts): `MaterializeAccounts` *skips* a tombstoned manifest but never reaps |
| +the row a previous start already materialized, so cleaning up the console |
| +required hand-run SQL over SSM. Deletion that is real in the bucket should |
| +become real everywhere on its own. |
| + |
| +And deletion needs a policy, not just a mechanism: a development app should be |
| +deletable as cheaply as it was created, while an app someone declared |
| +production should be protected by that same declaration. The production flag |
| +already exists and already has a deliberately slow off-switch; deletion should |
| +lean on it rather than grow a parallel protection scheme. |
| + |
| +## 2. The trash marker |
| + |
| +Deleting an app writes one new object: |
| + |
| + apps/<sqid>/<app>/deleted.json |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "account": "<sqid>", |
| + "app": "<app>", |
| + "deleted_at": "2026-08-04T16:00:00Z", |
| + "actor": "paul@keymail.dev" |
| +} |
| +``` |
| + |
| +Apps have no manifest object today, and this spec does not invent one — the |
| +marker is a tombstone, not an identity record. It is **additive**: nothing |
| +written before this spec changes shape, and a consumer that has never heard of |
| +`deleted.json` keeps behaving exactly as it does now (it keeps serving the |
| +app) until it is upgraded. That is the wire-additive rule working as intended: |
| +a trashed app lingering on an old box is visible and harmless; an old box |
| +crashing on a new object shape is neither. |
| + |
| +The marker is written create-only (`ifMatch ""`). It is *not* signed: trashing |
| +is reversible for 30 days, membership already authorizes it (§6), and the |
| +irreversible step has its own gates (§5, §7). `account` and `app` are in the |
| +body as well as the path for the same reason the account manifest carries its |
| +sqid — a reader validating a marker must find that path and body agree, and a |
| +disagreement means the marker is ignored and logged, never acted on. |
| + |
| +`deleted_at` is the cooloff clock and it lives bucket-side, in the marker. |
| +There is no box-side memory to ratchet (compare `prodMemory`): restore deletes |
| +the marker outright, so there is no stale object to replay — a marker that |
| +exists *is* the current state. |
| + |
| +## 3. Lifecycle: trash → restore | purge |
| + |
| +**Trashed.** While the marker exists: |
| + |
| +- Reconcile drops the app's registry routes and mints no new ones — the |
| + app-shaped twin of the account-tombstone clause already in §1 of |
| + reconcile.go. The check slots into §1b: an `apps/<sqid>/<app>/…` key whose |
| + app has a valid marker claims no route. Platform domains stop resolving |
| + within a tick; custom domains stop routing the same way. |
| +- The console stops listing the app everywhere except the Trash page (§6). |
| + The `apps` row survives, marked (§4), so the name stays claimed. |
| +- Where the console is in the path, ship and promote to a trashed app are |
| + refused by the claim check — the row still exists, still belongs to its |
| + account, and a trashed app is not a shipping target. A headless ship signs |
| + straight into the bucket and cannot be gated there, and does not need to |
| + be: the objects it lands stay dark (no routes while the marker exists) and |
| + are purged with everything else. Shipping into a trashed app is shipping |
| + into the trash. |
| + |
| +**Restore** deletes the marker (idempotent — `Store.Delete` on a missing key |
| +is not an error). The channel pointers, payloads and production flag never |
| +went anywhere, so the next reconcile tick re-mints the routes from the same |
| +objects it always read. Restore is conflict-free *by construction*: the name |
| +was never released (§4), so nothing can have taken it. |
| + |
| +**Purge** is the irreversible end, reached two ways — "Delete now" in the |
| +console Trash (§6) or the 30-day automatic pass (§7). A purge deletes every |
| +key under `apps/<sqid>/<app>/` — channels, payloads, environments, the |
| +production flag — with the **marker deleted last**, so a purge that dies |
| +half-way leaves a trashed app with fewer objects, not a live app with holes; |
| +the next pass finishes the job. Then the console `apps` row is deleted, which |
| +is the moment the name frees. Registry route rows are already gone (trash did |
| +that); tenant units on boxes are already stopped for the same reason. |
| + |
| +## 4. Name reservation |
| + |
| +The console `apps` table (`PRIMARY KEY (account_id, app)`) gains an additive |
| +column: |
| + |
| + deleted_at INTEGER NOT NULL DEFAULT 0 |
| + |
| +Trash sets it; restore clears it; purge deletes the row. Claim-once keeps its |
| +existing logic — the row exists, so the name is claimed — which makes the |
| +30-day restore a hard promise: nobody can take the name out from under a |
| +trashed app, not even its own account. After purge the name is claimable |
| +again, by this account or any other, exactly as if it had never been used. |
| +Dev iteration (create `foo`, trash it, recreate `foo`) therefore passes |
| +through "Delete now" — deliberate, one console ceremony, and cheap next to |
| +what it protects. |
| + |
| +Deleted-app *names* are not spent forever, unlike account ids. The asymmetry |
| +is justified: an account's sqid is its identity and its hostname prefix |
| +forever, minted once by the frozen encoder; an app's name is a label within |
| +an account, and after a purge there is no object left for a stale reference |
| +to resurrect. |
| + |
| +## 5. Production is undeletable — built in |
| + |
| +Trashing is refused while the app's production flag is on. Not opt-in, no |
| +checkbox: the person who would forget to enable deletion protection is |
| +exactly the person who needed it. There is no break-glass either — the only |
| +off-ramp is the one that already exists: the ratcheted off-switch (owner, |
| +passkey step-up, 72-hour box-side hold). Turn production off, then trash. |
| +Deletion adds **zero new protection machinery**; the production flag's |
| +ceremony is the deletion ceremony. |
| + |
| +The check reads the flag via `GetProduction` at every gate that can start a |
| +trash (CLI headless, console API, console form) and again in the auto-purge |
| +pass (§7). An **unreadable flag fails toward refusal** — same direction as |
| +bake.go, where unreadable fails toward baking: when the bucket cannot say |
| +"not production", the platform acts as if it had said "production". |
| + |
| +A flag that is set *after* the app is trashed is a contradiction (§3 refuses |
| +ship/promote to a trashed app, and `SetProduction` on a trashed app is |
| +refused too) — but the purge pass still checks, because impossible states get |
| +guards on the destructive path. |
| + |
| +## 6. Surfaces and ceremony |
| + |
| +The rule: **the reversible action is cheap, the irreversible one is |
| +ceremonial.** The 30-day window is the safety net that makes cheap safe. |
| + |
| +**CLI** — `carlos apps delete <app>` and `carlos apps restore <app>`, in the |
| +`apps` namespace that `carlos apps create` (PR #52, draft) is introducing; |
| +whichever branch merges second rebases onto the namespace. Both verbs work in |
| +the same two modes as account creation: |
| + |
| +- *Signed-in* (`CARLOS_CREDENTIALS` token): `POST |
| + /api/cli/apps/<sqid>/<app>/delete` and `…/restore`. Any member of the |
| + owning account; bearer token is enough. The API resolves the account with |
| + the same membership-scoped resolution as everything else (`-account` / |
| + `-account-id` from the slugs spec). |
| +- *Headless* (root key on a box, no console anywhere): writes/deletes the |
| + marker bucket-direct. Symmetric with headless `accounts create` — a |
| + deployment like tito must be able to trash an app without a console |
| + existing. |
| + |
| +Neither CLI mode can purge. The CLI cannot do a passkey step-up, and §7's |
| +ceremony is not negotiable, so "delete now" simply does not exist over the |
| +API. Headless deployments wait out the 30 days (or an operator with the root |
| +key deletes bucket keys by hand, which is outside the platform's promises). |
| + |
| +**Console** — each account gets a **Trash** page listing its trashed apps: |
| +name, who trashed it, days remaining, a Restore button, and **Delete now**. |
| +Restore is a plain member action (CSRF-checked POST, no step-up — it is the |
| +undo button). Delete now is gated exactly like account deletion, same order, |
| +same reasons: owner → CSRF → type-the-app-name confirm → `requireFreshAuth` |
| +step-up → production-flag re-check → purge. A non-member asking for another |
| +account's trash meets the byte-identical 404. |
| + |
| +The trash marker's `actor` field records who, for the Trash page and the |
| +audit row; console audit rows are written for trash, restore and purge, and |
| +survive the purge (audit is the record of what was done — deletion is not a |
| +reason to lose it). |
| + |
| +## 7. Auto-purge — the platform's first destructive automation |
| + |
| +A pass in the reconcile tick purges any app whose marker is older than 30 |
| +days. This is the first place the platform deletes data on a timer, so the |
| +guards are ratchet-grade: |
| + |
| +- **Re-read before destroy.** The pass lists markers, and for each candidate |
| + re-reads `deleted.json` immediately before purging. A restore that landed |
| + between listing and acting deleted the marker, the re-read finds nothing, |
| + and the purge is skipped. The re-read is the decision; the listing is only |
| + a hint. |
| +- **Production re-check.** `GetProduction` before purging; on or unreadable |
| + refuses (and logs — a trashed production app means an invariant broke |
| + upstream, which an operator should hear about rather than have tidied |
| + away). |
| +- **Validate the marker.** Path/body agreement (§2) and a parseable |
| + `deleted_at`; anything else is logged and skipped forever rather than |
| + guessed at. A malformed marker holds the app in trash — safe, visible, |
| + fixable by hand. |
| +- **Idempotent and race-proof.** Every step is a delete that tolerates |
| + already-gone (the `Store.Delete` contract), and the marker goes last, so |
| + two boxes purging the same app concurrently — or one box dying mid-purge — |
| + converge on the same end state. |
| +- **Jittered** like the bake clock, so a fleet does not stampede the bucket |
| + at the same instant. |
| + |
| +The console `apps` row for an auto-purged app is *not* reachable from the |
| +reconcile pass (registry boxes have no console DB). It is reaped by the same |
| +mechanism as §8: materialization notices the marker is gone and the bucket |
| +holds no keys for the app. Simpler and sufficient: the row's only meaning |
| +after purge is name reservation, and holding a name until the next console |
| +start is a delay, not a defect. |
| + |
| +## 8. Materialization reaps — closing the Kass gap |
| + |
| +`MaterializeAccounts` today *skips* a tombstoned manifest but keeps any row a |
| +previous start materialized. Now it reaps: an account row whose manifest is |
| +tombstoned has its memberships, invites and account row deleted and |
| +`users.last_account` reset — the same statement set as `Store.DeleteAccount`, |
| +minus the app-count refusal, because a bucket tombstone is authoritative: the |
| +control plane has already ruled, and the console's job is to converge on it. |
| +(An account tombstoned while still holding app rows was tombstoned by |
| +something other than the console's own guarded path; its app rows are reaped |
| +with it.) |
| + |
| +Cost note, accepted deliberately: the current pass short-circuits known |
| +accounts *before reading the manifest body*, so a steady-state start does no |
| +object reads. Tombstone state lives in the body, so reaping requires reading |
| +every account manifest each start. That is one small GET per account, at |
| +startup only. The security property of the short-circuit is preserved |
| +verbatim: owner adoption still happens at most once, at first |
| +materialization — a reap never adopts, and a reaped account that is somehow |
| +un-tombstoned later re-materializes as if new (birth-only adoption applies to |
| +the re-birth). |
| + |
| +The same pass reaps app rows: an `apps` row is deleted when the bucket holds |
| +neither a marker nor any `apps/<sqid>/<app>/` keys (purged), and has its |
| +`deleted_at` set when a valid marker exists (trashed elsewhere — e.g. |
| +headless — while this console was down). |
| + |
| +## 9. Testing |
| + |
| +- Marker round-trip: trash → routes drop on next reconcile; restore → routes |
| + return; purge → keys gone, marker last, row gone, name reclaimable. |
| +- Production refusals at all three trash doors, plus the unreadable-flag |
| + refusal (a store whose `Get` fails must refuse, not proceed). |
| +- Auto-purge: under-30-days untouched; restore racing the pass loses nothing |
| + (marker re-read); malformed marker skipped and logged; half-purged app |
| + (marker present, keys partially gone) finishes on the next pass. |
| +- Materialize reap: tombstoned-account rows reaped incl. memberships and |
| + `last_account` reset; trashed-elsewhere app rows marked; purged app rows |
| + deleted; owner adoption not re-run on reap or re-materialize. |
| +- Console gates in order, byte-identical 404 for non-members of the Trash |
| + page, step-up on Delete now only. |
| +- Every test that shells out to the carlos binary uses `carlosTestEnv` — the |
| + Kass rule. |
| + |
| +## 10. Non-goals |
| + |
| +- **Account trash/restore.** Accounts keep today's instant tombstone; ids |
| + stay spent forever. The trash model is apps-only. |
| +- **CLI purge or trash-browsing.** "Delete now" is console-only (§6); the |
| + Trash is a page, not a verb. |
| +- **Un-purge.** After purge there is nothing to restore from; that is the |
| + point of the ceremony in front of it. |
| +- **A general app manifest.** `deleted.json` is a tombstone, not the start of |
| + an identity object; if apps ever need a manifest, that is its own design. |
| +- **Retention configurability.** 30 days is a constant. A deployment that |
| + wants a different number edits the constant; a per-account setting is |
| + machinery nobody asked for. |