zenkey_fleet/report/cutover.rs
1//! The cutover plane (RFC 09 §6): did a migration finish?
2//!
3//! [`CutoverVerdict`] has three states, not two, and that is the whole
4//! point: "the old family is quiet on a fleet that is provably speaking"
5//! and "everything is quiet" are different facts, and only the first is
6//! evidence.
7
8use serde::Serialize;
9
10/// The RFC 09 §6 cutover-acceptance verdict (issue #59). Three states, not
11/// two: "the old family is quiet on a fleet that is provably speaking" and
12/// "everything is quiet" are different facts, and only the first is
13/// evidence a migration finished.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum CutoverVerdict {
17 /// Old root silent, new plane carrying traffic — both halves held.
18 Pass,
19 /// The retired family still speaks — the migration is not done.
20 OldStillSpeaks,
21 /// The old root was silent but so was the new plane: a non-verdict
22 /// (RFC 05 §3.1) — a dead fleet passes the silence half for free.
23 Unproven,
24}
25
26impl CutoverVerdict {
27 /// The [`Judgement`](crate::report::Judgement) mapping (RFC 13,
28 /// v1.24). The judged claim is the finding — "the retired family still
29 /// speaks":
30 ///
31 /// | verdict | judgement | exit (RFC 13) |
32 /// |---|---|---|
33 /// | `OldStillSpeaks` | `Established` (finding) | 1 |
34 /// | `Pass` | `NotEstablished` (clean) | 0 |
35 /// | `Unproven` | `Unobservable` | 2 |
36 pub fn to_judgement(self) -> crate::report::Judgement {
37 use crate::report::Judgement;
38 match self {
39 CutoverVerdict::OldStillSpeaks => Judgement::Established,
40 CutoverVerdict::Pass => Judgement::NotEstablished {
41 reason: "the retired family is silent while the new plane carries traffic".into(),
42 },
43 CutoverVerdict::Unproven => Judgement::Unobservable {
44 reason: "both planes were silent — a dead fleet passes the silence half \
45 for free (RFC 05 §3.1)"
46 .into(),
47 },
48 }
49 }
50}
51
52/// The inverse of [`CutoverVerdict::to_judgement`]. Both unestablished poles
53/// fold to `Unproven`: a question that was not put (or could not be carried)
54/// proves no migration.
55impl From<crate::report::Judgement> for CutoverVerdict {
56 fn from(j: crate::report::Judgement) -> CutoverVerdict {
57 use crate::report::Judgement;
58 match j {
59 Judgement::Established => CutoverVerdict::OldStillSpeaks,
60 Judgement::NotEstablished { .. } => CutoverVerdict::Pass,
61 Judgement::NotAsked | Judgement::Unobservable { .. } => CutoverVerdict::Unproven,
62 }
63 }
64}
65
66/// The `zenctl cutover` report (issue #59; RFC 09 §6 half one).
67#[derive(Debug, Clone, Serialize)]
68pub struct CutoverReport {
69 pub old_root: String,
70 /// The stated meaning of "new plane": keys under this prefix. Stated,
71 /// not inferred — the version chunk is plain, so key algebra cannot
72 /// separate old from new (RFC 09 §6's note).
73 pub new_prefix: String,
74 pub window_s: f64,
75 /// Samples heard on the old root — every one is a failure fact.
76 pub old_samples: u64,
77 pub old_keys_seen: usize,
78 /// Up to a cap of offending keys, with per-key counts.
79 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub old_examples: Vec<String>,
81 /// Samples on the new plane over the window.
82 pub new_samples: u64,
83 /// Samples that were neither: outside `<base>/v1/` and not the old
84 /// root. Leaks by this check's stated definition.
85 pub leak_samples: u64,
86 pub leaked_keys_seen: usize,
87 #[serde(skip_serializing_if = "Vec::is_empty")]
88 pub leak_examples: Vec<String>,
89 /// Samples the bounded observer missed (O6): non-zero weakens the
90 /// silence claim and the report says so.
91 pub dropped: u64,
92 pub verdict: CutoverVerdict,
93}