carlos / platform Public

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>
Paul Campbell e9ca7e8b5d7092aade44c1058fbcd136fc820680 parent 761d3d0
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.