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 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/src/content/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    /// Hold the new run while a run of the same policy is in flight, whether this
162    /// schedule started it or not (a manual run, another schedule), rather than
163    /// let runs pile up (default).
164    #[default]
165    Forbid,
166    /// Allow the new run to start alongside the in-flight one.
167    Allow,
168    /// Cancel this schedule's in-flight run and start the new one in its place.
169    /// A run this schedule did not start is never cancelled; the slot waits for it.
170    Replace,
171}
172
173/// Observed state of a `SnapshotSchedule`: pinned firing slots and failure run.
174#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
175#[serde(rename_all = "camelCase")]
176pub struct SnapshotScheduleStatus {
177    /// The `metadata.generation` this status reflects, for staleness detection.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub observed_generation: Option<i64>,
180    /// Most recent firing (cron + jitter, pinned).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub last_schedule: Option<ScheduleRef>,
183    /// The next firing slot the controller has computed (cron + jitter, pinned).
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub next_schedule: Option<ScheduleRef>,
186    /// The most recent firing whose `Snapshot` succeeded.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub last_successful_schedule: Option<ScheduleRef>,
189    /// Count of back-to-back failed runs; resets on success.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub consecutive_failures: Option<i64>,
192    /// Standard Kubernetes conditions surfacing schedule health.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub conditions: Vec<Condition>,
195}
196
197/// A pinned schedule slot and (optionally) the `Snapshot` it created.
198#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
199#[serde(rename_all = "camelCase")]
200pub struct ScheduleRef {
201    /// The RFC3339 instant this slot fired (or is scheduled to); also accepts the `scheduledAt` alias.
202    #[serde(
203        default,
204        alias = "scheduledAt",
205        skip_serializing_if = "Option::is_none"
206    )]
207    pub at: Option<String>,
208    /// The `Snapshot` CR this slot produced, when one was created.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub snapshot_ref: Option<SnapshotReference>,
211    /// The IANA timezone the cron was evaluated in when this slot was pinned. This
212    /// struct is shared by `nextSchedule`, `lastSchedule` and
213    /// `lastSuccessfulSchedule`, so the field appears on all three, but the
214    /// controller only ever WRITES it on `nextSchedule` — it is a property of a pin
215    /// the controller may still have to invalidate, not a record of a slot that
216    /// already fired. Recorded so the controller can detect an
217    /// effective-timezone change — a `spec.schedule.timezone` edit or a change to
218    /// the target repository's `scheduleDefaults.timezone` — and invalidate the
219    /// pinned wall-clock slot, recomputing it in the new zone. Absent on legacy
220    /// pins written before this field existed (treated as "unchanged").
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub timezone: Option<String>,
223    /// The deterministic jitter window (Go-style duration, e.g. `10m`) the cron was
224    /// spread by when this slot was pinned. This struct is shared by `nextSchedule`,
225    /// `lastSchedule` and `lastSuccessfulSchedule`, so the field appears on all
226    /// three, but the controller only ever WRITES it on `nextSchedule` — it is a
227    /// property of a pin the controller may still have to invalidate, not a record
228    /// of a slot that already fired. Recorded for the
229    /// same reason as the pinned `timezone`: the window may be INHERITED from the
230    /// target repository's `scheduleDefaults.jitter`, so a change to that default (or
231    /// to `spec.schedule.jitter`) must invalidate the pinned wall-clock slot and
232    /// recompute it in the new window — otherwise the edit would only take effect an
233    /// arbitrary slot later. Absent both when no jitter applies and on legacy pins
234    /// written before this field existed; an absent recorded window is treated as
235    /// "unchanged" so an upgrade never churns an established pin.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub jitter: Option<String>,
238    /// The `spec.schedule.cron` expression this slot was computed from, as written
239    /// when it was pinned. Like `timezone` and `jitter`, only ever WRITTEN on
240    /// `nextSchedule`. Recorded so a cron edit invalidates the pinned slot at once
241    /// instead of letting the OLD expression fire one more time. Compared with
242    /// whitespace normalized, so re-spacing the expression is not a change. Absent
243    /// on pins written before this field existed, which is treated as "unchanged"
244    /// (the slot is recomputed with the new expression at its next natural re-pin).
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub cron: Option<String>,
247}
248
249/// A by-name reference to a `Snapshot` CR created by a schedule slot.
250#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
251#[serde(rename_all = "camelCase")]
252pub struct SnapshotReference {
253    /// The `Snapshot`'s `metadata.name` (same namespace as the schedule).
254    pub name: String,
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::testutil::from_yaml;
261    use kube::core::CustomResourceExt;
262
263    #[test]
264    fn backup_schedule_crd_metadata_is_correct() {
265        let crd = SnapshotSchedule::crd();
266        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
267        assert_eq!(crd.spec.names.kind, "SnapshotSchedule");
268        assert_eq!(crd.spec.scope, "Namespaced");
269        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
270    }
271
272    #[test]
273    fn failed_jobs_history_limit_schema_default_matches_the_constant() {
274        // Context-free default surfaced in the schema (server-side-materialized);
275        // safe because effective_failed_jobs_history_limit maps absent → this value.
276        let crd = SnapshotSchedule::crd();
277        let json = serde_json::to_value(&crd).unwrap();
278        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
279        assert_eq!(
280            spec["properties"]["failedJobsHistoryLimit"]["default"],
281            serde_json::json!(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
282        );
283        assert_eq!(
284            crate::consts::effective_failed_jobs_history_limit(None),
285            crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT
286        );
287    }
288
289    #[test]
290    fn schedule_deletion_on_schedule_delete_schema_default_is_retain() {
291        // Mirrors failed_jobs_history_limit_schema_default_matches_the_constant:
292        // a context-free default is safe to server-side-materialize because
293        // effective_on_schedule_delete maps an absent sub-object to the same value.
294        let crd = SnapshotSchedule::crd();
295        let json = serde_json::to_value(&crd).unwrap();
296        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
297        assert_eq!(
298            spec["properties"]["deletion"]["properties"]["onScheduleDelete"]["default"],
299            serde_json::json!("Retain")
300        );
301        assert_eq!(
302            effective_on_schedule_delete(None),
303            crate::common::ScheduleDeletePolicy::Retain
304        );
305    }
306
307    #[test]
308    fn schedule_deletion_round_trips_and_absent_stays_none() {
309        use crate::common::ScheduleDeletePolicy;
310
311        let spec: SnapshotScheduleSpec = from_yaml(
312            "policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\ndeletion: { onScheduleDelete: Delete }\n",
313        );
314        assert_eq!(
315            spec.deletion.as_ref().map(|d| d.on_schedule_delete),
316            Some(ScheduleDeletePolicy::Delete)
317        );
318        assert_eq!(
319            effective_on_schedule_delete(spec.deletion.as_ref()),
320            ScheduleDeletePolicy::Delete
321        );
322        let json = serde_json::to_value(&spec).unwrap();
323        assert_eq!(json["deletion"]["onScheduleDelete"], "Delete");
324        let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).unwrap();
325        assert_eq!(spec, reparsed);
326
327        // Absent sub-object stays None (not materialized to Retain client-side).
328        let bare: SnapshotScheduleSpec =
329            from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
330        assert!(bare.deletion.is_none());
331        assert!(
332            serde_json::to_value(&bare)
333                .unwrap()
334                .get("deletion")
335                .is_none(),
336            "absent deletion must be elided"
337        );
338        assert_eq!(
339            effective_on_schedule_delete(bare.deletion.as_ref()),
340            ScheduleDeletePolicy::Retain
341        );
342    }
343
344    #[test]
345    fn schedule_delete_policy_serializes_to_expected_strings() {
346        use crate::common::ScheduleDeletePolicy;
347
348        assert_eq!(
349            serde_json::to_value(ScheduleDeletePolicy::Retain).unwrap(),
350            "Retain"
351        );
352        assert_eq!(
353            serde_json::to_value(ScheduleDeletePolicy::Delete).unwrap(),
354            "Delete"
355        );
356        assert_eq!(
357            ScheduleDeletePolicy::default(),
358            ScheduleDeletePolicy::Retain
359        );
360    }
361
362    #[test]
363    fn schedule_crd_carries_policy_target_xor_validation() {
364        // §10/§15: the spec schema carries the policyRef-XOR-policySelector rule.
365        let crd = SnapshotSchedule::crd();
366        let json = serde_json::to_value(&crd).expect("serialize CRD");
367        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
368            ["x-kubernetes-validations"]
369            .as_array()
370            .expect("spec.x-kubernetes-validations present");
371        assert!(rules.iter().any(|r| {
372            r["rule"]
373                .as_str()
374                .is_some_and(|s| s.contains("policySelector"))
375        }));
376    }
377
378    #[test]
379    fn schedule_defaults_carry_static_openapi_defaults_in_crd() {
380        // ADR-0005 §1: schedule.runOnCreate (false) and schedule.concurrencyPolicy
381        // (Forbid) must carry real schema defaults so they materialize into the
382        // stored object / `kubectl explain` and GitOps stops diff-thrashing.
383        let crd = SnapshotSchedule::crd();
384        let json = serde_json::to_value(&crd).expect("serialize CRD");
385        let schedule = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
386            ["properties"]["schedule"]["properties"];
387        assert_eq!(
388            schedule["runOnCreate"]["default"], false,
389            "runOnCreate must emit `default: false`"
390        );
391        assert_eq!(
392            schedule["concurrencyPolicy"]["default"], "Forbid",
393            "concurrencyPolicy must emit `default: Forbid`"
394        );
395    }
396
397    #[test]
398    fn schedule_static_defaults_materialize_and_round_trip() {
399        // Both fields parse to their defaults when absent AND serialize (not elided),
400        // so the materialized value round-trips.
401        let spec: SnapshotScheduleSpec =
402            from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
403        assert!(!spec.schedule.run_on_create);
404        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
405        let json = serde_json::to_value(&spec).unwrap();
406        assert_eq!(json["schedule"]["runOnCreate"], false);
407        assert_eq!(json["schedule"]["concurrencyPolicy"], "Forbid");
408    }
409
410    #[test]
411    fn backup_schedule_roundtrip_matches_adr_shape() {
412        // Mirrors ADR-0001 §3.5.
413        let yaml = r#"
414policyRef:
415  name: postgres-data
416schedule:
417  cron: "H 2 * * *"
418  jitter: 30m
419  timezone: "America/Los_Angeles"
420  runOnCreate: false
421  suspend: false
422  concurrencyPolicy: Forbid
423  startingDeadlineSeconds: 600
424failedJobsHistoryLimit: 3
425"#;
426        let spec: SnapshotScheduleSpec = from_yaml(yaml);
427        assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
428        assert_eq!(spec.schedule.cron, "H 2 * * *");
429        assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
430        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
431        assert!(!spec.schedule.run_on_create);
432        assert_eq!(spec.failed_jobs_history_limit, Some(3));
433
434        let json = serde_json::to_value(&spec).expect("serialize");
435        let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).expect("reparse");
436        assert_eq!(spec, reparsed);
437    }
438
439    #[test]
440    fn schedule_defaults_are_gitops_friendly() {
441        // Mirrors ADR-0001 §5.1: minimal schedule.
442        let spec: SnapshotScheduleSpec = from_yaml(
443            "policyRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
444        );
445        // runOnCreate and suspend default false; concurrency defaults Forbid.
446        assert!(!spec.schedule.run_on_create);
447        assert!(!spec.schedule.suspend);
448        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
449        // No successfulJobsHistoryLimit exists on the type at all (ADR-0003 §4.4).
450    }
451
452    #[test]
453    fn concurrency_policy_serializes_to_expected_strings() {
454        assert_eq!(
455            serde_json::to_value(ConcurrencyPolicy::Forbid).unwrap(),
456            "Forbid"
457        );
458        assert_eq!(
459            serde_json::to_value(ConcurrencyPolicy::Allow).unwrap(),
460            "Allow"
461        );
462        assert_eq!(
463            serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
464            "Replace"
465        );
466        assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
467    }
468
469    #[test]
470    fn schedule_status_accepts_both_at_and_scheduled_at() {
471        // ADR §3.5 uses `scheduledAt` on lastSchedule and `at` on next/lastSuccessful.
472        let status: SnapshotScheduleStatus = from_yaml(
473            r#"
474lastSchedule:
475  scheduledAt: 2026-05-24T02:13:00Z
476  snapshotRef: { name: postgres-data-20260524-021300 }
477nextSchedule:
478  at: 2026-05-25T02:21:00Z
479lastSuccessfulSchedule:
480  at: 2026-05-24T02:13:00Z
481  snapshotRef: { name: postgres-data-20260524-021300 }
482consecutiveFailures: 0
483"#,
484        );
485        assert_eq!(
486            status.last_schedule.as_ref().unwrap().at.as_deref(),
487            Some("2026-05-24T02:13:00Z")
488        );
489        assert_eq!(
490            status.next_schedule.as_ref().unwrap().at.as_deref(),
491            Some("2026-05-25T02:21:00Z")
492        );
493        // Round-trips (serializes back as `at`).
494        let json = serde_json::to_value(&status).unwrap();
495        let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
496        assert_eq!(status, reparsed);
497    }
498
499    #[test]
500    fn next_schedule_timezone_round_trips() {
501        // The pinned-slot timezone (recorded so an effective-timezone change can
502        // invalidate the pin) parses from YAML and serializes back unchanged.
503        let status: SnapshotScheduleStatus = from_yaml(
504            r#"
505nextSchedule:
506  at: 2026-05-25T09:00:00Z
507  timezone: America/Chicago
508"#,
509        );
510        assert_eq!(
511            status.next_schedule.as_ref().unwrap().timezone.as_deref(),
512            Some("America/Chicago")
513        );
514        let json = serde_json::to_value(&status).unwrap();
515        assert_eq!(json["nextSchedule"]["timezone"], "America/Chicago");
516        let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
517        assert_eq!(status, reparsed);
518
519        // Absent timezone (legacy pins) stays absent, not `null`.
520        let bare: SnapshotScheduleStatus =
521            from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
522        assert!(bare.next_schedule.as_ref().unwrap().timezone.is_none());
523        let bare_json = serde_json::to_value(&bare).unwrap();
524        assert!(bare_json["nextSchedule"].get("timezone").is_none());
525    }
526
527    #[test]
528    fn next_schedule_jitter_round_trips() {
529        // The pinned-slot jitter window (recorded so a change to the effective
530        // window — including one inherited from the repository's
531        // `scheduleDefaults.jitter` — can invalidate the pin) parses from YAML and
532        // serializes back unchanged, alongside the timezone.
533        let status: SnapshotScheduleStatus = from_yaml(
534            r#"
535nextSchedule:
536  at: 2026-05-25T09:00:00Z
537  timezone: America/Chicago
538  jitter: 30m
539"#,
540        );
541        let pin = status.next_schedule.as_ref().unwrap();
542        assert_eq!(pin.jitter.as_deref(), Some("30m"));
543        assert_eq!(pin.timezone.as_deref(), Some("America/Chicago"));
544        let json = serde_json::to_value(&status).unwrap();
545        assert_eq!(json["nextSchedule"]["jitter"], "30m");
546        let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
547        assert_eq!(status, reparsed);
548    }
549
550    #[test]
551    fn next_schedule_absent_jitter_decodes_and_stays_absent() {
552        // Upgrade path: a pin STORED before `jitter` existed must decode (never a
553        // deserialization error that would poison the watcher) and must serialize
554        // back with no `jitter` key at all — not `null`, which a merge patch would
555        // treat as a deliberate deletion and which would also change the stored
556        // object's bytes for every pre-upgrade schedule.
557        let legacy: SnapshotScheduleStatus = from_yaml(
558            r#"
559nextSchedule:
560  at: 2026-05-25T09:00:00Z
561  timezone: America/Chicago
562"#,
563        );
564        let pin = legacy.next_schedule.as_ref().unwrap();
565        assert!(pin.jitter.is_none());
566        let json = serde_json::to_value(&legacy).unwrap();
567        assert!(json["nextSchedule"].get("jitter").is_none());
568
569        // The oldest shape (no timezone either) still decodes.
570        let oldest: SnapshotScheduleStatus =
571            from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
572        let pin = oldest.next_schedule.as_ref().unwrap();
573        assert!(pin.jitter.is_none() && pin.timezone.is_none());
574        assert_eq!(
575            serde_json::to_value(&oldest).unwrap()["nextSchedule"],
576            serde_json::json!({ "at": "2026-05-25T09:00:00Z" }),
577            "a legacy pin must round-trip byte-identically"
578        );
579    }
580}