Skip to main content

zenkey_fleet/
report.rs

1//! Typed reports — the shared contract between the engine and every frontend.
2//!
3//! Moved from zenctl (issue #34; the redesign doc called this move "one
4//! refactor unlocking scripting, tests, GUI parity"). A report struct is the
5//! stable output shape: zenctl renders it as a table or serde JSON/NDJSON,
6//! zengui renders it as widgets, and both stay in agreement because neither
7//! owns it.
8
9use std::collections::BTreeMap;
10
11use serde::Serialize;
12
13use crate::facts::{KeyDescription, KeyShape, Registration};
14
15#[derive(Debug, Clone, Serialize)]
16pub struct TopicRow {
17    pub producer: String,
18    pub registry_version: String,
19    pub class: String,
20    pub path: String,
21    pub type_name: String,
22    /// Trailing `{var...}` family: the registry fixes the shape, not the
23    /// members.
24    pub open_ended: bool,
25    /// Registry version the subject first appeared in, when declared.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub since: Option<String>,
28    /// A retired subject (from the slice's `[[deprecated]]` ledger, RFC 08
29    /// §6) — rendered only under `topic list --deprecated`.
30    #[serde(skip_serializing_if = "std::ops::Not::not")]
31    pub deprecated: bool,
32    /// When it was retired, if the ledger says.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub deprecated_since: Option<String>,
35    /// The declared replacement subject, if any.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub replaced_by: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct TopicList {
42    pub subjects: Vec<TopicRow>,
43}
44
45/// One key, described as far as the RFC 09 §5.1 ladder reached.
46///
47/// Redesigned in issue #34 from an all-or-nothing struct (whose builder
48/// hard-errored on any key that was not a registered v1 data subject — an O1
49/// violation) into a **partial** report: every key yields one, and `verdict`
50/// says how far it got. Fields below the ladder's failure point are absent,
51/// never defaulted.
52#[derive(Debug, Clone, Serialize)]
53pub struct TopicInfo {
54    pub key: String,
55    /// The ladder verdict, machine-stable (see [`TopicVerdict`]).
56    pub verdict: TopicVerdict,
57    /// Human-readable elaboration of the verdict (why, and what would answer
58    /// it) — rendered, never parsed.
59    pub note: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub origin: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub producer: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub class: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub subject: Option<String>,
68    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
69    pub variables: BTreeMap<String, String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub payload_type: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub unit: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub qos: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub ttl_s: Option<i64>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub rate: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub cardinality: Option<i64>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub encoding: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub since: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub description: Option<String>,
88}
89
90/// Where the ladder stopped. Serialized snake_case; stable for scripts.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum TopicVerdict {
94    /// Parses, refines, declared — the full story is present.
95    Registered,
96    /// Parses as a v1 data key; the producer's slice does not declare it.
97    Unregistered,
98    /// Parses; no loaded slice covers this producer (or service origin).
99    NoSliceForProducer,
100    /// Parses, but onto a verbatim plane — there is no `[[subject]]` surface
101    /// to consult (RFC 03 §1.4).
102    NotADataClass,
103    /// A legal Zenoh key that is not this convention's (O1: a fact).
104    NotV1,
105    /// Sits under a different deployment base than the one configured.
106    NotUnderBase,
107    /// Parses as a data key, but no registry has been loaded — "not asked"
108    /// is not "answered no" (O4).
109    RegistryNotLoaded,
110}
111
112impl TopicInfo {
113    /// Render a [`KeyDescription`] into the report shape.
114    pub fn from_description(d: &KeyDescription) -> TopicInfo {
115        let mut info = TopicInfo {
116            key: d.key.clone(),
117            verdict: TopicVerdict::NotV1,
118            note: String::new(),
119            origin: None,
120            producer: None,
121            class: None,
122            subject: None,
123            variables: BTreeMap::new(),
124            payload_type: None,
125            unit: None,
126            qos: None,
127            ttl_s: None,
128            rate: None,
129            cardinality: None,
130            encoding: None,
131            since: None,
132            description: None,
133        };
134        match &d.facts.shape {
135            KeyShape::NotUnderBase => {
136                info.verdict = TopicVerdict::NotUnderBase;
137                info.note = "under a different deployment base than the configured one \
138                             (RFC 03 §1.1); `zenctl base list` discovers the bases in use"
139                    .into();
140                return info;
141            }
142            KeyShape::Unparsed { reason } => {
143                info.verdict = TopicVerdict::NotV1;
144                info.note = format!(
145                    "not a keyspace-v2 key — a fact, not an error (RFC 09 §5.1 O1): {reason}"
146                );
147                return info;
148            }
149            KeyShape::V1(v) => {
150                info.origin = Some(v.origin.clone());
151                info.class = Some(v.class.clone());
152                info.producer = v.producer.clone();
153            }
154        }
155        match &d.facts.registration {
156            Registration::Registered(s) => {
157                info.verdict = TopicVerdict::Registered;
158                info.subject = Some(s.path.clone());
159                info.variables = s.vars.iter().cloned().collect();
160                info.payload_type = Some(s.type_name.clone());
161                info.unit = s.unit.clone();
162                info.qos = s.qos.clone();
163                info.encoding = s.encoding.clone();
164                info.ttl_s = s.ttl_s;
165            }
166            Registration::Unregistered => {
167                info.verdict = TopicVerdict::Unregistered;
168                info.note = "parses as a v1 data key, but the producer's slice does not \
169                             declare this subject — for a conforming producer, a subject \
170                             that is not registered does not exist (RFC 08)"
171                    .into();
172            }
173            Registration::NoSliceForProducer => {
174                info.verdict = TopicVerdict::NoSliceForProducer;
175                info.note = "no loaded registry slice covers this producer — `--registry \
176                             <dir>` supplies slices offline; on-bus they come from \
177                             introspect (RFC 08 §6)"
178                    .into();
179            }
180            Registration::Unknown => {
181                info.verdict = TopicVerdict::RegistryNotLoaded;
182                info.note = "no registry loaded — \"not asked\" is not \"answered no\" \
183                             (RFC 09 §5.1 O4)"
184                    .into();
185            }
186            Registration::NotApplicable => {
187                info.verdict = TopicVerdict::NotADataClass;
188                info.note = "a verbatim plane, not a data class — there is no [[subject]] \
189                             surface to describe (RFC 03 §1.4)"
190                    .into();
191            }
192        }
193        info
194    }
195}
196
197#[derive(Debug, Clone, Serialize)]
198pub struct ServiceRow {
199    pub producer: String,
200    pub registry_version: String,
201    pub kind: String,
202    pub path: String,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub request: Option<String>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub reply: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize)]
210pub struct ServiceList {
211    pub procedures: Vec<ServiceRow>,
212}
213
214#[derive(Debug, Clone, Serialize)]
215pub struct InterfaceTypeRow {
216    pub name: String,
217    pub carriers: usize,
218}
219
220#[derive(Debug, Clone, Serialize)]
221pub struct InterfaceList {
222    pub types: Vec<InterfaceTypeRow>,
223}
224
225#[derive(Debug, Clone, Serialize)]
226pub struct CarrierRow {
227    pub producer: String,
228    pub class: String,
229    pub path: String,
230}
231
232#[derive(Debug, Clone, Serialize)]
233pub struct InterfaceShow {
234    pub type_name: String,
235    pub carriers: Vec<CarrierRow>,
236    /// What each producer serving this type name says its schema is
237    /// (issue #51). Empty = nothing asked or nothing served; two rows with
238    /// different hashes *is* the RFC 08 §7 drift finding, visible right here
239    /// rather than only in `doctor`.
240    #[serde(skip_serializing_if = "Vec::is_empty", default)]
241    pub schemas: Vec<SchemaRow>,
242}
243
244/// One type's schema entry as one producer serves it (issue #51).
245#[derive(Debug, Clone, Serialize)]
246pub struct SchemaRow {
247    pub producer: String,
248    pub type_name: String,
249    pub kind: String,
250    pub hash: String,
251    /// The schema document, when the caller asked for the full form.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub document: Option<serde_json::Value>,
254}
255
256/// One origin's reply-latency distribution in a benchmark (issue #52).
257/// Timed **per reply**, so a fast origin in a fan-out is not charged the
258/// slowest origin's round trip.
259#[derive(Debug, Clone, Serialize)]
260pub struct OriginLatency {
261    pub origin: String,
262    pub replies: usize,
263    pub min_ms: f64,
264    pub p50_ms: f64,
265    pub p95_ms: f64,
266    pub p99_ms: f64,
267    pub max_ms: f64,
268}
269
270/// `zenctl bench rpc` (issue #52).
271#[derive(Debug, Clone, Serialize)]
272pub struct BenchReport {
273    pub key: String,
274    pub requested: usize,
275    pub completed: usize,
276    pub concurrency: usize,
277    /// Error replies (RFC 05 §3) plus calls the GET itself failed.
278    pub errors: usize,
279    /// Calls that drew **zero** replies — counted apart from errors, because
280    /// silence is not a failure and averaging it away would hide it
281    /// (RFC 05 §3.1).
282    pub silent: usize,
283    pub elapsed_s: f64,
284    pub calls_per_s: f64,
285    pub origins: Vec<OriginLatency>,
286}
287
288/// One producer, as the bus serves it versus as the checkout declares it
289/// (issue #50). A `None` version means "not present on that side", which is a
290/// fact with two very different explanations — the findings say which.
291#[derive(Debug, Clone, Serialize)]
292pub struct ProducerDiff {
293    pub producer: String,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub served_version: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub local_version: Option<String>,
298    /// RFC 08 §6 findings, rendered. Empty = the two agree.
299    pub findings: Vec<String>,
300}
301
302/// `zenctl registry diff` (issue #50).
303#[derive(Debug, Clone, Serialize)]
304pub struct RegistryDiff {
305    pub producers: Vec<ProducerDiff>,
306}
307
308impl RegistryDiff {
309    /// Producers whose two sides disagree.
310    pub fn disagreeing(&self) -> usize {
311        self.producers
312            .iter()
313            .filter(|p| !p.findings.is_empty())
314            .count()
315    }
316}
317
318/// One producer's served `describe` reply, rendered (issue #51).
319///
320/// `served = false` is the honest degradation RFC 08 §7 leaves room for —
321/// `describe` is a SHOULD, so a producer that serves none has said nothing
322/// about its types, which is not the same as having no types.
323#[derive(Debug, Clone, Serialize)]
324pub struct SchemaDump {
325    pub producer: String,
326    pub served: bool,
327    /// The declaring app, as the served set names it.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub app: Option<String>,
330    pub types: Vec<SchemaRow>,
331    /// Registry-declared type names this producer's set does **not** cover —
332    /// RFC 08 §7's totality clause, checked where the user is already looking.
333    #[serde(skip_serializing_if = "Vec::is_empty", default)]
334    pub missing: Vec<String>,
335}
336
337/// One producer on one origin — row-shaped so a `--watch` loop can diff it
338/// and a GUI can select it (`origin/producer` is the stable row identity).
339#[derive(Debug, Clone, Serialize)]
340pub struct NodeRow {
341    pub origin: String,
342    /// Live producer name (from the liveliness token; zero payload).
343    pub producer: String,
344    /// The producing app, when a registry slice joined (`--verbose`).
345    /// `None` = not asked / no slice — never a default (O4).
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub app: Option<String>,
348    /// Registry version from the joined slice, same provenance rule.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub registry_version: Option<String>,
351}
352
353#[derive(Debug, Clone, Serialize)]
354pub struct NodeList {
355    pub nodes: Vec<NodeRow>,
356    /// Whether a slice join was even attempted (`--verbose`) — keeps "asked,
357    /// no slice served" distinguishable from "not asked" in rows whose
358    /// `app`/`registry_version` are `None` (O4).
359    pub slices_joined: bool,
360}
361
362#[derive(Debug, Clone, Serialize)]
363pub struct BaseList {
364    /// Discovered bases; `base` is a plain string, `""` for the empty base.
365    pub bases: Vec<crate::DiscoveredBase>,
366}
367
368#[derive(Debug, Clone, Serialize)]
369pub struct StorageList {
370    pub storages: Vec<crate::StorageInfo>,
371    pub coverage: Vec<crate::CoverageRow>,
372}
373
374#[derive(Debug, Clone, Serialize)]
375pub struct CallError {
376    pub name: String,
377    pub message: String,
378}
379
380#[derive(Debug, Clone, Serialize)]
381pub struct CallAnswer {
382    pub origin: String,
383    pub ok: bool,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub value: Option<serde_json::Value>,
386    /// Raw text when the value is not JSON-shaped (TOML introspect replies…).
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub text: Option<String>,
389    /// The reply's attachment, projected (JSON if it parses, UTF-8 text if
390    /// it decodes, else a size tag) — never schema-decoded, an attachment is
391    /// outside the registry's vocabulary (#117, #126). **Present only when
392    /// the wire carried one** — absent, never null-when-unknown (O4); both
393    /// fields are additive, so scripts on the old shape keep parsing.
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub attachment: Option<serde_json::Value>,
396    /// Its true size, regardless of how the projection reads.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub attachment_bytes: Option<usize>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub error: Option<CallError>,
401}
402
403#[derive(Debug, Clone, Serialize)]
404pub struct CallReport {
405    pub key: String,
406    pub answers: Vec<CallAnswer>,
407}
408
409impl CallReport {
410    /// The process exit code discipline (issue #12): 0 = at least one answer
411    /// and no error replies; 1 = at least one error reply; 2 = zero replies
412    /// (silence stays a distinct non-verdict — RFC 05 §3.1).
413    pub fn exit_code(&self) -> i32 {
414        if self.answers.is_empty() {
415            2
416        } else if self.answers.iter().any(|a| !a.ok) {
417            1
418        } else {
419            0
420        }
421    }
422}
423
424/// One key's measured traffic over a `topic hz`/`topic bw` window.
425#[derive(Debug, Clone, Serialize)]
426pub struct RateRow {
427    pub key: String,
428    pub count: u64,
429    pub bytes: u64,
430    /// Source-sequence gaps (zero also means "publishers attach no
431    /// SourceInfo" — an observation, not proof of losslessness).
432    pub sn_gaps: u64,
433    /// Observed **skewed** latency over the window (#119) — absent when no
434    /// sample was HLC-stamped, which is not zero latency.
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub latency: Option<crate::stats::LatencySummary>,
437    /// Samples that carried no HLC — the other half of the observation.
438    pub unstamped: u64,
439}
440
441/// The `topic hz` / `topic bw` report (issue #46) — measured counts plus the
442/// O6 bound honesty: a bounded [`StatsTable`](crate::stats::StatsTable) that retired
443/// keys must say so, or the totals silently claim more coverage than they
444/// have.
445#[derive(Debug, Clone, Serialize)]
446pub struct RateReport {
447    pub selector: String,
448    pub window_s: u64,
449    /// Rows are present only for a `--per-key` run, sorted by count
450    /// descending.
451    #[serde(skip_serializing_if = "Vec::is_empty")]
452    pub rows: Vec<RateRow>,
453    pub total_count: u64,
454    pub total_bytes: u64,
455    /// Concrete keys retained by the stats table over the window.
456    pub keys: usize,
457    /// Keys retired to stay within the table bound (RFC 09 §5.1 O6) — the
458    /// totals cover the retained set only.
459    pub evicted: u64,
460    /// The bound the table ran under.
461    pub max_keys: usize,
462    /// Total source-sequence gaps (`None` = `--loss` was not asked).
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub sn_gaps: Option<u64>,
465}
466
467/// How bad a doctor finding is.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
469#[serde(rename_all = "snake_case")]
470pub enum DoctorSeverity {
471    /// A contract violation — the fleet disagrees with the RFCs or with
472    /// itself.
473    Error,
474    /// Suspicious but explainable — judgement is degraded, not wrong.
475    Warning,
476    /// Worth knowing; not a defect.
477    Info,
478}
479
480/// One machine-readable doctor finding (issue #46): what check fired, on
481/// what, with the evidence and the normative citation — the shape the GUI
482/// doctor panel renders as-is.
483#[derive(Debug, Clone, Serialize)]
484pub struct DoctorFinding {
485    pub severity: DoctorSeverity,
486    /// Stable check id (kebab-case), e.g. `slice-sync`, `introspect-coverage`,
487    /// `schema-drift`, `stale-state`.
488    pub check: String,
489    /// What the finding is about (producer, key, or mesh-level subject).
490    pub subject: String,
491    /// The observed evidence, human-readable.
492    pub evidence: String,
493    /// The RFC section that makes this a finding (`None` when the check is
494    /// operational judgement rather than a normative clause).
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub citation: Option<String>,
497}
498
499/// The full doctor run: findings plus the coverage summary that makes an
500/// empty findings list legible (what was checked, not just what was found —
501/// RFC 05 §3.1: silence needs attribution).
502#[derive(Debug, Clone, Serialize)]
503pub struct DoctorReport {
504    pub findings: Vec<DoctorFinding>,
505    /// Producer slices confirmed in sync with the local registry
506    /// (`origin/producer`), when `--registry` was given.
507    #[serde(skip_serializing_if = "Vec::is_empty")]
508    pub synced: Vec<String>,
509    /// Introspect replies received across the fleet.
510    pub introspect_answered: usize,
511    /// Producers on the liveliness roster.
512    pub live_producers: usize,
513    /// Producers serving an RFC 08 §7 `describe`.
514    pub describe_served: usize,
515    /// Producers serving no `describe` (a SHOULD, not a MUST).
516    pub describe_missing: usize,
517    /// Routers that answered the admin sweep.
518    pub routers: usize,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub router_version: Option<String>,
521    /// Whether the `--deep` freshness/storage checks ran.
522    pub deep: bool,
523}
524
525impl DoctorReport {
526    pub fn count(&self, severity: DoctorSeverity) -> usize {
527        self.findings
528            .iter()
529            .filter(|f| f.severity == severity)
530            .count()
531    }
532}
533
534// ─── the @blob plane (RFC 07 §2, issues #58/#68) ────────────────────────────
535//
536// These are deliberately **not** feature-gated, and deliberately carry no
537// `zblob` type. `zenkey-fleet`'s blob *transport* is optional (the `blob`
538// feature); its blob *output shape* is not, because a report is a contract:
539// `zenctl blob probe --format json` must serialize the same document whether
540// or not the binary was built with the transport, and a frontend must be able
541// to render a probe it deserialized from somewhere else entirely.
542
543/// Where a [`BlobList`]'s rows came from — the O5 provenance line.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
545#[serde(rename_all = "kebab-case")]
546pub enum BlobListSource {
547    /// Introspect slices served by live producers (RFC 08 §6).
548    Bus,
549    /// `--registry <dir>` TOMLs.
550    RegistryDirs,
551    /// Both, unioned.
552    Union,
553}
554
555/// One producer's declaration that it serves one `@blob` tier.
556#[derive(Debug, Clone, Serialize)]
557pub struct BlobTierRow {
558    pub producer: String,
559    pub registry_version: String,
560    /// The tier token **as declared**. A token RFC 07 §2 does not reserve is
561    /// carried verbatim and flagged by `known_tier` rather than dropped: a
562    /// declaration we do not understand is a fact about the fleet (RFC 09
563    /// §5.1 O1), not noise.
564    pub tier: String,
565    /// Whether `tier` is one of the three reserved tokens.
566    pub known_tier: bool,
567    /// Declared endpoints (`artifact` only, RFC 07 §2.2).
568    #[serde(skip_serializing_if = "Vec::is_empty")]
569    pub endpoints: Vec<String>,
570    /// Content-hash algorithm (`store` only).
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub algo: Option<String>,
573    /// The type whose payload carries the content root (RFC 07 §2.1).
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub reference: Option<String>,
576    /// The blob *content*'s encoding, when declared.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub encoding: Option<String>,
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub since: Option<String>,
581    #[serde(skip_serializing_if = "Option::is_none")]
582    pub description: Option<String>,
583    /// Origins whose liveliness roster names this producer.
584    ///
585    /// `None` means the roster was never asked — an offline `--registry` read
586    /// learns nothing about who is up, and rendering that as "no origin serves
587    /// this tier" would report a verdict nobody obtained (RFC 09 §5.1 O4).
588    /// Even when present it is a *capability* claim: a producer that declares
589    /// a tier is saying it serves the endpoints, never that it holds any
590    /// particular blob. Only a probe answers that.
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub origins: Option<Vec<String>>,
593}
594
595/// Which producers declare which `@blob` tiers (RFC 07 §2.7 / 08 §2).
596#[derive(Debug, Clone, Serialize)]
597pub struct BlobList {
598    pub tiers: Vec<BlobTierRow>,
599    pub source: BlobListSource,
600    /// How many slices were read. Without it an empty `tiers` reads as "nobody
601    /// serves blobs" when it may mean "nothing was asked" (RFC 09 §5.1 O6).
602    pub slices_considered: usize,
603    /// How many of those declared no `@blob` tier at all.
604    pub slices_without_blob: usize,
605}
606
607/// A holder's chunk availability for one artifact (RFC 07 §2.5's `have`).
608#[derive(Debug, Clone, Serialize)]
609pub struct BlobAvailability {
610    pub chunk_count: u32,
611    /// Chunks this holder can serve right now.
612    pub have: u32,
613    pub complete: bool,
614}
615
616/// A holder's manifest for one artifact (RFC 07 §2.2's `manifest`).
617#[derive(Debug, Clone, Serialize)]
618pub struct BlobManifest {
619    pub id: String,
620    /// Advisory only. It is never joined to any path — a remote party does not
621    /// choose where bytes land.
622    #[serde(skip_serializing_if = "Option::is_none")]
623    pub filename: Option<String>,
624    pub total_len: u64,
625    pub chunk_size: u32,
626    pub chunk_count: u32,
627    /// The content root, hex — RFC 07 §2.1's integrity anchor.
628    pub root: String,
629    pub created_ms: i64,
630}
631
632/// One origin that answered a probe, and what it said.
633#[derive(Debug, Clone, Serialize)]
634pub struct BlobHolder {
635    /// From the reply's **own** key. `"?"` only when that key neither parsed
636    /// under the base nor had an origin in position 1.
637    pub origin: String,
638    /// The concrete key this origin answered on — the only fetchable form
639    /// (RFC 07 §2.5: probe wide, fetch one).
640    pub key: String,
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub availability: Option<BlobAvailability>,
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub manifest: Option<BlobManifest>,
645    /// A per-holder observation the counters cannot carry — e.g. a tree
646    /// holder with every chunk but no index (v1.17), which an index fetch
647    /// will fail against despite a full-looking count. Rendered verbatim.
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub note: Option<String>,
650    /// It answered, and we could not read it: the encoding it declared and
651    /// why. Answering unreadably is not not answering (RFC 09 §5.1 O4).
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub unreadable: Option<String>,
654    /// An RFC 05 §3 error envelope.
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub error: Option<CallError>,
657}
658
659/// Who holds an artifact, and at which root (RFC 07 §2.5).
660#[derive(Debug, Clone, Serialize)]
661pub struct BlobProbeReport {
662    /// The target as spelled back: `artifact/<id>`, `tree/<hex>`, …
663    pub target: String,
664    pub tier: String,
665    /// The selectors actually asked. A probe's coverage claim is exactly this
666    /// list and no wider (RFC 09 §5.1 O5).
667    pub asked: Vec<String>,
668    /// Why nothing was asked, when nothing was — a store algorithm the
669    /// reference client does not speak, chiefly, now that every tier has a
670    /// probe endpoint (RFC 07 §2.5, v1.17). Renders instead of a holder
671    /// list; an unasked probe must never read as "no holders".
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub not_probed: Option<String>,
674    pub holders: Vec<BlobHolder>,
675    pub answered: usize,
676    /// Distinct content roots across holders.
677    ///
678    /// More than one is a **finding, not a tie-break**: the id is a name and
679    /// RFC 07 §2.1's root is what disambiguates it, so a caller facing two
680    /// roots must pin one rather than trust whoever answered first.
681    pub roots: Vec<String>,
682    /// Producers whose slice declares this tier — a capability claim, carried
683    /// so a silent probe stays legible (RFC 05 §3.1: silence is not a verdict,
684    /// and "nobody declares this" and "the declarers are down" are different
685    /// silences).
686    #[serde(skip_serializing_if = "Vec::is_empty")]
687    pub declared_by: Vec<String>,
688}
689
690/// A fetch's progress, as the caller may render it.
691///
692/// Engine-owned rather than a re-export of the reference client's progress
693/// type: that one is `#[non_exhaustive]`, and a GUI message enum cannot carry
694/// a non-exhaustive payload without a wildcard arm in every match — which is
695/// how a new variant becomes invisible instead of a compile error.
696#[derive(Debug, Clone, Serialize)]
697#[serde(tag = "event", rename_all = "kebab-case")]
698pub enum BlobProgress {
699    Started {
700        total_len: u64,
701        chunk_count: u32,
702    },
703    /// A partial download resumed from its persisted chunk bitfield.
704    Resumed {
705        received: u32,
706        total: u32,
707    },
708    Chunk {
709        index: u32,
710        received: u32,
711        total: u32,
712        bytes_received: u64,
713    },
714    Verifying,
715    Completed {
716        path: String,
717    },
718    Cancelled {
719        received: u32,
720        total: u32,
721    },
722    Failed {
723        error: String,
724    },
725}
726
727/// What one fetch from one origin cost and proved (RFC 07 §2.1, §2.5, §2.6).
728#[derive(Debug, Clone, Serialize)]
729pub struct BlobFetchReport {
730    pub origin: String,
731    /// The one concrete key fetched from.
732    pub key: String,
733    pub dest: String,
734    pub bytes: u64,
735    pub chunks: u32,
736    /// Chunks a previous attempt had already banked.
737    pub chunks_resumed: u32,
738    /// Replies verification rejected **before disk** (RFC 07 §2.1).
739    pub rejected: u32,
740    pub retries: u32,
741    pub elapsed_ms: u64,
742    pub root: String,
743    /// `false` = trust-on-first-use, which the caller had to ask for out loud.
744    /// RFC 07 §2.1 requires a reference to carry the root; an operator typing
745    /// an id by hand has no reference, so the report says which it was.
746    pub root_pinned: bool,
747    /// The priority the GETs actually rode at (RFC 07 §2.6) — reported rather
748    /// than assumed, and filled from the same constant the client is built
749    /// with, so the sentence cannot drift from the behaviour.
750    pub priority: String,
751}
752
753/// A validated tree-index summary from one origin (RFC 07 §2.3, v1.17):
754/// inspection **without a content store**. The reply chain is untrusted at
755/// every step — index chunks verify against their own addresses and the
756/// reassembled index verifies against the root the caller asked for — so this
757/// is pinned by construction, and browsing a tree costs its index, never its
758/// content.
759#[derive(Debug, Clone, Serialize)]
760pub struct BlobTreeIndexReport {
761    pub origin: String,
762    /// The one concrete key asked.
763    pub key: String,
764    /// The identity fetched — also the pin.
765    pub root: String,
766    /// Directory entries of every kind.
767    pub entries: usize,
768    /// Files among them.
769    pub files: usize,
770    /// Total content bytes the snapshot references.
771    pub total_size: u64,
772    /// Distinct content chunks the snapshot references.
773    pub chunks: usize,
774    pub elapsed_ms: u64,
775    /// See [`BlobFetchReport::priority`].
776    pub priority: String,
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::facts::describe_key;
783    use crate::registry::SliceSet;
784
785    #[test]
786    fn call_exit_codes() {
787        let mut r = CallReport {
788            key: "k".into(),
789            answers: vec![],
790        };
791        assert_eq!(r.exit_code(), 2, "silence is its own exit code");
792        r.answers.push(CallAnswer {
793            origin: "h-1".into(),
794            ok: true,
795            value: None,
796            text: Some("x".into()),
797            attachment: None,
798            attachment_bytes: None,
799            error: None,
800        });
801        assert_eq!(r.exit_code(), 0);
802        r.answers.push(CallAnswer {
803            origin: "h-2".into(),
804            ok: false,
805            value: None,
806            text: None,
807            attachment: None,
808            attachment_bytes: None,
809            error: Some(CallError {
810                name: "error/busy".into(),
811                message: "later".into(),
812            }),
813        });
814        assert_eq!(r.exit_code(), 1, "any refusal fails the invocation");
815    }
816
817    /// O1/O2 end to end: every kind of key yields a TopicInfo, and the
818    /// verdicts are distinct.
819    #[test]
820    fn topic_info_is_partial_never_absent() {
821        let cases = [
822            ("demo/example/foo", TopicVerdict::NotV1),
823            (
824                "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
825                TopicVerdict::NotADataClass,
826            ),
827            (
828                "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
829                TopicVerdict::RegistryNotLoaded,
830            ),
831        ];
832        for (key, want) in cases {
833            let info = TopicInfo::from_description(&describe_key("", key, None));
834            assert_eq!(info.verdict, want, "{key}");
835            assert!(!info.note.is_empty(), "{key} must explain itself");
836        }
837        let info = TopicInfo::from_description(&describe_key(
838            "zensight",
839            "other/v1/h-3fa9c2d41b7e/state/x/y",
840            None,
841        ));
842        assert_eq!(info.verdict, TopicVerdict::NotUnderBase);
843        // Partial means partial: nothing below the failure point is invented.
844        assert!(info.origin.is_none() && info.payload_type.is_none());
845        // Loaded-and-empty is a different fact from not-loaded (O4).
846        let empty = SliceSet::default();
847        let info = TopicInfo::from_description(&describe_key(
848            "",
849            "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
850            Some(&empty),
851        ));
852        assert_eq!(info.verdict, TopicVerdict::NoSliceForProducer);
853        // The ladder reached the parse rung, so structural facts ARE present…
854        assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
855        // …but no registry facts were invented.
856        assert!(info.payload_type.is_none());
857    }
858
859    /// The serialized DoctorReport is a wire contract: `zenctl doctor
860    /// --format json` scripts and the GUI panel both consume this exact
861    /// shape. Field renames/removals break users — this golden pin makes
862    /// that a deliberate act.
863    #[test]
864    fn doctor_report_json_shape_is_pinned() {
865        let report = DoctorReport {
866            findings: vec![DoctorFinding {
867                severity: DoctorSeverity::Error,
868                check: "slice-sync".into(),
869                subject: "h-3fa9c2d41b7e/sysinfo".into(),
870                evidence: "registry version differs: served 1.0, local 2.0".into(),
871                citation: Some("RFC 08 §6".into()),
872            }],
873            synced: vec!["h-3fa9c2d41b7e/other (registry 1.0)".into()],
874            introspect_answered: 2,
875            live_producers: 3,
876            describe_served: 1,
877            describe_missing: 1,
878            routers: 1,
879            router_version: Some("1.9.0".into()),
880            deep: false,
881        };
882        let json = serde_json::to_value(&report).unwrap();
883        assert_eq!(
884            json,
885            serde_json::json!({
886                "findings": [{
887                    "severity": "error",
888                    "check": "slice-sync",
889                    "subject": "h-3fa9c2d41b7e/sysinfo",
890                    "evidence": "registry version differs: served 1.0, local 2.0",
891                    "citation": "RFC 08 §6",
892                }],
893                "synced": ["h-3fa9c2d41b7e/other (registry 1.0)"],
894                "introspect_answered": 2,
895                "live_producers": 3,
896                "describe_served": 1,
897                "describe_missing": 1,
898                "routers": 1,
899                "router_version": "1.9.0",
900                "deep": false,
901            })
902        );
903    }
904}
905
906/// The RFC 09 §6 cutover-acceptance verdict (issue #59). Three states, not
907/// two: "the old family is quiet on a fleet that is provably speaking" and
908/// "everything is quiet" are different facts, and only the first is
909/// evidence a migration finished.
910#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
911#[serde(rename_all = "snake_case")]
912pub enum CutoverVerdict {
913    /// Old root silent, new plane carrying traffic — both halves held.
914    Pass,
915    /// The retired family still speaks — the migration is not done.
916    OldStillSpeaks,
917    /// The old root was silent but so was the new plane: a non-verdict
918    /// (RFC 05 §3.1) — a dead fleet passes the silence half for free.
919    Unproven,
920}
921
922/// The `zenctl cutover` report (issue #59; RFC 09 §6 half one).
923#[derive(Debug, Clone, Serialize)]
924pub struct CutoverReport {
925    pub old_root: String,
926    /// The stated meaning of "new plane": keys under this prefix. Stated,
927    /// not inferred — the version chunk is plain, so key algebra cannot
928    /// separate old from new (RFC 09 §6's note).
929    pub new_prefix: String,
930    pub window_s: u64,
931    /// Samples heard on the old root — every one is a failure fact.
932    pub old_samples: u64,
933    pub old_keys_seen: usize,
934    /// Up to a cap of offending keys, with per-key counts.
935    #[serde(skip_serializing_if = "Vec::is_empty")]
936    pub old_examples: Vec<String>,
937    /// Samples on the new plane over the window.
938    pub new_samples: u64,
939    /// Samples that were neither: outside `<base>/v1/` and not the old
940    /// root. Leaks by this check's stated definition.
941    pub leak_samples: u64,
942    pub leaked_keys_seen: usize,
943    #[serde(skip_serializing_if = "Vec::is_empty")]
944    pub leak_examples: Vec<String>,
945    /// Samples the bounded observer missed (O6): non-zero weakens the
946    /// silence claim and the report says so.
947    pub dropped: u64,
948    pub verdict: CutoverVerdict,
949}
950
951/// The `zenctl probe` report (issue #59; RFC 09 §6 half two): how the
952/// identity resolved, and what the origin-scoped concrete-key call said.
953#[derive(Debug, Clone, Serialize)]
954pub struct ProbeReport {
955    /// What the operator typed (an origin id or a human label).
956    pub input: String,
957    /// The origin actually called.
958    pub origin: String,
959    /// `direct`, or `bridge:<key>` naming the self-certifying health
960    /// document that resolved it (RFC 06 §6.2).
961    pub via: String,
962    pub call: CallReport,
963}