Skip to main content

zenkey_fleet/judge/
retired.rs

1//! The deprecation burn-down (issue #226): who still speaks each retired
2//! subject.
3//!
4//! [`crate::judge::cutover`] proves a whole key family went silent; the append-only
5//! `[[deprecated]]` ledger (RFC 08 §3) records dozens of *individual*
6//! retirements, and nothing told you which ones are finished. This walks the
7//! ledger and reports four facts per entry: is it still on the wire, does a
8//! served introspect slice still declare it (the RFC 08 §6.1 lie — an entry
9//! the ledger retired that a live build still serves), does any session
10//! declare an intersecting subscriber, and does its `replaced_by` carry
11//! traffic — the `cutover` pair, per entry.
12//!
13//! Same honesty rules as everything else here: silence is never a verdict
14//! (RFC 05 §3.1), and a fact that was not asked renders as not-asked, never
15//! as "no" (RFC 09 §5.1 O4) — which is why every wire field is an `Option`
16//! and why the verdict deliberately reuses [`CutoverVerdict`]'s three states
17//! instead of minting a fourth vocabulary.
18//!
19//! Lives beside `cutover` for `cutover`'s own reason (issue #206): the
20//! per-entry ladder and the sample bucketing are judgement over bus traffic,
21//! and a second explorer must not have to re-derive them. The frontend keeps
22//! the session, the rendering and the exit code.
23
24use std::time::Duration;
25
26use crate::Result;
27use zenkey::slice::{DeprecationDecl, RegistrySlice};
28
29use crate::judge::common::new_prefix;
30use crate::report::{Asked, CutoverVerdict, RetiredEntry, RetiredReport};
31
32/// The scope sentence the listen phase operates under — rendered by the
33/// caller *before* the window opens (O5): a user watching a long silence
34/// deserves to know what was and was not being watched.
35pub fn scope_note(entries: usize, new_prefix: &str, window: Duration) -> String {
36    let window = window.as_secs_f64();
37    format!(
38        "retired check: {window}s window over {entries} ledger entr(y|ies) — \
39         watching the retired families and their replacements, with {new_prefix}** \
40         as the fleet's proof of life. `**` cannot cross `@`-chunks: verbatim \
41         planes and the admin space are outside this watch by construction (O5)."
42    )
43}
44
45/// The base-relative wire family one ledger entry maps to.
46///
47/// The ledger records a subject *tail* and no class, so the class position is
48/// `*` — a plain chunk wildcard, which by D2/D4 can reach every data class
49/// and no verbatim plane. A host producer's family carries its producer
50/// chunk; a service origin's does not (RFC 03 §1.5). Assembled by hand
51/// rather than through `zenkey::selector` because no typed builder spells a
52/// class wildcard — the shape is stated here and pinned by test instead.
53pub fn retired_selector(slice: &RegistrySlice, path: &str) -> String {
54    let tail = zenkey::pattern::SubjectPattern::parse(path)
55        .map(|p| p.selector_tail())
56        // A tail the pattern grammar refuses still names a family verbatim —
57        // an unparseable ledger line is a fact, not a reason to bail (O1).
58        .unwrap_or_else(|_| path.to_string());
59    match &slice.service_origin {
60        Some(origin) => format!("v1/{origin}/*/{tail}"),
61        None => format!("v1/*/*/{}/{tail}", slice.name),
62    }
63}
64
65/// The per-entry ladder, pure so it can be exercised without a bus — the
66/// same three states as [`crate::judge::cutover::verdict`], per ledger line.
67///
68/// Order matters, exactly as there: **any** sign of life on the retired
69/// subject is the failure, whatever else is true — a sample heard on the
70/// wire, a served slice still declaring the path active (§6.1), or a session
71/// still subscribed to it (a consumer that has not moved is a migration that
72/// is not done). The pass needs both halves: the retired family *observed*
73/// silent (`Some(0)`, never an unlistened `None`) while the entry's proof of
74/// life carried traffic — its replacement when one is declared, the v1 plane
75/// otherwise. Everything short of that is `Unproven`: a replacement nobody
76/// has heard speak proves nothing, and neither does a window that never ran.
77///
78/// A named-field struct, because the four arguments used to be `Option<u64>`,
79/// `Option<bool>`, `Option<usize>`, `Option<u64>` — with the two `u64`s
80/// separated by the other two, so a transposition compiled and returned a
81/// plausible wrong verdict (#349).
82#[derive(Debug, Clone, Copy, Default)]
83pub struct EntryEvidence {
84    /// Samples heard on the retired family itself. `NotAsked` is an
85    /// unlistened window — never a zero.
86    pub wire_samples: Asked<u64>,
87    /// Whether a served slice still declares the retired path active
88    /// (RFC 08 §6.1). `Asked<bool>`, not `Option<bool>`: "we did not ask"
89    /// and "we asked and it does not" are different facts, and this crate
90    /// already spells that distinction this way (#349).
91    pub still_declared: Asked<bool>,
92    /// Sessions still subscribed to the retired family — a consumer that has
93    /// not moved is a migration that is not done.
94    pub subscribers: Asked<usize>,
95    /// Samples heard on the *proof of life*: the replacement where one is
96    /// declared, the v1 plane otherwise.
97    pub life_samples: Asked<u64>,
98}
99
100pub fn entry_verdict(ev: EntryEvidence) -> CutoverVerdict {
101    let EntryEvidence {
102        wire_samples,
103        still_declared,
104        subscribers,
105        life_samples,
106    } = ev;
107    if wire_samples.as_option().is_some_and(|n| *n > 0)
108        || still_declared.as_option() == Some(&true)
109        || subscribers.as_option().is_some_and(|n| *n > 0)
110    {
111        return CutoverVerdict::OldStillSpeaks;
112    }
113    match (wire_samples.as_option(), life_samples.as_option()) {
114        (Some(0), Some(n)) if *n > 0 => CutoverVerdict::Pass,
115        _ => CutoverVerdict::Unproven,
116    }
117}
118
119/// Worst-of over the entries: any failure fails the run, else any unproven
120/// entry leaves it unproven, else pass. An empty ledger passes — nothing was
121/// retired, so nothing can still speak — and the report's coverage statement
122/// is what keeps that from reading as fleet-wide absolution.
123pub fn overall(entries: &[RetiredEntry]) -> CutoverVerdict {
124    if entries
125        .iter()
126        .any(|e| e.verdict == CutoverVerdict::OldStillSpeaks)
127    {
128        CutoverVerdict::OldStillSpeaks
129    } else if entries
130        .iter()
131        .any(|e| e.verdict == CutoverVerdict::Unproven)
132    {
133        CutoverVerdict::Unproven
134    } else {
135        CutoverVerdict::Pass
136    }
137}
138
139/// Who a ledger entry belongs to on the wire.
140enum Identity {
141    /// Match by producer base name (instance suffixes share the slice,
142    /// RFC 03 §1.5).
143    Host(String),
144    /// Match by verbatim service origin — these keys have no producer chunk.
145    Service(String),
146}
147
148/// One ledger entry's wire matchers, parsed once.
149struct Matcher {
150    identity: Identity,
151    old: Option<zenkey::pattern::SubjectPattern>,
152    replacement: Option<zenkey::pattern::SubjectPattern>,
153}
154
155impl Matcher {
156    fn covers(&self, parsed: &zenkey::grammar::StructuralKey<'_>) -> bool {
157        match &self.identity {
158            Identity::Host(name) => parsed.producer().is_some_and(|p| p.name() == name.as_str()),
159            Identity::Service(origin) => {
160                parsed.producer().is_none() && parsed.origin.chunk() == origin.as_str()
161            }
162        }
163    }
164}
165
166/// Walk the `[[deprecated]]` ledger of `local` and judge every entry.
167///
168/// - **Introspect** (fact 2) and the **admin sweep** (fact 3) run first, each
169///   bounded by `timeout`; the admin sweep runs *before* the listen phase so
170///   this tool's own data-plane subscriber cannot appear among the consumers
171///   it is counting.
172/// - **The listen window** (facts 1 and 4) runs only when `listen` is
173///   given: no window means every wire field stays `None` — "not asked" must
174///   never render as "no" (RFC 09 §5.1 O4).
175pub async fn run_retired(
176    fleet: &crate::Fleet<'_>,
177    local: &crate::SliceSet,
178    registries: Vec<String>,
179    listen: Option<Duration>,
180    timeout: Duration,
181) -> Result<RetiredReport> {
182    let (session, base) = (fleet.session(), fleet.base());
183    // The ledger: every [[deprecated]] entry the local registries declare,
184    // in a stable order.
185    let mut ledger: Vec<(&RegistrySlice, &DeprecationDecl)> = local
186        .slices()
187        .iter()
188        .flat_map(|s| s.deprecated.iter().map(move |d| (s, d)))
189        .collect();
190    ledger.sort_by(|(sa, da), (sb, db)| {
191        (sa.name.as_str(), da.path.as_str()).cmp(&(sb.name.as_str(), db.path.as_str()))
192    });
193
194    // Fact 2's source: what live builds actually serve (RFC 08 §6).
195    let served = crate::SliceSet::from_bus(fleet, timeout).await?;
196
197    // Fact 3's source. `None` = no admin space answered, which is "not
198    // available", never "nothing declared" (O4). Our own session is excluded:
199    // an explorer counting itself as an unmigrated consumer would be a
200    // self-inflicted finding.
201    let admin = crate::bus::admin::declared_entities(session, timeout).await?;
202    let own_zid = session.zid().to_string();
203
204    let matchers: Vec<Matcher> = ledger
205        .iter()
206        .map(|(slice, decl)| Matcher {
207            identity: match &slice.service_origin {
208                Some(origin) => Identity::Service(origin.token().to_string()),
209                None => Identity::Host(slice.name.clone()),
210            },
211            old: zenkey::pattern::SubjectPattern::parse(&decl.path).ok(),
212            replacement: decl
213                .replaced_by
214                .as_deref()
215                .and_then(|p| zenkey::pattern::SubjectPattern::parse(p).ok()),
216        })
217        .collect();
218
219    // Facts 1 and 4: the listen window, when one was asked for.
220    let new_prefix = new_prefix(base);
221    let mut old_counts = vec![0u64; ledger.len()];
222    let mut repl_counts = vec![0u64; ledger.len()];
223    let (mut plane_samples, mut dropped) = (0u64, 0u64);
224    if let Some(window) = listen {
225        let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
226        let mut events = monitor.events();
227        // `**`, and undeclared on every exit including a `?` (#336).
228        let monitor = monitor.watching(["**"]).await?;
229        let deadline = tokio::time::Instant::now() + window;
230        // One timer for the whole window, not one per iteration (#346).
231        // `sleep_until` builds a future and registers a timer each time it
232        // is evaluated, and a `select!` in a loop evaluates it on every
233        // pass — at 100k samples/s that is 100k registrations a second for
234        // a deadline that never moves.
235        let window_over = tokio::time::sleep_until(deadline);
236        tokio::pin!(window_over);
237        loop {
238            let item = tokio::select! {
239                item = events.recv() => item,
240                () = &mut window_over => break,
241            };
242            match item {
243                Some(crate::StreamItem::Event(crate::FleetEvent::Sample(s))) => {
244                    if s.key.starts_with(&new_prefix) {
245                        plane_samples += 1;
246                    }
247                    let Some(parsed) = zenkey::grammar::parse_full(base, &s.key) else {
248                        continue;
249                    };
250                    // The ledger retires data subjects; a verbatim plane has
251                    // no [[subject]] surface to retire (RFC 03 §1.4).
252                    if !matches!(parsed.class, zenkey::grammar::ClassOrPlane::Class(_)) {
253                        continue;
254                    }
255                    for (i, m) in matchers.iter().enumerate() {
256                        if !m.covers(&parsed) {
257                            continue;
258                        }
259                        if m.old
260                            .as_ref()
261                            .is_some_and(|p| p.matches(&parsed.subject).is_some())
262                        {
263                            old_counts[i] += 1;
264                        }
265                        if m.replacement
266                            .as_ref()
267                            .is_some_and(|p| p.matches(&parsed.subject).is_some())
268                        {
269                            repl_counts[i] += 1;
270                        }
271                    }
272                }
273                Some(crate::StreamItem::Dropped(n)) => dropped += n,
274                Some(_) => continue,
275                None => break,
276            }
277        }
278        monitor.shutdown().await?;
279    }
280
281    let entries: Vec<RetiredEntry> = ledger
282        .iter()
283        .enumerate()
284        .map(|(i, (slice, decl))| {
285            let selector = retired_selector(slice, &decl.path);
286            let wire_samples = listen.map(|_| old_counts[i]);
287            // Fact 2: the §6.1 check — the ledger says retired, does a served
288            // slice still declare the path *active*?
289            let still_declared = served
290                .get(&slice.name)
291                .map(|served| served.serves_subject(&decl.path));
292            // Fact 3: intersecting declared subscribers, when an admin space
293            // answered at all.
294            let subscribers = admin.as_ref().map(|entities| {
295                let family = zenkey::grammar::with_base(base, &selector);
296                let Ok(family) = zenoh::key_expr::KeyExpr::try_from(family) else {
297                    return 0;
298                };
299                entities
300                    .entities
301                    .iter()
302                    .filter(|e| e.kind == crate::EntityKind::Subscriber)
303                    .filter(|e| e.node_zid != own_zid)
304                    .filter(|e| {
305                        zenoh::key_expr::KeyExpr::try_from(e.keyexpr.as_str())
306                            .map(|k| k.intersects(&family))
307                            .unwrap_or(false)
308                    })
309                    .count()
310            });
311            let replacement_samples = match (&decl.replaced_by, listen) {
312                (Some(_), Some(_)) => Some(repl_counts[i]),
313                _ => None,
314            };
315            // The entry's proof of life: its replacement when one is
316            // declared, the v1 plane otherwise.
317            let life = match &decl.replaced_by {
318                Some(_) => replacement_samples,
319                None => listen.map(|_| plane_samples),
320            };
321            RetiredEntry {
322                producer: slice.name.clone(),
323                path: decl.path.clone(),
324                since: decl.since.clone(),
325                replaced_by: decl.replaced_by.clone(),
326                selector,
327                wire_samples: wire_samples.into(),
328                still_declared,
329                subscribers,
330                replacement_samples: replacement_samples.into(),
331                verdict: entry_verdict(EntryEvidence {
332                    wire_samples: wire_samples.into(),
333                    still_declared: still_declared.into(),
334                    subscribers: subscribers.into(),
335                    life_samples: life.into(),
336                }),
337            }
338        })
339        .collect();
340
341    let verdict = overall(&entries);
342    Ok(RetiredReport {
343        registries,
344        entries,
345        window_s: listen.map(|d| d.as_secs_f64()).into(),
346        plane_samples: listen.map(|_| plane_samples).into(),
347        // Gated like its sibling wire facts (R6): with no window there was
348        // no observer, and "observed cleanly" is a claim nobody made.
349        dropped: listen.map(|_| dropped).into(),
350        introspect_answered: served.slices().len(),
351        admin_entities: admin.as_ref().map(|e| e.entities.len()),
352        verdict,
353    })
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    /// The old positional spelling, kept for the tests that read well that
361    /// way — but as a *local* helper, so the published signature is the
362    /// named-field one a caller cannot transpose (#349).
363    fn verdict(
364        wire_samples: Option<u64>,
365        still_declared: Option<bool>,
366        subscribers: Option<usize>,
367        life_samples: Option<u64>,
368    ) -> CutoverVerdict {
369        entry_verdict(EntryEvidence {
370            wire_samples: wire_samples.into(),
371            still_declared: still_declared.into(),
372            subscribers: subscribers.into(),
373            life_samples: life_samples.into(),
374        })
375    }
376
377    fn slice(toml: &str) -> RegistrySlice {
378        zenkey::parse_slice(toml).expect("fixture slice parses")
379    }
380
381    /// The issue's acceptance criterion verbatim: an entry whose replacement
382    /// is silent produces `Unproven` — the existing three-state discipline,
383    /// not a fourth vocabulary.
384    #[test]
385    fn a_silent_replacement_is_unproven_not_a_pass() {
386        assert_eq!(
387            verdict(Some(0), Some(false), Some(0), Some(0)),
388            CutoverVerdict::Unproven
389        );
390        // …while a speaking replacement over an observed-silent entry passes.
391        assert_eq!(
392            verdict(Some(0), Some(false), Some(0), Some(12)),
393            CutoverVerdict::Pass
394        );
395    }
396
397    /// Any sign of life on the retired subject is the failure, whatever else
398    /// is true — and each of the three signs fails alone.
399    #[test]
400    fn any_sign_of_life_beats_everything_else() {
401        // Heard on the wire, even against a busy replacement.
402        assert_eq!(
403            verdict(Some(3), Some(false), Some(0), Some(10_000)),
404            CutoverVerdict::OldStillSpeaks
405        );
406        // Still declared active by a served slice — the §6.1 lie — with no
407        // listen window at all.
408        assert_eq!(
409            verdict(None, Some(true), None, None),
410            CutoverVerdict::OldStillSpeaks
411        );
412        // A session still subscribed: a consumer that has not moved.
413        assert_eq!(
414            verdict(Some(0), Some(false), Some(1), Some(12)),
415            CutoverVerdict::OldStillSpeaks
416        );
417    }
418
419    /// Not-asked is not "no" (RFC 09 §5.1 O4): without a listen window there
420    /// is no silence observation to build a pass on.
421    #[test]
422    fn an_unlistened_entry_cannot_pass() {
423        assert_eq!(
424            verdict(None, Some(false), Some(0), None),
425            CutoverVerdict::Unproven
426        );
427        // Even introspect and admin silence on every axis proves nothing.
428        assert_eq!(verdict(None, None, None, None), CutoverVerdict::Unproven);
429    }
430
431    /// The wire family a ledger entry maps to: class unknown, so `*` — which
432    /// D2/D4 keep off the verbatim planes — and the producer chunk present
433    /// exactly when the origin is a host (RFC 03 §1.5).
434    #[test]
435    fn the_selector_states_the_family_shape() {
436        let host = slice(
437            "[registry]\nversion = \"2.0\"\napp = \"demo\"\nconvention = 1\n\
438             [producer]\nname = \"logs\"\n",
439        );
440        assert_eq!(
441            retired_selector(&host, "logs/errors_total"),
442            "v1/*/*/logs/logs/errors_total"
443        );
444        // {var} widens to *, {var...} to ** — the family, not one member.
445        assert_eq!(
446            retired_selector(&host, "logs/by_unit/{unit}/messages_total"),
447            "v1/*/*/logs/logs/by_unit/*/messages_total"
448        );
449        let mut svc = slice(
450            "[registry]\nversion = \"2.0\"\napp = \"demo\"\nconvention = 1\n\
451             [producer]\nname = \"catalog\"\n",
452        );
453        svc.service_origin = Some(zenkey::Declared::parse("@catalog"));
454        assert_eq!(
455            retired_selector(&svc, "entity/{id}"),
456            "v1/@catalog/*/entity/*"
457        );
458    }
459
460    /// Worst-of: a failure outranks unproven outranks pass, and an empty
461    /// ledger passes — there is nothing left that could still speak.
462    #[test]
463    fn the_overall_verdict_is_worst_of() {
464        let entry = |verdict| RetiredEntry {
465            producer: "logs".into(),
466            path: "logs/errors_total".into(),
467            since: None,
468            replaced_by: None,
469            selector: "v1/*/*/logs/logs/errors_total".into(),
470            wire_samples: crate::report::Asked::NotAsked,
471            still_declared: None,
472            subscribers: None,
473            replacement_samples: crate::report::Asked::NotAsked,
474            verdict,
475        };
476        assert_eq!(overall(&[]), CutoverVerdict::Pass);
477        assert_eq!(
478            overall(&[entry(CutoverVerdict::Pass), entry(CutoverVerdict::Pass)]),
479            CutoverVerdict::Pass
480        );
481        assert_eq!(
482            overall(&[entry(CutoverVerdict::Pass), entry(CutoverVerdict::Unproven)]),
483            CutoverVerdict::Unproven
484        );
485        assert_eq!(
486            overall(&[
487                entry(CutoverVerdict::Unproven),
488                entry(CutoverVerdict::OldStillSpeaks),
489                entry(CutoverVerdict::Pass),
490            ]),
491            CutoverVerdict::OldStillSpeaks
492        );
493    }
494
495    #[test]
496    fn the_scope_note_states_what_it_cannot_see() {
497        let note = scope_note(16, "acme/v1/", Duration::from_secs(30));
498        assert!(note.contains("30s window"));
499        assert!(
500            note.contains("cannot cross"),
501            "a wildcard scope must not be presented as total coverage (O5): {note}"
502        );
503    }
504}