docs: deletion-policy implementation plan — 9 tasks, TDD, model policy per task
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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**. |