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/// 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}