carlos / platform Public

docs: deletion-policy implementation plan — 9 tasks, TDD, model policy per task

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 58e401742e6f5bb0cbee7047e9af81b2e16c2c59 parent 04ef881
1 file changed, +697 −0
  • docs/superpowers/plans/2026-08-04-deletion-policy.md +697 −0
diff --git a/docs/superpowers/plans/2026-08-04-deletion-policy.md b/docs/superpowers/plans/2026-08-04-deletion-policy.md
new file mode 100644
index 000000000..168eedc78
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-deletion-policy.md
@@ -0,0 +1,697 @@
+# App Deletion (Trash / Cooloff / Purge) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Apps become deletable — trash marker in the bucket, 30-day restorable cooloff, console Trash page with ceremonial "Delete now", jittered auto-purge, production flag = built-in deletion prevention — and the console learns to remember tombstones (accounts and apps both), closing the Kass materialization gap and a live resurrection hole. Per `docs/superpowers/specs/2026-08-04-deletion-policy-design.md`.
+
+**Architecture:** A new additive bucket object `apps/<sqid>/<app>/deleted.json` (primitives in `internal/release/trash.go` — NOT `internal/domains`, which would cycle: release must read the marker in `SetProduction`, and domains already imports release). Reconcile treats a valid marker as owner intent: drops ALL the app's routes, mints nothing, and runs the auto-purge pass. The console mirrors trash state in `apps.deleted_at` and remembers account tombstones in `accounts.deleted_at`; `MaterializeAccounts` reaps to marked-row state and refuses resurrection. CLI gets `carlos apps delete|restore` (console API + headless); the console gets the Trash page.
+
+**Tech Stack:** Go stdlib only; no new dependencies.
+
+## Global Constraints
+
+- Build/test/vet: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1` — all packages, from the repo root, before every commit. ("read-only file system" from the toolchain → run with sandbox disabled.)
+- `gofmt -l` clean for every file you touch.
+- **Wire shapes are additive.** The ONLY storage changes this plan sanctions: the new `deleted.json` object; `ALTER TABLE apps ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0`; `ALTER TABLE accounts ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0`; the matching column in `migrateAppsKey`'s rebuild DDL. Anything else that seems to need a shape change: stop and say so.
+- **Console security-gate order is fixed**: session → CSRF → member/owner (byte-identical 404 via `http.NotFound`) → `requireFreshAuth` LAST. A non-member must never learn an account or its trash exists.
+- **Production channel pins** (`internal/release/laddercheck_test.go`) stay green and untouched.
+- **Any test that shells out to the carlos binary uses `carlosTestEnv`** (the Kass rule). None of this plan's tests should need to shell out.
+- **Unreadable fails toward refusal**: wherever the production flag is consulted before destruction, a read error means REFUSE, matching bake.go's "unreadable fails toward baking".
+- 30 days is the constant `release.TrashCooloff`. Not configurable.
+- Commit after each task with trailer: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
+- Model policy (dispatcher): Tasks 1, 4, 7, 9 → **Sonnet**; Tasks 2, 3, 5, 6, 8 → **Opus** (reconcile route destruction, purge, materialize security, API delete door, console gates). **Opus reviews every task.**
+
+---
+
+### Task 1: Trash marker primitives — `release.TrashApp` / `ReadAppTombstone` / `RestoreApp` (model: Sonnet)
+
+**Files:**
+- Create: `internal/release/trash.go`
+- Test: `internal/release/trash_test.go`
+
+**Interfaces:**
+- Produces (package `release`):
+ - `type AppTombstone struct { Schema int; Account, App string; DeletedAt time.Time; Actor string }` with JSON tags `schema, account, app, deleted_at, actor`
+ - `func AppTombstoneKey(acct, app string) string` → `"apps/" + acct + "/" + app + "/deleted.json"`
+ - `const TrashCooloff = 30 * 24 * time.Hour`
+ - `func TrashApp(ctx context.Context, s Store, acct, app, actor string, now time.Time) error` — create-only; already-trashed is nil (idempotent)
+ - `func ReadAppTombstone(ctx context.Context, s Store, acct, app string) (*AppTombstone, error)` — `(nil, nil)` when absent; error when unreadable OR malformed OR path/body disagree
+- Consumes: `release.Store` (`Get`/`Put`/`Delete`, store.go:39-52), `release.ErrCASConflict`, `release.IsNotFound` — check store.go for the not-found convention; `DirStore.Get`/`S3Store.Get` return an error wrapping a sentinel; use exactly the same detection `GetProduction` (production.go:117) uses for "no flag object".
+
+- [ ] **Step 1: Write the failing test** — `internal/release/trash_test.go`:
+
+```go
+package release
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestTrashAppRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 4, 16, 0, 0, 0, time.UTC)
+
+ // Absent: (nil, nil), not an error.
+ if tomb, err := ReadAppTombstone(ctx, s, "bab", "derp"); err != nil || tomb != nil {
+ t.Fatalf("absent marker: got %v, %v; want nil, nil", tomb, err)
+ }
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", now); err != nil {
+ t.Fatalf("TrashApp: %v", err)
+ }
+ tomb, err := ReadAppTombstone(ctx, s, "bab", "derp")
+ if err != nil || tomb == nil {
+ t.Fatalf("ReadAppTombstone: got %v, %v", tomb, err)
+ }
+ if tomb.Account != "bab" || tomb.App != "derp" || tomb.Actor != "paul@keymail.dev" || !tomb.DeletedAt.Equal(now) {
+ t.Fatalf("tombstone fields: %+v", tomb)
+ }
+ // Idempotent: trashing a trashed app is nil and does NOT move the clock.
+ if err := TrashApp(ctx, s, "bab", "derp", "eve@keymail.dev", now.Add(time.Hour)); err != nil {
+ t.Fatalf("second TrashApp: %v", err)
+ }
+ tomb2, _ := ReadAppTombstone(ctx, s, "bab", "derp")
+ if !tomb2.DeletedAt.Equal(now) || tomb2.Actor != "paul@keymail.dev" {
+ t.Fatalf("second trash rewrote the marker: %+v", tomb2)
+ }
+ // Restore deletes the marker; restoring an untrashed app is fine.
+ if err := RestoreApp(ctx, s, "bab", "derp"); err != nil {
+ t.Fatalf("RestoreApp: %v", err)
+ }
+ if tomb, _ := ReadAppTombstone(ctx, s, "bab", "derp"); tomb != nil {
+ t.Fatalf("marker survived restore: %+v", tomb)
+ }
+ if err := RestoreApp(ctx, s, "bab", "derp"); err != nil {
+ t.Fatalf("restore of untrashed app: %v", err)
+ }
+}
+
+func TestReadAppTombstoneRefusesDisagreement(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ // A marker whose body claims a different app than its path is a lie
+ // about identity: error, never acted on.
+ body := []byte(`{"schema":1,"account":"bab","app":"OTHER","deleted_at":"2026-08-04T16:00:00Z","actor":"x@y.z"}`)
+ if _, err := s.Put(ctx, AppTombstoneKey("bab", "derp"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadAppTombstone(ctx, s, "bab", "derp"); err == nil {
+ t.Fatal("path/body disagreement was not refused")
+ }
+ // Malformed JSON: error too.
+ if _, err := s.Put(ctx, AppTombstoneKey("bab", "junk"), []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadAppTombstone(ctx, s, "bab", "junk"); err == nil {
+ t.Fatal("malformed marker was not refused")
+ }
+ // Zero deleted_at: refused — the cooloff clock would never expire and
+ // the marker would be acted on with no timestamp to reason from.
+ body = []byte(`{"schema":1,"account":"bab","app":"noat","actor":"x@y.z"}`)
+ if _, err := s.Put(ctx, AppTombstoneKey("bab", "noat"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadAppTombstone(ctx, s, "bab", "noat"); err == nil {
+ t.Fatal("marker with zero deleted_at was not refused")
+ }
+}
+```
+
+Check `NewDirStore`'s actual constructor name/signature in store.go before writing (it may be `NewDirStore(dir string) *DirStore` or a variant); adjust the test to whatever exists — the production_test.go file uses the same fixture and is the reference.
+
+- [ ] **Step 2: Run it, expect FAIL** — `go test ./internal/release/ -run 'TestTrashApp|TestReadAppTombstone' -count=1` fails with "undefined: TrashApp".
+
+- [ ] **Step 3: Implement** — `internal/release/trash.go`:
+
+```go
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// AppTombstone is the wire shape at apps/<sqid>/<app>/deleted.json — the
+// trash marker. Its PRESENCE means the app is in the Trash: consumers drop
+// its routes and hide it; the 30-day clock (DeletedAt + TrashCooloff) runs
+// bucket-side. There is no box-side memory to ratchet: restore deletes the
+// marker outright, so a marker that exists IS the current state.
+//
+// ADDITIVE by design: nothing written before this type changes shape, and a
+// consumer that has never heard of deleted.json keeps serving the app until
+// it is upgraded — visible and harmless, per the wire-additive rule.
+type AppTombstone struct {
+ Schema int `json:"schema"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ DeletedAt time.Time `json:"deleted_at"`
+ Actor string `json:"actor"`
+}
+
+// TrashCooloff is how long a trashed app stays restorable before the
+// auto-purge pass may destroy it. A constant, deliberately: a deployment
+// that wants a different number edits it here.
+const TrashCooloff = 30 * 24 * time.Hour
+
+// AppTombstoneKey is where an app's trash marker lives in the bucket.
+func AppTombstoneKey(acct, app string) string {
+ return "apps/" + acct + "/" + app + "/deleted.json"
+}
+
+// TrashApp writes the trash marker, create-only. Trashing an app that is
+// already in the trash is a no-op that does NOT move the cooloff clock or
+// rewrite the actor — the first trash is the one on record.
+func TrashApp(ctx context.Context, s Store, acct, app, actor string, now time.Time) error {
+ body, err := json.Marshal(AppTombstone{
+ Schema: 1, Account: acct, App: app, DeletedAt: now.UTC(), Actor: actor,
+ })
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, AppTombstoneKey(acct, app), body, ""); err != nil {
+ if errors.Is(err, ErrCASConflict) {
+ return nil // already trashed; the existing marker stands
+ }
+ return fmt.Errorf("release: write trash marker for %s/%s: %w", acct, app, err)
+ }
+ return nil
+}
+
+// ReadAppTombstone reads and VALIDATES the marker. (nil, nil) when absent.
+// Anything else short of a well-formed marker whose body agrees with its
+// path and carries a real timestamp is an ERROR — a caller about to destroy
+// or hide an app must never act on a marker it cannot fully believe.
+func ReadAppTombstone(ctx context.Context, s Store, acct, app string) (*AppTombstone, error) {
+ body, _, err := s.Get(ctx, AppTombstoneKey(acct, app))
+ if err != nil {
+ if IsNotFound(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("release: read trash marker for %s/%s: %w", acct, app, err)
+ }
+ var t AppTombstone
+ if err := json.Unmarshal(body, &t); err != nil {
+ return nil, fmt.Errorf("release: malformed trash marker for %s/%s: %w", acct, app, err)
+ }
+ if t.Account != acct || t.App != app {
+ return nil, fmt.Errorf("release: trash marker at %s claims %s/%s — path and body disagree",
+ AppTombstoneKey(acct, app), t.Account, t.App)
+ }
+ if t.DeletedAt.IsZero() {
+ return nil, fmt.Errorf("release: trash marker for %s/%s has no deleted_at", acct, app)
+ }
+ return &t, nil
+}
+
+// RestoreApp deletes the marker. Store.Delete is idempotent by contract, so
+// restoring an app that is not in the trash is a no-op, not an error.
+func RestoreApp(ctx context.Context, s Store, acct, app string) error {
+ return s.Delete(ctx, AppTombstoneKey(acct, app))
+}
+```
+
+If `IsNotFound` does not exist in package release, find how `GetProduction` (production.go:117) distinguishes "no flag object" from a real read failure and use exactly that mechanism; if it is an unexported helper, use it directly (same package). Do NOT invent a second not-found convention.
+
+- [ ] **Step 4: Run tests, expect PASS** — `go test ./internal/release/ -count=1`.
+- [ ] **Step 5: Commit** — `git add internal/release/trash.go internal/release/trash_test.go && git commit -m "feat(release): app trash marker — additive deleted.json, validated reads"` (+ trailer).
+
+---
+
+### Task 2: Reconcile treats trash as owner intent — routes drop, nothing mints (model: Opus)
+
+**Files:**
+- Modify: `internal/domains/reconcile.go` (function `Reconcile`, sections 1b–3)
+- Test: `internal/domains/reconcile_test.go` (extend; read its existing fixtures first — it has a fake registry + DirStore pattern)
+
+**Interfaces:**
+- Consumes: `release.ReadAppTombstone`, `release.AppTombstoneKey` (Task 1); existing `appKeyOwner`, `appKey`, `release.RouteAccount`.
+- Produces: inside `Reconcile`, a `trashed map[appKey]*release.AppTombstone` collected right after the §1b `owners` loop; Task 3 extends the same function with the purge pass and consumes this map. Route removal for trashed apps happens in a new block between §1b's route-account stamping and §2's minting.
+
+**The charter extension, stated in code comments (copy this reasoning in):** Reconcile's one destructive move used to be platform-minted canary hosts only, 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 removing ALL its routes (custom domains included) is executing owner intent. A custom domain that kept serving a "deleted" app would be the worse betrayal.
+
+- [ ] **Step 1: Write the failing test** — add to `reconcile_test.go` (adapt fixture names to the file's existing helpers — it builds a `registry.Registry` on a temp path and a DirStore; follow the existing `TestReconcile...` functions' setup verbatim):
+
+```go
+func TestReconcileTrashedAppDropsAllRoutesAndMintsNothing(t *testing.T) {
+ // Fixture: one known account (manifest for sqid), one app "derp" with
+ // - a canary pointer under apps/<sqid>/derp/channels/canary/x.json
+ // - a custom route (Platform: false) and a platform apex (Platform: true)
+ // pre-inserted into the registry
+ // Then: write a trash marker for (sqid, "derp") and run Reconcile.
+ //
+ // Assert, after one pass:
+ // - EVERY route with App == "derp" is gone — custom route included
+ // - no platform domain or canary host was re-minted (run Reconcile a
+ // second time; still no routes for derp)
+ // Assert also that an app WITHOUT a marker in the same account keeps its
+ // routes untouched by the same two passes.
+}
+
+func TestReconcileMalformedTrashMarkerIsIgnoredLoudly(t *testing.T) {
+ // A marker whose body disagrees with its path (write it raw with s.Put)
+ // must change NOTHING: routes stay, minting proceeds as if no marker
+ // existed. (ReadAppTombstone errors; Reconcile logs and treats the app
+ // as live — a marker we cannot believe must not destroy routes.)
+}
+```
+
+Write these as real tests against the file's existing fixture helpers — the descriptions above are the assertions, not placeholders to leave in.
+
+- [ ] **Step 2: Run, expect FAIL** — `go test ./internal/domains/ -run TestReconcileTrashed -count=1` (routes survive, because nothing reads markers yet).
+
+- [ ] **Step 3: Implement.** In `Reconcile`, immediately after the `owners` map loop (reconcile.go:139-148), insert:
+
+```go
+ // --- 1c. trashed apps ----------------------------------------------------
+ // A valid trash marker (apps/<sqid>/<app>/deleted.json) is the owner's
+ // explicit instruction to stop serving the app. This extends the pass's
+ // destructive-move charter deliberately: inference never destroys owner
+ // property, but a marker is not inference — so ALL the app's routes go,
+ // custom domains included. A custom domain that kept serving a "deleted"
+ // app would be the worse betrayal. A marker that fails validation
+ // (ReadAppTombstone) changes nothing: an instruction we cannot fully
+ // believe must not destroy routes, so the app stays live and the log
+ // says why, every pass, until someone fixes or removes the object.
+ trashed := map[appKey]*release.AppTombstone{}
+ for _, key := range keys {
+ acct, app, prefix, ok := appKeyOwner(key, known)
+ if !ok || key != prefix+"deleted.json" {
+ continue
+ }
+ tomb, err := release.ReadAppTombstone(ctx, s, acct, app)
+ if err != nil {
+ log.Printf("domains: ignoring trash marker for %s/%s: %v", acct, app, err)
+ continue
+ }
+ if tomb != nil {
+ trashed[appKey{acct, app}] = tomb
+ }
+ }
+```
+
+Then, after the route-account stamping loop (the `for i := range routes` block ending ~reconcile.go:162) and BEFORE section 2's minting, insert:
+
+```go
+ // Trashed apps stop routing NOW: every route of a trashed (account, app)
+ // is removed, and the shrunken slice is what minting sees, so nothing
+ // below re-mints an apex or canary host for an app in the trash.
+ if len(trashed) > 0 {
+ keptLive := routes[:0]
+ for _, rt := range routes {
+ if rt.App != "" && trashed[appKey{release.RouteAccount(rt), rt.App}] != nil {
+ if err := reg.Delete(rt.Host); err != nil {
+ log.Printf("domains: remove route %s of trashed app %s: %v", rt.Host, rt.App, err)
+ keptLive = append(keptLive, rt) // still there; try next pass
+ continue
+ }
+ log.Printf("domains: removed route %s — app %s is in the trash", rt.Host, rt.App)
+ continue
+ }
+ keptLive = append(keptLive, rt)
+ }
+ routes = keptLive
+ }
+```
+
+Finally, in section 3's canary loop, skip trashed apps: right after `k := appKey{acctOfKey, app}` (reconcile.go:219), add:
+
+```go
+ if trashed[k] != nil {
+ continue // no canary host for an app in the trash
+ }
+```
+
+Also exclude trashed apps from the `owners` linkage map (§1b): in the owners loop, after `acct, app, _, ok := appKeyOwner(key, known)`, you cannot check `trashed` yet (it is built after). Reorder: build `trashed` FIRST (move block 1c above the owners loop), then in the owners loop add `if trashed[appKey{acct, app}] != nil { continue }`. The final order in the function: §1 accounts → §1c trashed → §1b owners+stamping → route removal → §2 → §3.
+
+- [ ] **Step 4: Run the full package** — `go test ./internal/domains/ -count=1`; then the whole tree. Existing reconcile tests must stay green (no marker present → behavior identical).
+- [ ] **Step 5: Commit** — `git commit -m "feat(domains): reconcile executes trash markers — all routes drop, nothing mints"` (+ trailer).
+
+---
+
+### Task 3: Purge — `release.PurgeApp` + the auto-purge pass + agent wiring (model: Opus)
+
+**Files:**
+- Modify: `internal/release/trash.go` (add `PurgeApp`), `internal/release/trash_test.go`
+- Modify: `internal/domains/reconcile.go` (Config + purge pass), `internal/domains/reconcile_test.go`
+- Modify: `internal/host/agent.go` (`domainsTickConfig`, ~line 392: wire `ReleasePub`)
+
+**Interfaces:**
+- Produces: `func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error` in package release; `domains.Config` gains `ReleasePub ed25519.PublicKey`.
+- Consumes: `GetProduction(ctx, s, pub, acct, app) (*ProductionFlag, error)` (production.go:117) — establish by reading it: what does it return for flag-absent vs unreadable? Flag-absent must purge fine (most apps have no flag); unreadable must refuse.
+
+**PurgeApp contract (write these as tests first):**
+1. Refuses when the app is NOT in the trash (no valid marker): purge without a marker is destruction without an instruction.
+2. Refuses when `pub` is nil/empty — cannot verify the production flag, so cannot prove the app is not production. (Fails toward keeping data.)
+3. Refuses when the production flag verifies as ON, and when reading it errors (unreadable ≠ off). Flag ABSENT is fine.
+4. Deletes every key under `apps/<acct>/<app>/` EXCEPT the marker first, the marker LAST — a purge that dies half-way leaves a trashed app with fewer objects, never a live app with holes.
+5. Idempotent: purging a half-purged or already-purged app converges (Store.Delete tolerates already-gone; a second full purge after the marker is gone hits contract 1 and refuses — that is correct, the caller treats "not in the trash" as done; return a distinguishable sentinel `ErrNotTrashed = errors.New("release: app is not in the trash")` so callers can tell).
+
+- [ ] **Step 1: Write failing tests** for the five contracts in `trash_test.go` (DirStore; seed keys under `apps/bab/derp/channels/stable.json`, `.../releases/1.0.0/manifest.json`, marker; use `SetProduction` with a real test key to exercise contract 3 — production_test.go shows how to mint an ed25519 pair and set the flag). Test 4's half-way case: purge, then verify with a store listing that any remaining key set always includes the marker if anything remains — simplest: delete-count instrumentation is NOT required, instead assert final state (all gone, marker included) and separately assert that `PurgeApp` deletes the marker last by seeding an object and checking order via a wrapping Store that records Delete calls (define a tiny `recordingStore struct{ Store; deletes *[]string }` in the test file).
+
+- [ ] **Step 2: Run, expect FAIL** — undefined: PurgeApp.
+
+- [ ] **Step 3: Implement `PurgeApp`** in trash.go:
+
+```go
+// ErrNotTrashed is PurgeApp's refusal to destroy an app that has no valid
+// trash marker: purge without an instruction is not a purge, it is a bug.
+var ErrNotTrashed = errors.New("release: app is not in the trash")
+
+// PurgeApp irreversibly destroys a trashed app's bucket objects: every key
+// under apps/<acct>/<app>/, the trash marker LAST, so a purge that dies
+// half-way leaves a trashed app with fewer objects rather than a live app
+// with holes — the next pass finishes the job.
+//
+// This is the platform's first destructive automation surface, so every
+// guard fails toward keeping data:
+// - no valid marker → ErrNotTrashed (re-read HERE, immediately before
+// destruction — a restore that landed after the caller's listing wins);
+// - nil pub, production flag ON, or production flag UNREADABLE → refuse.
+// Flag ABSENT is fine: most apps never had one.
+//
+// The AGE of the marker is deliberately the caller's decision, not this
+// function's: the auto-purge pass enforces TrashCooloff, and the console's
+// "Delete now" purges early behind its own ceremony.
+func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error {
+ tomb, err := ReadAppTombstone(ctx, s, acct, app)
+ if err != nil {
+ return err
+ }
+ if tomb == nil {
+ return ErrNotTrashed
+ }
+ if len(pub) != ed25519.PublicKeySize {
+ return fmt.Errorf("release: no release public key — cannot prove %s/%s is not in production, refusing to purge", acct, app)
+ }
+ flag, err := GetProduction(ctx, s, pub, acct, app)
+ if err != nil {
+ return fmt.Errorf("release: cannot read the production flag for %s/%s — refusing to purge: %w", acct, app, err)
+ }
+ if flag != nil && flag.Production {
+ return fmt.Errorf("release: %s/%s is flagged production — a trashed production app should be impossible; refusing to purge", acct, app)
+ }
+ prefix := "apps/" + acct + "/" + app + "/"
+ marker := AppTombstoneKey(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return fmt.Errorf("release: list %s: %w", prefix, err)
+ }
+ for _, key := range keys {
+ if key == marker {
+ continue // last, below
+ }
+ if err := s.Delete(ctx, key); err != nil {
+ return fmt.Errorf("release: purge %s: %w", key, err)
+ }
+ }
+ return s.Delete(ctx, marker)
+}
+```
+
+Adjust the `GetProduction` absent/unreadable handling to its REAL contract after reading production.go — if it returns `(nil, nil)` for flag-absent the code above is right; if absent is an error type, detect it and treat as off.
+
+- [ ] **Step 4: Config + purge pass.** `domains.Config` gains:
+
+```go
+ // ReleasePub verifies the production flag before the auto-purge pass
+ // destroys anything. nil disables purging entirely (refusal, logged
+ // once per pass per app) — a box that cannot verify must not destroy.
+ ReleasePub ed25519.PublicKey
+```
+
+In `Reconcile`, after the trashed-routes removal block (Task 2), add:
+
+```go
+ // --- 1d. auto-purge ------------------------------------------------------
+ // Trashed longer than TrashCooloff → destroy. The marker's own
+ // deleted_at is the clock (bucket-side; restore deletes the marker, so
+ // there is nothing to replay). PurgeApp RE-READS the marker immediately
+ // before destroying — this listing is only a hint — and re-checks the
+ // production flag; both guards fail toward keeping data. Idempotent and
+ // race-proof: concurrent boxes converge because every step tolerates
+ // already-gone and the marker goes last.
+ for k, tomb := range trashed {
+ if cfg.Now().Sub(tomb.DeletedAt) < release.TrashCooloff {
+ continue
+ }
+ if err := release.PurgeApp(ctx, s, cfg.ReleasePub, k.acct, k.app, cfg.Now()); err != nil {
+ log.Printf("domains: auto-purge %s/%s: %v", k.acct, k.app, err)
+ continue
+ }
+ log.Printf("domains: purged %s/%s — trashed %s, past the %s cooloff",
+ k.acct, k.app, tomb.DeletedAt.Format(time.RFC3339), release.TrashCooloff)
+ }
+```
+
+(The domains loop that calls Reconcile is already jittered — agent.go:511 `delay = 5*time.Minute + rand.N(time.Minute)` — so the fleet never stampedes; no new jitter machinery.)
+
+Wire the key in `domainsTickConfig` (agent.go:392): parse `CARLOS_RELEASE_PUBKEY` exactly as adoptLoop does at agent.go:198 (`release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")`), tolerating absence (nil key = purging disabled, log it once in domainsLoop).
+
+Reconcile tests: (a) an expired marker purges — keys gone after one pass with a valid pub key and unflagged app; (b) an under-30-days marker does NOT purge; (c) nil ReleasePub → nothing destroyed, routes still dropped; (d) production-flagged trashed app → not purged.
+
+- [ ] **Step 5: Full tree green; commit** — `git commit -m "feat: auto-purge — trashed 30 days becomes destroyed, guards fail toward keeping data"` (+ trailer).
+
+---
+
+### Task 4: Console store — `deleted_at` on apps AND accounts, filtered queries, trash-row verbs (model: Sonnet)
+
+**Files:**
+- Modify: `internal/console/store.go`
+- Test: `internal/console/store_test.go` (extend)
+
+**Interfaces (produced for Tasks 5, 6, 8):**
+- `func (s *Store) TrashAppRow(accountID int64, app string, now time.Time) error` — sets `deleted_at`; already-trashed keeps the ORIGINAL timestamp (guard `AND deleted_at = 0`)
+- `func (s *Store) RestoreAppRow(accountID int64, app string) error` — `deleted_at = 0`
+- `func (s *Store) PurgeAppRow(accountID int64, app string) error` — DELETE the row (the name frees)
+- `type TrashedApp struct { App string; DeletedAt time.Time }`; `func (s *Store) TrashedAppsIn(accountID int64) ([]TrashedApp, error)` sorted by app
+- `func (s *Store) HasTrashedAppIn(accountID int64, app string) (bool, error)`
+- `func (s *Store) ReapAccount(accountID int64, now time.Time) error` — one transaction: DELETE memberships, invites, and apps rows for the account; `UPDATE users SET last_account = 0 WHERE last_account = ?`; `UPDATE accounts SET deleted_at = ? WHERE id = ? AND deleted_at = 0`. Refuses `OpsAccountID`.
+- `func (s *Store) AccountAnyBySqid(sqid string) (*Account, bool, error)` — unfiltered lookup returning (account, deleted, err); the ONLY caller is materialization, which must see marked rows. `Account` needs no new field; the bool carries it.
+- CHANGED SEMANTICS (verify every caller compiles and tests pass):
+ - `AppsIn`, `HasAppIn`, `AppCount` queries gain `AND deleted_at = 0` — every existing door (API resolution, ship claim, account page listings) hides trashed apps automatically. EXCEPTION — `AppCount` deliberately does NOT filter: account deletion must keep refusing while trashed apps exist (their payloads still exist). State this in a comment on AppCount.
+ - `AccountBySqid`, `AccountByID`, `AccountsFor` gain `AND deleted_at = 0` (via `a.deleted_at = 0` in the join query) — a tombstone-marked account is invisible everywhere.
+ - `DeleteAccount` (store.go:254): replace `DELETE FROM accounts WHERE id = ?` with `UPDATE accounts SET deleted_at = ? WHERE id = ?` (pass `time.Now().Unix()` — add a `now time.Time` parameter; the sole caller is `handleDeleteAccount`, ownership.go:147). THE CONSOLE MUST REMEMBER TOMBSTONES: deleting the row forgets the account was ever seen, which re-arms the bucket-writer resurrection attack the spec's §8 describes. Copy that reasoning into the comment.
+- Migration: in the additive ALTER list (store.go:189-202) append:
+
+```go
+ // Trash state for apps (30-day cooloff; the row IS the name
+ // reservation, so it survives trash and dies at purge) and tombstone
+ // MEMORY for accounts: a marked account row is never re-materialized,
+ // which is what stops a rewritten bucket manifest resurrecting a
+ // deleted account — see MaterializeAccounts.
+ `ALTER TABLE apps ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
+ `ALTER TABLE accounts ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
+```
+
+ and in `migrateAppsKey` (store.go:247-257) add `deleted_at INTEGER NOT NULL DEFAULT 0` to the `apps_new` DDL and to the INSERT-SELECT column lists (the ALTERs run before migrateAppsKey in `migrate()`, so the column always exists by the time the rebuild copies it).
+
+- [ ] **Step 1: Failing tests** — extend store_test.go, real code, covering: trash→hidden from AppsIn/HasAppIn but visible in TrashedAppsIn/HasTrashedAppIn; claim-once still refuses a trashed name (`CreateAppIn` on a trashed name → created=false — the PK row exists; add this exact assertion); restore→visible again; purge→`CreateAppIn` succeeds (name freed); TrashAppRow twice keeps the first timestamp; AppCount counts trashed rows; DeleteAccount marks (AccountBySqid → nil after, AccountAnyBySqid → row, deleted=true); ReapAccount clears memberships+apps+last_account and marks; ReapAccount refuses ops.
+- [ ] **Step 2: FAIL** — undefined methods.
+- [ ] **Step 3: Implement** exactly per the interface block above (all plain SQL, matching the file's existing style; keep each verb's comment in the file's voice — say WHY, not what).
+- [ ] **Step 4: Full tree green.** Existing tests exercising DeleteAccount semantics (`deletion_test.go`, `server_account_test.go`) may assert the row is GONE — update those assertions to "invisible via filtered lookups" (AccountBySqid nil) rather than raw-SQL row absence, and add the memory assertion. Do not weaken any 404 assertion.
+- [ ] **Step 5: Commit** — `git commit -m "feat(console): deleted_at on apps and accounts — trash state, tombstone memory, filtered doors"` (+ trailer).
+
+---
+
+### Task 5: Materialization reaps and refuses resurrection; app-row convergence (model: Opus)
+
+**Files:**
+- Modify: `internal/console/materialize.go`
+- Test: `internal/console/materialize_test.go` (extend; it has DirStore + Store fixtures already)
+
+**Interfaces:**
+- Consumes: `AccountAnyBySqid`, `ReapAccount`, `TrashAppRow`, `RestoreAppRow`, `PurgeAppRow`, `HasTrashedAppIn` (Task 4); `release.ReadAppTombstone` (Task 1); `domains.AccountManifestSqid`.
+- Produces: `func MaterializeApps(ctx context.Context, s release.Store, st *Store) error` — called from `materializeQuietly` after `MaterializeAccounts`.
+
+**The security invariant, verbatim into the code comment:** owner adoption is birth-only, and "birth" must mean FIRST-EVER. A marked (tombstoned) account row is never re-materialized and never re-adopted, tombstoned manifest or not — an un-tombstoned manifest over a marked row is logged as corruption. Without the marked row, reap→rewrite→re-birth is a full account takeover for anyone with bucket write access and no keys.
+
+- [ ] **Step 1: Failing tests**:
+
+```go
+func TestMaterializeReapsTombstonedAccounts(t *testing.T) {
+ // Seed: account materialized normally (manifest + first pass), with a
+ // member and an app row and users.last_account pointing at it.
+ // Then: tombstone the manifest (domains.TombstoneAccount) and run
+ // MaterializeAccounts again.
+ // Assert: AccountBySqid nil; AccountAnyBySqid (row, deleted=true);
+ // memberships gone; app rows gone; last_account reset to 0.
+}
+
+func TestMaterializeRefusesResurrection(t *testing.T) {
+ // Continue from a reaped state: rewrite the manifest UN-tombstoned with
+ // owner=eve@evil.example (raw s.Put over the object).
+ // Run MaterializeAccounts. Assert: AccountBySqid STILL nil, eve has NO
+ // membership anywhere, and nothing was adopted. Run it twice (stable).
+}
+
+func TestMaterializeAppsConverges(t *testing.T) {
+ // (a) live row + marker in bucket (trashed headlessly) → row marked;
+ // (b) marked row + no marker + keys still present (restored headlessly)
+ // → row restored (deleted_at = 0);
+ // (c) marked row + no marker + NO keys under the app prefix (purged by
+ // the auto-purge pass) → row DELETED (name freed);
+ // (d) LIVE row + no marker + no keys (a claimed-but-never-shipped app)
+ // → row untouched. This is the guard that stops convergence eating
+ // legitimate unshipped claims.
+}
+```
+
+Write them fully against the existing fixture style; the comments above are the assertions.
+
+- [ ] **Step 2: FAIL.**
+- [ ] **Step 3: Implement.** Restructure `MaterializeAccounts`'s loop: replace the `AccountBySqid` short-circuit (materialize.go:61-65) with:
+
+```go
+ a, deleted, err := st.AccountAnyBySqid(sqid)
+ if err != nil {
+ return n, err
+ }
+```
+
+then read + validate the manifest body (existing code, now unconditional — this is the accepted startup cost: tombstone state lives in the body), and branch:
+
+```go
+ switch {
+ case deleted && m.Deleted():
+ continue // both sides agree the account is retired; nothing to do
+ case deleted && !m.Deleted():
+ // A marked row is tombstone MEMORY, and memory does not argue
+ // with a rewritten manifest: re-materializing here — let alone
+ // re-adopting the owner the manifest now names — would hand the
+ // account, its sqid and its old hostnames to whoever holds
+ // bucket write access. An account id is spent once.
+ log.Printf("console: manifest %s is live again but the account was deleted here — "+
+ "refusing to resurrect it; an account id is spent once", key)
+ continue
+ case a != nil && m.Deleted():
+ // The control plane has ruled; converge on it. This is what
+ // makes a deletion performed anywhere become real here without
+ // hand-run SQL (the Kass gap).
+ if err := st.ReapAccount(a.ID, time.Now()); err != nil {
+ log.Printf("console: reap deleted account %s: %v", sqid, err)
+ } else {
+ log.Printf("console: reaped account %s (%s) — its manifest is tombstoned", sqid, a.Name)
+ }
+ continue
+ case a != nil:
+ continue // known and live: settled. NEVER revisit owner adoption.
+ case m.Deleted():
+ continue // never seen here and already retired: stays unseen
+ }
+ // (fall through to the existing EnsureAccount + materializeOwner path)
+```
+
+Keep `materializeOwner` reachable ONLY from the fall-through (first-ever materialization), preserving its birth-only argument verbatim. Then add `MaterializeApps` implementing the four convergence cases (list `apps/` once; parse `(sqid, app)` pairs via the same appKeyOwner logic — that helper is unexported in domains, so export the minimal thing needed: use `release.AppTombstoneKey` string-matching on the listed keys instead: a key equal to `"apps/"+sqid+"/"+app+"/deleted.json"` for a known account sqid; presence-of-any-keys per app prefix comes from the same listing). Call it from `materializeQuietly` (materialize.go:205) after MaterializeAccounts, same log-and-carry-on policy.
+
+- [ ] **Step 4: Full tree green.** `go vet` too — the switch must not shadow err.
+- [ ] **Step 5: Commit** — `git commit -m "feat(console): materialization reaps tombstones and refuses resurrection"` (+ trailer).
+
+---
+
+### Task 6: Console API — `POST /api/cli/apps/{account}/{app}/delete` and `/restore` (model: Opus)
+
+**Files:**
+- Modify: `internal/console/apicli.go`, `internal/console/server.go` (route table ~line 212-250)
+- Test: `internal/console/apicli_test.go` (extend; it has the token fixture — `postBody`, `fx.ts.URL` — follow apiledger_test.go's shape)
+
+**Interfaces:**
+- Consumes: `apiAccountApp` (delete door — resolves live apps only, since Task 4 filtered `HasAppIn`); `s.cli.write.allow(addr, …)`; `release.TrashApp`, `release.RestoreApp`, `release.GetProduction`; store `TrashAppRow`, `RestoreAppRow`, `HasTrashedAppIn`; `s.audit(acct, app, addr, action, msg)`; `jsonError`, `writeJSON`; `s.cfg.Releases`, `s.cfg.ReleasePub`.
+- Produces: routes `POST /api/cli/apps/{account}/{app}/delete` and `POST /api/cli/apps/{account}/{app}/restore`; a resolver `apiTrashedApp` mirroring `apiAccountApp` but answering only for TRASHED apps (`HasTrashedAppIn`), 404 otherwise — same byte-identical `http.NotFound` for unknown/foreign/not-trashed.
+
+**Gate order for delete (comment it):** token → account+app 404 (`apiAccountApp`) → rate limit → Releases-nil 503 (a console with no bucket cannot trash: a row-only trash would un-hide itself at the next materialize pass) → production flag: ON or UNREADABLE → 409 refusal naming the ratchet → `release.TrashApp` (bucket FIRST, same order as account deletion and for the same reason) → `TrashAppRow` → audit "trash". Membership is enough — any member, bearer token; the 30-day window is the safety net that makes the reversible action cheap.
+
+- [ ] **Step 1: Failing tests** (real code in apicli_test.go): member deletes an app → 200 `{"result":"trashed","restorable_until":…}`, marker exists in `fx` store, `HasAppIn` false, `HasTrashedAppIn` true, ship/promote/ledger doors now 404 that app; restore → 200, marker gone, `HasAppIn` true; non-member delete → byte-identical 404; delete with production flag ON → 409 whose error names the production off-switch; delete with a store whose `Get` on the production key errors (wrap the fixture store) → 409 refusal, NOTHING trashed; restore of a live (untrashed) app → 404; no-token → 401/404 per `cliUser`'s existing convention (read authcli.go and match it).
+- [ ] **Step 2: FAIL** — 404 (routes unregistered).
+- [ ] **Step 3: Implement** `handleAPITrashApp` + `handleAPIRestoreApp` in apicli.go per the gate order above, and `apiTrashedApp` (copy `apiAccountApp` lines 48-101 changing only the final lookup to `HasTrashedAppIn`; keep the resolution + ambiguity behavior IDENTICAL — same 409 wording). `restorable_until` in the response is `tomb.DeletedAt.Add(release.TrashCooloff)` — re-read the marker after writing (or use the write's own timestamp; on idempotent re-trash return the EXISTING marker's window). Register both routes in server.go next to the other `/api/cli/` lines.
+- [ ] **Step 4: Full tree green.**
+- [ ] **Step 5: Commit** — `git commit -m "feat(console): CLI API trash and restore doors — bucket first, production refuses"` (+ trailer).
+
+---
+
+### Task 7: CLI — `carlos apps delete|restore`, signed-in and headless (model: Sonnet)
+
+**Files:**
+- Create: `apps.go` (repo root, package main, next to accounts.go)
+- Modify: `main.go` (dispatch `case "apps":` + usage), `apimode.go` (two thin session methods)
+- Test: `apps_test.go` (unit-test the pure parts; do NOT shell out — if an e2e is added it must use `carlosTestEnv`)
+
+**Interfaces:**
+- Consumes: `apiAccount(raw, rawID *string) apiAcct`, `apiPath`, `(*apiSession).postJSON`, `apiErrorFromBody`, `resolveMode` (apimode.go:62 — read it first: it returns the session for signed-in mode or an error when not logged in); `release.TrashApp`, `release.RestoreApp`, `release.ReadAppTombstone`, `release.GetProduction`, `release.ParsePublicKey`, `domains.ValidSqid`; store opening — copy the env plumbing `runAccountsMigrate` uses (`CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_DEPLOYMENT_DIR`; the comment at cli.go:200 names it).
+- Produces: `func RunApps(args []string)` dispatching `delete` and `restore`. NO `create` case — draft PR #52 owns `apps create`; whichever branch merges second rebases onto the shared `case "apps"` dispatch (note this in a comment at the switch).
+
+**Mode selection (mirror accounts.go's explicitness):** `-account`/`-account-id` as everywhere; signed-in when a logged-in credential resolves (`resolveMode`), POSTing to the Task 6 doors and printing the server's answer (`restorable_until` becomes "restorable until <date> — carlos apps restore <app>"). Headless when NOT logged in AND a deployment store is configured: requires `-account-id <sqid>` (`domains.ValidSqid` — names need a console to resolve; the flag that never guesses is the only safe reference on a box), reads `CARLOS_RELEASE_PUBKEY` and REFUSES delete when the key is absent or the production flag is ON/unreadable (same refusal directions as everywhere), then `release.TrashApp` / `release.RestoreApp` bucket-direct. Neither logged in nor configured → fatal with the two-way guidance message pattern accounts.go:112-118 uses. There is NO purge verb — "Delete now" is console-only; say so in the usage text.
+
+- [ ] **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 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).
+
+---
+
+### Task 8: Console Trash page — Restore (cheap) and Delete now (ceremonial) (model: Opus)
+
+**Files:**
+- Modify: `internal/console/server.go` (routes), `internal/console/view.go` + `internal/console/templates/` (read the dir; follow the account-home template's structure and the existing form/CSRF idiom exactly), `internal/console/ownership.go` (the purge handler lives beside `handleDeleteAccount` — same family)
+- Test: `internal/console/deletion_test.go` (extend — it already tests handleDeleteAccount's gates; mirror its fixture)
+
+**Interfaces:**
+- 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/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.
+
+- [ ] **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.**
+- [ ] **Step 3: Implement**, template included (server-rendered, vendored-Lucide icons only if any — no CDN, emoji is content not chrome).
+- [ ] **Step 4: Full tree green.**
+- [ ] **Step 5: Commit** — `git commit -m "feat(console): the Trash — restore is cheap, Delete now is ceremonial"` (+ trailer).
+
+---
+
+### Task 9: `SetProduction` refuses a trashed app (model: Sonnet)
+
+**Files:**
+- Modify: `internal/release/production.go` (`SetProduction`, line 61)
+- Test: `internal/release/production_test.go` (extend)
+
+**Interfaces:** Consumes `ReadAppTombstone` (same package). Produces nothing new.
+
+- [ ] **Step 1: Failing test**: trash an app (Task 1 primitive), then `SetProduction(…, production=true)` → error naming the trash; marker read error (wrapping store) → error; untrashed app → unchanged behavior (existing tests prove it).
+- [ ] **Step 2: FAIL.**
+- [ ] **Step 3: Implement** — at the top of `SetProduction`, before any write:
+
+```go
+ // A trashed app cannot be declared production: §3 already refuses
+ // ship/promote to one, and a production flag appearing on an app in the
+ // trash would contradict the flag's own promise (production = never
+ // deletable). Unreadable refuses too — the usual direction.
+ tomb, err := ReadAppTombstone(ctx, s, acct, app)
+ if err != nil {
+ return fmt.Errorf("release: cannot check the trash marker for %s/%s: %w", acct, app, err)
+ }
+ if tomb != nil {
+ return fmt.Errorf("release: %s/%s is in the trash — restore it before flagging it production", acct, app)
+ }
+```
+
+(Adjust parameter names to SetProduction's real signature.) Setting the flag to FALSE on a trashed app is also refused by this check, deliberately: there is nothing legitimate to do to a trashed app's flag; restore first.
+
+- [ ] **Step 4: Full tree green.**
+- [ ] **Step 5: Commit** — `git commit -m "fix(release): a trashed app cannot be flagged production"` (+ trailer).
+
+---
+
+## Final gate (dispatcher, after Task 9)
+
+- `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1` — everything, one run, verbatim output in the report.
+- `gofmt -l` on every touched file.
+- superpowers:requesting-code-review — **Opus, whole branch**, with the spec as the review brief. Named review focuses: gate ORDER in Task 8 byte-for-byte against handleDeleteAccount; the resurrection refusal (Task 5) against materializeOwner's comment; PurgeApp's marker-last ordering; that no door ever confirms an account's existence to a non-member.
+- Draft PR onto main titled "App deletion: trash, 30-day cooloff, purge — and the console remembers tombstones", body linking spec + plan. At merge time (NOT now): website-feature-sync batch, tag, and the deploy order promote → adopt → **restart console**.