Skip to main content

kopiur_api/
retention.rs

1//! Grandfather-father-son (GFS) retention selection (ADR §4.4).
2//!
3//! `SnapshotPolicy.spec.retention` is the **only** successful-retention driver
4//! (SKILL "Retention is GFS-only"). The operator periodically runs this selection
5//! over the `Snapshot` CRs for one `(identity, source)` tuple and deletes the CRs
6//! that fall outside the kept set; each deleted CR's `deletionPolicy` then governs
7//! the snapshot (§4.5). This module is the pure selection kernel — no kube types,
8//! no clock — so it's unit-testable with lightweight fakes.
9//!
10//! ## Algorithm (ADR-0001 §4.4, steps 2–4)
11//!
12//! 1. Sort candidates by end time, newest first.
13//! 2. Apply buckets in order: `keepLatest`, `keepHourly`, `keepDaily`,
14//!    `keepWeekly`, `keepMonthly`, `keepAnnual`.
15//!    - `keepLatest: N` keeps the N newest backups outright.
16//!    - Each time bucket keeps the **most recent** backup within each distinct
17//!      period (hour / day / ISO-week / month / year), up to its count `N`,
18//!      walking newest→oldest.
19//! 3. A backup kept by **any** bucket survives (union). Everything else is deleted.
20//!
21//! This is deliberately *not* a flat count: a backup that is the newest of its
22//! year is held by `keepAnnual` even if hundreds of newer dailies exist — the
23//! exact case a flat cap would silently drop (ADR §4.4 "Why not flat-count").
24//!
25//! ## Empty-policy semantics
26//!
27//! An all-`None` [`Retention`] selects **no** buckets, so the kept set is empty and
28//! every backup is marked for deletion. The caller (controller) is responsible for
29//! only invoking GFS when a retention policy is actually configured; this function
30//! reports faithfully what the given policy implies. This is documented and tested.
31
32use crate::common::Retention;
33use chrono::{DateTime, Datelike, Utc};
34use std::collections::BTreeSet;
35
36/// Anything that can stand in for a `Snapshot` during retention selection. Kept tiny
37/// so tests use trivial fakes instead of constructing full `Snapshot` CRs.
38pub trait SnapshotLike {
39    /// The snapshot's completion time — the GFS bucketing key (ADR §4.4 step 2).
40    fn end_time(&self) -> DateTime<Utc>;
41    /// A stable identifier (kopia snapshot ID or CR name) used in the result sets.
42    fn id(&self) -> &str;
43    /// Whether this snapshot is pinned (`Snapshot.spec.pin`, ADR-0005 §13(c)). A
44    /// pinned snapshot is exempt from GFS retention: [`select_kept`] never places it
45    /// in `delete`, regardless of the policy. Defaults `false` so existing impls
46    /// (and discovered snapshots) keep their behavior.
47    fn pinned(&self) -> bool {
48        false
49    }
50}
51
52/// The outcome of a GFS selection: which ids to keep and which to delete. Both are
53/// returned explicitly so callers never have to recompute the complement.
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct KeptSet {
56    /// Ids retained by at least one bucket.
57    pub keep: Vec<String>,
58    /// Ids selected by no bucket — eligible for pruning.
59    pub delete: Vec<String>,
60}
61
62/// Calendar period a timestamp falls in, used to deduplicate "one per period."
63/// Distinct values mean distinct periods; comparing these is how each bucket keeps
64/// the newest entry per period.
65fn hour_key(t: DateTime<Utc>) -> (i32, u32, u32) {
66    (t.year(), t.ordinal(), t.hour())
67}
68fn day_key(t: DateTime<Utc>) -> (i32, u32) {
69    (t.year(), t.ordinal())
70}
71fn week_key(t: DateTime<Utc>) -> (i32, u32) {
72    let iso = t.iso_week();
73    (iso.year(), iso.week())
74}
75fn month_key(t: DateTime<Utc>) -> (i32, u32) {
76    (t.year(), t.month())
77}
78fn year_key(t: DateTime<Utc>) -> i32 {
79    t.year()
80}
81
82use chrono::Timelike;
83
84/// Walk `sorted` (newest→oldest) and collect the index of the newest entry in each
85/// distinct period, stopping once `count` periods have been kept.
86fn keep_per_period<K, F>(
87    sorted: &[usize],
88    times: &[DateTime<Utc>],
89    count: usize,
90    key: F,
91) -> Vec<usize>
92where
93    K: Ord,
94    F: Fn(DateTime<Utc>) -> K,
95{
96    let mut kept = Vec::new();
97    let mut seen: BTreeSet<K> = BTreeSet::new();
98    for &idx in sorted {
99        if kept.len() >= count {
100            break;
101        }
102        let k = key(times[idx]);
103        if seen.insert(k) {
104            // First (= newest, since sorted desc) entry in this period.
105            kept.push(idx);
106        }
107    }
108    kept
109}
110
111/// Select the GFS-kept set from `backups` under `policy` (ADR §4.4).
112///
113/// Returns a [`KeptSet`] partitioning every input id into `keep`/`delete`. Input
114/// order is irrelevant; `keep` is returned newest-first, `delete` newest-first too.
115/// Ties on `end_time` are broken by id for determinism.
116///
117/// ```
118/// use chrono::{DateTime, TimeZone, Utc};
119/// use kopiur_api::{select_kept, SnapshotLike};
120/// use kopiur_api::common::Retention;
121///
122/// // A trivial fake honoring SnapshotLike — no kube CRs needed for selection.
123/// struct Snap { id: String, end: DateTime<Utc> }
124/// impl SnapshotLike for Snap {
125///     fn end_time(&self) -> DateTime<Utc> { self.end }
126///     fn id(&self) -> &str { &self.id }
127/// }
128/// let day = |d: u32| Utc.with_ymd_and_hms(2026, 5, d, 2, 0, 0).single().unwrap();
129/// let snaps = vec![
130///     Snap { id: "d24".into(), end: day(24) },
131///     Snap { id: "d23".into(), end: day(23) },
132///     Snap { id: "d22".into(), end: day(22) },
133/// ];
134///
135/// // keepDaily: 2 — keep the newest per day for the 2 newest days; prune the rest.
136/// let policy: Retention =
137///     serde_json::from_value(serde_json::json!({ "keepDaily": 2 })).unwrap();
138/// let kept = select_kept(&snaps, &policy);
139/// assert_eq!(kept.keep, vec!["d24", "d23"]); // newest-first
140/// assert_eq!(kept.delete, vec!["d22"]);
141/// ```
142pub fn select_kept<T: SnapshotLike>(backups: &[T], policy: &Retention) -> KeptSet {
143    if backups.is_empty() {
144        return KeptSet::default();
145    }
146
147    let times: Vec<DateTime<Utc>> = backups.iter().map(|b| b.end_time()).collect();
148
149    // Indices sorted by end_time descending; id as a deterministic tiebreaker.
150    let mut order: Vec<usize> = (0..backups.len()).collect();
151    order.sort_by(|&a, &b| {
152        times[b]
153            .cmp(&times[a])
154            .then_with(|| backups[a].id().cmp(backups[b].id()))
155    });
156
157    let mut keep_idx: BTreeSet<usize> = BTreeSet::new();
158
159    // keepLatest: the N newest outright.
160    if let Some(n) = policy.keep_latest {
161        for &idx in order.iter().take(n as usize) {
162            keep_idx.insert(idx);
163        }
164    }
165    if let Some(n) = policy.keep_hourly {
166        keep_idx.extend(keep_per_period(&order, &times, n as usize, hour_key));
167    }
168    if let Some(n) = policy.keep_daily {
169        keep_idx.extend(keep_per_period(&order, &times, n as usize, day_key));
170    }
171    if let Some(n) = policy.keep_weekly {
172        keep_idx.extend(keep_per_period(&order, &times, n as usize, week_key));
173    }
174    if let Some(n) = policy.keep_monthly {
175        keep_idx.extend(keep_per_period(&order, &times, n as usize, month_key));
176    }
177    if let Some(n) = policy.keep_annual {
178        keep_idx.extend(keep_per_period(&order, &times, n as usize, year_key));
179    }
180
181    let mut keep = Vec::new();
182    let mut delete = Vec::new();
183    for &idx in &order {
184        // A pinned snapshot is exempt from GFS retention (ADR-0005 §13(c)): it always
185        // survives a prune even when no bucket selected it — kopia would also refuse to
186        // expire it. Kept newest-first alongside bucket-kept ids.
187        if keep_idx.contains(&idx) || backups[idx].pinned() {
188            keep.push(backups[idx].id().to_string());
189        } else {
190            delete.push(backups[idx].id().to_string());
191        }
192    }
193    KeptSet { keep, delete }
194}
195
196// --- the population, and how it is bucketed ---------------------------------
197
198/// A minimal view of a `Snapshot` for retention selection: its CR name (the id
199/// used in delete decisions) and its snapshot end time (the GFS bucketing key).
200///
201/// `Clone` so the adoption retention gate (adoption inv. 8) can union these
202/// views with candidate views without re-deriving them from the CRs.
203#[derive(Debug, Clone)]
204pub struct SnapshotRetentionView {
205    /// CR name — the stable id returned in the kept/delete sets.
206    pub name: String,
207    /// Snapshot completion time (from `status.snapshot`/`status.timing`).
208    pub end_time: DateTime<Utc>,
209    /// Whether the `Snapshot` is pinned (`spec.pin`, ADR-0005 §13(c)) — exempt
210    /// from GFS retention (never selected for deletion).
211    pub pinned: bool,
212}
213
214impl SnapshotLike for SnapshotRetentionView {
215    fn end_time(&self) -> DateTime<Utc> {
216        self.end_time
217    }
218    fn id(&self) -> &str {
219        &self.name
220    }
221    fn pinned(&self) -> bool {
222        self.pinned
223    }
224}
225
226/// Build a retention view from a `Snapshot` CR, using `status.timing.endTime`
227/// (falling back to the CR creation timestamp). Returns `None` if the backup is
228/// not in a terminal successful state — only successful snapshots participate in
229/// GFS (failures are bounded separately by `failedJobsHistoryLimit`).
230///
231/// Lives in `kopiur-api` rather than in the controller because the web UI's
232/// "would retention keep this?" preview has to select the same population the
233/// prune does. A preview built from a different population is not a preview: one
234/// that admitted `Deleting` rows, or dropped rows whose `endTime` was missing,
235/// answers about a set the operator will never evaluate.
236pub fn retention_view(b: &crate::Snapshot) -> Option<SnapshotRetentionView> {
237    use crate::SnapshotPhase;
238    let status = b.status.as_ref()?;
239    // Exhaustive, not `!= Succeeded`: GFS membership is a CLASSIFICATION whose
240    // "no" side spans four unrelated meanings (in-flight, failed, deduped,
241    // foreign). A new phase silently defaulting to "not retention-governed"
242    // would quietly stop protecting a real restore point, so the compiler asks
243    // here first. Deliberately NOT `is_terminal()`: `Discovered`/`Unchanged`
244    // are terminal but must not claim a GFS bucket.
245    let participates_in_gfs = status.phase.as_ref().is_some_and(|p| match p {
246        SnapshotPhase::Succeeded => true,
247        // `Unchanged` owns no manifest, so it must never displace one that
248        // exists; `Discovered` is bounded by the catalog, not by this policy's
249        // retention; the rest are not terminal successes at all.
250        SnapshotPhase::Unchanged
251        | SnapshotPhase::Discovered
252        | SnapshotPhase::Pending
253        | SnapshotPhase::Running
254        | SnapshotPhase::Failed
255        | SnapshotPhase::Deleting => false,
256        // Never let a phase this build cannot read enter a set whose losers get
257        // DELETED from the repository.
258        SnapshotPhase::Unknown(_) => false,
259    });
260    if !participates_in_gfs {
261        return None;
262    }
263    // PROVENANCE (defense in depth): a `Succeeded` row only participates in GFS
264    // when it carries CONTROLLER-WRITTEN provenance (`status.snapshot`, the kopia
265    // id the operator produced or adopted). This closes the phantom-Succeeded
266    // displacement even if a phase were ever pinned without one — a forged bare
267    // `origin: adopted` label whose creationTimestamp fallback would otherwise
268    // claim a GFS bucket and displace a real snapshot into the retention delete
269    // set. Every genuine produced/adopted row has `status.snapshot`.
270    status.snapshot.as_ref()?;
271    let end_time = status
272        .timing
273        .as_ref()
274        .and_then(|t| t.end_time.as_deref())
275        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
276        .map(|dt| dt.with_timezone(&Utc))
277        .or_else(|| {
278            // metadata.creationTimestamp is a k8s-openapi `Time` wrapping a
279            // jiff `Timestamp`; convert via unix seconds to chrono.
280            b.metadata
281                .creation_timestamp
282                .as_ref()
283                .and_then(|t| DateTime::<Utc>::from_timestamp(t.0.as_second(), 0))
284        })?;
285    Some(SnapshotRetentionView {
286        name: b.metadata.name.clone().unwrap_or_default(),
287        end_time,
288        // A pinned Snapshot is exempt from GFS pruning (ADR-0005 §13(c)).
289        // `spec.pin` is what the user asked for and what the prune reads;
290        // `status.pinned` is what kopia currently holds, and during an unpin the
291        // two disagree — reading status there would promise a keep the next
292        // prune will not honor.
293        pinned: b.spec.pin,
294    })
295}
296
297/// **Pure.** The retention bucket a `Snapshot` belongs to.
298///
299/// One bucket per distinct backup source, so GFS keeps `keepDaily` days *of
300/// each PVC* rather than `keepDaily` snapshots across all of them. Empty for an
301/// un-fanned Snapshot, which is what makes a single-source policy one bucket
302/// and therefore unchanged.
303///
304/// **Multi-repo fan-out (#368):** while the policy is CURRENTLY multi-repo
305/// (`policy_is_multi`), the key also carries the child's mint-time repository
306/// pin (`spec.repository`, normalized at mint), so GFS keeps `keepDaily` days
307/// per (source, repository) — the N repositories are independent captures and
308/// must retain independently. The repo component comes from the SPEC pin ONLY
309/// (never `status.resolved` — a status-derived key would flap with backfills),
310/// and applies ONLY while the policy is multi-repo:
311///
312/// - single-repo policy (including after a multi→single edit): source-only
313///   buckets, byte-identical to today. Old pinned children merge back into the
314///   flat buckets — a documented TRANSIENT GFS mixing (the surviving repo's
315///   rows and the removed repo's leftovers compete in one bucket) that
316///   self-resolves as the removed repo's rows age out of every keep window.
317/// - multi-repo policy: (source, pin) buckets. Rows with NO pin (pre-feature
318///   children minted before the single→multi edit) land in the ""-repo bucket
319///   and age out; the policy reconciler's spec-pin backfill converges them into
320///   their real buckets first, so the ""-bucket is a shrinking transition set,
321///   not a steady state.
322pub fn retention_group_key(b: &crate::Snapshot, policy_is_multi: bool) -> String {
323    let source = match b.spec.source.as_ref().map(|s| &s.target) {
324        Some(crate::SnapshotSourceTarget::Pvc(t)) => {
325            format!("pvc/{}/{}", t.namespace, t.name)
326        }
327        None => String::new(),
328    };
329    if !policy_is_multi {
330        return source;
331    }
332    let repo = b
333        .spec
334        .repository
335        .as_ref()
336        .map(|r| crate::common::repo_key(r, b.metadata.namespace.as_deref().unwrap_or_default()))
337        .unwrap_or_default();
338    // '\n' can appear in neither component (DNS names / repo keys), so the
339    // joined key is injective over (source, repo).
340    format!("{source}\n{repo}")
341}
342
343/// **Pure.** The GFS population of `backups`, bucketed exactly as a prune would
344/// bucket it.
345///
346/// This is the one place the *shape* of a retention run is decided:
347/// [`retention_view`] filters the population and `retention_group_key` splits
348/// it. `backups_to_delete` runs `select_kept` over these buckets and returns the
349/// losers; the web UI runs the same `select_kept` over the same buckets and asks
350/// whether one id is a winner. Sharing this means a preview cannot answer about
351/// a set the operator will never evaluate.
352///
353/// Terminating rows are excluded (#382 C1): with the population served from a
354/// reflector store, an externally-deleted-but-still-cached NEWER snapshot must
355/// not claim a keep slot and push a LIVE row into the delete set.
356pub fn retention_buckets(
357    backups: &[&crate::Snapshot],
358    policy_is_multi: bool,
359) -> std::collections::BTreeMap<String, Vec<SnapshotRetentionView>> {
360    let mut buckets: std::collections::BTreeMap<String, Vec<SnapshotRetentionView>> =
361        std::collections::BTreeMap::new();
362    for b in backups {
363        if b.metadata.deletion_timestamp.is_some() {
364            continue;
365        }
366        if let Some(v) = retention_view(b) {
367            buckets
368                .entry(retention_group_key(b, policy_is_multi))
369                .or_default()
370                .push(v);
371        }
372    }
373    buckets
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use chrono::TimeZone;
380
381    /// Minimal fake honoring `SnapshotLike` — no kube CRs in retention tests.
382    struct Fake {
383        id: String,
384        end: DateTime<Utc>,
385        pinned: bool,
386    }
387    impl SnapshotLike for Fake {
388        fn end_time(&self) -> DateTime<Utc> {
389            self.end
390        }
391        fn id(&self) -> &str {
392            &self.id
393        }
394        fn pinned(&self) -> bool {
395            self.pinned
396        }
397    }
398
399    fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
400        Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).single().unwrap()
401    }
402    fn fake(id: &str, t: DateTime<Utc>) -> Fake {
403        Fake {
404            id: id.into(),
405            end: t,
406            pinned: false,
407        }
408    }
409    fn pinned(id: &str, t: DateTime<Utc>) -> Fake {
410        Fake {
411            id: id.into(),
412            end: t,
413            pinned: true,
414        }
415    }
416
417    fn policy(
418        latest: Option<u32>,
419        hourly: Option<u32>,
420        daily: Option<u32>,
421        weekly: Option<u32>,
422        monthly: Option<u32>,
423        annual: Option<u32>,
424    ) -> Retention {
425        Retention {
426            keep_latest: latest,
427            keep_hourly: hourly,
428            keep_daily: daily,
429            keep_weekly: weekly,
430            keep_monthly: monthly,
431            keep_annual: annual,
432        }
433    }
434
435    fn as_set(v: &[String]) -> BTreeSet<&str> {
436        v.iter().map(String::as_str).collect()
437    }
438
439    #[test]
440    fn empty_input_yields_empty_sets() {
441        let got = select_kept::<Fake>(&[], &policy(Some(5), None, None, None, None, None));
442        assert!(got.keep.is_empty());
443        assert!(got.delete.is_empty());
444    }
445
446    #[test]
447    fn empty_policy_keeps_nothing() {
448        // All-None policy → no buckets selected → everything deleted.
449        let backups = vec![
450            fake("a", at(2026, 5, 24, 2, 0)),
451            fake("b", at(2026, 5, 23, 2, 0)),
452        ];
453        let got = select_kept(&backups, &Retention::default());
454        assert!(got.keep.is_empty(), "empty policy keeps nothing");
455        assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
456    }
457
458    #[test]
459    fn keep_latest_keeps_n_newest() {
460        let backups = vec![
461            fake("d1", at(2026, 5, 24, 2, 0)),
462            fake("d2", at(2026, 5, 23, 2, 0)),
463            fake("d3", at(2026, 5, 22, 2, 0)),
464            fake("d4", at(2026, 5, 21, 2, 0)),
465        ];
466        let got = select_kept(&backups, &policy(Some(2), None, None, None, None, None));
467        assert_eq!(as_set(&got.keep), ["d1", "d2"].into_iter().collect());
468        assert_eq!(as_set(&got.delete), ["d3", "d4"].into_iter().collect());
469    }
470
471    #[test]
472    fn keep_daily_keeps_one_newest_per_day() {
473        // Three backups on day 24 (keep the 02:00 one), one each on 23 and 22.
474        let backups = vec![
475            fake("a", at(2026, 5, 24, 0, 5)),
476            fake("b", at(2026, 5, 24, 1, 30)),
477            fake("c", at(2026, 5, 24, 2, 0)), // newest on the 24th
478            fake("d", at(2026, 5, 23, 2, 0)),
479            fake("e", at(2026, 5, 22, 2, 0)),
480        ];
481        let got = select_kept(&backups, &policy(None, None, Some(14), None, None, None));
482        // One per distinct day, newest within the day.
483        assert_eq!(as_set(&got.keep), ["c", "d", "e"].into_iter().collect());
484        assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
485    }
486
487    #[test]
488    fn keep_daily_count_caps_number_of_days() {
489        let backups = vec![
490            fake("d24", at(2026, 5, 24, 2, 0)),
491            fake("d23", at(2026, 5, 23, 2, 0)),
492            fake("d22", at(2026, 5, 22, 2, 0)),
493            fake("d21", at(2026, 5, 21, 2, 0)),
494        ];
495        let got = select_kept(&backups, &policy(None, None, Some(2), None, None, None));
496        // Only the 2 newest days kept.
497        assert_eq!(as_set(&got.keep), ["d24", "d23"].into_iter().collect());
498        assert_eq!(as_set(&got.delete), ["d22", "d21"].into_iter().collect());
499    }
500
501    #[test]
502    fn keep_latest_unions_with_keep_daily() {
503        // Two backups same day: keepDaily keeps the newest (c), keepLatest:2 also
504        // pulls in the second-newest overall (b) even though it shares c's day.
505        let backups = vec![
506            fake("c", at(2026, 5, 24, 6, 0)),
507            fake("b", at(2026, 5, 24, 5, 0)),
508            fake("a", at(2026, 5, 23, 5, 0)),
509        ];
510        let got = select_kept(&backups, &policy(Some(2), None, Some(7), None, None, None));
511        // c kept by both; b kept by keepLatest; a kept by keepDaily (day 23).
512        assert_eq!(as_set(&got.keep), ["a", "b", "c"].into_iter().collect());
513        assert!(got.delete.is_empty());
514    }
515
516    #[test]
517    fn annual_snapshot_survives_flood_of_newer_dailies() {
518        // The §4.4 "why not flat-count" case. One old end-of-2024 snapshot plus a
519        // pile of 2026 dailies. keepDaily:3 + keepAnnual:2 must retain the 2024
520        // snapshot as the newest-of-its-year even though it's far down the list.
521        let mut backups = vec![fake("y2024", at(2024, 12, 31, 23, 0))];
522        for d in 1..=10u32 {
523            backups.push(fake(&format!("y2026-{d:02}"), at(2026, 5, d, 2, 0)));
524        }
525        // Newest 2026 daily is day 10; year 2026's representative is day 10,
526        // year 2024's representative is y2024.
527        let got = select_kept(&backups, &policy(None, None, Some(3), None, None, Some(2)));
528        let keep = as_set(&got.keep);
529        assert!(
530            keep.contains("y2024"),
531            "annual snapshot must not be dropped by daily flood; kept={keep:?}"
532        );
533        // keepDaily:3 keeps the 3 newest days of 2026.
534        assert!(keep.contains("y2026-10"));
535        assert!(keep.contains("y2026-09"));
536        assert!(keep.contains("y2026-08"));
537        // Older 2026 dailies not covered by any bucket are deleted.
538        assert!(got.delete.contains(&"y2026-01".to_string()));
539    }
540
541    #[test]
542    fn monthly_and_weekly_pick_newest_in_period() {
543        let backups = vec![
544            fake("may-late", at(2026, 5, 28, 2, 0)),
545            fake("may-early", at(2026, 5, 2, 2, 0)),
546            fake("apr", at(2026, 4, 15, 2, 0)),
547            fake("mar", at(2026, 3, 15, 2, 0)),
548        ];
549        let got = select_kept(&backups, &policy(None, None, None, None, Some(2), None));
550        // keepMonthly:2 → newest of May (may-late) and newest of April (apr).
551        assert_eq!(as_set(&got.keep), ["may-late", "apr"].into_iter().collect());
552    }
553
554    #[test]
555    fn pinned_snapshot_survives_a_prune_that_would_delete_it() {
556        // ADR-0005 §13(c): a pinned snapshot is exempt from GFS retention. With
557        // keepLatest:1, the two older snapshots would normally be deleted — but the
558        // pinned one must survive while the unpinned one is pruned.
559        let backups = vec![
560            fake("newest", at(2026, 5, 24, 2, 0)),
561            pinned("pinned-old", at(2026, 5, 20, 2, 0)),
562            fake("unpinned-old", at(2026, 5, 19, 2, 0)),
563        ];
564        let got = select_kept(&backups, &policy(Some(1), None, None, None, None, None));
565        let keep = as_set(&got.keep);
566        let del = as_set(&got.delete);
567        assert!(keep.contains("newest"), "keepLatest:1 keeps the newest");
568        assert!(
569            keep.contains("pinned-old"),
570            "a pinned snapshot must survive a prune that would otherwise delete it"
571        );
572        assert!(
573            del.contains("unpinned-old"),
574            "the unpinned older snapshot is pruned"
575        );
576        assert!(!del.contains("pinned-old"), "pinned is never in delete");
577    }
578
579    #[test]
580    fn every_backup_kept_by_any_bucket_survives() {
581        // Mixed policy; assert the kept set is exactly the union and no kept id
582        // appears in delete.
583        let backups = vec![
584            fake("now", at(2026, 5, 24, 12, 0)),
585            fake("earlier-today", at(2026, 5, 24, 1, 0)),
586            fake("yesterday", at(2026, 5, 23, 1, 0)),
587            fake("last-week", at(2026, 5, 16, 1, 0)),
588        ];
589        let got = select_kept(
590            &backups,
591            &policy(Some(1), None, Some(2), Some(2), None, None),
592        );
593        let keep = as_set(&got.keep);
594        let del = as_set(&got.delete);
595        for id in keep.iter() {
596            assert!(!del.contains(id), "id {id} in both keep and delete");
597        }
598        // Every input is accounted for exactly once.
599        assert_eq!(keep.len() + del.len(), 4);
600    }
601
602    #[test]
603    fn e2e_gfs_history_partitions_exactly_as_the_retention_e2e_expects() {
604        // The EXACT history + spec the `gfs_time_buckets_prune_backdated_history`
605        // e2e (crates/e2e/tests/retention.rs) seeds, pinned hermetically so the
606        // e2e's in-test expectation can never drift from the kernel. Note
607        // keepAnnual: 2 — buckets are the N most recent periods CONTAINING
608        // snapshots, so holding a prior-year snapshot needs the 2026 bucket
609        // (the newest snapshot's year) plus one more.
610        let backups = vec![
611            fake("e2e-gfs-1", at(2025, 4, 10, 10, 0)),
612            fake("e2e-gfs-2", at(2026, 3, 2, 10, 0)),
613            fake("e2e-gfs-3", at(2026, 4, 6, 10, 0)),
614            fake("e2e-gfs-4", at(2026, 5, 25, 10, 0)),
615            fake("e2e-gfs-5", at(2026, 6, 1, 10, 0)),
616            fake("e2e-gfs-6", at(2026, 6, 8, 10, 0)),
617            fake("e2e-gfs-7", at(2026, 6, 8, 11, 0)),
618        ];
619        let policy: Retention = serde_json::from_value(serde_json::json!({
620            "keepLatest": 1, "keepDaily": 2, "keepWeekly": 2,
621            "keepMonthly": 2, "keepAnnual": 2
622        }))
623        .unwrap();
624        let got = select_kept(&backups, &policy);
625        assert_eq!(
626            as_set(&got.keep),
627            ["e2e-gfs-1", "e2e-gfs-4", "e2e-gfs-5", "e2e-gfs-7"]
628                .into_iter()
629                .collect(),
630            "keep: latest+daily+weekly (gfs-7/5), monthly #2 (gfs-4), annual #2 (gfs-1)"
631        );
632        assert_eq!(
633            as_set(&got.delete),
634            ["e2e-gfs-2", "e2e-gfs-3", "e2e-gfs-6"]
635                .into_iter()
636                .collect(),
637            "delete: months outside keepMonthly:2 and the same-day older duplicate"
638        );
639    }
640
641    /// Convergence keystone for adoption invariant 8 (controller crate): the
642    /// selection is stable on its own kept set — re-running it over exactly the
643    /// survivors deletes nothing and keeps the same set. This is what makes the
644    /// adoption gate's pre-prune evaluation equal the next retention pass's
645    /// decision: pruning the non-kept rows never re-selects (or de-selects) a
646    /// survivor. Holds because the selection is time-invariant (buckets derive
647    /// purely from end times), a non-kept row is never a newest-in-period
648    /// representative, and ties break deterministically by id.
649    #[test]
650    fn select_kept_is_stable_on_its_own_kept_set() {
651        let populations: Vec<Vec<Fake>> = vec![
652            // Dense multi-day spread with a same-instant id tie and a pinned
653            // straggler far outside every bucket.
654            vec![
655                fake("tie-a", at(2026, 5, 24, 2, 0)),
656                fake("tie-b", at(2026, 5, 24, 2, 0)),
657                fake("d23", at(2026, 5, 23, 2, 0)),
658                fake("d22-am", at(2026, 5, 22, 2, 0)),
659                fake("d22-pm", at(2026, 5, 22, 14, 0)),
660                fake("w-old", at(2026, 5, 1, 2, 0)),
661                pinned("pin-ancient", at(2020, 1, 1, 0, 0)),
662            ],
663            // Single row; empty input handled by select_kept directly.
664            vec![fake("only", at(2026, 5, 24, 2, 0))],
665        ];
666        let policies = [
667            policy(Some(2), None, None, None, None, None),
668            policy(None, None, Some(2), None, None, None),
669            policy(Some(1), None, Some(2), Some(1), Some(1), Some(1)),
670            policy(None, None, None, None, None, None), // keeps only pins
671        ];
672        for snaps in &populations {
673            for pol in &policies {
674                let first = select_kept(snaps, pol);
675                let survivors: Vec<Fake> = snaps
676                    .iter()
677                    .filter(|s| first.keep.iter().any(|k| k == &s.id))
678                    .map(|s| Fake {
679                        id: s.id.clone(),
680                        end: s.end,
681                        pinned: s.pinned,
682                    })
683                    .collect();
684                let second = select_kept(&survivors, pol);
685                assert!(
686                    second.delete.is_empty(),
687                    "keep(S) must be a fixed point; policy {pol:?} re-deleted {:?}",
688                    second.delete
689                );
690                assert_eq!(
691                    as_set(&second.keep),
692                    as_set(&first.keep),
693                    "keep(keep(S)) == keep(S) for policy {pol:?}"
694                );
695            }
696        }
697    }
698}