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}