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/// What a bounded reply says about itself (RFC 05 §3.2, v1.31): the
56/// envelope's `partial` flag and the three fields that qualify it.
57///
58/// **Derived, not serialized.** The wire already carries the envelope
59/// inside [`CallOutcome::Ok`]'s `value`; this is the renderer's reading of
60/// it, so it has no serde derive and no place in the pinned contract — a
61/// script that wants the flag reads the reply it is in.
62///
63/// `Some` only when the reply is a JSON object carrying a boolean `partial`
64/// — which is the envelope's one required marker. A procedure that replies
65/// with a bare list, a scalar, or TOML text (the introspect procedures) is
66/// not paginated and gets no signal at all rather than a synthetic
67/// `partial: false`: an absent envelope and a complete walk are different
68/// facts, and the caller must not be told the second when only the first
69/// is known.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct PageSignal {
72 /// The producer stopped before completing the walk — scan cap, tier
73 /// coverage, time budget — and a short page is not the end.
74 pub partial: bool,
75 /// Non-null means more; `null` means the walk is complete for the
76 /// filter given. Opaque to the caller (a value cursor, never a
77 /// position).
78 pub next_cursor: Option<String>,
79 /// Advisory: what the page cost, so an expensive empty page can be
80 /// told from a cheap one. Omitted by procedures that do not count.
81 pub scanned: Option<u64>,
82 /// The oldest instant the answer *could* have covered, for a computed
83 /// answer narrower than what was asked. Omitted by procedures with no
84 /// notion of coverage.
85 pub covers_from: Option<String>,
86}
87
88impl PageSignal {
89 /// `partial: true` **with** `next_cursor: null`: the producer says it
90 /// stopped early and offers no way on. RFC 05 §3.2 names this a
91 /// contract violation an observer MAY report (RFC 13 §3); `zenctl
92 /// call` says it as a caveat and does not move the exit code, because
93 /// a call is an act, not a judgement (#424).
94 pub fn is_contract_violation(&self) -> bool {
95 self.partial && self.next_cursor.is_none()
96 }
97}
98
99impl CallAnswer {
100 /// The RFC 05 §3.2 envelope fields of this answer, when it is one.
101 ///
102 /// `None` for an error envelope, a text reply, a non-object value, and
103 /// an object with no boolean `partial` — see [`PageSignal`] for why an
104 /// absent envelope is not reported as a complete one. A `next_cursor`
105 /// that is present but not a string is read as null: the RFC makes the
106 /// cursor opaque, and an opaque value the caller cannot pass back is
107 /// no way on.
108 pub fn page_signal(&self) -> Option<PageSignal> {
109 let CallOutcome::Ok {
110 value: Some(serde_json::Value::Object(o)),
111 ..
112 } = &self.outcome
113 else {
114 return None;
115 };
116 let partial = o.get("partial")?.as_bool()?;
117 Some(PageSignal {
118 partial,
119 next_cursor: o
120 .get("next_cursor")
121 .and_then(|c| c.as_str())
122 .map(str::to_string),
123 scanned: o.get("scanned").and_then(|n| n.as_u64()),
124 covers_from: o
125 .get("covers_from")
126 .and_then(|c| c.as_str())
127 .map(str::to_string),
128 })
129 }
130}
131
132/// The wire shape is unchanged by the enum (pinned by
133/// `tests/report_contract.rs`): `origin`, then `ok`, then the outcome's own
134/// fields, then the attachment pair, with `error` last — every optional
135/// absent rather than null, exactly as the derived struct serialized.
136impl Serialize for CallAnswer {
137 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
138 use serde::ser::SerializeMap;
139 let mut m = serializer.serialize_map(None)?;
140 m.serialize_entry("origin", &self.origin)?;
141 m.serialize_entry("ok", &self.outcome.is_ok())?;
142 if let CallOutcome::Ok { value, text } = &self.outcome {
143 if let Some(v) = value {
144 m.serialize_entry("value", v)?;
145 }
146 if let Some(t) = text {
147 m.serialize_entry("text", t)?;
148 }
149 }
150 if let Some(a) = &self.attachment {
151 m.serialize_entry("attachment", a)?;
152 }
153 if let Some(n) = self.attachment_bytes {
154 m.serialize_entry("attachment_bytes", &n)?;
155 }
156 if let CallOutcome::Err(e) = &self.outcome {
157 m.serialize_entry("error", e)?;
158 }
159 m.end()
160 }
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct CallReport {
165 pub key: String,
166 /// Seconds the GET waited — the other half of the coverage claim
167 /// (zenctl's `GetReport` is the model), and what makes a silent result
168 /// legible: the renderer's silence note used to name a timeout the
169 /// document never stated (RFC 09 §5.1 O5, review finding R5). Additive,
170 /// so scripts on the old shape keep parsing.
171 pub timeout_s: f64,
172 pub answers: Vec<CallAnswer>,
173}
174
175impl CallReport {
176 /// The process exit code discipline (issue #12): 0 = at least one answer
177 /// and no error replies; 1 = at least one error reply; 2 = zero replies
178 /// (silence stays a distinct non-verdict — RFC 05 §3.1).
179 pub fn exit_code(&self) -> i32 {
180 if self.answers.is_empty() {
181 2
182 } else if self
183 .answers
184 .iter()
185 .any(|a| matches!(a.outcome, CallOutcome::Err(_)))
186 {
187 1
188 } else {
189 0
190 }
191 }
192}
193
194/// The `zenctl probe` report (issue #59; RFC 09 §6 half two): how the
195/// identity resolved, and what the origin-scoped concrete-key call said.
196#[derive(Debug, Clone, Serialize)]
197pub struct ProbeReport {
198 /// What the operator typed (an origin id or a human label).
199 pub input: String,
200 /// The origin actually called.
201 pub origin: String,
202 /// `direct`, or `bridge:<key>` naming the self-certifying health
203 /// document that resolved it (RFC 06 §6.2).
204 pub via: String,
205 pub call: CallReport,
206}
207
208/// Which rung of the fetch ladder produced a value.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ValueSource {
212 /// A GET on the concrete key answered — a router storage (or any plain
213 /// queryable standing at that key).
214 Storage,
215 /// The publisher's AdvancedPublisher cache answered on `<key>/@adv/**`.
216 Cache,
217 /// A brief bounded subscription caught a live sample.
218 Window,
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn call_exit_codes() {
227 let mut r = CallReport {
228 key: "k".into(),
229 timeout_s: 5.0,
230 answers: vec![],
231 };
232 assert_eq!(r.exit_code(), 2, "silence is its own exit code");
233 r.answers.push(CallAnswer {
234 origin: "h-1".into(),
235 outcome: CallOutcome::Ok {
236 value: None,
237 text: Some("x".into()),
238 },
239 attachment: None,
240 attachment_bytes: None,
241 });
242 assert_eq!(r.exit_code(), 0);
243 r.answers.push(CallAnswer {
244 origin: "h-2".into(),
245 outcome: CallOutcome::Err(CallError {
246 name: "error/busy".into(),
247 message: "later".into(),
248 }),
249 attachment: None,
250 attachment_bytes: None,
251 });
252 assert_eq!(r.exit_code(), 1, "any refusal fails the invocation");
253 }
254
255 fn answer(outcome: CallOutcome) -> CallAnswer {
256 CallAnswer {
257 origin: "h-1".into(),
258 outcome,
259 attachment: None,
260 attachment_bytes: None,
261 }
262 }
263
264 fn value(v: serde_json::Value) -> CallAnswer {
265 answer(CallOutcome::Ok {
266 value: Some(v),
267 text: None,
268 })
269 }
270
271 #[test]
272 fn page_signal_reads_only_object_replies_with_partial() {
273 // The envelope, whole (RFC 05 §3.2).
274 let full = value(serde_json::json!({
275 "items": [1, 2],
276 "next_cursor": "k-2",
277 "partial": true,
278 "scanned": 4096,
279 "covers_from": "2026-09-06T10:00:00Z"
280 }));
281 assert_eq!(
282 full.page_signal(),
283 Some(PageSignal {
284 partial: true,
285 next_cursor: Some("k-2".into()),
286 scanned: Some(4096),
287 covers_from: Some("2026-09-06T10:00:00Z".into()),
288 })
289 );
290 assert!(!full.page_signal().unwrap().is_contract_violation());
291
292 // The minimal envelope: the optional fields absent, and a null
293 // cursor after `partial: true` is the violation the RFC names.
294 let stuck = value(serde_json::json!({"items": [], "next_cursor": null, "partial": true}));
295 let p = stuck
296 .page_signal()
297 .expect("an object with a boolean partial");
298 assert!(p.is_contract_violation());
299 assert_eq!((p.scanned, p.covers_from), (None, None));
300
301 // A complete page is a signal too, and not a violation.
302 let done = value(serde_json::json!({"items": [], "next_cursor": null, "partial": false}));
303 assert!(!done.page_signal().unwrap().is_contract_violation());
304
305 // Not envelopes: a bare list, a scalar, an object without the flag,
306 // a non-boolean flag, a text reply, an error. None of these is a
307 // complete walk, so none is told as one.
308 for a in [
309 value(serde_json::json!([1, 2, 3])),
310 value(serde_json::json!(42)),
311 value(serde_json::json!({"count": 214})),
312 value(serde_json::json!({"partial": "yes"})),
313 answer(CallOutcome::Ok {
314 value: None,
315 text: Some("partial = true".into()),
316 }),
317 answer(CallOutcome::Err(CallError {
318 name: "error/busy".into(),
319 message: "later".into(),
320 })),
321 ] {
322 assert_eq!(a.page_signal(), None, "{a:?}");
323 }
324 }
325}