carlos / platform Public

release: the purge's guards factored out, a sweep that keeps the keys a caller retired, and the expired-trash listing

PurgeAppGuards is every guard PurgeAppIfTrashedAt applies (the marker and
its clock, the release key, the production flag, the store-estate guard)
and nothing else, so the console's lifetime purge asks them before any
lifetime step. PurgeAppExcept is the same purge whose sweep leaves the keys
the caller names (the instance records it retired through their own
protocol); the marker still goes last. ExpiredTrashApps lists every valid
marker past TrashCooloff, the store-driven sweep the console's expiry pass
owns now that claims-off boxes purge nothing. PurgeApp and
PurgeAppIfTrashedAt are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8596a764beadcfad63e6f3abdeaa8e8f0216a904 parent c354c19
3 files changed, +156 −4
  • internal/release/temporary.go +41 −1
  • internal/release/temporary_test.go +57 −0
  • internal/release/trash.go +58 −3
diff --git a/internal/release/temporary.go b/internal/release/temporary.go
index 351455b51..f581bcb11 100644
--- a/internal/release/temporary.go
+++ b/internal/release/temporary.go
@@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"errors"
"fmt"
+ "log"
"strings"
"time"
)
@@ -47,5 +48,44 @@ func PurgeAppIfTrashedAt(ctx context.Context, s Store, pub ed25519.PublicKey, ac
t.DeletedAt.UTC().Format(time.RFC3339), wantDeletedAt.UTC().Format(time.RFC3339), ErrTrashClockMismatch)
}
return nil
- })
+ }, nil)
+}
+
+// TrashedApp is one app ExpiredTrashApps found.
+type TrashedApp struct {
+ Account, App string
+ DeletedAt time.Time
+}
+
+// ExpiredTrashApps lists every app whose valid trash marker is older than
+// TrashCooloff at now: the store-driven half of the trash sweep, which the
+// console's expiry pass owns on a claims-off deployment now that boxes purge
+// nothing (self-serve moves plan 1, Task 4). It reads the apps/ listing and
+// each marker; a marker that will not validate is logged and skipped, never
+// acted on. The list is a hint: the purge re-reads the marker under the app
+// lock before it acts.
+func ExpiredTrashApps(ctx context.Context, s Store, now time.Time) ([]TrashedApp, error) {
+ keys, err := s.List(ctx, "apps/")
+ if err != nil {
+ return nil, err
+ }
+ var out []TrashedApp
+ for _, key := range keys {
+ parts := strings.Split(key, "/")
+ if len(parts) != 4 || parts[0] != "apps" || key != AppTombstoneKey(parts[1], parts[2]) {
+ continue
+ }
+ tomb, err := ReadAppTombstone(ctx, s, parts[1], parts[2])
+ if err != nil || tomb == nil {
+ if err != nil {
+ log.Printf("release: the trash sweep skips %s: %v", key, err)
+ }
+ continue
+ }
+ if now.Sub(tomb.DeletedAt) < TrashCooloff {
+ continue
+ }
+ out = append(out, TrashedApp{Account: parts[1], App: parts[2], DeletedAt: tomb.DeletedAt})
+ }
+ return out, nil
}
diff --git a/internal/release/temporary_test.go b/internal/release/temporary_test.go
index 48ea68266..0b9a2da16 100644
--- a/internal/release/temporary_test.go
+++ b/internal/release/temporary_test.go
@@ -84,3 +84,60 @@ func TestPurgeAppIfTrashedAtKeepsPurgeAppsGuards(t *testing.T) {
t.Fatal("a refused purge destroyed objects")
}
}
+
+// The guards alone, and the sweep that keeps the keys a caller retired its
+// own way (self-serve moves plan 1, Task 4).
+func TestPurgeAppGuardsAndTheSweepThatKeepsKeys(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ _, pub := testKeys(t)
+ seedApp(t, s, "bab", "shop")
+ keep := "apps/bab/shop/instances/one.shop.example.com.json"
+ if _, err := s.Put(ctx, keep, []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeAppGuards(ctx, s, pub, "bab", "shop", time.Time{}, time.Now()); !errors.Is(err, ErrNotTrashed) {
+ t.Fatalf("guards on a live app = %v, want ErrNotTrashed", err)
+ }
+ trashedAt := time.Date(2026, 9, 26, 10, 0, 0, 0, time.UTC)
+ if err := TrashApp(ctx, s, "bab", "shop", "paul@keymail.dev", trashedAt); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeAppGuards(ctx, s, pub, "bab", "shop", trashedAt.Add(time.Hour), time.Now()); !errors.Is(err, ErrTrashClockMismatch) {
+ t.Fatalf("guards on another trash = %v, want ErrTrashClockMismatch", err)
+ }
+ if err := PurgeAppGuards(ctx, s, nil, "bab", "shop", time.Time{}, time.Now()); err == nil {
+ t.Fatal("guards with no release key passed")
+ }
+ before := listUnder(t, s, "apps/bab/shop/")
+ if err := PurgeAppGuards(ctx, s, pub, "bab", "shop", trashedAt, time.Now()); err != nil {
+ t.Fatalf("guards = %v", err)
+ }
+ if got := listUnder(t, s, "apps/bab/shop/"); len(got) != len(before) {
+ t.Fatal("the guards destroyed something")
+ }
+ if err := PurgeAppExcept(ctx, s, pub, "bab", "shop", trashedAt, time.Now(), func(k string) bool { return k == keep }); err != nil {
+ t.Fatalf("purge = %v", err)
+ }
+ if got := listUnder(t, s, "apps/bab/shop/"); len(got) != 1 || got[0] != keep {
+ t.Fatalf("after the purge = %v, want only the kept key", got)
+ }
+}
+
+func TestExpiredTrashAppsListsMarkersPastTheCooloff(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
+ for app, at := range map[string]time.Time{"old": now.Add(-31 * 24 * time.Hour), "new": now.Add(-time.Hour)} {
+ seedApp(t, s, "bab", app)
+ if err := TrashApp(ctx, s, "bab", app, "paul@keymail.dev", at); err != nil {
+ t.Fatal(err)
+ }
+ }
+ seedApp(t, s, "bab", "live")
+ if _, err := s.Put(ctx, AppTombstoneKey("bab", "broken"), []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ExpiredTrashApps(ctx, s, now)
+ if err != nil || len(got) != 1 || got[0].Account != "bab" || got[0].App != "old" {
+ t.Fatalf("expired = %+v %v, want bab/old alone", got, err)
+ }
+}
diff --git a/internal/release/trash.go b/internal/release/trash.go
index 797f09e60..8818ea57d 100644
--- a/internal/release/trash.go
+++ b/internal/release/trash.go
@@ -193,12 +193,49 @@ func purgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app s
// and no future guard added inside this function has to reach for
// time.Now() behind the tests' backs.
func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error {
- return purgeApp(ctx, s, pub, acct, app, nil)
+ return purgeApp(ctx, s, pub, acct, app, nil, nil)
+}
+
+// PurgeAppGuards is every guard PurgeAppIfTrashedAt applies, and nothing
+// else: the marker (and, when wantDeletedAt is not zero, its clock), the
+// release key, the production flag and the store-estate guard (self-serve
+// moves plan 1, Task 4). The console's lifetime purge asks it before any
+// lifetime step, so a purge these guards refuse retires nothing. now is the
+// caller's clock seam, as for PurgeApp.
+func PurgeAppGuards(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, wantDeletedAt, now time.Time) error {
+ if err := purgeReady(ctx, s, pub, acct, app, trashedAtCheck(acct, app, wantDeletedAt)); err != nil {
+ return err
+ }
+ return purgeStoreGuard(ctx, s, acct, app)
+}
+
+// PurgeAppExcept is PurgeAppIfTrashedAt (a zero wantDeletedAt checks no
+// clock) whose sweep leaves every key keep reports: the console's lifetime
+// purge has already retired the instance records through their own protocol
+// and keeps the sweep off those keys. The guards run again first, and the
+// marker still goes last.
+func PurgeAppExcept(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, wantDeletedAt, now time.Time, keep func(key string) bool) error {
+ return purgeApp(ctx, s, pub, acct, app, trashedAtCheck(acct, app, wantDeletedAt), keep)
+}
+
+// trashedAtCheck is PurgeAppIfTrashedAt's clock check, or nil for a zero
+// wantDeletedAt.
+func trashedAtCheck(acct, app string, wantDeletedAt time.Time) func(*AppTombstone) error {
+ if wantDeletedAt.IsZero() {
+ return nil
+ }
+ return func(t *AppTombstone) error {
+ if t.DeletedAt.Unix() != wantDeletedAt.Unix() {
+ return fmt.Errorf("release: %s/%s was trashed at %s, not %s: %w", acct, app,
+ t.DeletedAt.UTC().Format(time.RFC3339), wantDeletedAt.UTC().Format(time.RFC3339), ErrTrashClockMismatch)
+ }
+ return nil
+ }
}
// purgeApp is PurgeApp's body with purgeReady's optional check threaded
-// through. Everything below the precondition call is PurgeApp as it was.
-func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error {
+// through, and keep (nil keeps nothing) naming keys the sweep leaves.
+func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error, keep func(string) bool) error {
// The preconditions live in purgeReady so that the console's purge door,
// which must satisfy them BEFORE it tears an object store down, checks the
// same list this function does. One list, one place: a guard added there is
@@ -206,6 +243,14 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str
if err := purgeReady(ctx, s, pub, acct, app, check); err != nil {
return err
}
+ if err := purgeStoreGuard(ctx, s, acct, app); err != nil {
+ return err
+ }
+ return purgeSweep(ctx, s, acct, app, keep)
+}
+
+// purgeStoreGuard is the store-estate guard of purgeApp.
+func purgeStoreGuard(ctx context.Context, s Store, acct, app string) error {
prefix := appPrefix(acct, app)
// An object store with backend estate refuses the purge outright (objstore
// spec §9): auto-purge runs on BOXES, boxes hold no backend authority, and
@@ -268,6 +313,13 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str
return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned)
}
}
+ return nil
+}
+
+// purgeSweep is purgeApp's destruction: every key of the app but those keep
+// reports, the trash marker last.
+func purgeSweep(ctx context.Context, s Store, acct, app string, keep func(string) bool) error {
+ prefix := appPrefix(acct, app)
marker := AppTombstoneKey(acct, app)
keys, err := s.List(ctx, prefix)
if err != nil {
@@ -298,6 +350,9 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str
if key == marker {
continue // last, below
}
+ if keep != nil && keep(key) {
+ continue // the caller retired it its own way
+ }
if err := s.Delete(ctx, key); err != nil {
return fmt.Errorf("release: purge %s: %w", key, err)
}