feat(release): app trash marker — additive deleted.json, validated reads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+164
−0
internal/release/trash.go+88 −0internal/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") |
| + } |
| +} |