carlos / platform Public

plan: releases retention implementation (7 tasks, TDD)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6299ae2dbc1713f3def7ce29e6dd244009c707ed parent 712e2fe
1 file changed, +1901 −0
  • docs/superpowers/plans/2026-08-10-releases-retention.md +1901 −0
diff --git a/docs/superpowers/plans/2026-08-10-releases-retention.md b/docs/superpowers/plans/2026-08-10-releases-retention.md
new file mode 100644
index 000000000..081905a04
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-releases-retention.md
@@ -0,0 +1,1901 @@
+# Releases Retention Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ambient per-app release retention (issue #136): a member sets `keep N` via `carlos releases retention`, boxes continuously delete old unprotected releases, per spec `docs/superpowers/specs/2026-08-10-releases-retention-design.md`.
+
+**Architecture:** A plain-JSON policy record at `apps/<acct>/<app>/retention.json`, written through a new member CLI door on the console (restart-style gates); a shared fail-closed selector `release.PruneEligible` (keep-N + 72h bake ceiling + channel pointers/history + tag releases + caller extras); a new 5-min slow loop in the host agent that runs the selector per hosted app and deletes manifest-first.
+
+**Tech Stack:** Go, existing `internal/release` Store/ModLister machinery (PR #134), `internal/registry`, `internal/console`, `internal/host`.
+
+## Global Constraints
+
+- Branch: `worktree-releases-retention` (worktree `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/releases-retention`, based on main `bdadb3f`). Run everything from the worktree.
+- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, and the sandbox disabled ("read-only file system" from the toolchain is the tell).
+- Gate before ANY commit lands: `go build ./...`, `go vet ./...`, affected-package tests; full `go test ./... -count=1` + `GOOS=darwin go build ./...` + `gofmt -l` (clean for touched files) in the final task.
+- Wire shapes are additive; `Retention` is schema-pinned to 1, unknown schema refuses.
+- API identity failures are one byte-identical 404 (`apiAccountApp` provides this — never add a different refusal before it).
+- Any test that shells out to the carlos binary must build env via `carlosTestEnv` AND set `cmd.Dir` to a scratch dir.
+- `KeepFloor = 20`, bake ceiling = `HoldFor(DefaultHolds, "")` (72h, fail-closed) — never fresh constants.
+- Commit messages end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
+
+---
+
+### Task 1: Retention record
+
+**Files:**
+- Create: `internal/release/retention.go`
+- Test: `internal/release/retention_test.go`
+
+**Interfaces:**
+- Consumes: existing `Store`, `AppPrefix`, `ValidAccount`, `ValidApp`, `ErrCASConflict` (all in `internal/release`).
+- Produces (later tasks use these exact names):
+ - `const KeepFloor = 20`
+ - `type Retention struct { Schema int; App string; Keep int; SetBy string; SetAt time.Time }`
+ - `func RetentionKey(acct, app string) string`
+ - `func GetRetention(ctx context.Context, s Store, acct, app string) (*Retention, error)` — `(nil, nil)` when absent
+ - `func SetRetention(ctx context.Context, s Store, acct, app string, keep int, setBy string, now time.Time) error`
+ - `func ClearRetention(ctx context.Context, s Store, acct, app string) error`
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/release/retention_test.go` (package `release`, like `versions_test.go`):
+
+```go
+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")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run TestRetention -count=1` (with the Global Constraints env, sandbox off)
+Expected: FAIL — `undefined: GetRetention` etc.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/release/retention.go`:
+
+```go
+// 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))
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/release/ -run TestRetention -count=1` — PASS; then `go test ./internal/release/ -count=1` (whole package still green), `go vet ./internal/release/`, `gofmt -l internal/release/retention.go internal/release/retention_test.go` (no output).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/retention.go internal/release/retention_test.go
+git commit -m "release: retention policy record (keep newest N, floor 20)
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Ranking core extraction + PruneEligible + DeleteVersion
+
+**Files:**
+- Modify: `internal/release/candidates.go` (extract the ranked core; `VersionCandidates` behavior byte-identical)
+- Create: `internal/release/prune.go`
+- Test: `internal/release/prune_test.go`
+
+**Interfaces:**
+- Consumes: `ModLister`, `Store`, `ChannelsPrefix`, `Channel(ctx, s, pub, acct, app, channel) (*ChannelPointer, error)`, `ListTags(ctx, s, acct, app) ([]Tag, error)`, `HoldFor(DefaultHolds, "")`, `ValidVersion`, `ManifestKey`, `ReleasesPrefix`, Task 1's `KeepFloor`.
+- Produces:
+ - `func PruneEligible(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, keep int, now time.Time, alsoProtected map[string]bool) ([]string, error)` — eligible versions, oldest-first
+ - `func DeleteVersion(ctx context.Context, s Store, acct, app, version string) error` — manifest first, then artifacts
+ - unexported `rankedVersionStamps(ctx, ml ModLister, prefix string) ([]versionStamp, error)` with `type versionStamp struct { version string; mod time.Time; manifest bool }`
+
+- [ ] **Step 1: Extract the ranked core in candidates.go**
+
+Replace `VersionCandidates`'s body so the ModLister branch delegates to a new shared function. The `note` closure moves into `rankedVersionStamps`; the plain-Store fallback keeps its own copy of the aggregation with zero times. Full new content of the two functions:
+
+```go
+// versionStamp is one distinct version under a releases prefix with the
+// stamp candidate ranking uses: manifest.json's own mod time once seen,
+// else the newest object's.
+type versionStamp struct {
+ version string
+ mod time.Time
+ manifest bool
+}
+
+// noteKey folds one listed key into the per-version aggregation — the
+// manifest-stamp-wins rule VersionCandidates has always applied, shared so
+// prune's selector and the render path can never disagree about a
+// version's age.
+func noteKey(cands []versionStamp, index map[string]int, prefix, key string, mod time.Time) []versionStamp {
+ v, rest, ok := strings.Cut(strings.TrimPrefix(key, prefix), "/")
+ if !ok || v == "" {
+ return cands
+ }
+ isManifest := rest == "manifest.json"
+ i, seen := index[v]
+ if !seen {
+ index[v] = len(cands)
+ return append(cands, versionStamp{version: v, mod: mod, manifest: isManifest})
+ }
+ switch {
+ case isManifest:
+ cands[i].mod, cands[i].manifest = mod, true
+ case !cands[i].manifest && mod.After(cands[i].mod):
+ cands[i].mod = mod
+ }
+ return cands
+}
+
+// rankedVersionStamps lists EVERY distinct version under prefix from a
+// mod-aware store, newest first by the manifest-stamp-wins rule, ties
+// broken by version name descending. Uncapped: VersionCandidates caps for
+// rendering; PruneEligible needs the whole history.
+func rankedVersionStamps(ctx context.Context, ml ModLister, prefix string) ([]versionStamp, error) {
+ keys, err := ml.ListMod(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ var cands []versionStamp
+ index := map[string]int{}
+ for _, km := range keys {
+ cands = noteKey(cands, index, prefix, km.Key, km.Mod)
+ }
+ sort.SliceStable(cands, func(i, j int) bool {
+ if !cands[i].mod.Equal(cands[j].mod) {
+ return cands[i].mod.After(cands[j].mod)
+ }
+ return cands[i].version > cands[j].version
+ })
+ return cands, nil
+}
+```
+
+`VersionCandidates` keeps its doc comment and signature; its body becomes:
+
+```go
+func VersionCandidates(ctx context.Context, s Store, acct, app string, max int) (versions []string, ranked bool, err error) {
+ prefix := ReleasesPrefix(acct, app)
+ var cands []versionStamp
+ ml, ranked := s.(ModLister)
+ if ranked {
+ cands, err = rankedVersionStamps(ctx, ml, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ if len(cands) > max {
+ cands = cands[:max]
+ }
+ } else {
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, false, err
+ }
+ index := map[string]int{}
+ for _, k := range keys {
+ cands = noteKey(cands, index, prefix, k, time.Time{})
+ }
+ }
+ versions = make([]string, len(cands))
+ for i, c := range cands {
+ versions[i] = c.version
+ }
+ return versions, ranked, nil
+}
+```
+
+- [ ] **Step 2: Verify the refactor is behavior-preserving**
+
+Run: `go test ./internal/release/ -run TestVersionCandidates -count=1`
+Expected: PASS (both `Ranked` and `Fallback` — these tests pin the old behavior and must not change).
+
+- [ ] **Step 3: Write the failing prune tests**
+
+`internal/release/prune_test.go` (package `release`; reuses `modStamp` from `versions_test.go`):
+
+```go
+package release
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// pruneFixture seeds n versions v01..vNN with ascending hour-apart stamps
+// ending 30 days before now, so age protection (72h) never applies unless a
+// test asks for it. Returns the signing pair for pointer/production writes.
+func pruneFixture(t *testing.T, n int) (dir string, s *DirStore, now time.Time) {
+ t.Helper()
+ dir = t.TempDir()
+ s = NewDirStore(dir)
+ now = time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
+ base := now.Add(-30 * 24 * time.Hour)
+ for i := 1; i <= n; i++ {
+ v := version(i)
+ modStamp(t, s, dir, ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour))
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), base.Add(time.Duration(i)*time.Hour))
+ }
+ return dir, s, now
+}
+
+func version(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
+```
+
+Signing recipe (used inline below; `prune_test.go` is package `release`, so the names are unqualified): `GenerateKey() (seedB64, pubB64, err)`, `ParseSigningKey(seedB64, "test") ed25519.PrivateKey`, `ParsePublicKey(pubB64, "test") ed25519.PublicKey` — exactly `laddercheck_test.go`'s recipe.
+
+The tests:
+
+```go
+func TestPruneEligibleKeepsNewestNAndReturnsOldestFirst(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ seedB64, pubB64, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = seedB64
+ pub, err := ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 25 versions, keep 20 → the 5 oldest are eligible, oldest first.
+ want := []string{"v01", "v02", "v03", "v04", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsTheBakeCeiling(t *testing.T) {
+ dir, s, now := pruneFixture(t, 25)
+ // Restamp the OLDEST version to 1h ago: inside the 72h ceiling, so even
+ // though it ranks outside keep-20 it must not be eligible.
+ modStamp(t, s, dir, ManifestKey("bab", "kass", "v01"), now.Add(-time.Hour))
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, v := range got {
+ if v == "v01" {
+ t.Fatal("a version inside the 72h bake ceiling was eligible")
+ }
+ }
+ // v01 now ranks NEWEST, so keep-20 shifts: v02..v06 become the eligible five.
+ want := []string{"v02", "v03", "v04", "v05", "v06"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsPointerVersionAndHistory(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ seedB64, pubB64, _ := GenerateKey()
+ priv, _ := ParseSigningKey(seedB64, "test")
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ // Promote v01 then v02 onto edge: pointer names v02, History carries v01.
+ if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v01"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v02"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"v03", "v04", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v (pointer + history protected)", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsTagReleases(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ // A tag whose Release names v03 — Tag.Release is the protected field,
+ // Tag.Version is just the semver name.
+ raw := []byte(`{"schema":1,"version":"1.2.0","release":"v03","tagged_at":"2026-08-01T00:00:00Z"}`)
+ if _, err := s.Put(ctx, TagKey("bab", "kass", "1.2.0"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"v01", "v02", "v04", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v (tagged release protected)", got, want)
+ }
+}
+
+func TestPruneEligibleProtectsCallerExtras(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now,
+ map[string]bool{"v02": true, "v04": true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"v01", "v03", "v05"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("eligible = %v, want %v (box pins protected)", got, want)
+ }
+}
+
+func TestPruneEligibleAbortsOnUnverifiablePointer(t *testing.T) {
+ _, s, now := pruneFixture(t, 25)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ // Garbage where a signed pointer should be: the whole app's prune must
+ // abort — never "prune what we could verify".
+ if _, err := s.Put(ctx, ChannelKey("bab", "kass", "edge"), []byte(`{"not":"signed"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := PruneEligible(ctx, s, pub, "bab", "kass", 20, now, nil); err == nil {
+ t.Fatal("an unverifiable pointer did not abort the prune")
+ }
+}
+
+func TestPruneEligibleFailClosedGates(t *testing.T) {
+ _, s, now := pruneFixture(t, 5)
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ ctx := context.Background()
+ if _, err := PruneEligible(ctx, s, nil, "bab", "kass", 20, now, nil); err == nil {
+ t.Fatal("nil pubkey accepted")
+ }
+ if _, err := PruneEligible(ctx, s, pub, "bab", "kass", KeepFloor-1, now, nil); err == nil {
+ t.Fatal("keep below floor accepted")
+ }
+ if _, err := PruneEligible(ctx, plainStore{s}, pub, "bab", "kass", 20, now, nil); err == nil {
+ t.Fatal("a store that cannot rank by age was accepted")
+ }
+}
+
+func TestPruneEligibleManifestlessAgedShipIsEligible(t *testing.T) {
+ dir, s, now := pruneFixture(t, 25)
+ // An aborted ship (artifact only, no manifest), OLDER than every shipped
+ // version (the fixture's stamps start 30 days back): junk, outside
+ // keep-N, eligible. (Stamped newer it would occupy a keep slot instead —
+ // aborted ships surface in listings by design, PR #134.)
+ modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), now.Add(-31*24*time.Hour))
+ _, pubB64, _ := GenerateKey()
+ pub, _ := ParsePublicKey(pubB64, "test")
+ got, err := PruneEligible(context.Background(), s, pub, "bab", "kass", 20, now, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, v := range got {
+ found = found || v == "dead1"
+ }
+ if !found {
+ t.Fatalf("eligible = %v, want dead1 included (aged aborted ship)", got)
+ }
+}
+
+// deleteRecorder pins the manifest-first rule: the order of Deletes is the
+// difference between a half-deleted version that reads as an aborted ship
+// and one that reads as a complete version with missing artifacts.
+type deleteRecorder struct {
+ Store
+ order []string
+}
+
+func (d *deleteRecorder) Delete(ctx context.Context, key string) error {
+ d.order = append(d.order, key)
+ return d.Store.Delete(ctx, key)
+}
+
+func TestDeleteVersionManifestFirstThenIdempotent(t *testing.T) {
+ dir, s, _ := pruneFixture(t, 1)
+ _ = dir
+ rec := &deleteRecorder{Store: s}
+ ctx := context.Background()
+ if err := DeleteVersion(ctx, rec, "bab", "kass", "v01"); err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.order) < 2 || rec.order[0] != ManifestKey("bab", "kass", "v01") {
+ t.Fatalf("delete order = %v, want manifest first", rec.order)
+ }
+ keys, err := s.List(ctx, ReleasesPrefix("bab", "kass")+"v01/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 0 {
+ t.Fatalf("v01 keys after delete = %v, want none", keys)
+ }
+ // Deleting again is a no-op, not an error.
+ if err := DeleteVersion(ctx, s, "bab", "kass", "v01"); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+Note: `DeleteVersion` takes the `Store` interface, so `deleteRecorder` works — but the recorder hides `ListMod`; `DeleteVersion` must use only `List`/`Delete`, which is exactly why the recorder also proves that.
+
+- [ ] **Step 4: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run 'TestPrune|TestDeleteVersion' -count=1`
+Expected: FAIL — `undefined: PruneEligible`, `undefined: DeleteVersion`.
+
+- [ ] **Step 5: Write the implementation**
+
+`internal/release/prune.go`:
+
+```go
+// Prune selection and deletion for shipped releases (issue #136, spec
+// 2026-08-10-releases-retention-design.md §5): which of an app's versions
+// are safe to delete under a keep-newest-N retention policy, and how one
+// version's objects are removed.
+//
+// Everything here is FAIL CLOSED. Any listing, read, or signature error
+// aborts the whole app's answer — the caller skips the app this round.
+// "Prune what we could see" is exactly the bug this posture prevents.
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// PruneEligible returns the versions of acct/app that are safe to delete,
+// oldest first. A version is protected — never returned — if it is any of:
+//
+// – among the newest keep versions (manifest-stamp ranking, PR #134's rule)
+// – stamped inside the longest bake hold (HoldFor's fail-closed 72h), so
+// an in-flight promote can never race its own prune
+// – named by any channel pointer: Version or anything in History (a
+// rollback must keep somewhere to go); pointers are signature-verified,
+// and one that does not verify aborts the whole answer
+// – named by any tag's Release field (tags are durable names)
+// – in alsoProtected — the box-side caller passes its own registry's
+// adopted/previous versions; the console passes nil (its preview can
+// only overcount, never undercount, what a box will delete)
+//
+// Pointer AUDIT entries are deliberately NOT protected (ruling in the spec):
+// the audit trail is a log, not a rollback target.
+func PruneEligible(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, keep int, now time.Time, alsoProtected map[string]bool) ([]string, error) {
+ if len(pub) == 0 {
+ return nil, errors.New("prune: no release public key — refusing to select without verifying pointers")
+ }
+ if keep < KeepFloor {
+ return nil, fmt.Errorf("prune: keep %d below floor %d", keep, KeepFloor)
+ }
+ ml, ok := s.(ModLister)
+ if !ok {
+ return nil, errors.New("prune: store cannot rank versions by age — refusing to select")
+ }
+ stamps, err := rankedVersionStamps(ctx, ml, ReleasesPrefix(acct, app))
+ if err != nil {
+ return nil, err
+ }
+ protected := make(map[string]bool, keep+len(alsoProtected))
+ cutoff := now.Add(-HoldFor(DefaultHolds, ""))
+ for i, st := range stamps {
+ if i < keep || st.mod.After(cutoff) {
+ protected[st.version] = true
+ }
+ }
+ chPrefix := ChannelsPrefix(acct, app)
+ chKeys, err := s.List(ctx, chPrefix)
+ if err != nil {
+ return nil, err
+ }
+ for _, k := range chKeys {
+ name, ok := strings.CutSuffix(strings.TrimPrefix(k, chPrefix), ".json")
+ if !ok || name == "" {
+ continue
+ }
+ ptr, err := Channel(ctx, s, pub, acct, app, name)
+ if err != nil {
+ return nil, fmt.Errorf("prune: channel %s: %w", name, err)
+ }
+ protected[ptr.Version] = true
+ for _, v := range ptr.History {
+ protected[v] = true
+ }
+ }
+ tags, err := ListTags(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ for _, t := range tags {
+ protected[t.Release] = true
+ }
+ for v, on := range alsoProtected {
+ if on && v != "" {
+ protected[v] = true
+ }
+ }
+ var eligible []string
+ for i := len(stamps) - 1; i >= 0; i-- {
+ v := stamps[i].version
+ // A name that fails ValidVersion is never turned into a delete key;
+ // skipping it is safe — we simply refuse to touch it.
+ if !protected[v] && ValidVersion(v) {
+ eligible = append(eligible, v)
+ }
+ }
+ return eligible, nil
+}
+
+// DeleteVersion removes one shipped version's objects, MANIFEST FIRST: a
+// half-deleted version then reads as an aborted ship (VersionCandidates'
+// error row), never as a complete version with missing artifacts. Built
+// from idempotent Deletes, so re-running after a partial failure — or
+// racing another box — finishes the job instead of erroring.
+func DeleteVersion(ctx context.Context, s Store, acct, app, version string) error {
+ if !ValidAccount(acct) {
+ return fmt.Errorf("delete version: invalid account %q", acct)
+ }
+ if !ValidApp(app) {
+ return fmt.Errorf("delete version: invalid app %q", app)
+ }
+ if !ValidVersion(version) {
+ return fmt.Errorf("delete version: invalid version %q", version)
+ }
+ prefix := ReleasesPrefix(acct, app) + version + "/"
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return err
+ }
+ manifest := ManifestKey(acct, app, version)
+ if err := s.Delete(ctx, manifest); err != nil {
+ return err
+ }
+ for _, k := range keys {
+ if k == manifest || !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ if err := s.Delete(ctx, k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `go test ./internal/release/ -count=1` (whole package: prune tests pass AND candidates/versions tests still green), `go vet ./internal/release/`, `gofmt -l` on the three touched files.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/release/candidates.go internal/release/prune.go internal/release/prune_test.go
+git commit -m "release: PruneEligible selector + DeleteVersion, sharing the candidate ranking core
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Box enforcement pass + slow loop
+
+**Files:**
+- Create: `internal/host/retention.go`
+- Test: `internal/host/retention_test.go`
+- Modify: `internal/host/agent.go` (add `retentionLoop` beside `instancesLoop` ~line 1302, and `go retentionLoop(reg)` at the launch site ~line 173)
+
+**Interfaces:**
+- Consumes: `release.GetRetention`, `release.PruneEligible`, `release.DeleteVersion` (Tasks 1–2), `registry.Registry.List() ([]registry.Route, error)`, `openDeployStore(ctx)`, `release.ParsePublicKey`.
+- Produces: `func retentionPass(ctx context.Context, store release.Store, pub ed25519.PublicKey, reg *registry.Registry) error` (unexported, host package).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/host/retention_test.go` (package `host`, mirroring `agent_test.go`'s registry usage):
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// stampKey writes key into the DirStore and pins its mtime, versions_test's
+// modStamp recipe (that helper is package release; this is the host-side twin).
+func stampKey(t *testing.T, s *release.DirStore, dir, key string, mod time.Time) {
+ t.Helper()
+ if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func retentionKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
+ t.Helper()
+ seedB64, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seedB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return priv, pub
+}
+
+func TestRetentionPassPrunesEligibleAndProtectsBoxPins(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ now := time.Now()
+ base := now.Add(-30 * 24 * time.Hour)
+ vname := func(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
+ for i := 1; i <= 25; i++ {
+ stampKey(t, s, dir, release.ManifestKey("bab", "kass", vname(i)), base.Add(time.Duration(i)*time.Hour))
+ stampKey(t, s, dir, release.ArtifactKey("bab", "kass", vname(i), "linux-arm64"), base.Add(time.Duration(i)*time.Hour))
+ }
+ _, pub := retentionKeys(t)
+ if err := release.SetRetention(ctx, s, "bab", "kass", 20, "test", now); err != nil {
+ t.Fatal(err)
+ }
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ // This box runs kass at v03 (previous v02): both are pins, even though
+ // both rank outside keep-20.
+ if err := reg.Upsert(registry.Route{
+ Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab",
+ Version: "v03", PrevVersion: "v02", Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := retentionPass(ctx, s, pub, reg); err != nil {
+ t.Fatal(err)
+ }
+
+ // keep-20 protects v06..v25; pins protect v02, v03 → v01, v04, v05 gone.
+ for _, gone := range []string{"v01", "v04", "v05"} {
+ if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", gone)); err == nil {
+ t.Fatalf("%s still present, want pruned", gone)
+ }
+ }
+ for _, kept := range []string{"v02", "v03", "v06", "v25"} {
+ if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", kept)); err != nil {
+ t.Fatalf("%s pruned, want kept: %v", kept, err)
+ }
+ }
+}
+
+func TestRetentionPassSkipsAppsWithoutAPolicy(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ base := time.Now().Add(-30 * 24 * time.Hour)
+ for i := 1; i <= 25; i++ {
+ v := "v" + string(rune('0'+i/10)) + string(rune('0'+i%10))
+ stampKey(t, s, dir, release.ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour))
+ }
+ _, pub := retentionKeys(t)
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ if err := reg.Upsert(registry.Route{
+ Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := retentionPass(ctx, s, pub, reg); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", "v01")); err != nil {
+ t.Fatalf("no policy, yet v01 was pruned: %v", err)
+ }
+}
+```
+
+(If `registry.Upsert` refuses the minimal Route — it may validate more columns — add the smallest fields it demands, copying an existing `agent_test.go` Upsert. If Upsert turns out not to persist the Version/PrevVersion columns, call `reg.SetVersions("kass.example.com", "v03", "v02")` right after the Upsert instead — that setter is `registry.go:1034`, the one `adopt.go` uses.)
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/host/ -run TestRetentionPass -count=1`
+Expected: FAIL — `undefined: retentionPass`.
+
+- [ ] **Step 3: Write retention.go**
+
+`internal/host/retention.go`:
+
+```go
+// Ambient release retention, the box half (issue #136, spec
+// 2026-08-10-releases-retention-design.md §6): for each app this box has
+// routes for, read the app's retention record from the bucket and delete
+// what release.PruneEligible says is safe — protecting, on top of the
+// bucket-derived safety list, the versions THIS box's registry still
+// records as adopted or previous. Boxes race each other harmlessly: every
+// delete is idempotent by store contract.
+//
+// Hosted-apps-only is a ruling, not an accident: an app with routes on no
+// box has nothing at stake, and whole-app trash→purge owns its end of life.
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// retentionPass runs one enforcement sweep. Per-app isolation: one app's
+// error is logged and skipped, never allowed to stop another app's
+// enforcement; a partial delete leaves the remainder for the next tick.
+func retentionPass(ctx context.Context, store release.Store, pub ed25519.PublicKey, reg *registry.Registry) error {
+ routes, err := reg.List()
+ if err != nil {
+ return err
+ }
+ type appKey struct{ acct, app string }
+ pins := map[appKey]map[string]bool{}
+ for _, rt := range routes {
+ if rt.Account == "" || rt.App == "" {
+ continue
+ }
+ k := appKey{rt.Account, rt.App}
+ if pins[k] == nil {
+ pins[k] = map[string]bool{}
+ }
+ if rt.Version != "" {
+ pins[k][rt.Version] = true
+ }
+ if rt.PrevVersion != "" {
+ pins[k][rt.PrevVersion] = true
+ }
+ }
+ for k, protected := range pins {
+ ret, err := release.GetRetention(ctx, store, k.acct, k.app)
+ if err != nil {
+ log.Printf("agent: retention %s/%s: %v", k.acct, k.app, err)
+ continue
+ }
+ if ret == nil {
+ continue
+ }
+ eligible, err := release.PruneEligible(ctx, store, pub, k.acct, k.app, ret.Keep, time.Now(), protected)
+ if err != nil {
+ log.Printf("agent: retention %s/%s: %v", k.acct, k.app, err)
+ continue
+ }
+ if len(eligible) == 0 {
+ continue
+ }
+ deleted := 0
+ for _, v := range eligible {
+ if err := release.DeleteVersion(ctx, store, k.acct, k.app, v); err != nil {
+ log.Printf("agent: retention %s/%s: delete %s: %v", k.acct, k.app, v, err)
+ break
+ }
+ deleted++
+ }
+ log.Printf("agent: retention: pruned %d of %d eligible versions for %s/%s (keep %d)",
+ deleted, len(eligible), k.acct, k.app, ret.Keep)
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/host/ -run TestRetentionPass -count=1` — PASS; then whole package `go test ./internal/host/ -count=1`.
+
+- [ ] **Step 5: Add the loop and launch it**
+
+In `internal/host/agent.go`, directly after `instancesLoop`'s function (after its closing brace, ~line 1320), add:
+
+```go
+// retentionLoop runs the release-retention pass forever on the slow
+// reconcile cadence (5min + jitter, first tick after 1 minute) — never the
+// 2s fast pass: pruning is not urgent, and a policy written now is enforced
+// within minutes, which is the promise the CLI makes ("boxes prune within
+// ~10 minutes"). Thin and untested by convention (like domainsLoop /
+// instancesLoop); everything with behavior lives in retentionPass. Missing
+// deployment config or a missing/unparseable release pubkey disables the
+// loop with one log line — fail closed, domainsLoop's auto-purge shape: a
+// box that cannot verify pointers must not delete anything.
+func retentionLoop(reg *registry.Registry) {
+ ctx := context.Background()
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: retention loop disabled: %v", err)
+ return
+ }
+ raw := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY"))
+ if raw == "" {
+ log.Printf("agent: retention loop disabled: CARLOS_RELEASE_PUBKEY is not set — retention policies will not be enforced by this box")
+ return
+ }
+ pub, err := release.ParsePublicKey(raw, "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Printf("agent: retention loop disabled: %v — retention policies will not be enforced by this box", err)
+ return
+ }
+ log.Printf("agent: retention loop armed (5min + jitter)")
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ if err := retentionPass(ctx, store, pub, reg); err != nil {
+ log.Printf("agent: retention pass: %v", err)
+ }
+ }
+}
+```
+
+At the launch site (agent.go ~line 173, beside `go instancesLoop(reg)`), add:
+
+```go
+ go retentionLoop(reg)
+```
+
+- [ ] **Step 6: Build + test + commit**
+
+Run: `go build ./... && go vet ./internal/host/ && go test ./internal/host/ -count=1`, `gofmt -l` on touched files.
+
+```bash
+git add internal/host/retention.go internal/host/retention_test.go internal/host/agent.go
+git commit -m "host: ambient retention pass — slow loop pruning per hosted app, box pins protected
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Console door
+
+**Files:**
+- Create: `internal/console/apiretention.go`
+- Test: `internal/console/apiretention_test.go`
+- Modify: `internal/console/authcli.go` (two routes, beside the restart registration at ~line 232)
+
+**Interfaces:**
+- Consumes: `s.apiAccountApp`, `s.cli.write.allow`, `s.cfg.Releases`, `s.cfg.ReleasePub`, `s.audit`, `s.bumpSerial`, `jsonError`, `writeJSON`; Tasks 1–2's `release.GetRetention/SetRetention/ClearRetention/PruneEligible/KeepFloor`; `release.VersionCandidates` for the total.
+- Produces (the CLI's wire contract, Task 5 decodes exactly this):
+ - `GET /api/cli/apps/{account}/{app}/retention` → `{"account","app","keep",("eligible","total")}` — `keep` 0 when off
+ - `POST` same path, body `{"keep":int,"off":bool,"dry_run":bool}` → same shape, plus `"off":true` for clears and `"dry_run":true,"eligible_versions":[...]` for previews
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/console/apiretention_test.go` (package `console`; uses `newConsoleFixture`, `cliToken`, `postBody`, `serial` — all existing test helpers; `getBody` may not exist, so a local GET helper is included):
+
+```go
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func retentionFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ // A MEMBER: retention is app config, like env set (spec §7 ruling).
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+func getRetention(t *testing.T, u, bearer string) (int, []byte) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, u, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(res.Body)
+ return res.StatusCode, body
+}
+
+func TestAPIRetentionSetWritesAuditsAndAnnounces(t *testing.T) {
+ fx, token := retentionFixture(t, "code-ret-set")
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token,
+ `{"keep": 50}`)
+ if status != http.StatusOK {
+ t.Fatalf("set = %d %s, want 200", status, out)
+ }
+ var res struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Keep int `json:"keep"`
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.Account != "bab" || res.App != "titogo" || res.Keep != 50 {
+ t.Fatalf("set body = %+v", res)
+ }
+ ret, err := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo")
+ if err != nil || ret == nil || ret.Keep != 50 {
+ t.Fatalf("record after set = %+v, %v; want keep 50", ret, err)
+ }
+ if ret.SetBy != "alice@keymail.dev" {
+ t.Fatalf("SetBy = %q, want the caller's address", ret.SetBy)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial = %d, want 1", got)
+ }
+}
+
+func TestAPIRetentionFloorIs400(t *testing.T) {
+ fx, token := retentionFixture(t, "code-ret-floor")
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token,
+ `{"keep": 5}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("keep 5 = %d %s, want 400", status, out)
+ }
+ if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil {
+ t.Fatal("a refused set still wrote a record")
+ }
+}
+
+func TestAPIRetentionOffClearsTheRecord(t *testing.T) {
+ fx, token := retentionFixture(t, "code-ret-off")
+ if err := release.SetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo", 50, "seed", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token, `{"off": true}`)
+ if status != http.StatusOK {
+ t.Fatalf("off = %d %s, want 200", status, out)
+ }
+ if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil {
+ t.Fatalf("record after off = %+v, want nil", ret)
+ }
+}
+
+func TestAPIRetentionDryRunWritesNothing(t *testing.T) {
+ fx, token := retentionFixture(t, "code-ret-dry")
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token,
+ `{"keep": 20, "dry_run": true}`)
+ if status != http.StatusOK {
+ t.Fatalf("dry-run = %d %s, want 200", status, out)
+ }
+ var res struct {
+ DryRun bool `json:"dry_run"`
+ EligibleVersions []string `json:"eligible_versions"`
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatal(err)
+ }
+ if !res.DryRun {
+ t.Fatalf("dry-run body = %s, want dry_run true", out)
+ }
+ if ret, _ := release.GetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo"); ret != nil {
+ t.Fatal("dry-run wrote a record")
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after dry-run = %d, want 0 (nothing to announce)", got)
+ }
+}
+
+func TestAPIRetentionGetShowsPolicyAndOffState(t *testing.T) {
+ fx, token := retentionFixture(t, "code-ret-get")
+ status, out := getRetention(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token)
+ if status != http.StatusOK {
+ t.Fatalf("get (off) = %d %s, want 200", status, out)
+ }
+ var res struct {
+ Keep int `json:"keep"`
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.Keep != 0 {
+ t.Fatalf("keep with no policy = %d, want 0", res.Keep)
+ }
+ if err := release.SetRetention(context.Background(), fx.rel, release.OpsAccount, "titogo", 25, "seed", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ status, out = getRetention(t, fx.ts.URL+"/api/cli/apps/bab/titogo/retention", token)
+ if status != http.StatusOK {
+ t.Fatalf("get = %d %s, want 200", status, out)
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.Keep != 25 {
+ t.Fatalf("keep = %d, want 25", res.Keep)
+ }
+}
+
+func TestAPIRetentionUnknownAppIsPlain404(t *testing.T) {
+ fx, token := retentionFixture(t, "code-ret-404")
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nothere/retention", token, `{"keep": 50}`)
+ if status != http.StatusNotFound {
+ t.Fatalf("unknown app = %d %s, want the plain 404", status, out)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run TestAPIRetention -count=1`
+Expected: FAIL with 404s (routes not registered).
+
+- [ ] **Step 3: Write the handlers and register the routes**
+
+`internal/console/apiretention.go`:
+
+```go
+package console
+
+// The `carlos releases retention` doors (issue #136). Gate order is
+// handleAPIRestart's exactly — token, one byte-identical 404 for every
+// identity failure, the shared per-address write budget, 503 when the
+// console has no bucket — and the serial bump comes strictly after a
+// successful write. Member-reachable by ruling (spec §7): retention is app
+// config, and the safety list in release.PruneEligible is the guardrail —
+// nothing promoted, rollback-reachable, tagged, or newer than the bake
+// ceiling is ever eligible, whoever sets the policy.
+//
+// The GET (and the eligible counts on every answer) run the SAME selector
+// the boxes run, minus box-local registry pins — so the console's preview
+// can only OVERCOUNT what a box will actually delete, never undercount.
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "math"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// apiRetentionResponse is the one wire shape both doors answer with.
+type apiRetentionResponse struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Keep int `json:"keep"`
+ Off bool `json:"off,omitempty"`
+ DryRun bool `json:"dry_run,omitempty"`
+ Eligible *int `json:"eligible,omitempty"`
+ Total int `json:"total,omitempty"`
+ EligibleVersions []string `json:"eligible_versions,omitempty"`
+}
+
+// fillEligible computes the counts best-effort: a selector error on a
+// successful set must not turn the set into a failure, so it logs and
+// leaves Eligible nil (the CLI says "count unavailable").
+func (s *Server) fillEligible(ctx context.Context, resp *apiRetentionResponse, acct, app string, keep int) {
+ eligible, err := release.PruneEligible(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, keep, time.Now(), nil)
+ if err != nil {
+ log.Printf("console: api retention eligible %s/%s: %v", acct, app, err)
+ return
+ }
+ n := len(eligible)
+ resp.Eligible = &n
+ if versions, _, err := release.VersionCandidates(ctx, s.cfg.Releases, acct, app, math.MaxInt); err == nil {
+ resp.Total = len(versions)
+ }
+ resp.EligibleVersions = eligible
+}
+
+// handleAPIRetentionGet answers `carlos releases retention -app X` (show).
+// No write budget and no audit: it is a read.
+func (s *Server) handleAPIRetentionGet(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — this console cannot read retention")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ ret, err := release.GetRetention(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api retention get %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the retention policy — try again")
+ return
+ }
+ resp := apiRetentionResponse{Account: acct.Sqid, App: app}
+ if ret != nil {
+ resp.Keep = ret.Keep
+ s.fillEligible(ctx, &resp, acct.Sqid, app, ret.Keep)
+ resp.EligibleVersions = nil // show answers counts; the list is dry-run's
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// handleAPIRetentionSet answers the write half: set, clear (off), or
+// preview (dry_run). Dry-run passes every gate INCLUDING the write budget —
+// it is a POST someone can loop — but writes, audits, and announces
+// nothing.
+func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — this console cannot write retention")
+ return
+ }
+ var in struct {
+ Keep int `json:"keep"`
+ Off bool `json:"off"`
+ DryRun bool `json:"dry_run"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil {
+ jsonError(w, http.StatusBadRequest, "unreadable request body")
+ return
+ }
+ if in.Off && (in.Keep != 0 || in.DryRun) {
+ jsonError(w, http.StatusBadRequest, "off cannot be combined with keep or dry_run")
+ return
+ }
+ if !in.Off && in.Keep < release.KeepFloor {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(
+ "keep must be at least %d — the console and CLI always show the newest %d releases, and a pruned hole must never appear inside that window",
+ release.KeepFloor, release.KeepFloor))
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ switch {
+ case in.Off:
+ if err := release.ClearRetention(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
+ log.Printf("console: api retention clear %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not clear the retention policy — nothing was changed; try again")
+ return
+ }
+ s.audit(acct, app, addr, "retention", "cleared release retention (cli)")
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, apiRetentionResponse{Account: acct.Sqid, App: app, Off: true})
+ case in.DryRun:
+ resp := apiRetentionResponse{Account: acct.Sqid, App: app, Keep: in.Keep, DryRun: true}
+ eligible, err := release.PruneEligible(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, in.Keep, time.Now(), nil)
+ if err != nil {
+ log.Printf("console: api retention dry-run %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not compute the prune preview — nothing was written; try again")
+ return
+ }
+ n := len(eligible)
+ resp.Eligible, resp.EligibleVersions = &n, eligible
+ if versions, _, err := release.VersionCandidates(ctx, s.cfg.Releases, acct.Sqid, app, math.MaxInt); err == nil {
+ resp.Total = len(versions)
+ }
+ writeJSON(w, http.StatusOK, resp)
+ default:
+ if err := release.SetRetention(ctx, s.cfg.Releases, acct.Sqid, app, in.Keep, addr, time.Now()); err != nil {
+ log.Printf("console: api retention set %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not write the retention policy — nothing was set; try again")
+ return
+ }
+ s.audit(acct, app, addr, "retention", fmt.Sprintf("set release retention: keep %d (cli)", in.Keep))
+ s.bumpSerial(ctx)
+ resp := apiRetentionResponse{Account: acct.Sqid, App: app, Keep: in.Keep}
+ s.fillEligible(ctx, &resp, acct.Sqid, app, in.Keep)
+ resp.EligibleVersions = nil // set answers counts; the list is dry-run's
+ writeJSON(w, http.StatusOK, resp)
+ }
+}
+```
+
+In `internal/console/authcli.go`, after the restart registration (~line 232):
+
+```go
+ // `carlos releases retention` (issue #136): a member's ambient-prune
+ // policy — read, set, clear, preview. Same identity gates as every
+ // door here; the safety list in release.PruneEligible is what keeps a
+ // policy from ever deleting anything promoted, tagged, or recent.
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/retention", s.handleAPIRetentionGet)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/retention", s.handleAPIRetentionSet)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/console/ -run TestAPIRetention -count=1` — PASS; then whole package `go test ./internal/console/ -count=1`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/apiretention.go internal/console/apiretention_test.go internal/console/authcli.go
+git commit -m "console: retention doors — GET/POST /api/cli/apps/{account}/{app}/retention
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: CLI verb + API client
+
+**Files:**
+- Create: `retention.go` (repo root, package main — beside `restart.go`)
+- Modify: `main.go` (the `case "releases":` dispatch, ~line 66)
+- Modify: `apimode.go` (client calls, after the restart client ~line 455)
+- Test: extend `e2e_test.go`-style coverage with `retention_cli_test.go` (root package)
+
+**Interfaces:**
+- Consumes: Task 4's wire contract; existing `accountFlags`, `apiAccount`, `resolveMode`, `printUsing`, `validAppName`, `apiPath`, `getJSON`, `postJSON`, `apiErrorFromBody`, `colorFor`, `mutedf`, `release.KeepFloor`.
+- Produces: `carlos releases retention` (show/set/off/dry-run).
+
+- [ ] **Step 1: Write the failing CLI gate tests**
+
+`retention_cli_test.go` (package main; these exercise the client-side gates, which fail before any network — copy the exec pattern of the nearest existing test in `e2e_test.go`, including `carlosTestEnv(t)` for env and `cmd.Dir = t.TempDir()`):
+
+```go
+package main
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// runRetentionCLI shells the built binary with the given args from a
+// scratch dir with the masked test env (the 40-accounts rule).
+func runRetentionCLI(t *testing.T, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(carlosBin(t), args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func TestReleasesRetentionRefusesBelowFloorClientSide(t *testing.T) {
+ out, err := runRetentionCLI(t, "releases", "retention", "-app", "kass", "-keep", "5")
+ if err == nil {
+ t.Fatalf("keep 5 exited 0: %s", out)
+ }
+ if !strings.Contains(out, "20") {
+ t.Fatalf("floor refusal must name the floor, got: %s", out)
+ }
+}
+
+func TestReleasesRetentionRefusesOffPlusKeep(t *testing.T) {
+ out, err := runRetentionCLI(t, "releases", "retention", "-app", "kass", "-keep", "50", "-off")
+ if err == nil {
+ t.Fatalf("-off with -keep exited 0: %s", out)
+ }
+ if !strings.Contains(out, "-off") {
+ t.Fatalf("refusal should name the conflict, got: %s", out)
+ }
+}
+
+func TestReleasesRetentionNeedsApp(t *testing.T) {
+ out, err := runRetentionCLI(t, "releases", "retention")
+ if err == nil {
+ t.Fatalf("no -app exited 0: %s", out)
+ }
+ if !strings.Contains(out, "usage:") {
+ t.Fatalf("want usage line, got: %s", out)
+ }
+}
+```
+
+> `carlosBin(t)` — use whatever helper `e2e_test.go` uses to locate/build the test binary; if it builds per-test via `go build`, reuse that helper verbatim rather than inventing one. If no such helper exists (tests use a TestMain-built path), follow that pattern instead.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test . -run TestReleasesRetention -count=1`
+Expected: FAIL — the verb prints the releases usage (unknown sub-verb) instead of retention's gates.
+
+- [ ] **Step 3: Write the API client**
+
+In `apimode.go`, after the restart client (~line 455):
+
+```go
+// apiRetentionResult mirrors the console's retention answer: the policy as
+// recorded, plus the console's own count of currently-eligible versions —
+// computed console-side because this terminal has no bucket. Eligible is a
+// pointer: nil means the console could not compute the count, which the CLI
+// says out loud rather than printing a lying zero.
+type apiRetentionResult struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Keep int `json:"keep"`
+ Off bool `json:"off"`
+ DryRun bool `json:"dry_run"`
+ Eligible *int `json:"eligible"`
+ Total int `json:"total"`
+ EligibleVersions []string `json:"eligible_versions"`
+}
+
+func (s *apiSession) retention(ctx context.Context, a apiAcct, app string) (apiRetentionResult, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/retention"))
+ return decodeRetention(status, body, err)
+}
+
+func (s *apiSession) setRetention(ctx context.Context, a apiAcct, app string, keep int, off, dryRun bool) (apiRetentionResult, error) {
+ in := struct {
+ Keep int `json:"keep,omitempty"`
+ Off bool `json:"off,omitempty"`
+ DryRun bool `json:"dry_run,omitempty"`
+ }{keep, off, dryRun}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/retention"), in)
+ return decodeRetention(status, body, err)
+}
+
+func decodeRetention(status int, body []byte, err error) (apiRetentionResult, error) {
+ if err != nil {
+ return apiRetentionResult{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return apiRetentionResult{}, fmt.Errorf("%w — or this console predates `carlos releases retention` "+
+ "(deploy a console built from current main)", apiErrorFromBody(status, body))
+ }
+ return apiRetentionResult{}, apiErrorFromBody(status, body)
+ }
+ var res apiRetentionResult
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiRetentionResult{}, fmt.Errorf("retention: unreadable response: %w", err)
+ }
+ return res, nil
+}
+```
+
+- [ ] **Step 4: Write the verb**
+
+`retention.go` (repo root):
+
+```go
+// retention.go is `carlos releases retention`: the ambient-prune policy for
+// an app's shipped releases (issue #136). Set it once and the platform
+// keeps the newest N forever after — the safety list (channel pointers,
+// rollback history, tags, the 72h bake ceiling, each box's own adopted
+// versions) always wins over the number, so nothing promoted or recent is
+// ever deleted by policy.
+//
+// API mode ONLY, like restart and for its reason: the policy is a console
+// write with an audit row, and the eligibility preview runs console-side.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func runReleasesRetention(args []string) {
+ fs := flag.NewFlagSet("releases retention", flag.ExitOnError)
+ app := fs.String("app", "", "app whose retention policy to read or write")
+ keep := fs.Int("keep", 0, fmt.Sprintf("retain the newest N releases (minimum %d)", release.KeepFloor))
+ off := fs.Bool("off", false, "clear the retention policy — keep releases forever again")
+ dryRun := fs.Bool("dry-run", false, "with -keep: list what would be pruned, write nothing")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ usage := "usage: carlos releases retention -app <a> [-keep <n> [-dry-run] | -off] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if name == "" {
+ log.Fatal(usage)
+ }
+ if err := validAppName(name); err != nil {
+ log.Fatalf("releases retention: %v", err)
+ }
+ // Client-side gates before the server's: a request the console is
+ // certain to refuse should cost a typo nothing.
+ if *off && (*keep != 0 || *dryRun) {
+ log.Fatal("releases retention: -off cannot be combined with -keep or -dry-run")
+ }
+ if !*off && *keep != 0 && *keep < release.KeepFloor {
+ log.Fatalf("releases retention: -keep must be at least %d — the CLI and console always "+
+ "show the newest %d releases, and a pruned hole must never appear inside that window",
+ release.KeepFloor, release.KeepFloor)
+ }
+ if *dryRun && *keep == 0 {
+ log.Fatal("releases retention: -dry-run needs -keep <n> to preview")
+ }
+
+ sess := requireRetentionAPISession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ ctx := context.Background()
+ switch {
+ case *off:
+ res, err := sess.setRetention(ctx, acct, name, 0, true, false)
+ if err != nil {
+ log.Fatalf("releases retention: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s for %s/%s — releases are kept forever again\n",
+ colorFor(stdout).ok("retention cleared"), res.Account, res.App)
+ case *keep == 0:
+ res, err := sess.retention(ctx, acct, name)
+ if err != nil {
+ log.Fatalf("releases retention: %v", err)
+ }
+ if res.Keep == 0 {
+ mutedf(stdout, "retention for %s: off — releases are kept forever\n", res.App)
+ mutedf(stdout, "enable with: carlos releases retention -app %s -keep <n>\n", res.App)
+ return
+ }
+ fmt.Fprintf(stdout, "retention for %s: keep %d\n", res.App, res.Keep)
+ printEligible(res)
+ case *dryRun:
+ res, err := sess.setRetention(ctx, acct, name, *keep, false, true)
+ if err != nil {
+ log.Fatalf("releases retention: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s — keep %d would prune:\n", colorFor(stdout).ok("dry run"), res.Keep)
+ for _, v := range res.EligibleVersions {
+ fmt.Fprintf(stdout, " %s\n", v)
+ }
+ printEligible(res)
+ mutedf(stdout, "nothing was written; drop -dry-run to set the policy\n")
+ default:
+ res, err := sess.setRetention(ctx, acct, name, *keep, false, false)
+ if err != nil {
+ log.Fatalf("releases retention: %v", err)
+ }
+ // "set", not "pruned": boxes enforce on their own slow loop, and this
+ // terminal is not watching them do it (restart.go's honesty rule).
+ fmt.Fprintf(stdout, "%s for %s/%s: keep %d\n",
+ colorFor(stdout).ok("retention set"), res.Account, res.App, res.Keep)
+ printEligible(res)
+ mutedf(stdout, "verify later with: carlos releases -app %s\n", res.App)
+ }
+}
+
+// printEligible reports the console's eligibility count honestly: a nil
+// count is SAID to be unavailable, never rendered as a zero.
+func printEligible(res apiRetentionResult) {
+ switch {
+ case res.Eligible == nil:
+ mutedf(stdout, "eligible count unavailable right now\n")
+ case res.Total > 0:
+ fmt.Fprintf(stdout, "%d of %d versions currently eligible; boxes prune within ~10 minutes\n",
+ *res.Eligible, res.Total)
+ default:
+ fmt.Fprintf(stdout, "%d versions currently eligible; boxes prune within ~10 minutes\n", *res.Eligible)
+ }
+}
+
+// requireRetentionAPISession refuses direct-bucket mode with retention's OWN
+// message, requireRestartAPISession's pattern and reason.
+func requireRetentionAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos releases retention works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). The policy is a console write with an\n" +
+ "audit row and a console-side preview. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
+```
+
+In `main.go`, replace the `case "releases":` arm (~line 66) with (mirroring `case "version"`'s sub-verb precedent at main.go:38-44 — one dispatched word from an otherwise-flat verb):
+
+```go
+ case "releases":
+ // One sub-verb, `retention` — the `carlos version target` judgment
+ // call: flags-only verbs stay flat until a word genuinely names a
+ // different action, and a policy write is not a listing.
+ if len(args) > 0 && args[0] == "retention" {
+ runReleasesRetention(args[1:])
+ return
+ }
+ runReleases(args)
+```
+
+(Match the surrounding switch's control flow — if other arms end with nothing or `return`, do the same.)
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `go test . -run TestReleasesRetention -count=1` — PASS; then `go build ./... && go vet ./...`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add retention.go retention_cli_test.go main.go apimode.go
+git commit -m "cli: carlos releases retention — set/show/clear/dry-run the ambient prune policy
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Console Releases tab read-only line
+
+**Files:**
+- Modify: `internal/console/server.go` (page struct field near `Versions []VersionView` ~line 451; `handleAppReleases` ~line 1526)
+- Modify: `internal/console/templates/app_releases.html`
+
+**Interfaces:**
+- Consumes: Task 1's `release.GetRetention`.
+- Produces: page field `RetentionKeep int` rendered as one muted line. No browser write path (spec §7 non-goal).
+
+- [ ] **Step 1: Add the field and the read**
+
+In the page struct beside `Versions []VersionView` (server.go ~line 451):
+
+```go
+ RetentionKeep int // releases tab: >0 when an ambient retention policy is set (spec 2026-08-10-releases-retention-design.md §7)
+```
+
+In `handleAppReleases`, after the `Versions` read, add:
+
+```go
+ // Read-only in v1: the policy is set via `carlos releases retention`;
+ // this line just keeps the page honest about why old versions vanish.
+ if ret, err := release.GetRetention(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil {
+ log.Printf("console: retention %s: %v", p.AppName, err)
+ } else if ret != nil {
+ p.RetentionKeep = ret.Keep
+ }
+```
+
+- [ ] **Step 2: Add the template line**
+
+In `templates/app_releases.html`, directly above the releases/versions table, add (matching the file's existing muted/hint styling — reuse whatever class the page already uses for secondary copy rather than inventing one):
+
+```html
+{{if .RetentionKeep}}
+<p class="muted">Retention: the newest {{.RetentionKeep}} releases are kept; older releases not promoted, tagged, or recently shipped are pruned automatically.</p>
+{{end}}
+```
+
+- [ ] **Step 3: Verify**
+
+Run: `go build ./... && go test ./internal/console/ -count=1` (template parse errors surface in the console tests).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/console/server.go internal/console/templates/app_releases.html
+git commit -m "console: read-only retention line on the Releases tab
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Invariant amendment, full gate, PR
+
+**Files:**
+- Modify: `internal/release/release.go` (`putImmutable`'s comment, ~line 591)
+
+- [ ] **Step 1: Amend the immutability comment**
+
+Find `putImmutable`'s doc comment in `internal/release/release.go` (~line 591) and amend its immutability claim in place — releases are now **immutable-while-present**. Keep the existing wording's structure; add one sentence to the effect of:
+
+```
+// Since issue #136, "immutable" means immutable-WHILE-PRESENT: a release
+// object's bytes never change under its key, but retention (prune.go) and
+// whole-app purge (trash.go) may delete it. Create-only writes still refuse
+// to overwrite, and a re-ship of a pruned version is a NEW release that
+// happens to reuse the name.
+```
+
+(Adapt to the comment's actual surrounding text; do not delete its existing rationale.)
+
+- [ ] **Step 2: Full gate**
+
+Run, in order, all with the Global Constraints env and sandbox off:
+
+```bash
+go build ./...
+GOOS=darwin go build ./...
+go vet ./...
+go test ./... -count=1
+gofmt -l internal/release/ internal/console/ internal/host/ retention.go apimode.go main.go
+```
+
+Expected: builds clean, vet clean, ALL packages pass, gofmt prints nothing for touched files. Fix anything that fails before proceeding.
+
+- [ ] **Step 3: Commit and push**
+
+```bash
+git add internal/release/release.go
+git commit -m "release: putImmutable — immutable now means immutable-while-present
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+git push -u origin worktree-releases-retention
+```
+
+- [ ] **Step 4: Open the draft PR**
+
+```bash
+gh pr create --draft --title "releases retention: ambient prune for shipped releases (#136)" --body "$(cat <<'EOF'
+Ambient half of #136: a member sets a per-app keep-newest-N policy
+(`carlos releases retention -app X -keep 50`, floor 20) through a new
+console door; boxes enforce it on a 5-minute slow loop with a fail-closed
+shared selector. Never eligible: channel-pointer versions + full rollback
+history, tag releases, anything inside the 72h bake ceiling, the newest N,
+and each box's own adopted/previous versions. Deletion is manifest-first
+and idempotent; racing boxes are benign.
+
+Spec: docs/superpowers/specs/2026-08-10-releases-retention-design.md
+(rulings: audit entries unprotected, promote-vs-prune TOCTOU accepted,
+hosted-apps-only scope).
+
+The manual one-shot `carlos releases prune` verb is a possible follow-up
+over the same engine — this PR does not close the manual-verb half of #136
+unless we decide ambient is the whole answer.
+
+Gate: build, GOOS=darwin build, vet, gofmt, go test ./... -count=1 all
+green.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+---
+
+## Deploy notes (post-merge; not part of this plan's execution)
+
+Spec §11: consoles first (door + CLI live), then box binaries via the
+existing update-platform-binary.sh path (no new units — the loop is
+in-process). Proving case: `keep 50` on titogo (account `bae`), verify
+`carlos releases -app titogo` shrinks and the box logs
+`agent: retention: pruned ...`.