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}