carlos / platform Public

release: retention policy record (keep newest N, floor 20)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0ccd25527e2598c079a4dc55ef7ddc55e342f1e1 parent 6299ae2
2 files changed, +213 −0
  • internal/release/retention.go +125 −0
  • internal/release/retention_test.go +88 −0
diff --git a/internal/release/retention.go b/internal/release/retention.go
new file mode 100644
index 000000000..fb5184146
--- /dev/null
+++ b/internal/release/retention.go
@@ -0,0 +1,125 @@
+// The retention policy record: one small unsigned object per app that says
+// "keep the newest N shipped releases; the rest may be pruned" (issue #136,
+// spec 2026-08-10-releases-retention-design.md §3). Unsigned like
+// restart-gen.json because it carries bounded authority: the safety list in
+// prune.go — channel pointers, rollback history, tags, the bake ceiling —
+// always wins over it, so the worst a forged record can do is delete old,
+// unpromoted, untagged CI churn sooner than intended.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/retention.json CAS-guarded, schema-1 policy record
+//
+// ABSENT IS LEGAL FOREVER: no record means retention is off — the
+// instances.json convention. Old boxes that predate this feature ignore the
+// key entirely; the wire shape is purely additive.
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+)
+
+// KeepFloor is the smallest keep a retention policy may name, matching the
+// CLI's releasesCap and the console's versionsCap (both 20): the floor
+// guarantees a pruned hole can never appear inside what either listing
+// shows.
+const KeepFloor = 20
+
+// Retention is the wire shape at apps/<acct>/<app>/retention.json.
+type Retention struct {
+ Schema int `json:"schema"` // 1
+ App string `json:"app"` // must agree with the key path
+ Keep int `json:"keep"` // >= KeepFloor
+ SetBy string `json:"set_by,omitempty"`
+ SetAt time.Time `json:"set_at"`
+}
+
+// RetentionKey is the bucket key of app's retention record, derived from
+// AppPrefix like every other per-app key.
+func RetentionKey(acct, app string) string { return AppPrefix(acct, app) + "retention.json" }
+
+// GetRetention reads the app's retention policy. (nil, nil) when absent —
+// retention off is a legal permanent state, ReadAppTombstone's convention.
+// A record that lies about identity, carries an unknown schema, or names a
+// keep below the floor is refused, never repaired: the enforcement pass
+// must skip the app (fail closed), not guess.
+func GetRetention(ctx context.Context, s Store, acct, app string) (*Retention, error) {
+ key := RetentionKey(acct, app)
+ raw, _, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var ret Retention
+ if err := json.Unmarshal(raw, &ret); err != nil {
+ return nil, fmt.Errorf("retention at %s: %w", key, err)
+ }
+ if ret.Schema != 1 {
+ return nil, fmt.Errorf("retention at %s: unknown schema %d", key, ret.Schema)
+ }
+ if ret.App != app {
+ return nil, fmt.Errorf("retention at %s names app %q", key, ret.App)
+ }
+ if ret.Keep < KeepFloor {
+ return nil, fmt.Errorf("retention at %s: keep %d below floor %d", key, ret.Keep, KeepFloor)
+ }
+ return &ret, nil
+}
+
+// SetRetention writes the policy, replacing any existing one. CAS with a
+// short retry: two members racing a set is last-writer-wins, which is the
+// right answer for a policy knob — but a conflict must re-read the etag,
+// never blind-write.
+func SetRetention(ctx context.Context, s Store, acct, app string, keep int, setBy string, now time.Time) error {
+ if !ValidAccount(acct) {
+ return fmt.Errorf("retention: invalid account %q", acct)
+ }
+ if !ValidApp(app) {
+ return fmt.Errorf("retention: invalid app %q", app)
+ }
+ if keep < KeepFloor {
+ return fmt.Errorf("retention: keep %d below floor %d", keep, KeepFloor)
+ }
+ raw, err := json.Marshal(Retention{Schema: 1, App: app, Keep: keep, SetBy: setBy, SetAt: now.UTC()})
+ if err != nil {
+ return err
+ }
+ key := RetentionKey(acct, app)
+ var lastErr error
+ for attempt := 0; attempt < 3; attempt++ {
+ _, etag, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ etag = ""
+ } else if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ if errors.Is(err, ErrCASConflict) {
+ lastErr = err
+ continue
+ }
+ return err
+ }
+ return nil
+ }
+ return fmt.Errorf("retention: could not write %s: %w", key, lastErr)
+}
+
+// ClearRetention turns retention off by deleting the record. Idempotent by
+// the store's own Delete contract.
+func ClearRetention(ctx context.Context, s Store, acct, app string) error {
+ if !ValidAccount(acct) {
+ return fmt.Errorf("retention: invalid account %q", acct)
+ }
+ if !ValidApp(app) {
+ return fmt.Errorf("retention: invalid app %q", app)
+ }
+ return s.Delete(ctx, RetentionKey(acct, app))
+}
diff --git a/internal/release/retention_test.go b/internal/release/retention_test.go
new file mode 100644
index 000000000..9c8fc3202
--- /dev/null
+++ b/internal/release/retention_test.go
@@ -0,0 +1,88 @@
+package release
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestRetentionRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
+
+ // Absent is legal forever: off, not an error.
+ got, err := GetRetention(ctx, s, "bab", "kass")
+ if err != nil || got != nil {
+ t.Fatalf("absent retention = %v, %v; want nil, nil", got, err)
+ }
+
+ if err := SetRetention(ctx, s, "bab", "kass", 50, "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ got, err = GetRetention(ctx, s, "bab", "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil || got.Keep != 50 || got.SetBy != "alice@keymail.dev" || !got.SetAt.Equal(now) {
+ t.Fatalf("retention = %+v, want keep 50 by alice", got)
+ }
+
+ // Overwrite: a second set replaces the first (CAS re-read, not create-only).
+ if err := SetRetention(ctx, s, "bab", "kass", 30, "bob@keymail.dev", now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = GetRetention(ctx, s, "bab", "kass")
+ if got == nil || got.Keep != 30 {
+ t.Fatalf("after overwrite = %+v, want keep 30", got)
+ }
+
+ // Clear: record gone, absent again, and clearing twice is a no-op
+ // (Delete is idempotent by store contract).
+ if err := ClearRetention(ctx, s, "bab", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ if err := ClearRetention(ctx, s, "bab", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := GetRetention(ctx, s, "bab", "kass"); err != nil || got != nil {
+ t.Fatalf("after clear = %v, %v; want nil, nil", got, err)
+ }
+}
+
+func TestSetRetentionRefusesBelowFloor(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ err := SetRetention(context.Background(), s, "bab", "kass", KeepFloor-1, "x", time.Now())
+ if err == nil || !strings.Contains(err.Error(), "20") {
+ t.Fatalf("keep below floor = %v, want an error naming the floor", err)
+ }
+}
+
+// A record that lies about identity is never guessed at (provision.Record's
+// rule): wrong app in the body, or an unknown schema, refuses.
+func TestGetRetentionRefusesLiesAndUnknownSchema(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ put := func(body string) {
+ if _, err := s.Put(ctx, RetentionKey("bab", "kass"), []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.Delete(ctx, RetentionKey("bab", "kass")) })
+ }
+ put(`{"schema":1,"app":"other","keep":50,"set_at":"2026-08-10T12:00:00Z"}`)
+ if _, err := GetRetention(ctx, s, "bab", "kass"); err == nil {
+ t.Fatal("a body naming a different app was accepted")
+ }
+ s.Delete(ctx, RetentionKey("bab", "kass"))
+ put(`{"schema":2,"app":"kass","keep":50,"set_at":"2026-08-10T12:00:00Z"}`)
+ if _, err := GetRetention(ctx, s, "bab", "kass"); err == nil {
+ t.Fatal("an unknown schema was accepted")
+ }
+ s.Delete(ctx, RetentionKey("bab", "kass"))
+ // Keep below floor in a stored record is corrupt, not a policy.
+ put(`{"schema":1,"app":"kass","keep":3,"set_at":"2026-08-10T12:00:00Z"}`)
+ if _, err := GetRetention(ctx, s, "bab", "kass"); err == nil {
+ t.Fatal("a stored keep below the floor was accepted")
+ }
+}