carlos / platform Public

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>
Paul Campbell 04ef881cb6301c18d4934d5b44fb44dceb3f2a3b parent e9ca7e8
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