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}