Skip to main content

kopiur_api/
snapshot_schedule.rs

1//! The `SnapshotSchedule` CRD — *when* a backup runs. Creates `Snapshot` CRs on a
2//! cron schedule in the `SnapshotPolicy`'s namespace. ADR-0001 §3.5, ADR-0003 §4.4.
3//!
4//! ```
5//! use kopiur_api::{SnapshotScheduleSpec, ConcurrencyPolicy};
6//!
7//! // The cluster path: YAML -> JSON value -> typed (never serde_yaml -> typed).
8//! let spec: SnapshotScheduleSpec = serde_json::from_value(serde_json::json!({
9//!     "policyRef": { "name": "postgres-data" },
10//!     "schedule": { "cron": "H 2 * * *", "jitter": "30m" },
11//! }))
12//! .unwrap();
13//! assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
14//! // GitOps-friendly defaults: no immediate fire, not suspended, Forbid overlap.
15//! assert!(!spec.schedule.run_on_create);
16//! assert!(!spec.schedule.suspend);
17//! assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
18//! ```
19
20use crate::common::PolicyRef;
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
22use kube::CustomResource;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26/// Cron schedule that fires `Snapshot` CRs from a `SnapshotPolicy`.
27#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
28#[kube(
29    group = "kopiur.home-operations.com",
30    version = "v1alpha1",
31    kind = "SnapshotSchedule",
32    namespaced,
33    status = "SnapshotScheduleStatus",
34    shortname = "kopiasched",
35    category = "kopiur",
36    printcolumn = r#"{"name":"Config","type":"string","jsonPath":".spec.policyRef.name"}"#,
37    printcolumn = r#"{"name":"Schedule","type":"string","jsonPath":".spec.schedule.cron"}"#,
38    printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.schedule.suspend"}"#,
39    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
40)]
41// §10/§15: exactly one of policyRef / policySelector (apiserver + CI validation,
42// complementing the webhook validator). Both optional at the type level.
43#[schemars(extend("x-kubernetes-validations" = [{
44    "rule": "[has(self.policyRef), has(self.policySelector)].filter(x, x).size() == 1",
45    "message": "exactly one of policyRef or policySelector"
46}]))]
47#[serde(rename_all = "camelCase")]
48pub struct SnapshotScheduleSpec {
49    /// The single `SnapshotPolicy` this schedule invokes; mutually exclusive with `policySelector`.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub policy_ref: Option<PolicyRef>,
52    /// Label selector fanning out over `SnapshotPolicy` objects; mutually exclusive with `policyRef`.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub policy_selector: Option<LabelSelector>,
55    /// Cron, jitter, timezone, and concurrency for the firing cadence.
56    pub schedule: ScheduleSpec,
57    /// Maximum number of failed `Snapshot` CRs from this schedule to retain
58    /// (default `10`; `0` keeps none).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    #[schemars(default = "default_failed_jobs_history_limit")]
61    pub failed_jobs_history_limit: Option<u32>,
62    /// Deletion semantics for the Snapshots this schedule produced.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub deletion: Option<ScheduleDeletionSpec>,
65}
66
67/// Deletion semantics for a schedule's produced `Snapshot`s (sub-object per
68/// docs/dev/api-conventions.md §4 so future deletion knobs slot in without
69/// API breakage).
70#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct ScheduleDeletionSpec {
73    /// Stamped onto every produced Snapshot at creation (`spec.onScheduleDelete`)
74    /// and consulted by the Snapshot finalizer when the owning schedule is gone
75    /// or replaced. Absent resolves to `Retain`.
76    #[serde(default = "default_on_schedule_delete")]
77    #[schemars(default = "default_on_schedule_delete")]
78    pub on_schedule_delete: crate::common::ScheduleDeletePolicy,
79}
80
81fn default_on_schedule_delete() -> crate::common::ScheduleDeletePolicy {
82    crate::common::ScheduleDeletePolicy::Retain
83}
84
85/// The effective cascade policy for a schedule: `spec.deletion.onScheduleDelete`
86/// when the sub-object is present, else `Retain`. (A default nested under an
87/// ABSENT optional sub-object does not materialize server-side — every read
88/// goes through this resolver.)
89pub fn effective_on_schedule_delete(
90    deletion: Option<&ScheduleDeletionSpec>,
91) -> crate::common::ScheduleDeletePolicy {
92    deletion.map(|d| d.on_schedule_delete).unwrap_or_default()
93}
94
95/// schemars default for [`SnapshotScheduleSpec::failed_jobs_history_limit`] —
96/// [`DEFAULT_FAILED_JOBS_HISTORY_LIMIT`](crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
97/// (`10`), matching `effective_failed_jobs_history_limit`'s absent→CONST
98/// resolution. Returns the field's `Option` type so schemars 1 emits the
99/// schema `default:`.
100fn default_failed_jobs_history_limit() -> Option<u32> {
101    Some(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
102}
103
104/// serde/schemars `default` for [`ScheduleSpec::run_on_create`] — `false`
105/// (ADR-0005 §1). A named fn so it backs BOTH `#[serde(default = ...)]` and
106/// `#[schemars(default = ...)]`, which is what makes schemars 1 emit the OpenAPI
107/// `default:` in the generated CRD schema.
108fn default_run_on_create() -> bool {
109    false
110}
111
112/// serde/schemars `default` for [`ScheduleSpec::concurrency_policy`] — `Forbid`
113/// (ADR-0005 §1). Same dual-attribute pattern as [`default_run_on_create`].
114fn default_concurrency_policy() -> ConcurrencyPolicy {
115    ConcurrencyPolicy::Forbid
116}
117
118/// Cron schedule with deterministic jitter, timezone, and concurrency controls.
119#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ScheduleSpec {
122    /// Cron expression with Jenkins-style `H` substitution.
123    pub cron: String,
124    /// Deterministic jitter (Go-style duration), derived from `(scheduleUID, slot)`.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub jitter: Option<String>,
127    /// IANA timezone the cron is evaluated in; absent uses the controller's default.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub timezone: Option<String>,
130    /// Whether to fire immediately on create (default `false`).
131    #[serde(default = "default_run_on_create")]
132    #[schemars(default = "default_run_on_create")]
133    pub run_on_create: bool,
134    /// Skip future firings while true.
135    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
136    pub suspend: bool,
137    /// How to handle a firing while a prior run is still in flight (default `Forbid`).
138    #[serde(default = "default_concurrency_policy")]
139    #[schemars(default = "default_concurrency_policy")]
140    pub concurrency_policy: ConcurrencyPolicy,
141    /// If a slot is missed by more than this many seconds, skip it instead of firing late.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub starting_deadline_seconds: Option<i64>,
144}
145
146/// What to do when a previous run is still in flight. Closed enum, default `Forbid`. ADR §4.1 (G5/G18).
147///
148/// ```
149/// use kopiur_api::ConcurrencyPolicy;
150///
151/// // The safe default: never let runs pile up.
152/// assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
153/// // Serializes as the bare PascalCase string the CRD schema expects.
154/// assert_eq!(
155///     serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
156///     serde_json::json!("Replace"),
157/// );
158/// ```
159#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
160pub enum ConcurrencyPolicy {
161    /// Skip the new run rather than let runs pile up (default).
162    #[default]
163    Forbid,
164    /// Allow the new run to start alongside the in-flight one.
165    Allow,
166    /// Cancel the in-flight run and start the new one in its place.
167    Replace,
168}
169
170/// Observed state of a `SnapshotSchedule`: pinned firing slots and failure run.
171#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct SnapshotScheduleStatus {
174    /// The `metadata.generation` this status reflects, for staleness detection.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub observed_generation: Option<i64>,
177    /// Most recent firing (cron + jitter, pinned).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub last_schedule: Option<ScheduleRef>,
180    /// The next firing slot the controller has computed (cron + jitter, pinned).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub next_schedule: Option<ScheduleRef>,
183    /// The most recent firing whose `Snapshot` succeeded.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub last_successful_schedule: Option<ScheduleRef>,
186    /// Count of back-to-back failed runs; resets on success.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub consecutive_failures: Option<i64>,
189    /// Standard Kubernetes conditions surfacing schedule health.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub conditions: Vec<Condition>,
192}
193
194/// A pinned schedule slot and (optionally) the `Snapshot` it created.
195#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
196#[serde(rename_all = "camelCase")]
197pub struct ScheduleRef {
198    /// The RFC3339 instant this slot fired (or is scheduled to); also accepts the `scheduledAt` alias.
199    #[serde(
200        default,
201        alias = "scheduledAt",
202        skip_serializing_if = "Option::is_none"
203    )]
204    pub at: Option<String>,
205    /// The `Snapshot` CR this slot produced, when one was created.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub snapshot_ref: Option<SnapshotReference>,
208    /// The IANA timezone the cron was evaluated in when this slot was pinned. This
209    /// struct is shared by `nextSchedule`, `lastSchedule` and
210    /// `lastSuccessfulSchedule`, so the field appears on all three, but the
211    /// controller only ever WRITES it on `nextSchedule` — it is a property of a pin
212    /// the controller may still have to invalidate, not a record of a slot that
213    /// already fired. Recorded so the controller can detect an
214    /// effective-timezone change — a `spec.schedule.timezone` edit or a change to
215    /// the target repository's `scheduleDefaults.timezone` — and invalidate the
216    /// pinned wall-clock slot, recomputing it in the new zone. Absent on legacy
217    /// pins written before this field existed (treated as "unchanged").
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub timezone: Option<String>,
220    /// The deterministic jitter window (Go-style duration, e.g. `10m`) the cron was
221    /// spread by when this slot was pinned. This struct is shared by `nextSchedule`,
222    /// `lastSchedule` and `lastSuccessfulSchedule`, so the field appears on all
223    /// three, but the controller only ever WRITES it on `nextSchedule` — it is a
224    /// property of a pin the controller may still have to invalidate, not a record
225    /// of a slot that already fired. Recorded for the
226    /// same reason as the pinned `timezone`: the window may be INHERITED from the
227    /// target repository's `scheduleDefaults.jitter`, so a change to that default (or
228    /// to `spec.schedule.jitter`) must invalidate the pinned wall-clock slot and
229    /// recompute it in the new window — otherwise the edit would only take effect an
230    /// arbitrary slot later. Absent both when no jitter applies and on legacy pins
231    /// written before this field existed; an absent recorded window is treated as
232    /// "unchanged" so an upgrade never churns an established pin.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub jitter: Option<String>,
235}
236
237/// A by-name reference to a `Snapshot` CR created by a schedule slot.
238#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct SnapshotReference {
241    /// The `Snapshot`'s `metadata.name` (same namespace as the schedule).
242    pub name: String,
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::testutil::from_yaml;
249    use kube::core::CustomResourceExt;
250
251    #[test]
252    fn backup_schedule_crd_metadata_is_correct() {
253        let crd = SnapshotSchedule::crd();
254        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
255        assert_eq!(crd.spec.names.kind, "SnapshotSchedule");
256        assert_eq!(crd.spec.scope, "Namespaced");
257        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
258    }
259
260    #[test]
261    fn failed_jobs_history_limit_schema_default_matches_the_constant() {
262        // Context-free default surfaced in the schema (server-side-materialized);
263        // safe because effective_failed_jobs_history_limit maps absent → this value.
264        let crd = SnapshotSchedule::crd();
265        let json = serde_json::to_value(&crd).unwrap();
266        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
267        assert_eq!(
268            spec["properties"]["failedJobsHistoryLimit"]["default"],
269            serde_json::json!(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
270        );
271        assert_eq!(
272            crate::consts::effective_failed_jobs_history_limit(None),
273            crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT
274        );
275    }
276
277    #[test]
278    fn schedule_deletion_on_schedule_delete_schema_default_is_retain() {
279        // Mirrors failed_jobs_history_limit_schema_default_matches_the_constant:
280        // a context-free default is safe to server-side-materialize because
281        // effective_on_schedule_delete maps an absent sub-object to the same value.
282        let crd = SnapshotSchedule::crd();
283        let json = serde_json::to_value(&crd).unwrap();
284        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
285        assert_eq!(
286            spec["properties"]["deletion"]["properties"]["onScheduleDelete"]["default"],
287            serde_json::json!("Retain")
288        );
289        assert_eq!(
290            effective_on_schedule_delete(None),
291            crate::common::ScheduleDeletePolicy::Retain
292        );
293    }
294
295    #[test]
296    fn schedule_deletion_round_trips_and_absent_stays_none() {
297        use crate::common::ScheduleDeletePolicy;
298
299        let spec: SnapshotScheduleSpec = from_yaml(
300            "policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\ndeletion: { onScheduleDelete: Delete }\n",
301        );
302        assert_eq!(
303            spec.deletion.as_ref().map(|d| d.on_schedule_delete),
304            Some(ScheduleDeletePolicy::Delete)
305        );
306        assert_eq!(
307            effective_on_schedule_delete(spec.deletion.as_ref()),
308            ScheduleDeletePolicy::Delete
309        );
310        let json = serde_json::to_value(&spec).unwrap();
311        assert_eq!(json["deletion"]["onScheduleDelete"], "Delete");
312        let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).unwrap();
313        assert_eq!(spec, reparsed);
314
315        // Absent sub-object stays None (not materialized to Retain client-side).
316        let bare: SnapshotScheduleSpec =
317            from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
318        assert!(bare.deletion.is_none());
319        assert!(
320            serde_json::to_value(&bare)
321                .unwrap()
322                .get("deletion")
323                .is_none(),
324            "absent deletion must be elided"
325        );
326        assert_eq!(
327            effective_on_schedule_delete(bare.deletion.as_ref()),
328            ScheduleDeletePolicy::Retain
329        );
330    }
331
332    #[test]
333    fn schedule_delete_policy_serializes_to_expected_strings() {
334        use crate::common::ScheduleDeletePolicy;
335
336        assert_eq!(
337            serde_json::to_value(ScheduleDeletePolicy::Retain).unwrap(),
338            "Retain"
339        );
340        assert_eq!(
341            serde_json::to_value(ScheduleDeletePolicy::Delete).unwrap(),
342            "Delete"
343        );
344        assert_eq!(
345            ScheduleDeletePolicy::default(),
346            ScheduleDeletePolicy::Retain
347        );
348    }
349
350    #[test]
351    fn schedule_crd_carries_policy_target_xor_validation() {
352        // §10/§15: the spec schema carries the policyRef-XOR-policySelector rule.
353        let crd = SnapshotSchedule::crd();
354        let json = serde_json::to_value(&crd).expect("serialize CRD");
355        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
356            ["x-kubernetes-validations"]
357            .as_array()
358            .expect("spec.x-kubernetes-validations present");
359        assert!(rules.iter().any(|r| {
360            r["rule"]
361                .as_str()
362                .is_some_and(|s| s.contains("policySelector"))
363        }));
364    }
365
366    #[test]
367    fn schedule_defaults_carry_static_openapi_defaults_in_crd() {
368        // ADR-0005 §1: schedule.runOnCreate (false) and schedule.concurrencyPolicy
369        // (Forbid) must carry real schema defaults so they materialize into the
370        // stored object / `kubectl explain` and GitOps stops diff-thrashing.
371        let crd = SnapshotSchedule::crd();
372        let json = serde_json::to_value(&crd).expect("serialize CRD");
373        let schedule = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
374            ["properties"]["schedule"]["properties"];
375        assert_eq!(
376            schedule["runOnCreate"]["default"], false,
377            "runOnCreate must emit `default: false`"
378        );
379        assert_eq!(
380            schedule["concurrencyPolicy"]["default"], "Forbid",
381            "concurrencyPolicy must emit `default: Forbid`"
382        );
383    }
384
385    #[test]
386    fn schedule_static_defaults_materialize_and_round_trip() {
387        // Both fields parse to their defaults when absent AND serialize (not elided),
388        // so the materialized value round-trips.
389        let spec: SnapshotScheduleSpec =
390            from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
391        assert!(!spec.schedule.run_on_create);
392        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
393        let json = serde_json::to_value(&spec).unwrap();
394        assert_eq!(json["schedule"]["runOnCreate"], false);
395        assert_eq!(json["schedule"]["concurrencyPolicy"], "Forbid");
396    }
397
398    #[test]
399    fn backup_schedule_roundtrip_matches_adr_shape() {
400        // Mirrors ADR-0001 §3.5.
401        let yaml = r#"
402policyRef:
403  name: postgres-data
404schedule:
405  cron: "H 2 * * *"
406  jitter: 30m
407  timezone: "America/Los_Angeles"
408  runOnCreate: false
409  suspend: false
410  concurrencyPolicy: Forbid
411  startingDeadlineSeconds: 600
412failedJobsHistoryLimit: 3
413"#;
414        let spec: SnapshotScheduleSpec = from_yaml(yaml);
415        assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
416        assert_eq!(spec.schedule.cron, "H 2 * * *");
417        assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
418        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
419        assert!(!spec.schedule.run_on_create);
420        assert_eq!(spec.failed_jobs_history_limit, Some(3));
421
422        let json = serde_json::to_value(&spec).expect("serialize");
423        let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).expect("reparse");
424        assert_eq!(spec, reparsed);
425    }
426
427    #[test]
428    fn schedule_defaults_are_gitops_friendly() {
429        // Mirrors ADR-0001 §5.1: minimal schedule.
430        let spec: SnapshotScheduleSpec = from_yaml(
431            "policyRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
432        );
433        // runOnCreate and suspend default false; concurrency defaults Forbid.
434        assert!(!spec.schedule.run_on_create);
435        assert!(!spec.schedule.suspend);
436        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
437        // No successfulJobsHistoryLimit exists on the type at all (ADR-0003 §4.4).
438    }
439
440    #[test]
441    fn concurrency_policy_serializes_to_expected_strings() {
442        assert_eq!(
443            serde_json::to_value(ConcurrencyPolicy::Forbid).unwrap(),
444            "Forbid"
445        );
446        assert_eq!(
447            serde_json::to_value(ConcurrencyPolicy::Allow).unwrap(),
448            "Allow"
449        );
450        assert_eq!(
451            serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
452            "Replace"
453        );
454        assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
455    }
456
457    #[test]
458    fn schedule_status_accepts_both_at_and_scheduled_at() {
459        // ADR §3.5 uses `scheduledAt` on lastSchedule and `at` on next/lastSuccessful.
460        let status: SnapshotScheduleStatus = from_yaml(
461            r#"
462lastSchedule:
463  scheduledAt: 2026-05-24T02:13:00Z
464  snapshotRef: { name: postgres-data-20260524-021300 }
465nextSchedule:
466  at: 2026-05-25T02:21:00Z
467lastSuccessfulSchedule:
468  at: 2026-05-24T02:13:00Z
469  snapshotRef: { name: postgres-data-20260524-021300 }
470consecutiveFailures: 0
471"#,
472        );
473        assert_eq!(
474            status.last_schedule.as_ref().unwrap().at.as_deref(),
475            Some("2026-05-24T02:13:00Z")
476        );
477        assert_eq!(
478            status.next_schedule.as_ref().unwrap().at.as_deref(),
479            Some("2026-05-25T02:21:00Z")
480        );
481        // Round-trips (serializes back as `at`).
482        let json = serde_json::to_value(&status).unwrap();
483        let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
484        assert_eq!(status, reparsed);
485    }
486
487    #[test]
488    fn next_schedule_timezone_round_trips() {
489        // The pinned-slot timezone (recorded so an effective-timezone change can
490        // invalidate the pin) parses from YAML and serializes back unchanged.
491        let status: SnapshotScheduleStatus = from_yaml(
492            r#"
493nextSchedule:
494  at: 2026-05-25T09:00:00Z
495  timezone: America/Chicago
496"#,
497        );
498        assert_eq!(
499            status.next_schedule.as_ref().unwrap().timezone.as_deref(),
500            Some("America/Chicago")
501        );
502        let json = serde_json::to_value(&status).unwrap();
503        assert_eq!(json["nextSchedule"]["timezone"], "America/Chicago");
504        let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
505        assert_eq!(status, reparsed);
506
507        // Absent timezone (legacy pins) stays absent, not `null`.
508        let bare: SnapshotScheduleStatus =
509            from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
510        assert!(bare.next_schedule.as_ref().unwrap().timezone.is_none());
511        let bare_json = serde_json::to_value(&bare).unwrap();
512        assert!(bare_json["nextSchedule"].get("timezone").is_none());
513    }
514
515    #[test]
516    fn next_schedule_jitter_round_trips() {
517        // The pinned-slot jitter window (recorded so a change to the effective
518        // window — including one inherited from the repository's
519        // `scheduleDefaults.jitter` — can invalidate the pin) parses from YAML and
520        // serializes back unchanged, alongside the timezone.
521        let status: SnapshotScheduleStatus = from_yaml(
522            r#"
523nextSchedule:
524  at: 2026-05-25T09:00:00Z
525  timezone: America/Chicago
526  jitter: 30m
527"#,
528        );
529        let pin = status.next_schedule.as_ref().unwrap();
530        assert_eq!(pin.jitter.as_deref(), Some("30m"));
531        assert_eq!(pin.timezone.as_deref(), Some("America/Chicago"));
532        let json = serde_json::to_value(&status).unwrap();
533        assert_eq!(json["nextSchedule"]["jitter"], "30m");
534        let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
535        assert_eq!(status, reparsed);
536    }
537
538    #[test]
539    fn next_schedule_absent_jitter_decodes_and_stays_absent() {
540        // Upgrade path: a pin STORED before `jitter` existed must decode (never a
541        // deserialization error that would poison the watcher) and must serialize
542        // back with no `jitter` key at all — not `null`, which a merge patch would
543        // treat as a deliberate deletion and which would also change the stored
544        // object's bytes for every pre-upgrade schedule.
545        let legacy: SnapshotScheduleStatus = from_yaml(
546            r#"
547nextSchedule:
548  at: 2026-05-25T09:00:00Z
549  timezone: America/Chicago
550"#,
551        );
552        let pin = legacy.next_schedule.as_ref().unwrap();
553        assert!(pin.jitter.is_none());
554        let json = serde_json::to_value(&legacy).unwrap();
555        assert!(json["nextSchedule"].get("jitter").is_none());
556
557        // The oldest shape (no timezone either) still decodes.
558        let oldest: SnapshotScheduleStatus =
559            from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
560        let pin = oldest.next_schedule.as_ref().unwrap();
561        assert!(pin.jitter.is_none() && pin.timezone.is_none());
562        assert_eq!(
563            serde_json::to_value(&oldest).unwrap()["nextSchedule"],
564            serde_json::json!({ "at": "2026-05-25T09:00:00Z" }),
565            "a legacy pin must round-trip byte-identically"
566        );
567    }
568}