zenkey_fleet/report/schema.rs
1//! The schema plane (RFC 08 §7): what a producer serves for a type name,
2//! and the drift between what was served and what was declared.
3
4use super::asked::Asked;
5use serde::Serialize;
6
7/// One type's schema entry as one producer serves it (issue #51).
8#[derive(Debug, Clone, Serialize)]
9pub struct SchemaRow {
10 pub producer: String,
11 pub type_name: String,
12 pub kind: String,
13 pub hash: String,
14 /// The schema document, when the caller asked for the full form.
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub document: Option<serde_json::Value>,
17}
18
19/// One producer's served `describe` reply, rendered (issue #51).
20///
21/// `served = false` is the honest degradation RFC 08 §7 leaves room for —
22/// `describe` is a SHOULD, so a producer that serves none has said nothing
23/// about its types, which is not the same as having no types.
24#[derive(Debug, Clone, Serialize)]
25pub struct SchemaDump {
26 pub producer: String,
27 pub served: bool,
28 /// The declaring app, as the served set names it.
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub app: Option<String>,
31 pub types: Vec<SchemaRow>,
32 /// Registry-declared type names this producer's set does **not** cover —
33 /// RFC 08 §7's totality clause, checked where the user is already looking.
34 /// `NotAsked` = no registry was loaded, so totality was never checked —
35 /// not asked is not answered no (RFC 09 §5.1 O4); `Asked(vec![])` is the
36 /// actual clean bill.
37 #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
38 pub missing: Asked<Vec<String>>,
39}
40
41/// One producer's identity claim for a type name, attributed to the host that
42/// made it (#398).
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
44pub struct SchemaServer {
45 pub producer: String,
46 /// The origin that served this claim — the `h-…` host id, or a verbatim
47 /// service origin (#398).
48 ///
49 /// `describe` fans in across every host running the producer, so the
50 /// producer alone does not name a claimant. Without this a mid-rollout
51 /// fleet reported that a type had two identities and gave no host to go
52 /// and look at — the finding you can do least with. `"?"` when the reply
53 /// key did not parse under the base, the same lossy-but-stated convention
54 /// [`FleetAnswer::origin`](crate::FleetAnswer::origin) uses.
55 pub origin: String,
56 /// The `sha256:` identity this producer served, if it served one.
57 ///
58 /// `NotAsked` means the describe reply carried **no** hash — which is not
59 /// an empty hash, and is the distinction the flat `(String, String)` shape
60 /// could not make: two producers that each said nothing compared equal and
61 /// were reported as agreeing (#370).
62 #[serde(skip_serializing_if = "Asked::is_not_asked")]
63 pub hash: Asked<String>,
64}
65
66/// What comparing a type name's identity claims established.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
68#[serde(rename_all = "snake_case")]
69pub enum DriftVerdict {
70 /// Two or more producers served *different* identities. A defect —
71 /// RFC 08 §7 calls it a `doctor` finding in as many words.
72 Disagree,
73 /// At least one producer served no identity at all, so agreement cannot
74 /// be established. **Not a defect**: an unanswered question, and reporting
75 /// it as agreement was the O4 failure (RFC 09 §5.1) this exists to name.
76 Unjudgeable,
77}
78
79/// One type name's identity claims across the fleet — "a `doctor` finding" by
80/// RFC 08 §7's own words (issue #41), with the O4 split #370 added.
81#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
82pub struct SchemaDrift {
83 pub type_name: String,
84 /// Every producer observed serving the name, and what it claimed.
85 pub servers: Vec<SchemaServer>,
86 pub verdict: DriftVerdict,
87}
88
89/// A type the producer's slice references that its served describe set does
90/// not cover — a violation of RFC 08 §7's totality clause.
91#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
92pub struct TotalityGap {
93 pub producer: String,
94 pub missing: Vec<String>,
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 /// The serialized `SchemaDrift` is a wire contract, and until #398 it had
102 /// no pin at all — the one report shape in this file with none.
103 ///
104 /// The `origin` added there is the load-bearing half: a script reading a
105 /// drift finding needs a host to act on, and a field that only *sometimes*
106 /// appeared would be worse than one that never did.
107 #[test]
108 fn schema_drift_json_shape_is_pinned() {
109 let drift = SchemaDrift {
110 type_name: "Health".into(),
111 servers: vec![
112 SchemaServer {
113 producer: "sysinfo".into(),
114 origin: "h-3fa9c2d41b7e".into(),
115 hash: Asked::Asked("sha256:abc".into()),
116 },
117 SchemaServer {
118 producer: "sysinfo".into(),
119 origin: "h-8b1e07af22c9".into(),
120 // Served no identity: absent on the wire, never `null` and
121 // never `""` — the two spellings #370 pulled apart.
122 hash: Asked::NotAsked,
123 },
124 ],
125 verdict: DriftVerdict::Disagree,
126 };
127 assert_eq!(
128 serde_json::to_value(&drift).expect("serialize"),
129 serde_json::json!({
130 "type_name": "Health",
131 "servers": [
132 {
133 "producer": "sysinfo",
134 "origin": "h-3fa9c2d41b7e",
135 "hash": "sha256:abc",
136 },
137 {
138 "producer": "sysinfo",
139 "origin": "h-8b1e07af22c9",
140 },
141 ],
142 "verdict": "disagree",
143 })
144 );
145 }
146}