// 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" "os" "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. // // A concurrent re-ship of the same version name can still interleave here: // Ship writes artifacts first and the manifest last, while DeleteVersion // deletes the manifest first from a pre-race listing — the window can leave // a manifest-present/artifacts-missing state until the next prune tick // re-deletes it. Same accepted-TOCTOU family as the spec's promote-vs-prune // ruling. func claimRawDeleteVersion(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 } } // The version's promotion record goes with it (spec §4.4 keeps only a // protected build's record). Idempotent like every delete here. if err := s.Delete(ctx, PromotionRecordKey(acct, app, version)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } func DeleteVersion(ctx context.Context, s Store, acct, app, version string) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.DeleteVersion", func(claimContext context.Context) error { claimResult0 = claimRawDeleteVersion(claimContext, s, acct, app, version) return claimResult0 }) return }