docs: restore resurrects no routes — the anti-ghost rule governs (Paul's ruling)
Reconcile's minting is route-driven by design; a pointer-driven mint would resurrect every deliberately-retired app. A restored app is as routed as a newborn one: carlos add re-arms the apex, and both surfaces say so.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+22
−14
docs/superpowers/plans/2026-08-04-deletion-policy.md+3 −3docs/superpowers/specs/2026-08-04-deletion-policy-design.md+19 −11
diff --git a/docs/superpowers/plans/2026-08-04-deletion-policy.md b/docs/superpowers/plans/2026-08-04-deletion-policy.md| index 168eedc78..972cdfc13 100644 |
| --- a/docs/superpowers/plans/2026-08-04-deletion-policy.md |
| +++ b/docs/superpowers/plans/2026-08-04-deletion-policy.md |
| @@ -628,7 +628,7 @@ Keep `materializeOwner` reachable ONLY from the fall-through (first-ever materia |
| - [ ] **Step 1: Failing test** for the pure decision helper — extract mode/flag validation into a testable function (e.g. `appsDeleteMode(loggedIn bool, bucketConfigured bool, accountID string) (mode string, err error)`) and table-test it: signed-in wins; headless without `-account-id` errors mentioning `-account-id`; neither → error mentioning both `carlos auth login` and env vars. |
| - [ ] **Step 2: FAIL.** |
| -- [ ] **Step 3: Implement**, following accounts.go's structure and log.Fatal voice. Headless delete prints the restore window from `TrashCooloff` and the restore command; both verbs print custom-domain advice on restore ("custom domains are not restored — carlos add them again"). |
| +- [ ] **Step 3: Implement**, following accounts.go's structure and log.Fatal voice. Headless delete prints the restore window from `TrashCooloff` and the restore command; both verbs print custom-domain advice on restore ("routes are not restored — carlos add <host> to route it again"). |
| - [ ] **Step 4: Full tree green.** |
| - [ ] **Step 5: Commit** — `git commit -m "feat(cli): carlos apps delete and restore — signed-in via the console, headless via the bucket"` (+ trailer). |
| @@ -644,9 +644,9 @@ Keep `materializeOwner` reachable ONLY from the fall-through (first-ever materia |
| - Consumes: `s.account` (member gate used by GET pages — find its exact name/signature by reading how `handleAccountHome` opens), `s.owner`, `requireFreshAuth`, `setFlash`, `renderMessage`, CSRF idiom (`subtle.ConstantTimeCompare` per ownership.go:36), `TrashedAppsIn`, `RestoreAppRow`, `PurgeAppRow`, `release.RestoreApp`, `release.PurgeApp`, `release.TrashCooloff`, `s.audit`. |
| - Produces routes: |
| - `GET /a/{sqid}/trash` — member: list `TrashedAppsIn` with trashed-by/date/days-remaining and per-app Restore + Delete-now forms; the account home gains a "Trash (N)" link only when N > 0. Non-member: byte-identical 404 (whatever `s.account` already does). The WHO comes from the bucket marker, not the row: for each trashed app call `release.ReadAppTombstone` and show its `Actor`; best-effort — an unreadable marker renders "—" for the actor and the ROW's `DeletedAt` for the date (the page must render even when the bucket is down; only destruction requires believing the marker). |
| - - `POST /a/{sqid}/trash/restore` — member gate → CSRF → `release.RestoreApp` (bucket first) → `RestoreAppRow` → audit "restore" → flash notes custom domains need `carlos add` again. NO step-up: this is the undo button. |
| + - `POST /a/{sqid}/trash/restore` — member gate → CSRF → `release.RestoreApp` (bucket first) → `RestoreAppRow` → audit "restore" → flash notes the app has no routes until `carlos add`. NO step-up: this is the undo button. |
| - `POST /a/{sqid}/trash/delete` — gates in EXACTLY this order, each a copy of handleDeleteAccount's idiom (ownership.go:84-155): owner (`s.owner`) → CSRF (constant-time) → type-the-APP-name confirm (`r.FormValue("confirm") != app` → flash+redirect, checked before step-up so a slip does not cost a passkey touch) → `requireFreshAuth` LAST of the auth gates → `s.cfg.Releases == nil` refusal → `release.PurgeApp(…, s.cfg.ReleasePub, …)` (which itself re-reads the marker and re-checks production — impossible states get guards on the destructive path; surface `ErrNotTrashed` as "not in the trash") → `PurgeAppRow` → audit "purge" → flash "Deleted "<app>" permanently. The name is free to claim again." |
| -- Trash page copy states, at trash time: what auto-purge will do and when; that custom domains will need `carlos add` after a restore. |
| +- Trash page copy states, at trash time: what auto-purge will do and when; that a restored app routes again only after `carlos add`. |
| - [ ] **Step 1: Failing tests** — extend deletion_test.go: member sees the trash page listing a trashed app; non-member gets the byte-identical 404 body for GET and both POSTs (assert bytes equal against a nonexistent-account request, the file's existing pattern); restore works without step-up; delete-now REQUIRES owner (member → 404), requires exact app name (wrong → nothing purged), requires fresh auth (fixture for `requireFreshAuth` exists in stepup-related tests — reuse it), and after it: bucket keys gone, row gone, `CreateAppIn` re-claims the name. |
| - [ ] **Step 2: FAIL.** |
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 1ba7273bd..2e855bd5a 100644 |
| --- a/docs/superpowers/specs/2026-08-04-deletion-policy-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-deletion-policy-design.md |
| @@ -75,11 +75,17 @@ exists *is* the current state. |
| 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. |
| +- Restore does NOT resurrect routes — none of them, the platform apex |
| + included. Registry rows are per-box and gone, and reconcile's minting is |
| + deliberately route-driven (the anti-ghost rule: an app whose only routes |
| + are minted ones has been removed by its operator, and re-minting from |
| + pointers alone would resurrect every deliberately-retired app forever). |
| + A restored app is exactly as routed as a newborn one: visible, claimable, |
| + shippable — and dark until `carlos add <host>`, which re-arms apex |
| + minting the same way it did at the app's birth. The Trash page and `apps |
| + restore`'s output both print the command, at trash time and at restore |
| + time. (Ruled 2026-08-04 during implementation: the alternative — a |
| + pointer-driven mint path — breaks the anti-ghost invariant.) |
| - 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 |
| @@ -96,10 +102,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 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. |
| +went anywhere, so ship, promote and the console pick the app up exactly |
| +where it left off; routing restarts with `carlos add`, 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 |
| @@ -284,8 +290,10 @@ headless — while this console was down). |
| 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. |
| +- Trash removes ALL routes (custom and platform); restore resurrects none — |
| + the restored app routes again only after `carlos add`, and both surfaces |
| + say so. A marker under one account never touches a same-named app in |
| + another account. |
| - 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 |