carlos / platform Public

feat(release): app trash marker — additive deleted.json, validated reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4645a9ea5239f0567895406775c9e9a4eff08a54 parent 58e4017
2 files changed, +164 −0
  • internal/release/trash.go +88 −0
  • internal/release/trash_test.go +76 −0
diff --git a/internal/release/trash.go b/internal/release/trash.go
new file mode 100644
index 000000000..8d158c9f9
--- /dev/null
+++ b/internal/release/trash.go
@@ -0,0 +1,88 @@
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "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 errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != 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))
+}
diff --git a/internal/release/trash_test.go b/internal/release/trash_test.go
new file mode 100644
index 000000000..7cd2dceaf
--- /dev/null
+++ b/internal/release/trash_test.go
@@ -0,0 +1,76 @@
+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")
+ }
+}