Skip to main content

agentmux_srv/broker/
state.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure reducer for the Credential Broker's per-credential coordination
5//! state — fresh / refreshing / failed / needs-reauth. Same shape as every
6//! other reducer in this codebase (`crates/launcher/src/reducer/`,
7//! `crates/srv/src/reducer.rs`, `crates/cef/src/reducer/mod.rs`):
8//! `update(&mut State, Command) -> Vec<Event>`, no I/O, no async,
9//! sub-millisecond lock hold when called from `scheduler.rs`'s orchestrator.
10//!
11//! Internal-only, like the CEF host reducer — this never crosses IPC, so it
12//! does not reuse `agentmux_common::ipc::{Command, Event}` (the
13//! srv<->launcher<->host wire protocol); it defines its own local types.
14//!
15//! This module never holds a credential's actual secret/value — only enough
16//! coordination state to decide WHEN `scheduler.rs`'s caller-supplied
17//! `is_fresh`/`refresh` closures should run, and to make that decision
18//! observable. Events are mapped 1:1 to `auth.broker.*`-prefixed `tracing`
19//! calls by the orchestrator — that prefix lands in `muxlog auth`'s existing
20//! filter regex (`\bauth\.\w+|...`) with zero changes needed there.
21
22use std::collections::HashMap;
23
24/// After this many consecutive TRANSIENT refresh failures, a credential is
25/// considered permanently broken rather than retried forever — the sweep
26/// loop stops hammering a dead refresh_token on every tick. `ensure_fresh`
27/// (on-demand, e.g. right before a reconnect attempt) still re-checks
28/// freshness even from `NeedsReauth`: if an external login has since
29/// succeeded, `is_fresh()` will report true and state naturally moves back
30/// to `Fresh`. No explicit "clear" API needed.
31pub const NEEDS_REAUTH_THRESHOLD: u32 = 5;
32
33/// Coordination state for one registered credential.
34#[derive(Debug, Clone, PartialEq)]
35pub enum CredentialState {
36    /// Registered; last check (if any) said fresh, or never checked yet.
37    Fresh,
38    /// A freshness check or refresh attempt is currently in flight — this
39    /// IS the single-flight guard: a second `CheckRequested` while already
40    /// `Refreshing` gets `Event::AlreadyInFlight`, never a second
41    /// `RunFreshnessCheck`/`RunRefresh`. Carries forward the failure count
42    /// from whatever `Failed` state (if any) preceded this attempt — the
43    /// only place that count lives, since transitioning through
44    /// `Refreshing` would otherwise lose it before `RefreshFailed` can read
45    /// it back out to increment.
46    Refreshing { prior_failures: u32 },
47    /// Last refresh attempt failed with a transient error (network, parse,
48    /// etc.) — will be retried on the next `ensure_fresh`/sweep tick.
49    Failed {
50        consecutive_failures: u32,
51        last_error: String,
52    },
53    /// Either `consecutive_failures` crossed `NEEDS_REAUTH_THRESHOLD`, or a
54    /// refresh closure explicitly reported
55    /// `RefreshErrorKind::PermanentAuthFailure` (no need to accumulate
56    /// failures for an error already known permanent). Retrying
57    /// automatically is pointless; a human needs to log in again.
58    /// `rejected` tells the two apart: true when the credential itself was
59    /// refused, false when transient failures merely piled up (which a
60    /// returning network can still cure through an on-demand check).
61    NeedsReauth {
62        since_unix: u64,
63        reason: String,
64        rejected: bool,
65    },
66}
67
68/// What a registered refresh closure's failure means for scheduling.
69#[derive(Debug, Clone, PartialEq)]
70pub enum RefreshErrorKind {
71    /// Network blip, parse failure, transient server error — worth retrying.
72    Transient(String),
73    /// The credential itself is rejected (e.g. an OAuth server's
74    /// `invalid_grant` for a revoked/expired refresh_token) — retrying with
75    /// the SAME credential will never succeed; only a fresh login fixes it.
76    PermanentAuthFailure(String),
77}
78
79impl RefreshErrorKind {
80    fn message(&self) -> &str {
81        match self {
82            RefreshErrorKind::Transient(m) | RefreshErrorKind::PermanentAuthFailure(m) => m,
83        }
84    }
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub enum Command {
89    Register { id: String },
90    #[cfg(test)]
91    Unregister { id: String },
92    /// `ensure_fresh` or a sweep tick wants to know/ensure this credential
93    /// is fresh.
94    CheckRequested { id: String },
95    /// The orchestrator ran `is_fresh()` and is reporting the result.
96    /// `generation` is the value from the `RunFreshnessCheck` event that
97    /// triggered this call — see `Event::RunFreshnessCheck`'s doc comment.
98    FreshnessChecked {
99        id: String,
100        is_fresh: bool,
101        generation: u64,
102    },
103    /// `generation` carried forward from the `RunRefresh` event that
104    /// triggered this call.
105    RefreshSucceeded { id: String, generation: u64 },
106    RefreshFailed {
107        id: String,
108        error: RefreshErrorKind,
109        generation: u64,
110    },
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub enum Event {
115    /// Orchestrator should call the registered `is_fresh()` closure now.
116    /// `generation` is this id's registration epoch at dispatch time — the
117    /// orchestrator must echo it back unchanged in the resulting
118    /// `Command::FreshnessChecked` (and, if a refresh follows, in
119    /// `RefreshSucceeded`/`RefreshFailed` too). This lets the reducer detect
120    /// a stale completion: if `register()` replaces this id's closures
121    /// (bumping its generation) while `is_fresh()`/`refresh()` is still
122    /// awaiting, the eventual result would otherwise silently clobber the
123    /// freshly re-registered credential's state with a verdict about
124    /// closures that no longer apply (reagent re-review on #2275).
125    RunFreshnessCheck { id: String, generation: u64 },
126    /// Orchestrator should call the registered `refresh()` closure now.
127    /// `generation` carries the same epoch forward from `RunFreshnessCheck`.
128    RunRefresh { id: String, generation: u64 },
129    /// Another caller is already checking/refreshing this id — the
130    /// orchestrator should wait on the id's `Notify` and re-dispatch
131    /// `CheckRequested` once woken.
132    AlreadyInFlight { id: String },
133    /// `id` was never registered (or was just unregistered).
134    Unregistered { id: String },
135    /// Diagnostics-only — mapped 1:1 to `tracing` by the orchestrator.
136    BecameFresh { id: String },
137    BecameFailed {
138        id: String,
139        consecutive_failures: u32,
140        error: String,
141    },
142    BecameNeedsReauth { id: String, reason: String },
143}
144
145fn now_unix() -> u64 {
146    agentmux_common::time::now_secs_u64()
147}
148
149/// `states` is the orchestrator's whole coordination table; `generations`
150/// tracks each id's CURRENT registration epoch so a completion from a
151/// superseded registration can be told apart from a current one (see
152/// `Event::RunFreshnessCheck`'s doc comment); `next_generation` is a single
153/// counter shared across ALL ids, strictly increasing, never reused —
154/// deliberately NOT a per-id counter restarted at 0 on each `Register`,
155/// because an unregister-then-re-register sequence would then hand the new
156/// registration the SAME generation number the old one had, and a stale
157/// completion from the old registration (still in flight when the
158/// unregister/re-register raced in) would wrongly pass the staleness check
159/// instead of being discarded (reagent re-review on #2275). One reducer
160/// call per dispatched command, sub-millisecond, never awaits.
161pub fn update(
162    states: &mut HashMap<String, CredentialState>,
163    generations: &mut HashMap<String, u64>,
164    next_generation: &mut u64,
165    cmd: Command,
166) -> Vec<Event> {
167    match cmd {
168        Command::Register { id } => {
169            // Replacing an already-registered id is safe here specifically
170            // because single-flight is guarded by THIS reducer's own state,
171            // not by an external lock a caller could be left blocked on
172            // (the pre-refactor design's latent race). A caller currently
173            // waiting via AlreadyInFlight re-dispatches CheckRequested once
174            // woken and just sees the fresh entry's is_fresh() result.
175            let generation = *next_generation;
176            *next_generation += 1;
177            generations.insert(id.clone(), generation);
178            states.insert(id, CredentialState::Fresh);
179            Vec::new()
180        }
181        #[cfg(test)]
182        Command::Unregister { id } => {
183            // Removing the entry here is just memory hygiene, not a
184            // correctness requirement — next_generation never reuses a
185            // number, so a future re-registration can't collide with a
186            // stale completion's captured generation either way.
187            generations.remove(&id);
188            states.remove(&id);
189            vec![Event::Unregistered { id }]
190        }
191        Command::CheckRequested { id } => match states.get(&id) {
192            None => vec![Event::Unregistered { id }],
193            Some(CredentialState::Refreshing { .. }) => vec![Event::AlreadyInFlight { id }],
194            Some(state) => {
195                // Carry the failure count forward through the Refreshing
196                // transition — it's the only place it lives, so overwriting
197                // it with a bare Refreshing here would otherwise strand
198                // RefreshFailed with no way to read the prior count back
199                // out and every failure would reset to 1.
200                let prior_failures = match state {
201                    CredentialState::Failed {
202                        consecutive_failures,
203                        ..
204                    } => *consecutive_failures,
205                    CredentialState::Fresh | CredentialState::NeedsReauth { .. } => 0,
206                    CredentialState::Refreshing { .. } => unreachable!("matched above"),
207                };
208                states.insert(id.clone(), CredentialState::Refreshing { prior_failures });
209                let generation = *generations.get(&id).unwrap_or(&0);
210                vec![Event::RunFreshnessCheck { id, generation }]
211            }
212        },
213        Command::FreshnessChecked {
214            id,
215            is_fresh,
216            generation,
217        } => {
218            if generations.get(&id).copied() != Some(generation) {
219                // Stale completion: id was unregistered (generation gone)
220                // or re-registered (generation bumped) while is_fresh() was
221                // still awaiting. Either way this result is about closures
222                // that no longer apply — discard it rather than clobber the
223                // current epoch's state (reagent re-review on #2275).
224                return Vec::new();
225            }
226            if is_fresh {
227                let became_fresh = !matches!(states.get(&id), Some(CredentialState::Fresh));
228                states.insert(id.clone(), CredentialState::Fresh);
229                if became_fresh {
230                    vec![Event::BecameFresh { id }]
231                } else {
232                    Vec::new()
233                }
234            } else {
235                // Stays Refreshing — orchestrator now runs the actual refresh.
236                vec![Event::RunRefresh { id, generation }]
237            }
238        }
239        Command::RefreshSucceeded { id, generation } => {
240            if generations.get(&id).copied() != Some(generation) {
241                // Same stale-epoch hazard as FreshnessChecked above.
242                return Vec::new();
243            }
244            states.insert(id.clone(), CredentialState::Fresh);
245            vec![Event::BecameFresh { id }]
246        }
247        Command::RefreshFailed {
248            id,
249            error,
250            generation,
251        } => {
252            if generations.get(&id).copied() != Some(generation) {
253                // Same stale-epoch hazard as FreshnessChecked above.
254                return Vec::new();
255            }
256            let reason = error.message().to_string();
257            let permanent = matches!(error, RefreshErrorKind::PermanentAuthFailure(_));
258            let prior_failures = match states.get(&id) {
259                Some(CredentialState::Refreshing { prior_failures }) => *prior_failures,
260                _ => 0,
261            };
262            let consecutive_failures = prior_failures + 1;
263            if permanent || consecutive_failures >= NEEDS_REAUTH_THRESHOLD {
264                states.insert(
265                    id.clone(),
266                    CredentialState::NeedsReauth {
267                        since_unix: now_unix(),
268                        reason: reason.clone(),
269                        rejected: permanent,
270                    },
271                );
272                vec![Event::BecameNeedsReauth { id, reason }]
273            } else {
274                states.insert(
275                    id.clone(),
276                    CredentialState::Failed {
277                        consecutive_failures,
278                        last_error: reason.clone(),
279                    },
280                );
281                vec![Event::BecameFailed {
282                    id,
283                    consecutive_failures,
284                    error: reason,
285                }]
286            }
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    /// Bundles `states` + `generations` + `next_generation` so tests can
296    /// dispatch commands without threading three pieces of state through
297    /// every call site by hand.
298    #[derive(Default)]
299    struct Harness {
300        states: HashMap<String, CredentialState>,
301        generations: HashMap<String, u64>,
302        next_generation: u64,
303    }
304
305    impl Harness {
306        fn dispatch(&mut self, cmd: Command) -> Vec<Event> {
307            update(
308                &mut self.states,
309                &mut self.generations,
310                &mut self.next_generation,
311                cmd,
312            )
313        }
314
315        fn get(&self, id: &str) -> Option<&CredentialState> {
316            self.states.get(id)
317        }
318
319        fn contains_key(&self, id: &str) -> bool {
320            self.states.contains_key(id)
321        }
322
323        fn generation_of(&self, id: &str) -> u64 {
324            *self.generations.get(id).expect("id should be registered")
325        }
326    }
327
328    fn registered() -> Harness {
329        let mut h = Harness::default();
330        h.dispatch(Command::Register { id: "cred".into() });
331        h
332    }
333
334    #[test]
335    fn register_starts_fresh() {
336        let m = registered();
337        assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
338    }
339
340    #[test]
341    fn check_on_fresh_transitions_to_refreshing_and_requests_a_check() {
342        let mut m = registered();
343        let gen = m.generation_of("cred");
344        let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
345        assert_eq!(
346            events,
347            vec![Event::RunFreshnessCheck {
348                id: "cred".into(),
349                generation: gen
350            }]
351        );
352        assert_eq!(
353            m.get("cred"),
354            Some(&CredentialState::Refreshing { prior_failures: 0 })
355        );
356    }
357
358    #[test]
359    fn a_second_check_while_refreshing_gets_already_in_flight_not_a_second_run() {
360        let mut m = registered();
361        m.dispatch(Command::CheckRequested { id: "cred".into() });
362        let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
363        assert_eq!(events, vec![Event::AlreadyInFlight { id: "cred".into() }]);
364        // Still just one credential, still Refreshing — the second dispatch
365        // did not disturb the in-flight state.
366        assert_eq!(
367            m.get("cred"),
368            Some(&CredentialState::Refreshing { prior_failures: 0 })
369        );
370    }
371
372    #[test]
373    fn freshness_checked_true_returns_to_fresh_and_reports_became_fresh() {
374        let mut m = registered();
375        let gen = m.generation_of("cred");
376        m.dispatch(Command::CheckRequested { id: "cred".into() });
377        let events = m.dispatch(Command::FreshnessChecked {
378            id: "cred".into(),
379            is_fresh: true,
380            generation: gen,
381        });
382        assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
383        assert_eq!(events, vec![Event::BecameFresh { id: "cred".into() }]);
384    }
385
386    #[test]
387    fn freshness_checked_false_requests_a_refresh_and_stays_refreshing() {
388        let mut m = registered();
389        let gen = m.generation_of("cred");
390        m.dispatch(Command::CheckRequested { id: "cred".into() });
391        let events = m.dispatch(Command::FreshnessChecked {
392            id: "cred".into(),
393            is_fresh: false,
394            generation: gen,
395        });
396        assert_eq!(
397            events,
398            vec![Event::RunRefresh {
399                id: "cred".into(),
400                generation: gen
401            }]
402        );
403        assert_eq!(
404            m.get("cred"),
405            Some(&CredentialState::Refreshing { prior_failures: 0 })
406        );
407    }
408
409    #[test]
410    fn refresh_succeeded_returns_to_fresh_and_resets_any_failure_count() {
411        let mut m = registered();
412        let gen = m.generation_of("cred");
413        m.dispatch(Command::CheckRequested { id: "cred".into() });
414        m.dispatch(Command::RefreshFailed {
415            id: "cred".into(),
416            error: RefreshErrorKind::Transient("boom".into()),
417            generation: gen,
418        });
419        m.dispatch(Command::CheckRequested { id: "cred".into() });
420        let events = m.dispatch(Command::RefreshSucceeded {
421            id: "cred".into(),
422            generation: gen,
423        });
424        assert_eq!(events, vec![Event::BecameFresh { id: "cred".into() }]);
425        assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
426    }
427
428    #[test]
429    fn refresh_failed_transient_moves_to_failed_with_count_one() {
430        let mut m = registered();
431        let gen = m.generation_of("cred");
432        m.dispatch(Command::CheckRequested { id: "cred".into() });
433        let events = m.dispatch(Command::RefreshFailed {
434            id: "cred".into(),
435            error: RefreshErrorKind::Transient("network blip".into()),
436            generation: gen,
437        });
438        assert_eq!(
439            events,
440            vec![Event::BecameFailed {
441                id: "cred".into(),
442                consecutive_failures: 1,
443                error: "network blip".into(),
444            }]
445        );
446        assert_eq!(
447            m.get("cred"),
448            Some(&CredentialState::Failed {
449                consecutive_failures: 1,
450                last_error: "network blip".into(),
451            })
452        );
453    }
454
455    #[test]
456    fn a_failed_refresh_does_not_mark_the_credential_fresh() {
457        // Port of the pre-refactor scheduler test — a second check after a
458        // failure must retry (not treat the failed attempt as success).
459        let mut m = registered();
460        let gen = m.generation_of("cred");
461        m.dispatch(Command::CheckRequested { id: "cred".into() });
462        m.dispatch(Command::RefreshFailed {
463            id: "cred".into(),
464            error: RefreshErrorKind::Transient("nope".into()),
465            generation: gen,
466        });
467        assert!(!matches!(m.get("cred"), Some(CredentialState::Fresh)));
468        let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
469        assert_eq!(
470            events,
471            vec![Event::RunFreshnessCheck {
472                id: "cred".into(),
473                generation: gen
474            }]
475        );
476    }
477
478    #[test]
479    fn permanent_auth_failure_skips_straight_to_needs_reauth_on_the_first_failure() {
480        let mut m = registered();
481        let gen = m.generation_of("cred");
482        m.dispatch(Command::CheckRequested { id: "cred".into() });
483        let events = m.dispatch(Command::RefreshFailed {
484            id: "cred".into(),
485            error: RefreshErrorKind::PermanentAuthFailure("invalid_grant".into()),
486            generation: gen,
487        });
488        assert!(matches!(
489            events.as_slice(),
490            [Event::BecameNeedsReauth { .. }]
491        ));
492        assert!(matches!(
493            m.get("cred"),
494            Some(CredentialState::NeedsReauth { rejected: true, .. })
495        ));
496    }
497
498    #[test]
499    fn five_consecutive_transient_failures_escalate_to_needs_reauth() {
500        let mut m = registered();
501        let gen = m.generation_of("cred");
502        for i in 1..=(NEEDS_REAUTH_THRESHOLD - 1) {
503            m.dispatch(Command::CheckRequested { id: "cred".into() });
504            let events = m.dispatch(Command::RefreshFailed {
505                id: "cred".into(),
506                error: RefreshErrorKind::Transient(format!("attempt {i}")),
507                generation: gen,
508            });
509            assert!(
510                matches!(events.as_slice(), [Event::BecameFailed { .. }]),
511                "attempt {i} should still be a transient Failed, not NeedsReauth yet"
512            );
513        }
514        m.dispatch(Command::CheckRequested { id: "cred".into() });
515        let events = m.dispatch(Command::RefreshFailed {
516            id: "cred".into(),
517            error: RefreshErrorKind::Transient("final straw".into()),
518            generation: gen,
519        });
520        assert!(matches!(
521            events.as_slice(),
522            [Event::BecameNeedsReauth { .. }]
523        ));
524        // Nothing refused the credential itself: an offline stretch must not
525        // read as a dead sign-in.
526        assert!(matches!(
527            m.get("cred"),
528            Some(CredentialState::NeedsReauth { rejected: false, .. })
529        ));
530    }
531
532    #[test]
533    fn check_requested_on_needs_reauth_still_runs_a_fresh_check_on_demand() {
534        // A human may have logged in again out-of-band (e.g. muxbus.login)
535        // since the credential was marked NeedsReauth — on-demand checks
536        // (unlike the sweep loop) must not just give up permanently.
537        let mut m = registered();
538        let gen = m.generation_of("cred");
539        m.dispatch(Command::CheckRequested { id: "cred".into() });
540        m.dispatch(Command::RefreshFailed {
541            id: "cred".into(),
542            error: RefreshErrorKind::PermanentAuthFailure("dead".into()),
543            generation: gen,
544        });
545        let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
546        assert_eq!(
547            events,
548            vec![Event::RunFreshnessCheck {
549                id: "cred".into(),
550                generation: gen
551            }]
552        );
553    }
554
555    #[test]
556    fn check_requested_on_an_unregistered_id_reports_unregistered() {
557        let mut m = Harness::default();
558        let events = m.dispatch(Command::CheckRequested { id: "nope".into() });
559        assert_eq!(events, vec![Event::Unregistered { id: "nope".into() }]);
560    }
561
562    #[test]
563    fn unregister_then_check_reports_unregistered() {
564        let mut m = registered();
565        m.dispatch(Command::Unregister { id: "cred".into() });
566        let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
567        assert_eq!(events, vec![Event::Unregistered { id: "cred".into() }]);
568    }
569
570    #[test]
571    fn re_registering_an_id_that_is_mid_refresh_does_not_leave_anything_orphaned() {
572        // The pre-refactor design's latent race: register() on an
573        // already-registered id replaced the Entry (including its lock), so
574        // a caller already blocked on the OLD entry's lock waited forever.
575        // Here there's no separate lock to orphan — Register always resets
576        // cleanly to Fresh, and the next CheckRequested from any caller
577        // (old or new) just sees that fresh state.
578        let mut m = registered();
579        m.dispatch(Command::CheckRequested { id: "cred".into() });
580        assert_eq!(
581            m.get("cred"),
582            Some(&CredentialState::Refreshing { prior_failures: 0 })
583        );
584        m.dispatch(Command::Register { id: "cred".into() });
585        assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
586        let new_gen = m.generation_of("cred");
587        let events = m.dispatch(Command::CheckRequested { id: "cred".into() });
588        assert_eq!(
589            events,
590            vec![Event::RunFreshnessCheck {
591                id: "cred".into(),
592                generation: new_gen
593            }]
594        );
595    }
596
597    #[test]
598    fn a_stale_completion_after_unregister_does_not_resurrect_a_phantom_entry() {
599        // reagent re-review on #2275: an in-flight check/refresh that loses
600        // a race with a concurrent unregister() must not reinsert an entry
601        // for an id nothing owns anymore — the sweep loop only walks
602        // registered closures, so a resurrected entry would leak forever
603        // and scheduler.state(id) would misreport it as still registered.
604        fn mid_check_then_unregistered() -> (Harness, u64) {
605            let mut m = registered();
606            let gen = m.generation_of("cred");
607            m.dispatch(Command::CheckRequested { id: "cred".into() });
608            m.dispatch(Command::Unregister { id: "cred".into() });
609            assert!(!m.contains_key("cred"));
610            (m, gen)
611        }
612
613        let stale_completion_builders: [fn(u64) -> Command; 4] = [
614            |gen| Command::FreshnessChecked {
615                id: "cred".into(),
616                is_fresh: true,
617                generation: gen,
618            },
619            |gen| Command::FreshnessChecked {
620                id: "cred".into(),
621                is_fresh: false,
622                generation: gen,
623            },
624            |gen| Command::RefreshSucceeded {
625                id: "cred".into(),
626                generation: gen,
627            },
628            |gen| Command::RefreshFailed {
629                id: "cred".into(),
630                error: RefreshErrorKind::Transient("late".into()),
631                generation: gen,
632            },
633        ];
634        for build_stale_completion in stale_completion_builders {
635            let (mut m, gen) = mid_check_then_unregistered();
636            let events = m.dispatch(build_stale_completion(gen));
637            assert_eq!(events, Vec::new());
638            assert!(
639                !m.contains_key("cred"),
640                "a stale completion must not resurrect the entry"
641            );
642        }
643    }
644
645    #[test]
646    fn a_stale_completion_after_re_register_does_not_clobber_the_new_epoch() {
647        // reagent re-review on #2275: if register() is called again for an
648        // id while an OLD in-flight check/refresh for that same id is still
649        // awaiting, the stale completion (belonging to the pre-registration
650        // closures) must not overwrite the freshly re-registered
651        // credential's Fresh state — it belongs to a superseded epoch and
652        // has nothing meaningful to say about the current one.
653        let mut m = registered();
654        let old_gen = m.generation_of("cred");
655        m.dispatch(Command::CheckRequested { id: "cred".into() });
656
657        // Re-register while the "old" check/refresh is still in flight —
658        // bumps the generation and resets to Fresh.
659        m.dispatch(Command::Register { id: "cred".into() });
660        assert_eq!(m.get("cred"), Some(&CredentialState::Fresh));
661        let new_gen = m.generation_of("cred");
662        assert_ne!(old_gen, new_gen);
663
664        // The old attempt's belated failure must not land.
665        let events = m.dispatch(Command::RefreshFailed {
666            id: "cred".into(),
667            error: RefreshErrorKind::PermanentAuthFailure("stale invalid_grant".into()),
668            generation: old_gen,
669        });
670        assert_eq!(events, Vec::new());
671        assert_eq!(
672            m.get("cred"),
673            Some(&CredentialState::Fresh),
674            "a stale completion from a superseded registration must not mask the new one"
675        );
676    }
677
678    #[test]
679    fn a_stale_completion_survives_an_unregister_then_re_register_of_the_same_id() {
680        // reagent re-review on #2275, second round: a PER-ID counter that
681        // restarts at 0 on each Register would hand an unregister-then-
682        // re-register sequence the SAME generation number the original
683        // registration had, so a stale completion from before the
684        // unregister would wrongly pass the staleness check after the
685        // re-register instead of being discarded. next_generation is a
686        // single counter shared across every id specifically so this can't
687        // happen — assert the two registrations never collide even across
688        // an intervening unregister.
689        let mut m = registered();
690        let old_gen = m.generation_of("cred");
691        m.dispatch(Command::CheckRequested { id: "cred".into() });
692
693        m.dispatch(Command::Unregister { id: "cred".into() });
694        m.dispatch(Command::Register { id: "cred".into() });
695        let new_gen = m.generation_of("cred");
696        assert_ne!(
697            old_gen, new_gen,
698            "unregister-then-register must never reissue a prior generation number"
699        );
700
701        let events = m.dispatch(Command::RefreshFailed {
702            id: "cred".into(),
703            error: RefreshErrorKind::PermanentAuthFailure("stale invalid_grant".into()),
704            generation: old_gen,
705        });
706        assert_eq!(events, Vec::new());
707        assert_eq!(
708            m.get("cred"),
709            Some(&CredentialState::Fresh),
710            "a stale completion from before the unregister must not mask the re-registered credential"
711        );
712    }
713}