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}