Skip to main content

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}