Skip to main content

kanade_shared/wire/
staleness.rs

1//! v0.26 — Manifest-declared staleness policy for the Layer 2
2//! defense (see SPEC.md §2.6.2).
3//!
4//! Carries the operator's intent re: "what should the agent do when
5//! it can't talk to the broker and so can't verify `script_current`
6//! / `script_status`?" — three answers cover the realistic span:
7//!
8//! - [`Staleness::Cached`] (default) — fire from cache; matches
9//!   pre-v0.26 behaviour so existing manifests keep working.
10//! - [`Staleness::Strict`] — must have been connected to the broker
11//!   within `max_cache_age` of fire time; else skip.
12//! - [`Staleness::Unchecked`] — skip the Layer 2 KV checks entirely.
13//!
14//! Lives in `wire/` because both the YAML-defined `Manifest` and the
15//! over-the-wire `Command` carry it (publisher copies it forward so
16//! the agent doesn't have to re-look-up the manifest at fire time).
17
18use serde::{Deserialize, Serialize};
19
20/// Staleness policy declared on a Manifest, forwarded onto each
21/// emitted Command. Internally tagged via `mode` so YAML reads
22/// naturally:
23///
24/// ```yaml
25/// staleness:
26///   mode: strict
27///   max_cache_age: 5m
28/// ```
29///
30/// `Cached` and `Unchecked` carry no payload, so they collapse to
31/// just `staleness: { mode: cached }` / `staleness: { mode: unchecked }`.
32#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
33#[serde(tag = "mode", rename_all = "snake_case")]
34pub enum Staleness {
35    /// Use whatever the agent has cached for `script_current` /
36    /// `script_status`, no age limit. Silently proceed if the entries
37    /// are missing. Historical default — every pre-v0.26 Manifest
38    /// reads as this on deserialize, so introducing the field is
39    /// fully back-compatible.
40    #[default]
41    Cached,
42    /// Only run when the agent can verify reasonably-fresh KV state.
43    /// "Reasonably fresh" = the last confirmed connection to the
44    /// broker happened within `max_cache_age` (NATS KV watch is
45    /// push-based, so while connected the cache is provably up to
46    /// date; the timer only starts when the agent disconnects). When
47    /// the window expires the agent attempts a live `kv.get()`; if
48    /// *that* also fails the agent publishes a synthetic skipped
49    /// result with `exit_code = 127`.
50    Strict {
51        /// Humantime duration (e.g. `"0s"`, `"5m"`, `"1h"`). Required
52        /// for `strict` mode — there's no implicit default because
53        /// the right answer is workload-specific.
54        ///
55        /// `0s` is the conservative choice — agent must be online
56        /// right now. Larger values trade urgency for tolerance to
57        /// brief network blips.
58        max_cache_age: String,
59    },
60    /// Skip Layer 2 entirely — don't look at `script_current` or
61    /// `script_status`. Use only for fully idempotent local scripts
62    /// where revoke semantics don't make sense.
63    Unchecked,
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn cached_round_trips() {
72        let s: Staleness = serde_json::from_str(r#"{"mode":"cached"}"#).unwrap();
73        assert_eq!(s, Staleness::Cached);
74        let back = serde_json::to_value(&s).unwrap();
75        assert_eq!(back["mode"], "cached");
76    }
77
78    #[test]
79    fn strict_requires_max_cache_age() {
80        let s: Staleness =
81            serde_json::from_str(r#"{"mode":"strict","max_cache_age":"5m"}"#).unwrap();
82        match s {
83            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "5m"),
84            other => panic!("expected strict, got {other:?}"),
85        }
86    }
87
88    #[test]
89    fn strict_without_max_cache_age_errors() {
90        // Operator forgot to set the field — fail loud at parse, not
91        // at fire time.
92        let res: Result<Staleness, _> = serde_json::from_str(r#"{"mode":"strict"}"#);
93        assert!(res.is_err(), "expected error, got {res:?}");
94    }
95
96    #[test]
97    fn unchecked_round_trips() {
98        let s: Staleness = serde_json::from_str(r#"{"mode":"unchecked"}"#).unwrap();
99        assert_eq!(s, Staleness::Unchecked);
100    }
101
102    #[test]
103    fn default_is_cached() {
104        // Critical for back-compat — Manifest #[serde(default)] on the
105        // staleness field must give us Cached, not e.g. Unchecked.
106        assert_eq!(Staleness::default(), Staleness::Cached);
107    }
108
109    #[test]
110    fn yaml_strict_parses() {
111        let yaml = r#"
112mode: strict
113max_cache_age: 0s
114"#;
115        let s: Staleness = serde_yaml::from_str(yaml).unwrap();
116        assert!(matches!(s, Staleness::Strict { .. }));
117    }
118
119    #[test]
120    fn yaml_cached_minimal_form() {
121        let yaml = "mode: cached\n";
122        let s: Staleness = serde_yaml::from_str(yaml).unwrap();
123        assert_eq!(s, Staleness::Cached);
124    }
125}