Skip to main content

zenkey_fleet/report/
storage.rs

1//! The storage plan (RFC 09 §2, #393): what a deployment file asks for, what
2//! the registry makes of it, and how a live router compares.
3//!
4//! Three documents cross the wire here. [`Deployment`] comes *in* — the small
5//! TOML an operator writes naming volumes and the class each storage takes —
6//! and it is here rather than beside the planner because a `Deserialize`
7//! shape is somebody else's file format, which is the placement rule's whole
8//! test. [`StoragePlan`] goes *out* as the plan, [`StorageCheck`] as the
9//! verdict of `--check`, and [`StorageExplain`] as `--explain`'s answer.
10//!
11//! The planner itself is [`crate::model::storage`]; nothing here computes.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use super::asked::Asked;
18use super::judgement::Judgement;
19
20// ── The deployment file ───────────────────────────────────────────────────
21
22/// The deployment file `zenctl storage gen --deployment` reads (#393).
23///
24/// Deliberately small: a base, the volumes, and one entry per storage naming
25/// its **class** — the selector, the `strip_prefix` and the tombstone
26/// lifespan are derived, because typing them is where a router that starts
27/// happily and stores nothing comes from (RFC 09 §2). Backend parameters pass
28/// through verbatim; this type validates the convention's part and not the
29/// backend's.
30///
31/// ```toml
32/// base = "zensight"                 # optional; default = --base / context / ""
33///
34/// [volumes.fs]
35/// plugin = "fs"                     # memory | fs | rocksdb | influxdb | redb | <other>
36/// # history = "latest"              # the plugin fixes it, except redb (per volume, RFC 09 §2.1)
37/// dir = "/var/lib/zenoh/fs"         # every other key passes through to the volume block
38///
39/// [volumes.influxdb]
40/// plugin = "influxdb"
41/// url = "http://localhost:8086"
42///
43/// [storages.latest]
44/// class = "state"                   # state | telemetry | events | catalog | catalog-pdns
45/// volume = "fs"
46/// replication = true                # or a table of RFC 09 §2.2 parameters
47/// complete = true                   # honoured only where §2.2 allows it
48/// params = { dir = "latest" }       # merged into `volume: { id: …, … }`
49///
50/// [storages.timeseries]
51/// class = "telemetry"
52/// volume = "influxdb"
53/// params = { db = "telemetry" }
54/// gc_margin = 2.0                   # lifespan = ceil(max ttl_s × margin); default 2.0
55/// ```
56#[derive(Debug, Clone, Default, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct Deployment {
59    /// The deployment base (RFC 03 §1.1). `None` = take the observer's
60    /// resolved `--base`, the empty base being the bus-root deployment.
61    pub base: Option<String>,
62    #[serde(default)]
63    pub volumes: BTreeMap<String, VolumeSpec>,
64    #[serde(default)]
65    pub storages: BTreeMap<String, StorageSpec>,
66}
67
68/// One volume of the deployment file.
69///
70/// No `deny_unknown_fields`, on purpose: everything but `plugin` and
71/// `history` is the backend's own vocabulary (`dir`, `url`, `org`, `token`,
72/// `path`…) and rides through to the emitted volume block untouched.
73#[derive(Debug, Clone, Deserialize)]
74pub struct VolumeSpec {
75    /// The backend plugin: `memory`, `fs`, `rocksdb`, `influxdb`, `redb`, or
76    /// an out-of-tree name this tool does not know the capability of.
77    pub plugin: String,
78    /// The history mode (RFC 09 §2.1). Fixed by the plugin for the four
79    /// known rows; **per volume**, and required, for `redb` and for any
80    /// plugin this tool does not know.
81    pub history: Option<HistoryMode>,
82    /// Backend parameters, verbatim.
83    #[serde(flatten)]
84    pub params: BTreeMap<String, serde_json::Value>,
85}
86
87/// The history half of a volume's capability pair (RFC 09 §2.1, v1.28):
88/// whether the backend keeps one value per key or every sample.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum HistoryMode {
92    Latest,
93    All,
94}
95
96impl HistoryMode {
97    pub fn as_str(self) -> &'static str {
98        match self {
99            HistoryMode::Latest => "latest",
100            HistoryMode::All => "all",
101        }
102    }
103}
104
105/// The persistence half of the capability pair (RFC 09 §2.1).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum Persistence {
109    Volatile,
110    Durable,
111}
112
113impl Persistence {
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Persistence::Volatile => "volatile",
117            Persistence::Durable => "durable",
118        }
119    }
120}
121
122/// One storage of the deployment file. `deny_unknown_fields`, unlike the
123/// volume: every key here is the convention's, so a typo is a refusal rather
124/// than a silently ignored intent.
125#[derive(Debug, Clone, Default, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct StorageSpec {
128    /// The class-driven selector (RFC 04 §4's table). Exactly one of `class`
129    /// and `selector`.
130    pub class: Option<StorageClass>,
131    /// A base-relative selector override, for a storage the class table does
132    /// not name (`v1/*/state/sysinfo/**` — a per-producer carve-out, say).
133    pub selector: Option<String>,
134    /// The volume id, declared under `[volumes]`.
135    pub volume: String,
136    /// Backend parameters merged into the storage's `volume: { id: …, … }`
137    /// block (`dir`, `db`…), verbatim.
138    #[serde(default)]
139    pub params: BTreeMap<String, serde_json::Value>,
140    /// `true` for RFC 09 §2.2's example parameters, or a table of your own.
141    #[serde(default)]
142    pub replication: Replication,
143    /// Ask for `complete: true`. Honoured only on a replicated, fully covering
144    /// latest storage (RFC 09 §2.2); refused, and said so, elsewhere.
145    #[serde(default)]
146    pub complete: bool,
147    /// A backend retention block (`redb`, RFC 09 §2.1), verbatim.
148    pub retention: Option<serde_json::Value>,
149    /// `garbage_collection.period`, seconds. Default 30, Zenoh's own.
150    pub gc_period_s: Option<u64>,
151    /// The margin over the longest covered `ttl_s` (RFC 09 §2.3). Default 2.0.
152    pub gc_margin: Option<f64>,
153    /// An explicit `garbage_collection.lifespan`, seconds — overrides the
154    /// derivation, and is warned about when it sits below the longest `ttl_s`
155    /// it has to cover.
156    pub gc_lifespan_s: Option<i64>,
157}
158
159/// The class-driven storages of RFC 04 §4 / RFC 09 §2, by name.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "kebab-case")]
162pub enum StorageClass {
163    /// `<base>/v1/*/state/**` — the fleet's LWW truth and late-joiner seed.
164    State,
165    /// `<base>/v1/*/telemetry/**` — the append-per-key series.
166    Telemetry,
167    /// `<base>/v1/*/events/**` — the immutable record.
168    Events,
169    /// `<base>/v1/@catalog/state/**` — explicit, because `*` never matches
170    /// `@catalog` (RFC 03 §4 D4).
171    Catalog,
172    /// `<base>/v1/@catalog/state/pdns/**` — history of LWW state, a storage
173    /// choice (RFC 04 §4).
174    CatalogPdns,
175}
176
177impl StorageClass {
178    /// The kebab-case token the deployment file spells.
179    pub fn as_str(self) -> &'static str {
180        match self {
181            StorageClass::State => "state",
182            StorageClass::Telemetry => "telemetry",
183            StorageClass::Events => "events",
184            StorageClass::Catalog => "catalog",
185            StorageClass::CatalogPdns => "catalog-pdns",
186        }
187    }
188
189    /// The base-relative selector (RFC 04 §4's table, verbatim).
190    pub fn selector(self) -> &'static str {
191        match self {
192            StorageClass::State => "v1/*/state/**",
193            StorageClass::Telemetry => "v1/*/telemetry/**",
194            StorageClass::Events => "v1/*/events/**",
195            StorageClass::Catalog => "v1/@catalog/state/**",
196            StorageClass::CatalogPdns => "v1/@catalog/state/pdns/**",
197        }
198    }
199
200    /// Whether the storage's *seed* is what matters — the RFC 09 §2.1
201    /// caveat on a volatile volume applies to these and not to a series.
202    pub fn seeds(self) -> bool {
203        matches!(
204            self,
205            StorageClass::State | StorageClass::Catalog | StorageClass::CatalogPdns
206        )
207    }
208}
209
210/// `replication = true | false | { interval = 10.0, … }`.
211#[derive(Debug, Clone, PartialEq, Deserialize)]
212#[serde(untagged)]
213pub enum Replication {
214    Enabled(bool),
215    Params(BTreeMap<String, serde_json::Value>),
216}
217
218impl Default for Replication {
219    fn default() -> Self {
220        Replication::Enabled(false)
221    }
222}
223
224// ── The plan ──────────────────────────────────────────────────────────────
225
226/// What `zenctl storage gen` planned (#393).
227#[derive(Debug, Clone, Serialize)]
228pub struct StoragePlan {
229    /// The base every selector below was composed under.
230    pub base: String,
231    /// What the registry said, when one was asked. **Absent** when none was:
232    /// every lifespan below is then RFC 09 §2.3's default, unverified, and
233    /// the derivation strings say so (RFC 13 §3 O4).
234    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
235    pub registry: Asked<RegistryFacts>,
236    pub volumes: Vec<PlannedVolume>,
237    pub storages: Vec<PlannedStorage>,
238    /// What the plan left out, and why. A refused storage is **omitted** from
239    /// `storages` and named here — the plan is still emitted around it.
240    pub refusals: Vec<Refusal>,
241}
242
243impl StoragePlan {
244    /// Every warning, with the storage or volume it concerns.
245    pub fn warnings(&self) -> impl Iterator<Item = (&str, &PlanWarning)> {
246        self.volumes
247            .iter()
248            .flat_map(|v| v.warnings.iter().map(move |w| (v.id.as_str(), w)))
249            .chain(
250                self.storages
251                    .iter()
252                    .flat_map(|s| s.warnings.iter().map(move |w| (s.name.as_str(), w))),
253            )
254    }
255}
256
257/// The registry, as the plan read it.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
259pub struct RegistryFacts {
260    pub slices: usize,
261    /// The longest `ttl_s` of any state subject — the RFC 09 §2.3 floor for a
262    /// storage that covers everything. `None` = the registry declares no
263    /// state subject at all.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub max_ttl_s: Option<i64>,
266    /// Which subject carries it, as `producer/path`.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub ttl_source: Option<String>,
269}
270
271/// One volume, as the plan will emit it.
272#[derive(Debug, Clone, Serialize)]
273pub struct PlannedVolume {
274    pub id: String,
275    pub plugin: String,
276    pub history: HistoryMode,
277    /// `None` = a plugin this tool does not know the capability of.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub persistence: Option<Persistence>,
280    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
281    pub params: BTreeMap<String, serde_json::Value>,
282    #[serde(skip_serializing_if = "Vec::is_empty")]
283    pub warnings: Vec<PlanWarning>,
284}
285
286/// One storage, as the plan will emit it.
287#[derive(Debug, Clone, Serialize)]
288pub struct PlannedStorage {
289    pub name: String,
290    /// `None` = a `selector` override.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub class: Option<StorageClass>,
293    /// The full wire selector, base included.
294    pub key_expr: String,
295    /// Derived: the literal leftmost run of `key_expr`.
296    pub strip_prefix: String,
297    pub volume: String,
298    /// The volume's history mode — the fact every §2.2 decision reads.
299    pub history: HistoryMode,
300    /// The replication block, when replicated.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub replication: Option<BTreeMap<String, serde_json::Value>>,
303    /// As it will be emitted — `false` where the request was refused.
304    pub complete: bool,
305    pub garbage_collection: GarbageCollection,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub retention: Option<serde_json::Value>,
308    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
309    pub params: BTreeMap<String, serde_json::Value>,
310    /// Declared subjects (all classes) whose family this selector intersects.
311    /// Absent when no registry was asked.
312    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
313    pub covers: Asked<usize>,
314    #[serde(skip_serializing_if = "Vec::is_empty")]
315    pub warnings: Vec<PlanWarning>,
316}
317
318/// `garbage_collection: { period, lifespan }` with the computation shown
319/// (RFC 09 §2.3).
320#[derive(Debug, Clone, PartialEq, Serialize)]
321pub struct GarbageCollection {
322    pub period_s: u64,
323    pub lifespan_s: i64,
324    /// How `lifespan_s` came about, in one line a reader can check.
325    pub derivation: String,
326}
327
328/// One thing the plan wants said beside a storage or a volume.
329#[derive(Debug, Clone, PartialEq, Serialize)]
330pub struct PlanWarning {
331    pub kind: WarningKind,
332    pub text: String,
333    /// The clause it cites.
334    pub cite: String,
335}
336
337/// The closed vocabulary of plan warnings — a new way for a deployment to be
338/// wrong is a new variant, not a free-text note.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
340#[serde(rename_all = "snake_case")]
341pub enum WarningKind {
342    /// Two storages' selectors intersect: a GET under both is answered twice
343    /// (RFC 09 §2).
344    Overlap,
345    /// `complete = true` asked for where RFC 09 §2.2 does not allow it; the
346    /// plan emits `false`.
347    CompleteRefused,
348    /// Retention lives in the database; `garbage_collection` is not it
349    /// (RFC 09 §2.3).
350    RetentionIsTheDatabases,
351    /// An all-mode `redb` storage without a retention block refuses to start
352    /// (RFC 09 §2.1).
353    RetentionRequired,
354    /// A retention block on a latest-mode volume is refused at startup
355    /// (RFC 09 §2.1).
356    RetentionPointless,
357    /// A seed-bearing class on a volatile volume: the seed is gone on a
358    /// router restart (RFC 09 §2.1).
359    VolatileSeed,
360    /// An explicit lifespan below the longest covered `ttl_s` (RFC 09 §2.3).
361    LifespanBelowTtl,
362    /// Replication parameters that break RFC 09 §2.2's rule.
363    ReplicationParams,
364    /// A plugin this tool does not know; the declared capability is taken on
365    /// trust.
366    UnknownPlugin,
367}
368
369/// One storage or volume the plan refused to emit.
370#[derive(Debug, Clone, PartialEq, Serialize)]
371pub struct Refusal {
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub storage: Option<String>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub volume: Option<String>,
376    /// The selector the refused storage would have taken — so `--explain`
377    /// can say "a refused storage would have taken this key".
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub key_expr: Option<String>,
380    pub reason: String,
381    pub cite: String,
382}
383
384// ── --check ───────────────────────────────────────────────────────────────
385
386/// `zenctl storage gen --check`: the plan against what a live router runs.
387#[derive(Debug, Clone, Serialize)]
388pub struct StorageCheck {
389    pub base: String,
390    /// The admin selector put to the bus (RFC 13 §3 O5).
391    pub asked: String,
392    pub planned: usize,
393    pub observed: usize,
394    pub findings: Vec<CheckFinding>,
395    /// Comparisons the admin document could not carry — a field the layout
396    /// omits is not a field that agrees.
397    #[serde(skip_serializing_if = "Vec::is_empty")]
398    pub unjudged: Vec<String>,
399    pub judgement: Judgement,
400}
401
402/// One way the running configuration differs from the plan.
403#[derive(Debug, Clone, PartialEq, Serialize)]
404pub struct CheckFinding {
405    pub kind: CheckKind,
406    pub storage: String,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub zid: Option<String>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub planned: Option<String>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub observed: Option<String>,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
416#[serde(rename_all = "snake_case")]
417pub enum CheckKind {
418    /// Planned, and no router runs it.
419    Missing,
420    /// Running, and the plan does not name it.
421    Extra,
422    KeyExprDiffers,
423    StripPrefixDiffers,
424    VolumeDiffers,
425    /// The running `garbage_collection.lifespan` is below the computed
426    /// minimum — a slow replica may resurrect a retired key (RFC 09 §2.3).
427    LifespanBelowMinimum,
428}
429
430// ── --explain ─────────────────────────────────────────────────────────────
431
432/// `zenctl storage gen --explain <key>`: which planned storage takes a key.
433#[derive(Debug, Clone, Serialize)]
434pub struct StorageExplain {
435    pub key: String,
436    pub base: String,
437    pub takers: Vec<Taker>,
438    /// Refused storages whose selector would have included the key.
439    #[serde(skip_serializing_if = "Vec::is_empty")]
440    pub refused_takers: Vec<String>,
441    /// Why nothing takes it, when nothing does.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub none_reason: Option<String>,
444}
445
446/// One storage that takes the key, and why.
447#[derive(Debug, Clone, PartialEq, Serialize)]
448pub struct Taker {
449    pub storage: String,
450    pub key_expr: String,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub class: Option<StorageClass>,
453    /// `includes` — every key the expression names lands here; `intersects`
454    /// — the expression is itself a selector and only some of it does.
455    pub relation: TakerRelation,
456    pub why: String,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
460#[serde(rename_all = "snake_case")]
461pub enum TakerRelation {
462    Includes,
463    Intersects,
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use serde_json::json;
470
471    fn gc() -> GarbageCollection {
472        GarbageCollection {
473            period_s: 30,
474            lifespan_s: 1800,
475            derivation: "max ttl_s 900 (netring/alert/{alert_key}) × 2.0 = 1800 s".into(),
476        }
477    }
478
479    /// The deployment file's shape, pinned from the TOML side: the class and
480    /// history tokens are kebab/lower-case, `replication` takes a bool or a
481    /// table, and a volume's unknown keys ride through while a storage's are
482    /// refused.
483    #[test]
484    fn the_deployment_file_parses_as_documented() {
485        let d: Deployment = serde_json::from_value(json!({
486            "base": "zensight",
487            "volumes": {
488                "fs": {"plugin": "fs", "dir": "/var/lib/zenoh"},
489                "redb-history": {"plugin": "redb", "history": "all"}
490            },
491            "storages": {
492                "latest": {"class": "state", "volume": "fs", "replication": true, "complete": true},
493                "pdns": {"class": "catalog-pdns", "volume": "redb-history",
494                          "replication": {"interval": 10.0}, "retention": {"max_age_s": 86400}}
495            }
496        }))
497        .unwrap();
498        assert_eq!(d.base.as_deref(), Some("zensight"));
499        assert_eq!(d.volumes["fs"].params["dir"], json!("/var/lib/zenoh"));
500        assert_eq!(d.volumes["redb-history"].history, Some(HistoryMode::All));
501        assert_eq!(d.storages["latest"].class, Some(StorageClass::State));
502        assert_eq!(d.storages["latest"].replication, Replication::Enabled(true));
503        assert_eq!(d.storages["pdns"].class, Some(StorageClass::CatalogPdns));
504        assert!(matches!(
505            d.storages["pdns"].replication,
506            Replication::Params(ref p) if p["interval"] == json!(10.0)
507        ));
508
509        let typo: Result<Deployment, _> = serde_json::from_value(json!({
510            "storages": {"latest": {"class": "state", "volume": "fs", "replicaton": true}}
511        }));
512        assert!(
513            typo.is_err(),
514            "a storage key this tool does not know is refused"
515        );
516    }
517
518    /// The plan's wire shape: not-asked registry is absence, a refused
519    /// `complete` is emitted as `false`, and every vocabulary is snake_case.
520    #[test]
521    fn the_plan_pins_its_shape() {
522        let plan = StoragePlan {
523            base: "zensight".into(),
524            registry: Asked::NotAsked,
525            volumes: vec![PlannedVolume {
526                id: "fs".into(),
527                plugin: "fs".into(),
528                history: HistoryMode::Latest,
529                persistence: Some(Persistence::Durable),
530                params: BTreeMap::new(),
531                warnings: vec![],
532            }],
533            storages: vec![PlannedStorage {
534                name: "latest".into(),
535                class: Some(StorageClass::State),
536                key_expr: "zensight/v1/*/state/**".into(),
537                strip_prefix: "zensight/v1".into(),
538                volume: "fs".into(),
539                history: HistoryMode::Latest,
540                replication: None,
541                complete: false,
542                garbage_collection: gc(),
543                retention: None,
544                params: BTreeMap::new(),
545                covers: Asked::NotAsked,
546                warnings: vec![PlanWarning {
547                    kind: WarningKind::CompleteRefused,
548                    text: "t".into(),
549                    cite: "RFC 09 §2.2".into(),
550                }],
551            }],
552            refusals: vec![Refusal {
553                storage: Some("events".into()),
554                volume: None,
555                key_expr: Some("zensight/v1/*/events/**".into()),
556                reason: "r".into(),
557                cite: "RFC 09 §2".into(),
558            }],
559        };
560        let v = serde_json::to_value(&plan).unwrap();
561        assert!(v.get("registry").is_none(), "not asked is absence");
562        assert_eq!(v["volumes"][0]["persistence"], json!("durable"));
563        assert_eq!(v["volumes"][0]["history"], json!("latest"));
564        assert!(v["volumes"][0].get("params").is_none());
565        let s = &v["storages"][0];
566        assert_eq!(s["class"], json!("state"));
567        assert_eq!(s["complete"], json!(false));
568        assert!(s.get("replication").is_none());
569        assert!(s.get("covers").is_none());
570        assert_eq!(s["garbage_collection"]["lifespan_s"], json!(1800));
571        assert_eq!(s["warnings"][0]["kind"], json!("complete_refused"));
572        assert_eq!(v["refusals"][0]["storage"], json!("events"));
573        assert!(v["refusals"][0].get("volume").is_none());
574
575        let asked = StoragePlan {
576            registry: Asked::Asked(RegistryFacts {
577                slices: 3,
578                max_ttl_s: Some(900),
579                ttl_source: Some("netring/alert/{alert_key}".into()),
580            }),
581            ..plan
582        };
583        let v = serde_json::to_value(&asked).unwrap();
584        assert_eq!(v["registry"]["max_ttl_s"], json!(900));
585        assert_eq!(
586            serde_json::to_value(StorageClass::CatalogPdns).unwrap(),
587            json!("catalog-pdns")
588        );
589    }
590
591    /// `--check` carries its selector, its judgement and the finding
592    /// vocabulary on the wire.
593    #[test]
594    fn the_check_pins_its_shape() {
595        let check = StorageCheck {
596            base: "".into(),
597            asked: "@/*/router/**/storage_manager/storages/**".into(),
598            planned: 1,
599            observed: 1,
600            findings: vec![CheckFinding {
601                kind: CheckKind::LifespanBelowMinimum,
602                storage: "latest".into(),
603                zid: Some("aabb".into()),
604                planned: Some("1800".into()),
605                observed: Some("600".into()),
606            }],
607            unjudged: vec![],
608            judgement: Judgement::Established,
609        };
610        let v = serde_json::to_value(&check).unwrap();
611        assert_eq!(v["findings"][0]["kind"], json!("lifespan_below_minimum"));
612        assert_eq!(v["judgement"], json!({"answer": "established"}));
613        assert!(v.get("unjudged").is_none(), "empty unjudged is absence");
614    }
615
616    /// `--explain` distinguishes a key nothing takes from one a refused
617    /// storage would have.
618    #[test]
619    fn the_explain_pins_its_shape() {
620        let e = StorageExplain {
621            key: "zensight/v1/@catalog/state/entity/x".into(),
622            base: "zensight".into(),
623            takers: vec![Taker {
624                storage: "catalog".into(),
625                key_expr: "zensight/v1/@catalog/state/**".into(),
626                class: Some(StorageClass::Catalog),
627                relation: TakerRelation::Includes,
628                why: "w".into(),
629            }],
630            refused_takers: vec![],
631            none_reason: None,
632        };
633        let v = serde_json::to_value(&e).unwrap();
634        assert_eq!(v["takers"][0]["relation"], json!("includes"));
635        assert!(v.get("none_reason").is_none());
636        assert!(v.get("refused_takers").is_none());
637    }
638}