carlos / platform Public

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>
Paul Campbell 01569c35cb6e23805338c06ce02ae4eeb823e390 parent a00485a
2 files changed, +22 −14
  • docs/superpowers/plans/2026-08-04-deletion-policy.md +3 −3
  • docs/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