Skip to main content

zenkey_fleet/report/
alert.rs

1//! The alert plane as an observer sees it (#388): one producer's alert
2//! document changing state on its stable key.
3//!
4//! RFC 04 §1.2 makes alerts **state**: a `put` on
5//! `…/state/<producer>/alert/<alert_key>` is the alert firing, a `delete`
6//! is the tombstone that resolves it, and the key is the identity across
7//! both. [`AlertTransition`] is that observation as a wire shape — what a
8//! notifier (zenwatch) routes and what a script reads — and
9//! [`crate::model::alert::alert_transition`] is the one place it is built.
10
11use std::collections::BTreeMap;
12
13use serde::Serialize;
14
15/// Which way one alert key just moved (RFC 04 §1.2).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AlertState {
19    /// A `put` landed on the alert key: the alert is firing (or re-fired).
20    Firing,
21    /// A `delete` landed on the alert key: the producer retired the alert.
22    Resolved,
23}
24
25/// Where the fields riding an [`AlertTransition`] came from — said out loud,
26/// because "no severity" reads differently under each (RFC 13 §3 O4).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RenderSource {
30    /// Decoded through the producer's served schema (RFC 08 §7).
31    Schema,
32    /// No served schema: a structural reading of the bytes (JSON if they
33    /// parse, CBOR diagnostic otherwise), which may still carry the fields.
34    Structural,
35    /// Nothing was decoded — a tombstone carries no payload, and a payload
36    /// that read as nothing structured carries no fields.
37    KeyOnly,
38}
39
40/// One alert key's state change, with everything the key and the payload
41/// said about it.
42///
43/// `origin`, `producer` and `alert_key` are read from the **key**, never
44/// the payload (RFC 11 §3.2 — a proxy producer's `source` is the polled
45/// device). The optional fields are lifted from the decoded document when it
46/// carried them and are `None` otherwise; a `Resolved` transition carries
47/// none of them, because a tombstone carries no document.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct AlertTransition {
50    /// The publishing origin chunk (`h-<12hex>`, or a service origin).
51    pub origin: String,
52    /// The producer chunk.
53    pub producer: String,
54    /// The `<alert_key>` chunk (RFC 11 §3.1 for the reference profile).
55    pub alert_key: String,
56    /// `origin.producer.alert_key` (RFC 11 §3.2) — the one-chunk identity an
57    /// acknowledgement is keyed by.
58    pub alert_ref: String,
59    pub state: AlertState,
60    /// The document's `severity`, verbatim, when it carried one.
61    pub severity: Option<String>,
62    /// The document's `rule`, when it carried one.
63    pub rule: Option<String>,
64    /// The document's `labels` (string-valued; the `host` label is dropped —
65    /// the origin already says which host, RFC 11 §3.1).
66    pub labels: BTreeMap<String, String>,
67    /// The document's `summary` or `message`, when it carried one.
68    pub summary: Option<String>,
69    /// The sample's HLC timestamp as the engine renders it, when the sample
70    /// carried one — whose clock that is is the [`crate::StampProvenance`]
71    /// question, not answered here.
72    pub timestamp: Option<String>,
73    /// RFC 3339 wall clock of the observation.
74    pub at: String,
75    pub rendering: RenderSource,
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    /// The ndjson shape a notifier's consumers read: field names, snake_case
83    /// states and sources, `null` for what the document did not say.
84    #[test]
85    fn alert_transition_json_shape_is_pinned() {
86        let mut labels = BTreeMap::new();
87        labels.insert("port".to_string(), "eth0".to_string());
88        let t = AlertTransition {
89            origin: "h-3fa9c2d41b7e".into(),
90            producer: "netlink".into(),
91            alert_key: "a659f813308ad1da".into(),
92            alert_ref: "h-3fa9c2d41b7e.netlink.a659f813308ad1da".into(),
93            state: AlertState::Firing,
94            severity: Some("warning".into()),
95            rule: Some("link_down".into()),
96            labels,
97            summary: Some("eth0 is down".into()),
98            timestamp: None,
99            at: "2026-09-06T00:00:00Z".into(),
100            rendering: RenderSource::Schema,
101        };
102        assert_eq!(
103            serde_json::to_value(&t).unwrap(),
104            serde_json::json!({
105                "origin": "h-3fa9c2d41b7e",
106                "producer": "netlink",
107                "alert_key": "a659f813308ad1da",
108                "alert_ref": "h-3fa9c2d41b7e.netlink.a659f813308ad1da",
109                "state": "firing",
110                "severity": "warning",
111                "rule": "link_down",
112                "labels": {"port": "eth0"},
113                "summary": "eth0 is down",
114                "timestamp": null,
115                "at": "2026-09-06T00:00:00Z",
116                "rendering": "schema",
117            })
118        );
119        let resolved = AlertTransition {
120            state: AlertState::Resolved,
121            severity: None,
122            rule: None,
123            labels: BTreeMap::new(),
124            summary: None,
125            rendering: RenderSource::KeyOnly,
126            ..t
127        };
128        let json = serde_json::to_value(&resolved).unwrap();
129        assert_eq!(json["state"], "resolved");
130        assert_eq!(json["rendering"], "key_only");
131        assert_eq!(json["labels"], serde_json::json!({}));
132        assert_eq!(json["severity"], serde_json::Value::Null);
133    }
134}