docs: deletion spec amendments from code grounding
Three findings while reading the real code before planning: 1. SECURITY: reaping account rows re-arms the bucket-writer takeover that birth-only owner adoption blocks (reap, un-tombstone the manifest naming yourself owner, get re-birthed and adopted). The same resurrection hole exists today for console-deleted accounts. Fix: accounts.deleted_at — the console remembers tombstones; a marked row is never re-birthed. 2. Trash removes custom-domain routes too (the marker is owner intent, a deliberate extension of reconcile's destructive-move charter); restore re-mints only platform domains and says so. 3. Tenant units hibernate rather than being stopped — no route, no wake.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+62
−25
docs/superpowers/specs/2026-08-04-deletion-policy-design.md+62 −25
diff --git a/docs/superpowers/specs/2026-08-04-deletion-policy-design.md b/docs/superpowers/specs/2026-08-04-deletion-policy-design.md| index 9859a8131..1ba7273bd 100644 |
| --- a/docs/superpowers/specs/2026-08-04-deletion-policy-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-deletion-policy-design.md |
| @@ -66,11 +66,24 @@ exists *is* the current state. |
| **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. |
| +- Reconcile drops the app's registry routes — ALL of them, custom domains |
| + included — and mints no new ones. This deliberately extends reconcile's |
| + destructive-move charter ("removing a route happens ONLY for |
| + platform-minted canary hosts"): that restraint exists because reconcile |
| + normally *infers*, and inference must never destroy owner property. The |
| + trash marker is not inference — it is the owner's explicit instruction to |
| + stop serving this app — so acting on it is executing owner intent, not |
| + overriding it. A custom domain that kept serving a "deleted" app would be |
| + the worse betrayal. |
| +- Restore re-mints platform domains automatically (§2 of reconcile, from the |
| + channel pointers that never moved). Custom-domain routes are NOT |
| + resurrected — the registry rows are per-box and gone — so they need |
| + `carlos add` again; the Trash page and `apps restore`'s output both say |
| + so, at trash time and at restore time. |
| +- Running tenant units need no synchronous kill: units are socket-activated |
| + and hibernate when idle, and with every route gone nothing ever wakes one |
| + again. Long-running platform units are the platform's own and never |
| + belong to a deletable app. |
| - 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 |
| @@ -83,9 +96,10 @@ exists *is* the current state. |
| **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. |
| +went anywhere, so the next reconcile tick re-mints the platform domains from |
| +the same objects it always read (custom domains excepted, per above). |
| +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 |
| @@ -94,7 +108,7 @@ 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. |
| +that), and hibernated tenant units stay asleep forever — no route, no wake. |
| ## 4. Name reservation |
| @@ -213,24 +227,41 @@ 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.) |
| +previous start materialized. Now it reaps: an account whose manifest is |
| +tombstoned has its memberships, invites and `users.last_account` references |
| +deleted, and its `accounts` row **marked, not removed** — the `accounts` |
| +table gains an additive `deleted_at INTEGER NOT NULL DEFAULT 0` column, |
| +mirroring the apps column, and every account query filters it out. 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.) |
| + |
| +**Why the row is kept: the console must remember tombstones.** Owner |
| +adoption is birth-only (`materializeOwner`), and the whole security argument |
| +for the manifest's `owner` field rests on "an account the console has seen |
| +before is never revisited" — the manifest is not write-once, so adoption on |
| +any later pass would let anyone with bucket write access grant themselves a |
| +live account. Deleting the row *forgets the account was ever seen*, which |
| +re-arms exactly that attack: tombstone (or wait for a deletion), let the |
| +console reap, rewrite the manifest un-tombstoned with yourself as owner, and |
| +the next pass re-births the account — same id, same sqid, same platform |
| +hostnames — and adopts you. The marked row closes this: a sqid with |
| +`deleted_at` set is **never re-materialized**, tombstoned manifest or not, |
| +and an un-tombstoned manifest over a marked row is logged as the corruption |
| +it is. `Store.DeleteAccount` (the console's own path) changes the same way — |
| +mark, don't remove — because the identical resurrection hole exists TODAY |
| +for console-deleted accounts; this spec closes it. Un-deleting an account is |
| +not a thing; an account id is spent once, and now the console enforces that |
| +as firmly as the allocator does. |
| 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). |
| +startup only. Owner adoption's birth-only rule is untouched: a reap never |
| +adopts, a marked row is never re-birthed, and first-time materialization is |
| +the only moment adoption ever runs — exactly as before. |
| 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 |
| @@ -246,9 +277,15 @@ headless — while this console was down). |
| - 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. |
| +- Materialize reap: tombstoned-account memberships/invites reaped and |
| + `last_account` reset, row marked; trashed-elsewhere app rows marked; |
| + purged app rows deleted; owner adoption never re-run on reap. |
| +- Resurrection refused: a manifest un-tombstoned after its row was marked |
| + is NOT re-materialized (no row revival, no owner adoption) and is logged; |
| + same via `DeleteAccount` — a console-deleted account whose manifest is |
| + rewritten live again stays deleted in the console. |
| +- Trash removes custom-domain routes too; restore re-mints only platform |
| + domains and says custom domains need `carlos add` again. |
| - 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 |