carlos / platform Public

fix(console): PurgeAppRow refuses a live row — only trashed rows are purgeable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0b576bb47342cbacc8fd08f18c9521748e4436b4 parent 8e2d97e
2 files changed, +53 −3
  • internal/console/store.go +10 −3
  • internal/console/store_test.go +43 −0
diff --git a/internal/console/store.go b/internal/console/store.go
index ee6da2fc7..d3585aac0 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1087,10 +1087,17 @@ func (s *Store) RestoreAppRow(accountID int64, app string) error {
}
// PurgeAppRow deletes the row outright — the one place trash state actually
-// ends. The row IS the name reservation (see CreateAppIn), so this is also
-// the moment the name frees for reuse; nothing before it may delete the row.
+// ends, and the only irreversible verb in this set. The row IS the name
+// reservation (see CreateAppIn), so this is also the moment the name frees
+// for reuse.
+//
+// Guarded by `deleted_at != 0`: a live row must be structurally unpurgeable,
+// because the console row and the bucket marker can diverge (a restore wrote
+// the row back while a marker delete failed elsewhere, or raced it) and the
+// auto-purge pass this feeds must never be the thing that frees a serving
+// app's name out from under it.
func (s *Store) PurgeAppRow(accountID int64, app string) error {
- _, err := s.db.Exec(`DELETE FROM apps WHERE account_id = ? AND app = ?`, accountID, app)
+ _, err := s.db.Exec(`DELETE FROM apps WHERE account_id = ? AND app = ? AND deleted_at != 0`, accountID, app)
return err
}
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index 3b778b001..594b4ce6f 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -479,6 +479,49 @@ func TestTrashAppRowRoundTrip(t *testing.T) {
// TestAppCountCountsTrashedRows: account deletion's pre-flight must keep
// refusing while a trashed app's payload still exists, so the count is
// deliberately unfiltered by deleted_at.
+// TestPurgeAppRowRefusesLiveRow: purge is the one irreversible verb here, so a
+// live row must be structurally unpurgeable even if a caller gets confused —
+// the console row and a bucket marker can diverge (a restore that raced or
+// out-lived a failed marker delete elsewhere), and that must never be the
+// thing that frees a serving app's name.
+func TestPurgeAppRowRefusesLiveRow(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ acct := testAccountID()
+ if _, err := s.CreateAppIn(acct, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.TrashAppRow(acct, "kass", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RestoreAppRow(acct, "kass"); err != nil {
+ t.Fatal(err)
+ }
+ // Live again: PurgeAppRow must refuse, not free the name.
+ if err := s.PurgeAppRow(acct, "kass"); err != nil {
+ t.Fatal(err)
+ }
+ if has, err := s.HasAppIn(acct, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn after purging a restored (live) row = %v %v, want true — the row must survive", has, err)
+ }
+ if created, err := s.CreateAppIn(acct, "kass", "bob@keymail.dev", now); err != nil || created {
+ t.Fatalf("CreateAppIn after purging a live row = %v %v, want false — the name is still reserved", created, err)
+ }
+ // Trashed: PurgeAppRow now takes effect.
+ if err := s.TrashAppRow(acct, "kass", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.PurgeAppRow(acct, "kass"); err != nil {
+ t.Fatal(err)
+ }
+ if has, err := s.HasTrashedAppIn(acct, "kass"); err != nil || has {
+ t.Fatalf("HasTrashedAppIn after purging a trashed row = %v %v, want false — the row should be gone", has, err)
+ }
+ if created, err := s.CreateAppIn(acct, "kass", "bob@keymail.dev", now); err != nil || !created {
+ t.Fatalf("CreateAppIn after purge = %v %v, want true — the name should be free", created, err)
+ }
+}
+
func TestAppCountCountsTrashedRows(t *testing.T) {
s := openTestStore(t)
now := time.Now()