zenkey_fleet/report/call.rs
1//! The call plane (RFC 05 §3): one invocation, every origin that answered,
2//! and the exit code that follows from the set.
3//!
4//! [`CallReport::exit_code`] is the interesting part: silence has an exit
5//! code of its own, distinct from "answered, and refused" — a caller that
6//! collapsed the two would be making silence a verdict (RFC 05 §2.1).
7
8use serde::Serialize;
9
10#[derive(Debug, Clone, Serialize)]
11pub struct CallError {
12 pub name: String,
13 pub message: String,
14}
15
16/// How one origin answered: a value reply or an RFC 05 §3 error envelope.
17///
18/// An enum, not `{ok: bool, error: Option<CallError>}` — the flat shape
19/// could spell `ok: false` with no error attached, and the renderers
20/// silently dropped exactly that row. A state that cannot be rendered
21/// honestly must not be representable.
22#[derive(Debug, Clone)]
23pub enum CallOutcome {
24 /// A value reply: the JSON document when it parses, the raw text
25 /// otherwise (TOML introspect replies…).
26 Ok {
27 value: Option<serde_json::Value>,
28 text: Option<String>,
29 },
30 /// An RFC 05 §3 error envelope.
31 Err(CallError),
32}
33
34impl CallOutcome {
35 /// Whether this is a value reply (the wire's `ok` field).
36 pub fn is_ok(&self) -> bool {
37 matches!(self, CallOutcome::Ok { .. })
38 }
39}
40
41#[derive(Debug, Clone)]
42pub struct CallAnswer {
43 pub origin: String,
44 pub outcome: CallOutcome,
45 /// The reply's attachment, projected (JSON if it parses, UTF-8 text if
46 /// it decodes, else a size tag) — never schema-decoded, an attachment is
47 /// outside the registry's vocabulary (#117, #126). **Present only when
48 /// the wire carried one** — absent, never null-when-unknown (O4); both
49 /// fields are additive, so scripts on the old shape keep parsing.
50 pub attachment: Option<serde_json::Value>,
51 /// Its true size, regardless of how the projection reads.
52 pub attachment_bytes: Option<usize>,
53}
54
55/// The wire shape is unchanged by the enum (pinned by
56/// `tests/report_contract.rs`): `origin`, then `ok`, then the outcome's own
57/// fields, then the attachment pair, with `error` last — every optional
58/// absent rather than null, exactly as the derived struct serialized.
59impl Serialize for CallAnswer {
60 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
61 use serde::ser::SerializeMap;
62 let mut m = serializer.serialize_map(None)?;
63 m.serialize_entry("origin", &self.origin)?;
64 m.serialize_entry("ok", &self.outcome.is_ok())?;
65 if let CallOutcome::Ok { value, text } = &self.outcome {
66 if let Some(v) = value {
67 m.serialize_entry("value", v)?;
68 }
69 if let Some(t) = text {
70 m.serialize_entry("text", t)?;
71 }
72 }
73 if let Some(a) = &self.attachment {
74 m.serialize_entry("attachment", a)?;
75 }
76 if let Some(n) = self.attachment_bytes {
77 m.serialize_entry("attachment_bytes", &n)?;
78 }
79 if let CallOutcome::Err(e) = &self.outcome {
80 m.serialize_entry("error", e)?;
81 }
82 m.end()
83 }
84}
85
86#[derive(Debug, Clone, Serialize)]
87pub struct CallReport {
88 pub key: String,
89 /// Seconds the GET waited — the other half of the coverage claim
90 /// (zenctl's `GetReport` is the model), and what makes a silent result
91 /// legible: the renderer's silence note used to name a timeout the
92 /// document never stated (RFC 09 §5.1 O5, review finding R5). Additive,
93 /// so scripts on the old shape keep parsing.
94 pub timeout_s: f64,
95 pub answers: Vec<CallAnswer>,
96}
97
98impl CallReport {
99 /// The process exit code discipline (issue #12): 0 = at least one answer
100 /// and no error replies; 1 = at least one error reply; 2 = zero replies
101 /// (silence stays a distinct non-verdict — RFC 05 §3.1).
102 pub fn exit_code(&self) -> i32 {
103 if self.answers.is_empty() {
104 2
105 } else if self
106 .answers
107 .iter()
108 .any(|a| matches!(a.outcome, CallOutcome::Err(_)))
109 {
110 1
111 } else {
112 0
113 }
114 }
115}
116
117/// The `zenctl probe` report (issue #59; RFC 09 §6 half two): how the
118/// identity resolved, and what the origin-scoped concrete-key call said.
119#[derive(Debug, Clone, Serialize)]
120pub struct ProbeReport {
121 /// What the operator typed (an origin id or a human label).
122 pub input: String,
123 /// The origin actually called.
124 pub origin: String,
125 /// `direct`, or `bridge:<key>` naming the self-certifying health
126 /// document that resolved it (RFC 06 §6.2).
127 pub via: String,
128 pub call: CallReport,
129}
130
131/// Which rung of the fetch ladder produced a value.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
133#[serde(rename_all = "snake_case")]
134pub enum ValueSource {
135 /// A GET on the concrete key answered — a router storage (or any plain
136 /// queryable standing at that key).
137 Storage,
138 /// The publisher's AdvancedPublisher cache answered on `<key>/@adv/**`.
139 Cache,
140 /// A brief bounded subscription caught a live sample.
141 Window,
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn call_exit_codes() {
150 let mut r = CallReport {
151 key: "k".into(),
152 timeout_s: 5.0,
153 answers: vec![],
154 };
155 assert_eq!(r.exit_code(), 2, "silence is its own exit code");
156 r.answers.push(CallAnswer {
157 origin: "h-1".into(),
158 outcome: CallOutcome::Ok {
159 value: None,
160 text: Some("x".into()),
161 },
162 attachment: None,
163 attachment_bytes: None,
164 });
165 assert_eq!(r.exit_code(), 0);
166 r.answers.push(CallAnswer {
167 origin: "h-2".into(),
168 outcome: CallOutcome::Err(CallError {
169 name: "error/busy".into(),
170 message: "later".into(),
171 }),
172 attachment: None,
173 attachment_bytes: None,
174 });
175 assert_eq!(r.exit_code(), 1, "any refusal fails the invocation");
176 }
177}