// 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///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///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 claimRawSetRetention(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 claimRawClearRetention(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)) } func ClearRetention(ctx context.Context, s Store, acct, app string) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.ClearRetention", func(claimContext context.Context) error { claimResult0 = claimRawClearRetention(claimContext, s, acct, app) return claimResult0 }) return } func SetRetention(ctx context.Context, s Store, acct, app string, keep int, setBy string, now time.Time) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.SetRetention", func(claimContext context.Context) error { claimResult0 = claimRawSetRetention(claimContext, s, acct, app, keep, setBy, now) return claimResult0 }) return }