Skip to main content

max / makenotwork

Add ops-status, the shared operator status contract Every operator daemon observes differently and so nobody observed any of them. The 2026-07-21 audit's second root cause was not that checks were missing but that each one lived somewhere else: CI was dead 73 days, bentod crash-looped 13,836 times over twenty hours, and the prod backup aged 40 days while a gate kept validating migrations against it. This is the contract that makes one surface possible. A producer says what a value MEANS; the renderer decides what it LOOKS LIKE. That split is enforced by the types rather than by discipline: there is nowhere in Value to put a pre-formatted string, so a producer cannot own presentation even by accident. Ten value kinds, a five-value status enum, conditions carrying the why, and actions declared as data so a viewer never learns domain vocabulary. Two fallbacks keep a skewed producer/viewer pair working instead of erroring, since they are separate binaries deployed at different times: an unrecognized value kind deserializes to text, an unrecognized status to unknown. A KNOWN kind with a malformed payload still errors -- version skew is expected, producer bugs are not, and hiding the second inside the first is how a contract stops meaning anything. Two deviations from the spec note, both found by writing it down: Conditions use the same Status as nodes, with pass/fail accepted as input aliases. The note's example wrote conditions as pass/pending while declaring "a closed enum of five" -- two vocabularies would defeat the consistent-color argument the whole contract rests on. A link's anchor text is `text`, not `label`. Fields serialize flat, so a link carrying `label` collides with the field's own and loses it silently. A roundtrip test caught it and now asserts no value kind can clobber the field label. unknown sorts above degraded in the rollup ordering, below only failed, so a source nobody can reach is nearly as loud as one that is failing. That silent gap is the failure this exists to close. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 23:37 UTC
Commit: d57f4420888682fbe9387eb31cfb2ab8a56e21bd
Parent: de11716
3 files changed, +907 insertions, -0 deletions
@@ -0,0 +1,396 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "android_system_properties"
7 + version = "0.1.5"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
10 + dependencies = [
11 + "libc",
12 + ]
13 +
14 + [[package]]
15 + name = "autocfg"
16 + version = "1.5.1"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
19 +
20 + [[package]]
21 + name = "bumpalo"
22 + version = "3.20.3"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
25 +
26 + [[package]]
27 + name = "cc"
28 + version = "1.3.0"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
31 + dependencies = [
32 + "find-msvc-tools",
33 + "shlex",
34 + ]
35 +
36 + [[package]]
37 + name = "cfg-if"
38 + version = "1.0.4"
39 + source = "registry+https://github.com/rust-lang/crates.io-index"
40 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
41 +
42 + [[package]]
43 + name = "chrono"
44 + version = "0.4.45"
45 + source = "registry+https://github.com/rust-lang/crates.io-index"
46 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
47 + dependencies = [
48 + "iana-time-zone",
49 + "js-sys",
50 + "num-traits",
51 + "serde",
52 + "wasm-bindgen",
53 + "windows-link",
54 + ]
55 +
56 + [[package]]
57 + name = "core-foundation-sys"
58 + version = "0.8.7"
59 + source = "registry+https://github.com/rust-lang/crates.io-index"
60 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
61 +
62 + [[package]]
63 + name = "find-msvc-tools"
64 + version = "0.1.9"
65 + source = "registry+https://github.com/rust-lang/crates.io-index"
66 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
67 +
68 + [[package]]
69 + name = "futures-core"
70 + version = "0.3.33"
71 + source = "registry+https://github.com/rust-lang/crates.io-index"
72 + checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
73 +
74 + [[package]]
75 + name = "futures-task"
76 + version = "0.3.33"
77 + source = "registry+https://github.com/rust-lang/crates.io-index"
78 + checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
79 +
80 + [[package]]
81 + name = "futures-util"
82 + version = "0.3.33"
83 + source = "registry+https://github.com/rust-lang/crates.io-index"
84 + checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
85 + dependencies = [
86 + "futures-core",
87 + "futures-task",
88 + "pin-project-lite",
89 + "slab",
90 + ]
91 +
92 + [[package]]
93 + name = "iana-time-zone"
94 + version = "0.1.65"
95 + source = "registry+https://github.com/rust-lang/crates.io-index"
96 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
97 + dependencies = [
98 + "android_system_properties",
99 + "core-foundation-sys",
100 + "iana-time-zone-haiku",
101 + "js-sys",
102 + "log",
103 + "wasm-bindgen",
104 + "windows-core",
105 + ]
106 +
107 + [[package]]
108 + name = "iana-time-zone-haiku"
109 + version = "0.1.2"
110 + source = "registry+https://github.com/rust-lang/crates.io-index"
111 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
112 + dependencies = [
113 + "cc",
114 + ]
115 +
116 + [[package]]
117 + name = "itoa"
118 + version = "1.0.18"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
121 +
122 + [[package]]
123 + name = "js-sys"
124 + version = "0.3.103"
125 + source = "registry+https://github.com/rust-lang/crates.io-index"
126 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
127 + dependencies = [
128 + "cfg-if",
129 + "futures-util",
130 + "wasm-bindgen",
131 + ]
132 +
133 + [[package]]
134 + name = "libc"
135 + version = "0.2.189"
136 + source = "registry+https://github.com/rust-lang/crates.io-index"
137 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
138 +
139 + [[package]]
140 + name = "log"
141 + version = "0.4.33"
142 + source = "registry+https://github.com/rust-lang/crates.io-index"
143 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
144 +
145 + [[package]]
146 + name = "memchr"
147 + version = "2.8.3"
148 + source = "registry+https://github.com/rust-lang/crates.io-index"
149 + checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
150 +
151 + [[package]]
152 + name = "num-traits"
153 + version = "0.2.19"
154 + source = "registry+https://github.com/rust-lang/crates.io-index"
155 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
156 + dependencies = [
157 + "autocfg",
158 + ]
159 +
160 + [[package]]
161 + name = "once_cell"
162 + version = "1.21.4"
163 + source = "registry+https://github.com/rust-lang/crates.io-index"
164 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
165 +
166 + [[package]]
167 + name = "ops-status"
168 + version = "0.1.0"
169 + dependencies = [
170 + "chrono",
171 + "serde",
172 + "serde_json",
173 + ]
174 +
175 + [[package]]
176 + name = "pin-project-lite"
177 + version = "0.2.17"
178 + source = "registry+https://github.com/rust-lang/crates.io-index"
179 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
180 +
181 + [[package]]
182 + name = "proc-macro2"
183 + version = "1.0.107"
184 + source = "registry+https://github.com/rust-lang/crates.io-index"
185 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
186 + dependencies = [
187 + "unicode-ident",
188 + ]
189 +
190 + [[package]]
191 + name = "quote"
192 + version = "1.0.47"
193 + source = "registry+https://github.com/rust-lang/crates.io-index"
194 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
195 + dependencies = [
196 + "proc-macro2",
197 + ]
198 +
199 + [[package]]
200 + name = "rustversion"
201 + version = "1.0.23"
202 + source = "registry+https://github.com/rust-lang/crates.io-index"
203 + checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
204 +
205 + [[package]]
206 + name = "serde"
207 + version = "1.0.229"
208 + source = "registry+https://github.com/rust-lang/crates.io-index"
209 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
210 + dependencies = [
211 + "serde_core",
212 + "serde_derive",
213 + ]
214 +
215 + [[package]]
216 + name = "serde_core"
217 + version = "1.0.229"
218 + source = "registry+https://github.com/rust-lang/crates.io-index"
219 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
220 + dependencies = [
221 + "serde_derive",
222 + ]
223 +
224 + [[package]]
225 + name = "serde_derive"
226 + version = "1.0.229"
227 + source = "registry+https://github.com/rust-lang/crates.io-index"
228 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
229 + dependencies = [
230 + "proc-macro2",
231 + "quote",
232 + "syn 3.0.2",
233 + ]
234 +
235 + [[package]]
236 + name = "serde_json"
237 + version = "1.0.151"
238 + source = "registry+https://github.com/rust-lang/crates.io-index"
239 + checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
240 + dependencies = [
241 + "itoa",
242 + "memchr",
243 + "serde",
244 + "serde_core",
245 + "zmij",
246 + ]
247 +
248 + [[package]]
249 + name = "shlex"
250 + version = "2.0.1"
251 + source = "registry+https://github.com/rust-lang/crates.io-index"
252 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
253 +
254 + [[package]]
255 + name = "slab"
256 + version = "0.4.12"
257 + source = "registry+https://github.com/rust-lang/crates.io-index"
258 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
259 +
260 + [[package]]
261 + name = "syn"
262 + version = "2.0.119"
263 + source = "registry+https://github.com/rust-lang/crates.io-index"
264 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
265 + dependencies = [
266 + "proc-macro2",
267 + "quote",
268 + "unicode-ident",
269 + ]
270 +
271 + [[package]]
272 + name = "syn"
273 + version = "3.0.2"
274 + source = "registry+https://github.com/rust-lang/crates.io-index"
275 + checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
276 + dependencies = [
277 + "proc-macro2",
278 + "quote",
279 + "unicode-ident",
280 + ]
281 +
282 + [[package]]
283 + name = "unicode-ident"
284 + version = "1.0.24"
285 + source = "registry+https://github.com/rust-lang/crates.io-index"
286 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
287 +
288 + [[package]]
289 + name = "wasm-bindgen"
290 + version = "0.2.126"
291 + source = "registry+https://github.com/rust-lang/crates.io-index"
292 + checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
293 + dependencies = [
294 + "cfg-if",
295 + "once_cell",
296 + "rustversion",
297 + "wasm-bindgen-macro",
298 + "wasm-bindgen-shared",
299 + ]
300 +
301 + [[package]]
302 + name = "wasm-bindgen-macro"
303 + version = "0.2.126"
304 + source = "registry+https://github.com/rust-lang/crates.io-index"
305 + checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
306 + dependencies = [
307 + "quote",
308 + "wasm-bindgen-macro-support",
309 + ]
310 +
311 + [[package]]
312 + name = "wasm-bindgen-macro-support"
313 + version = "0.2.126"
314 + source = "registry+https://github.com/rust-lang/crates.io-index"
315 + checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
316 + dependencies = [
317 + "bumpalo",
318 + "proc-macro2",
319 + "quote",
320 + "syn 2.0.119",
321 + "wasm-bindgen-shared",
322 + ]
323 +
324 + [[package]]
325 + name = "wasm-bindgen-shared"
326 + version = "0.2.126"
327 + source = "registry+https://github.com/rust-lang/crates.io-index"
328 + checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
329 + dependencies = [
330 + "unicode-ident",
331 + ]
332 +
333 + [[package]]
334 + name = "windows-core"
335 + version = "0.62.2"
336 + source = "registry+https://github.com/rust-lang/crates.io-index"
337 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
338 + dependencies = [
339 + "windows-implement",
340 + "windows-interface",
341 + "windows-link",
342 + "windows-result",
343 + "windows-strings",
344 + ]
345 +
346 + [[package]]
347 + name = "windows-implement"
348 + version = "0.60.2"
349 + source = "registry+https://github.com/rust-lang/crates.io-index"
350 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
351 + dependencies = [
352 + "proc-macro2",
353 + "quote",
354 + "syn 2.0.119",
355 + ]
356 +
357 + [[package]]
358 + name = "windows-interface"
359 + version = "0.59.3"
360 + source = "registry+https://github.com/rust-lang/crates.io-index"
361 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
362 + dependencies = [
363 + "proc-macro2",
364 + "quote",
365 + "syn 2.0.119",
366 + ]
367 +
368 + [[package]]
369 + name = "windows-link"
370 + version = "0.2.1"
371 + source = "registry+https://github.com/rust-lang/crates.io-index"
372 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
373 +
374 + [[package]]
375 + name = "windows-result"
376 + version = "0.4.1"
377 + source = "registry+https://github.com/rust-lang/crates.io-index"
378 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
379 + dependencies = [
380 + "windows-link",
381 + ]
382 +
383 + [[package]]
384 + name = "windows-strings"
385 + version = "0.5.1"
386 + source = "registry+https://github.com/rust-lang/crates.io-index"
387 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
388 + dependencies = [
389 + "windows-link",
390 + ]
391 +
392 + [[package]]
393 + name = "zmij"
394 + version = "1.0.23"
395 + source = "registry+https://github.com/rust-lang/crates.io-index"
396 + checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,11 @@
1 + [package]
2 + name = "ops-status"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license = "MIT"
6 + description = "The status payload contract every monitored service emits and the release viewer renders. Producers describe meaning; the renderer decides appearance."
7 +
8 + [dependencies]
9 + serde = { version = "1.0.228", features = ["derive"] }
10 + serde_json = "1"
11 + chrono = { version = "0.4", features = ["serde"] }
@@ -0,0 +1,842 @@
1 + //! The status payload contract every monitored service emits and the release
2 + //! viewer renders.
3 + //!
4 + //! Spec + rationale: maintainer wiki.
5 + //! <!-- wiki: release-status-payload -->
6 + //!
7 + //! # Producers describe meaning, the renderer decides appearance
8 + //!
9 + //! A producer says what a value *means*; the renderer decides what it *looks
10 + //! like*. That split is the whole point of the crate, and it is enforced by the
11 + //! type system rather than by discipline: there is nowhere in [`Value`] to put
12 + //! a pre-formatted string, so a producer cannot own presentation even by
13 + //! accident.
14 + //!
15 + //! ```text
16 + //! {"label": "burn-in", "value": "31h of 48h"} // no
17 + //! {"label": "burn-in", "kind": "progress", "value": 31, "max": 48} // yes
18 + //! ```
19 + //!
20 + //! The second form renders as a bar, colored against the same thresholds every
21 + //! other progress value in the UI uses, and it is sortable and filterable,
22 + //! which a pre-formatted string never is.
23 + //!
24 + //! # Version skew is expected
25 + //!
26 + //! Producers and the viewer are separate binaries deployed at different times.
27 + //! Two fallbacks keep a skewed pair working instead of erroring:
28 + //!
29 + //! - an unrecognized [`Value`] kind deserializes to [`Value::Text`]
30 + //! - an unrecognized [`Status`] deserializes to [`Status::Unknown`]
31 + //!
32 + //! Depending on this crate from both ends catches drift when things are built
33 + //! together; [`SCHEMA_VERSION`] plus those fallbacks cover the window when they
34 + //! are not.
35 + //!
36 + //! # Example
37 + //!
38 + //! ```
39 + //! use ops_status::{Payload, Status};
40 + //!
41 + //! let json = r#"{
42 + //! "schema": 1,
43 + //! "source": "sando",
44 + //! "generated_at": "2026-07-21T18:24:39Z",
45 + //! "nodes": [
46 + //! {
47 + //! "id": "tier:b",
48 + //! "kind": "tier",
49 + //! "label": "b (prod-1)",
50 + //! "status": "degraded",
51 + //! "fields": [{"label": "version", "kind": "version", "value": "0.10.14"}],
52 + //! "conditions": [{"type": "burn_in", "status": "pending", "detail": "31h of 48h"}]
53 + //! }
54 + //! ]
55 + //! }"#;
56 + //!
57 + //! let payload: Payload = serde_json::from_str(json).unwrap();
58 + //! assert_eq!(payload.worst_status(), Status::Degraded);
59 + //! ```
60 +
61 + use std::collections::BTreeMap;
62 +
63 + use chrono::{DateTime, TimeDelta, Utc};
64 + use serde::de::{self, Deserializer};
65 + use serde::{Deserialize, Serialize};
66 +
67 + /// The wire version this crate speaks. A producer stamps it into
68 + /// [`Payload::schema`]; a viewer compares it against its own to detect skew.
69 + pub const SCHEMA_VERSION: u32 = 1;
70 +
71 + // ---------------------------------------------------------------------------
72 + // Status
73 + // ---------------------------------------------------------------------------
74 +
75 + /// The closed status vocabulary, shared by nodes, conditions, and
76 + /// [`Value::State`].
77 + ///
78 + /// Consistent color is most of what the viewer is for, so there is exactly one
79 + /// vocabulary and producers cannot extend it. Anything unrecognized becomes
80 + /// [`Status::Unknown`] rather than failing the parse.
81 + ///
82 + /// `pass` and `fail` are accepted as aliases for [`Status::Ok`] and
83 + /// [`Status::Failed`], since condition-shaped producers reach for those words.
84 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
85 + #[serde(rename_all = "snake_case")]
86 + pub enum Status {
87 + Ok,
88 + Degraded,
89 + Failed,
90 + Pending,
91 + /// Also the default: nothing heard from is not the same as healthy.
92 + #[default]
93 + Unknown,
94 + }
95 +
96 + impl Status {
97 + /// How loudly this status should be shown, ascending.
98 + ///
99 + /// [`Status::Unknown`] outranks [`Status::Degraded`] deliberately: a source
100 + /// that cannot be reached is as important as one reporting a failure. A
101 + /// silent gap is the failure mode this whole contract exists to close.
102 + pub fn severity(self) -> u8 {
103 + match self {
104 + Status::Ok => 0,
105 + Status::Pending => 1,
106 + Status::Degraded => 2,
107 + Status::Unknown => 3,
108 + Status::Failed => 4,
109 + }
110 + }
111 +
112 + /// The wire spelling.
113 + pub fn as_str(self) -> &'static str {
114 + match self {
115 + Status::Ok => "ok",
116 + Status::Degraded => "degraded",
117 + Status::Failed => "failed",
118 + Status::Pending => "pending",
119 + Status::Unknown => "unknown",
120 + }
121 + }
122 + }
123 +
124 + /// Orders by [`Status::severity`], so `iter().max()` is "worst status".
125 + impl Ord for Status {
126 + fn cmp(&self, other: &Self) -> std::cmp::Ordering {
127 + self.severity().cmp(&other.severity())
128 + }
129 + }
130 +
131 + impl PartialOrd for Status {
132 + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
133 + Some(self.cmp(other))
134 + }
135 + }
136 +
137 + impl<'de> Deserialize<'de> for Status {
138 + fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
139 + // Hand-written rather than derived: `#[serde(other)]` is not allowed on
140 + // an externally tagged enum, and the unknown-value fallback is the
141 + // point.
142 + Ok(match String::deserialize(d)?.as_str() {
143 + "ok" | "pass" => Status::Ok,
144 + "degraded" => Status::Degraded,
145 + "failed" | "fail" => Status::Failed,
146 + "pending" => Status::Pending,
147 + _ => Status::Unknown,
148 + })
149 + }
150 + }
151 +
152 + // ---------------------------------------------------------------------------
153 + // Values
154 + // ---------------------------------------------------------------------------
155 +
156 + /// The ten value kinds. The spec's worth is entirely in staying short.
157 + ///
158 + /// Each variant says what a number or string *is*, never how to draw it.
159 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160 + #[serde(tag = "kind", rename_all = "snake_case")]
161 + pub enum Value {
162 + /// A plain string with no further meaning. Also the landing spot for a kind
163 + /// this build does not recognize.
164 + Text { value: String },
165 + /// An opaque identifier: a digest, a git sha. Rendered monospace and
166 + /// truncated at `abbrev_to` characters.
167 + Ident {
168 + value: String,
169 + #[serde(default, skip_serializing_if = "Option::is_none")]
170 + abbrev_to: Option<usize>,
171 + },
172 + /// A semver string, comparable against other versions.
173 + Version { value: String },
174 + /// A point in time. Rendered relative ("3m ago"), absolute on focus.
175 + Instant { value: DateTime<Utc> },
176 + /// An elapsed or remaining span, humanized.
177 + Duration { seconds: i64 },
178 + /// Progress toward a target. Rendered as a bar.
179 + Progress {
180 + value: f64,
181 + max: f64,
182 + #[serde(default, skip_serializing_if = "Option::is_none")]
183 + unit: Option<String>,
184 + },
185 + /// A magnitude with a unit, humanized (43.5 MB, 1.2k).
186 + Quantity {
187 + value: f64,
188 + #[serde(default, skip_serializing_if = "Option::is_none")]
189 + unit: Option<String>,
190 + },
191 + /// A status in field position. Rendered as the color alone.
192 + State { value: Status },
193 + /// Somewhere to go. Rendered as an actionable control.
194 + ///
195 + /// The anchor text is `text`, not `label`: a [`Field`] already owns `label`
196 + /// and these serialize into the same object, so the two would collide and
197 + /// the link's would lose.
198 + Link {
199 + url: String,
200 + #[serde(default, skip_serializing_if = "Option::is_none")]
201 + text: Option<String>,
202 + },
203 + /// A filesystem path. Rendered monospace, middle-elided.
204 + Path { value: String },
205 + }
206 +
207 + /// Every kind this build understands. Anything else falls back to
208 + /// [`Value::Text`] at parse time.
209 + const KNOWN_KINDS: &[&str] = &[
210 + "text", "ident", "version", "instant", "duration", "progress", "quantity", "state", "link",
211 + "path",
212 + ];
213 +
214 + impl Value {
215 + /// The wire spelling of this value's kind.
216 + pub fn kind(&self) -> &'static str {
217 + match self {
218 + Value::Text { .. } => "text",
219 + Value::Ident { .. } => "ident",
220 + Value::Version { .. } => "version",
221 + Value::Instant { .. } => "instant",
222 + Value::Duration { .. } => "duration",
223 + Value::Progress { .. } => "progress",
224 + Value::Quantity { .. } => "quantity",
225 + Value::State { .. } => "state",
226 + Value::Link { .. } => "link",
227 + Value::Path { .. } => "path",
228 + }
229 + }
230 + }
231 +
232 + /// One labeled value on a node.
233 + ///
234 + /// Serializes flat: `{"label": "digest", "kind": "ident", "value": "a3f9..."}`.
235 + #[derive(Debug, Clone, PartialEq, Serialize)]
236 + pub struct Field {
237 + pub label: String,
238 + #[serde(flatten)]
239 + pub value: Value,
240 + }
241 +
242 + impl Field {
243 + pub fn new(label: impl Into<String>, value: Value) -> Self {
244 + Field {
245 + label: label.into(),
246 + value,
247 + }
248 + }
249 + }
250 +
251 + impl<'de> Deserialize<'de> for Field {
252 + fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
253 + // Routed through serde_json rather than derived so that an unrecognized
254 + // `kind` degrades to text instead of failing the whole payload. A
255 + // viewer one release behind a producer must still render everything it
256 + // does understand.
257 + let mut raw = serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
258 +
259 + let label = match raw.remove("label") {
260 + Some(serde_json::Value::String(s)) => s,
261 + Some(other) => {
262 + return Err(de::Error::custom(format!(
263 + "label must be a string, got {other}"
264 + )));
265 + }
266 + None => return Err(de::Error::missing_field("label")),
267 + };
268 +
269 + let known = raw
270 + .get("kind")
271 + .and_then(serde_json::Value::as_str)
272 + .is_some_and(|k| KNOWN_KINDS.contains(&k));
273 +
274 + let value = if known {
275 + serde_json::from_value(serde_json::Value::Object(raw)).map_err(de::Error::custom)?
276 + } else {
277 + Value::Text {
278 + value: unknown_kind_text(&raw),
279 + }
280 + };
281 +
282 + Ok(Field { label, value })
283 + }
284 + }
285 +
286 + /// Best-effort text for a kind this build does not know: the `value` member if
287 + /// there is one, else the whole object verbatim. Something legible beats a
288 + /// blank cell.
289 + fn unknown_kind_text(raw: &serde_json::Map<String, serde_json::Value>) -> String {
290 + match raw.get("value") {
291 + Some(serde_json::Value::String(s)) => s.clone(),
292 + Some(v) => v.to_string(),
293 + None => serde_json::Value::Object(raw.clone()).to_string(),
294 + }
295 + }
296 +
297 + // ---------------------------------------------------------------------------
298 + // Conditions
299 + // ---------------------------------------------------------------------------
300 +
301 + /// Why a node is in the status it is in.
302 + ///
303 + /// Borrowed from the Kubernetes conditions pattern. This is the part usually
304 + /// omitted and the part that earns its keep: "blocked" is useless, "blocked
305 + /// because burn_in is 31h of 48h" is what saves an SSH.
306 + ///
307 + /// `type` is domain vocabulary the viewer never interprets. Sando's gates,
308 + /// Bento's build steps, and PoM's checks all map on without translation.
309 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310 + pub struct Condition {
311 + #[serde(rename = "type")]
312 + pub condition_type: String,
313 + pub status: Status,
314 + #[serde(default, skip_serializing_if = "Option::is_none")]
315 + pub since: Option<DateTime<Utc>>,
316 + #[serde(default, skip_serializing_if = "Option::is_none")]
317 + pub detail: Option<String>,
318 + }
319 +
320 + // ---------------------------------------------------------------------------
321 + // Nodes
322 + // ---------------------------------------------------------------------------
323 +
324 + /// One thing a source reports on: a tier, a host, an app, a build target.
325 + ///
326 + /// Children are referenced by id rather than nested. Flat is easier to diff
327 + /// between polls, easier to update incrementally, and keeps the renderer from
328 + /// recursing into something unbounded.
329 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330 + pub struct Node {
331 + pub id: String,
332 + /// Domain vocabulary ("tier", "node", "app", "target"). Free-form; the
333 + /// viewer may group by it but never branches on it.
334 + pub kind: String,
335 + pub label: String,
336 + pub status: Status,
337 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 + pub fields: Vec<Field>,
339 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
340 + pub conditions: Vec<Condition>,
341 + /// Ids of child nodes, which must appear elsewhere in [`Payload::nodes`].
342 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 + pub children: Vec<String>,
344 + /// Keys into [`Payload::actions`].
345 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 + pub actions: Vec<String>,
347 + }
348 +
349 + // ---------------------------------------------------------------------------
350 + // Actions
351 + // ---------------------------------------------------------------------------
352 +
353 + /// The HTTP verb an action is invoked with.
354 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355 + #[serde(rename_all = "UPPERCASE")]
356 + pub enum Method {
357 + Get,
358 + Post,
359 + Put,
360 + Delete,
361 + }
362 +
363 + /// Something the operator can do, declared as data.
364 + ///
365 + /// This is where a generic viewer usually dies. The moment the shell knows what
366 + /// "promote" means, it is not a shell. So the producer declares label, method,
367 + /// URL, and how dangerous it is; the viewer renders a control and issues the
368 + /// request, never learning domain vocabulary.
369 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370 + pub struct Action {
371 + pub label: String,
372 + pub method: Method,
373 + /// Resolved against the source's base URL.
374 + pub url: String,
375 + /// Prompt before issuing.
376 + #[serde(default)]
377 + pub confirm: bool,
378 + /// Render as destructive.
379 + #[serde(default)]
380 + pub danger: bool,
381 + /// JSON body to send, verbatim.
382 + #[serde(default, skip_serializing_if = "Option::is_none")]
383 + pub body: Option<serde_json::Value>,
384 + }
385 +
386 + // ---------------------------------------------------------------------------
387 + // Events
388 + // ---------------------------------------------------------------------------
389 +
390 + /// A recent notable moment, newest first. Optional: a source with no event
391 + /// history sends an empty list.
392 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393 + pub struct Event {
394 + pub at: DateTime<Utc>,
395 + pub label: String,
396 + #[serde(default, skip_serializing_if = "Option::is_none")]
397 + pub status: Option<Status>,
398 + #[serde(default, skip_serializing_if = "Option::is_none")]
399 + pub detail: Option<String>,
400 + /// The node this concerns, if any.
401 + #[serde(default, skip_serializing_if = "Option::is_none")]
402 + pub node_id: Option<String>,
403 + }
404 +
405 + // ---------------------------------------------------------------------------
406 + // Payload
407 + // ---------------------------------------------------------------------------
408 +
409 + /// What one source serves at `GET /status.json`.
410 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411 + pub struct Payload {
412 + /// [`SCHEMA_VERSION`] as of the build that produced this.
413 + pub schema: u32,
414 + /// Stable source name ("sando", "bento", "pom").
415 + pub source: String,
416 + /// When this snapshot was taken. A viewer treats a stale value as its own
417 + /// kind of unhealthy: the 40-day-stale backup was green by every check that
418 + /// existed.
419 + pub generated_at: DateTime<Utc>,
420 + #[serde(default)]
421 + pub nodes: Vec<Node>,
422 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 + pub events: Vec<Event>,
424 + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
425 + pub actions: BTreeMap<String, Action>,
426 + }
427 +
428 + impl Payload {
429 + /// An empty payload stamped with the current schema version.
430 + pub fn new(source: impl Into<String>, generated_at: DateTime<Utc>) -> Self {
431 + Payload {
432 + schema: SCHEMA_VERSION,
433 + source: source.into(),
434 + generated_at,
435 + nodes: Vec::new(),
436 + events: Vec::new(),
437 + actions: BTreeMap::new(),
438 + }
439 + }
440 +
441 + /// The worst status across every node: this source's line in the rollup.
442 + ///
443 + /// A source reporting no nodes at all is [`Status::Unknown`], not healthy.
444 + /// Absence of evidence is the thing that went unnoticed for twenty hours.
445 + pub fn worst_status(&self) -> Status {
446 + self.nodes
447 + .iter()
448 + .map(|n| n.status)
449 + .max()
450 + .unwrap_or(Status::Unknown)
451 + }
452 +
453 + /// How old this snapshot is at `now`. Negative if the producer's clock runs
454 + /// ahead.
455 + ///
456 + /// The clock is an explicit argument here and everywhere else in the
457 + /// contract: render is a pure function of `(payload, now)`, which is what
458 + /// makes golden-snapshot tests possible.
459 + pub fn age(&self, now: DateTime<Utc>) -> TimeDelta {
460 + now - self.generated_at
461 + }
462 +
463 + /// Whether this snapshot is older than `limit` at `now`.
464 + pub fn is_stale(&self, now: DateTime<Utc>, limit: TimeDelta) -> bool {
465 + self.age(now) > limit
466 + }
467 +
468 + /// The payload a viewer substitutes for a source it could not reach.
469 + ///
470 + /// Unreachability is a status, not a blank tab, and it carries the last
471 + /// time anyone heard from the source.
472 + pub fn unreachable(
473 + source: impl Into<String>,
474 + last_seen: DateTime<Utc>,
475 + why: impl Into<String>,
476 + ) -> Self {
477 + let source = source.into();
478 + let mut payload = Payload::new(source.clone(), last_seen);
479 + payload.nodes.push(Node {
480 + id: format!("source:{source}"),
481 + kind: "source".into(),
482 + label: source,
483 + status: Status::Unknown,
484 + fields: Vec::new(),
485 + conditions: vec![Condition {
486 + condition_type: "reachable".into(),
487 + status: Status::Failed,
488 + since: Some(last_seen),
489 + detail: Some(why.into()),
490 + }],
491 + children: Vec::new(),
492 + actions: Vec::new(),
493 + });
494 + payload
495 + }
496 +
497 + /// Nodes with no parent, in payload order: where a renderer starts.
498 + pub fn roots(&self) -> impl Iterator<Item = &Node> {
499 + let claimed: std::collections::HashSet<&str> = self
500 + .nodes
Lines truncated