plan: releases retention implementation (7 tasks, TDD)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 ...`. |