Skip to main content

meerkat_runtime/handles/
auth_lease.rs

1//! Runtime impl of [`meerkat_core::handles::AuthLeaseHandle`].
2//!
3//! Per-binding [`AuthMachine`](crate::auth_machine) instances, keyed by
4//! typed [`LeaseKey`](meerkat_core::handles::LeaseKey). Each trait
5//! method looks up or creates the corresponding machine, then fires
6//! the matching DSL input. `snapshot` projects the machine's phase
7//! + expires_at back out.
8//!
9//! The original Phase 1.5-rev design absorbed auth state into
10//! MeerkatMachine as Sets+Maps keyed by binding. Post-review that was
11//! rejected: auth lifecycle is orthogonal to MeerkatMachine's
12//! lifecycle, and carrying it there grew the TLC state space without
13//! unifying any semantics (dogma §19). Splitting it out per-binding
14//! keeps each machine small, aligns the fact "this binding's lease
15//! state" with one canonical owner per dogma §1, and decouples auth
16//! evolution from core-runtime evolution.
17
18use std::collections::HashMap;
19#[cfg(not(target_arch = "wasm32"))]
20use std::sync::Weak;
21use std::sync::{Arc, Mutex};
22
23#[cfg(not(target_arch = "wasm32"))]
24use meerkat_core::AuthBindingRef;
25use meerkat_core::auth::{RefreshFailureDisposition, RefreshFailureObservation, TokenKey};
26use meerkat_core::generated::auth_lease_durable_lifecycle_marker::AuthLeaseDurableRestorePublication;
27use meerkat_core::handles::{
28    AuthLeaseHandle, AuthLeasePhase, AuthLeaseRestoreSnapshot, AuthLeaseSnapshot,
29    AuthLeaseTransition, DslTransitionError, LeaseKey,
30};
31use meerkat_core::time_compat::{SystemTime, UNIX_EPOCH};
32
33use crate::auth_machine::dsl as auth_dsl;
34
35fn current_time_millis() -> u64 {
36    SystemTime::now()
37        .duration_since(UNIX_EPOCH)
38        .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
39        .unwrap_or(0)
40}
41
42/// Emit a structured audit record for every accepted auth-lease DSL
43/// transition. REST/RPC surfaces (and any other `tracing::Subscriber`
44/// consumer) can filter on `target = "meerkat::auth::audit"` to build
45/// a persistent audit log without the shell inventing the fact set
46/// (dogma §17 — surfaces observe, they do not own lifecycle truth).
47///
48/// Deferral §5: structured observability for auth events. We emit at
49/// the single choke point where DSL transitions succeed, not from each
50/// caller — that keeps the audit trail aligned with the machine's
51/// actual acceptance of the transition and avoids duplication /
52/// drift (dogma §1).
53fn emit_audit(
54    lease_key: &LeaseKey,
55    action: &'static str,
56    from_phase: AuthLeasePhase,
57    to_phase: AuthLeasePhase,
58) {
59    tracing::info!(
60        target: "meerkat::auth::audit",
61        lease_key = %lease_key,
62        realm = %lease_key.realm,
63        binding = %lease_key.binding,
64        profile = lease_key.profile.as_ref().map(meerkat_core::ProfileId::as_str),
65        action = %action,
66        from_phase = ?from_phase,
67        to_phase = ?to_phase,
68        "auth lease transition"
69    );
70}
71
72/// Runtime-backed [`AuthLeaseHandle`] impl.
73///
74/// Holds a mutex-guarded registry of per-binding [`auth_dsl::AuthMachineAuthority`]
75/// instances. Lookup-or-insert happens on first `acquire_lease`; release is
76/// also allowed before acquire so token-clear surfaces remain idempotent after
77/// process restart.
78#[derive(Clone)]
79pub struct RuntimeAuthLeaseHandle {
80    machines: Arc<Mutex<AuthLeaseRegistry>>,
81    #[cfg(not(target_arch = "wasm32"))]
82    release_observers: Arc<Mutex<Vec<Weak<dyn AuthLeaseReleaseObserver>>>>,
83}
84
85#[cfg(not(target_arch = "wasm32"))]
86#[derive(Debug, Clone)]
87pub(crate) struct ReleasedOAuthFlows {
88    pub lease_key: LeaseKey,
89    pub browser_flow_ids: Vec<String>,
90    pub device_flow_ids: Vec<String>,
91}
92
93#[cfg(not(target_arch = "wasm32"))]
94impl ReleasedOAuthFlows {
95    fn empty(lease_key: LeaseKey) -> Self {
96        Self {
97            lease_key,
98            browser_flow_ids: Vec::new(),
99            device_flow_ids: Vec::new(),
100        }
101    }
102
103    fn dedup(&mut self) {
104        self.browser_flow_ids.sort();
105        self.browser_flow_ids.dedup();
106        self.device_flow_ids.sort();
107        self.device_flow_ids.dedup();
108    }
109}
110
111fn restore_phase_to_dsl(phase: AuthLeasePhase) -> auth_dsl::AuthLifecyclePhase {
112    match phase {
113        AuthLeasePhase::Valid => auth_dsl::AuthLifecyclePhase::Valid,
114        AuthLeasePhase::Expiring => auth_dsl::AuthLifecyclePhase::Expiring,
115        AuthLeasePhase::Expired => auth_dsl::AuthLifecyclePhase::Expired,
116        AuthLeasePhase::Refreshing => auth_dsl::AuthLifecyclePhase::Refreshing,
117        AuthLeasePhase::ReauthRequired => auth_dsl::AuthLifecyclePhase::ReauthRequired,
118        AuthLeasePhase::Released => auth_dsl::AuthLifecyclePhase::Released,
119    }
120}
121
122fn restore_input_from_lifecycle(
123    lifecycle_phase: auth_dsl::AuthLifecyclePhase,
124    expires_at: Option<u64>,
125    last_refresh: Option<u64>,
126    refresh_attempt: u64,
127    credential_present: bool,
128    credential_generation: u64,
129    credential_published_at_millis: Option<u64>,
130) -> auth_dsl::AuthMachineInput {
131    auth_dsl::AuthMachineInput::RestoreAuthoritySnapshot {
132        lifecycle_phase,
133        expires_at,
134        last_refresh,
135        refresh_attempt,
136        credential_present,
137        credential_generation,
138        credential_published_at_millis,
139    }
140}
141
142#[allow(clippy::too_many_arguments)]
143fn restore_credential_lifecycle_snapshot_input(
144    lifecycle_phase: Option<auth_dsl::AuthLifecyclePhase>,
145    expires_at: Option<u64>,
146    last_refresh: Option<u64>,
147    refresh_attempt: u64,
148    credential_present: bool,
149    credential_generation: u64,
150    credential_published_at_millis: Option<u64>,
151    restored_oauth_membership_observed: bool,
152) -> auth_dsl::AuthMachineInput {
153    auth_dsl::AuthMachineInput::RestoreCredentialLifecycleSnapshot {
154        lifecycle_phase,
155        expires_at,
156        last_refresh,
157        refresh_attempt,
158        credential_present,
159        credential_generation,
160        credential_published_at_millis,
161        restored_oauth_membership_observed,
162    }
163}
164
165// Test-only restore-path fixture plumbing: the production restore arm was
166// deleted by K7 (clear+release is one AuthMachine-authorized transition);
167// these helpers remain solely to drive the generated restore guards in tests.
168#[cfg(test)]
169fn append_restore_oauth_inputs_from_state(
170    inputs: &mut Vec<auth_dsl::AuthMachineInput>,
171    poll_inputs: &mut Vec<auth_dsl::AuthMachineInput>,
172    state: &auth_dsl::AuthMachineState,
173) {
174    for flow_id in &state.oauth_browser_flow_ids {
175        inputs.push(auth_dsl::AuthMachineInput::RestoreOAuthBrowserFlow {
176            flow_id: flow_id.clone(),
177            provider: state.oauth_browser_flow_providers.get(flow_id).cloned(),
178            redirect_uri: state.oauth_browser_flow_redirect_uris.get(flow_id).cloned(),
179            expires_at_millis: state
180                .oauth_browser_flow_expires_at_millis
181                .get(flow_id)
182                .copied(),
183        });
184    }
185    for flow_id in &state.oauth_device_flow_ids {
186        inputs.push(auth_dsl::AuthMachineInput::RestoreOAuthDeviceFlow {
187            flow_id: flow_id.clone(),
188            provider: state.oauth_device_flow_providers.get(flow_id).cloned(),
189            expires_at_millis: state
190                .oauth_device_flow_expires_at_millis
191                .get(flow_id)
192                .copied(),
193        });
194    }
195    for poll_id in &state.oauth_device_poll_ids {
196        poll_inputs.push(auth_dsl::AuthMachineInput::RestoreOAuthDevicePoll {
197            flow_id: poll_id.clone(),
198        });
199    }
200}
201
202// Test-only restore-path fixture plumbing: the production restore arm was
203// deleted by K7 (clear+release is one AuthMachine-authorized transition);
204// these helpers remain solely to drive the generated restore guards in tests.
205#[cfg(test)]
206fn restore_oauth_inputs_from_states(
207    states: &[&auth_dsl::AuthMachineState],
208) -> Vec<auth_dsl::AuthMachineInput> {
209    let mut inputs = Vec::new();
210    let mut poll_inputs = Vec::new();
211    for state in states {
212        append_restore_oauth_inputs_from_state(&mut inputs, &mut poll_inputs, state);
213    }
214    inputs.extend(poll_inputs);
215    inputs
216}
217
218// Test-only restore-path fixture plumbing: the production restore arm was
219// deleted by K7 (clear+release is one AuthMachine-authorized transition);
220// these helpers remain solely to drive the generated restore guards in tests.
221#[cfg(test)]
222fn restore_oauth_membership_observed(states: &[&auth_dsl::AuthMachineState]) -> bool {
223    states
224        .iter()
225        .any(|state| state.oauth_outstanding_flow_count > 0)
226}
227
228fn map_auth_machine_error(
229    err: auth_dsl::AuthMachineTransitionError,
230    context: &'static str,
231) -> DslTransitionError {
232    let reason = err.to_string();
233    match err {
234        auth_dsl::AuthMachineTransitionError::GuardRejected { .. } => {
235            DslTransitionError::guard_rejected(context, reason)
236        }
237        auth_dsl::AuthMachineTransitionError::NoMatchingTransition { .. } => {
238            DslTransitionError::no_matching(context, reason)
239        }
240        auth_dsl::AuthMachineTransitionError::RecoveredStateInvariantRejected { .. } => {
241            DslTransitionError::recovered_state_invariant_rejected(context, reason)
242        }
243    }
244}
245
246fn apply_restore_input(
247    authority: &mut auth_dsl::AuthMachineAuthority,
248    lease_key: &LeaseKey,
249    input: auth_dsl::AuthMachineInput,
250    context: &'static str,
251) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
252    let transition = auth_dsl::AuthMachineMutator::apply(authority, input)
253        .map_err(|err| map_auth_machine_error(err, context))?;
254    let auth_transition = auth_lease_transition_from_generated_publication(
255        lease_key,
256        authority,
257        &transition,
258        context,
259    )?;
260    Ok((
261        map_phase(authority.state().lifecycle_phase),
262        auth_transition,
263    ))
264}
265
266fn apply_restore_input_to_registry(
267    registry: &mut AuthLeaseRegistry,
268    lease_key: &LeaseKey,
269    input: auth_dsl::AuthMachineInput,
270    context: &'static str,
271) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
272    let authority = registry
273        .authorities
274        .entry(lease_key.clone())
275        .or_insert_with(auth_dsl::AuthMachineAuthority::new);
276    apply_restore_input(authority, lease_key, input, context)
277}
278
279// Test-only restore-path fixture plumbing: the production restore arm was
280// deleted by K7 (clear+release is one AuthMachine-authorized transition);
281// these helpers remain solely to drive the generated restore guards in tests.
282#[cfg(test)]
283fn restore_authority_from_registry(
284    registry: &AuthLeaseRegistry,
285    lease_key: &LeaseKey,
286    context: &'static str,
287) -> Result<auth_dsl::AuthMachineAuthority, DslTransitionError> {
288    match registry.authorities.get(lease_key) {
289        Some(authority) => {
290            auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
291                .map_err(|err| map_auth_machine_error(err, context))
292        }
293        None => Ok(auth_dsl::AuthMachineAuthority::new()),
294    }
295}
296
297// Test-only restore-path fixture plumbing: the production restore arm was
298// deleted by K7 (clear+release is one AuthMachine-authorized transition);
299// these helpers remain solely to drive the generated restore guards in tests.
300#[cfg(test)]
301fn apply_restore_inputs_to_registry(
302    registry: &mut AuthLeaseRegistry,
303    lease_key: &LeaseKey,
304    lifecycle_input: auth_dsl::AuthMachineInput,
305    oauth_inputs: Vec<auth_dsl::AuthMachineInput>,
306    context: &'static str,
307) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
308    let mut authority = restore_authority_from_registry(registry, lease_key, context)?;
309    let restored = apply_restore_input(&mut authority, lease_key, lifecycle_input, context)?;
310    for input in oauth_inputs {
311        apply_restore_input(&mut authority, lease_key, input, context)?;
312    }
313    registry.authorities.insert(lease_key.clone(), authority);
314    Ok(restored)
315}
316
317// Test-only restore-path fixture plumbing: the production restore arm was
318// deleted by K7 (clear+release is one AuthMachine-authorized transition);
319// these helpers remain solely to drive the generated restore guards in tests.
320#[cfg(test)]
321fn restore_state_to_registry(
322    registry: &mut AuthLeaseRegistry,
323    lease_key: &LeaseKey,
324    state: &auth_dsl::AuthMachineState,
325    context: &'static str,
326) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
327    restore_state_with_oauth_sources_to_registry(registry, lease_key, state, &[state], context)
328}
329
330// Test-only restore-path fixture plumbing: the production restore arm was
331// deleted by K7 (clear+release is one AuthMachine-authorized transition);
332// these helpers remain solely to drive the generated restore guards in tests.
333#[cfg(test)]
334fn restore_state_with_oauth_sources_to_registry(
335    registry: &mut AuthLeaseRegistry,
336    lease_key: &LeaseKey,
337    lifecycle_state: &auth_dsl::AuthMachineState,
338    oauth_sources: &[&auth_dsl::AuthMachineState],
339    context: &'static str,
340) -> Result<(AuthLeasePhase, AuthLeaseTransition), DslTransitionError> {
341    let oauth_inputs = restore_oauth_inputs_from_states(oauth_sources);
342    apply_restore_inputs_to_registry(
343        registry,
344        lease_key,
345        restore_credential_lifecycle_snapshot_input(
346            Some(lifecycle_state.lifecycle_phase),
347            lifecycle_state.expires_at,
348            lifecycle_state.last_refresh,
349            lifecycle_state.refresh_attempt,
350            lifecycle_state.credential_present,
351            lifecycle_state.credential_generation,
352            lifecycle_state.credential_published_at_millis,
353            restore_oauth_membership_observed(oauth_sources),
354        ),
355        oauth_inputs,
356        context,
357    )
358}
359
360fn auth_lease_transition_from_generated_publication(
361    lease_key: &LeaseKey,
362    authority: &auth_dsl::AuthMachineAuthority,
363    transition: &auth_dsl::AuthMachineTransition,
364    context: &'static str,
365) -> Result<AuthLeaseTransition, DslTransitionError> {
366    match maybe_auth_lease_transition_from_generated_publication(
367        lease_key, authority, transition, context,
368    )? {
369        Some(transition) => Ok(transition),
370        None => Err(DslTransitionError::no_matching(
371            context,
372            "AuthMachine transition emitted no lifecycle publication obligation",
373        )),
374    }
375}
376
377fn maybe_auth_lease_transition_from_generated_publication(
378    lease_key: &LeaseKey,
379    authority: &auth_dsl::AuthMachineAuthority,
380    transition: &auth_dsl::AuthMachineTransition,
381    context: &'static str,
382) -> Result<Option<AuthLeaseTransition>, DslTransitionError> {
383    let mut obligations =
384        crate::protocol_auth_lease_lifecycle_publication::extract_obligations(transition);
385    if obligations.is_empty() {
386        return Ok(None);
387    }
388    if obligations.len() != 1 {
389        return Err(DslTransitionError::no_matching(
390            context,
391            format!(
392                "AuthMachine transition emitted {} lifecycle publication obligations",
393                obligations.len()
394            ),
395        ));
396    }
397    let scope =
398        crate::protocol_auth_lease_lifecycle_publication::AuthLeaseLifecyclePublicationScope::from_authority(
399            lease_key.clone(),
400            authority,
401        );
402    obligations
403        .remove(0)
404        .into_auth_lease_transition(scope)
405        .map(Some)
406        .map_err(|err| {
407            DslTransitionError::no_matching(
408                context,
409                format!("AuthMachine lifecycle publication handoff failed: {err}"),
410            )
411        })
412}
413
414#[cfg(not(target_arch = "wasm32"))]
415pub(crate) trait AuthLeaseReleaseObserver: Send + Sync {
416    fn begin_auth_lease_release<'a>(
417        &'a self,
418        _lease_key: &LeaseKey,
419    ) -> Result<Option<Box<dyn AuthLeaseReleasePermit + 'a>>, DslTransitionError> {
420        Ok(None)
421    }
422
423    fn oauth_flows_for_release(
424        &self,
425        lease_key: &LeaseKey,
426    ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
427        Ok(ReleasedOAuthFlows::empty(lease_key.clone()))
428    }
429
430    fn auth_lease_released(&self, released: &ReleasedOAuthFlows) -> Result<(), DslTransitionError>;
431}
432
433#[cfg(not(target_arch = "wasm32"))]
434pub(crate) trait AuthLeaseReleasePermit {}
435
436#[cfg(test)]
437pub(crate) type ReleaseAfterAcceptHook = Arc<dyn Fn(&LeaseKey) + Send + Sync>;
438
439#[cfg(test)]
440static RELEASE_AFTER_ACCEPT_HOOK: std::sync::OnceLock<Mutex<Option<ReleaseAfterAcceptHook>>> =
441    std::sync::OnceLock::new();
442
443#[cfg(test)]
444static RELEASE_AFTER_ACCEPT_HOOK_SERIAL: std::sync::OnceLock<Mutex<()>> =
445    std::sync::OnceLock::new();
446
447#[cfg(test)]
448pub(crate) type ReleaseBeforeCommitHook = Arc<dyn Fn(&LeaseKey) + Send + Sync>;
449
450#[cfg(test)]
451static RELEASE_BEFORE_COMMIT_HOOK: std::sync::OnceLock<Mutex<Option<ReleaseBeforeCommitHook>>> =
452    std::sync::OnceLock::new();
453
454#[cfg(test)]
455static RELEASE_BEFORE_COMMIT_HOOK_SERIAL: std::sync::OnceLock<Mutex<()>> =
456    std::sync::OnceLock::new();
457
458#[cfg(test)]
459pub(crate) struct ReleaseAfterAcceptHookGuard {
460    _serial: std::sync::MutexGuard<'static, ()>,
461}
462
463#[cfg(test)]
464impl Drop for ReleaseAfterAcceptHookGuard {
465    fn drop(&mut self) {
466        set_release_after_accept_hook_for_test(None);
467    }
468}
469
470#[cfg(test)]
471pub(crate) struct ReleaseBeforeCommitHookGuard {
472    _serial: std::sync::MutexGuard<'static, ()>,
473}
474
475#[cfg(test)]
476impl Drop for ReleaseBeforeCommitHookGuard {
477    fn drop(&mut self) {
478        set_release_before_commit_hook_for_test(None);
479    }
480}
481
482#[cfg(test)]
483pub(crate) fn install_release_after_accept_hook_for_test(
484    hook: ReleaseAfterAcceptHook,
485) -> ReleaseAfterAcceptHookGuard {
486    let serial = RELEASE_AFTER_ACCEPT_HOOK_SERIAL
487        .get_or_init(|| Mutex::new(()))
488        .lock()
489        .unwrap_or_else(std::sync::PoisonError::into_inner);
490    set_release_after_accept_hook_for_test(Some(hook));
491    ReleaseAfterAcceptHookGuard { _serial: serial }
492}
493
494#[cfg(test)]
495pub(crate) fn install_release_before_commit_hook_for_test(
496    hook: ReleaseBeforeCommitHook,
497) -> ReleaseBeforeCommitHookGuard {
498    let serial = RELEASE_BEFORE_COMMIT_HOOK_SERIAL
499        .get_or_init(|| Mutex::new(()))
500        .lock()
501        .unwrap_or_else(std::sync::PoisonError::into_inner);
502    set_release_before_commit_hook_for_test(Some(hook));
503    ReleaseBeforeCommitHookGuard { _serial: serial }
504}
505
506#[cfg(test)]
507fn set_release_after_accept_hook_for_test(hook: Option<ReleaseAfterAcceptHook>) {
508    *RELEASE_AFTER_ACCEPT_HOOK
509        .get_or_init(|| Mutex::new(None))
510        .lock()
511        .unwrap_or_else(std::sync::PoisonError::into_inner) = hook;
512}
513
514#[cfg(test)]
515fn set_release_before_commit_hook_for_test(hook: Option<ReleaseBeforeCommitHook>) {
516    *RELEASE_BEFORE_COMMIT_HOOK
517        .get_or_init(|| Mutex::new(None))
518        .lock()
519        .unwrap_or_else(std::sync::PoisonError::into_inner) = hook;
520}
521
522#[cfg(test)]
523fn run_release_after_accept_hook(lease_key: &LeaseKey) {
524    let hook = RELEASE_AFTER_ACCEPT_HOOK
525        .get_or_init(|| Mutex::new(None))
526        .lock()
527        .unwrap_or_else(std::sync::PoisonError::into_inner)
528        .clone();
529    if let Some(hook) = hook {
530        hook(lease_key);
531    }
532}
533
534#[cfg(test)]
535fn run_release_before_commit_hook(lease_key: &LeaseKey) {
536    let hook = RELEASE_BEFORE_COMMIT_HOOK
537        .get_or_init(|| Mutex::new(None))
538        .lock()
539        .unwrap_or_else(std::sync::PoisonError::into_inner)
540        .clone();
541    if let Some(hook) = hook {
542        hook(lease_key);
543    }
544}
545
546#[derive(Default)]
547struct AuthLeaseRegistry {
548    authorities: HashMap<LeaseKey, auth_dsl::AuthMachineAuthority>,
549}
550
551impl std::fmt::Debug for RuntimeAuthLeaseHandle {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        let guard = self
554            .machines
555            .lock()
556            .unwrap_or_else(std::sync::PoisonError::into_inner);
557        f.debug_struct("RuntimeAuthLeaseHandle")
558            .field("leases", &guard.authorities.keys().collect::<Vec<_>>())
559            .finish()
560    }
561}
562
563impl RuntimeAuthLeaseHandle {
564    pub fn new() -> Self {
565        Self {
566            machines: Arc::new(Mutex::new(AuthLeaseRegistry::default())),
567            #[cfg(not(target_arch = "wasm32"))]
568            release_observers: Arc::new(Mutex::new(Vec::new())),
569        }
570    }
571
572    /// Alias for [`Self::new`]; kept for parity with other runtime
573    /// handles that distinguish session-owned vs. ephemeral
574    /// constructors. AuthMachine instances are always ephemeral from
575    /// the registry's perspective — the registry itself is owned by
576    /// the session bindings.
577    pub fn ephemeral() -> Self {
578        Self::new()
579    }
580
581    #[cfg(not(target_arch = "wasm32"))]
582    pub(crate) fn add_release_observer(&self, observer: Weak<dyn AuthLeaseReleaseObserver>) {
583        self.release_observers
584            .lock()
585            .unwrap_or_else(std::sync::PoisonError::into_inner)
586            .push(observer);
587    }
588
589    #[cfg(not(target_arch = "wasm32"))]
590    fn live_release_observers(&self) -> Vec<Arc<dyn AuthLeaseReleaseObserver>> {
591        {
592            let mut guard = self
593                .release_observers
594                .lock()
595                .unwrap_or_else(std::sync::PoisonError::into_inner);
596            let mut observers = Vec::new();
597            guard.retain(|observer| match observer.upgrade() {
598                Some(observer) => {
599                    observers.push(observer);
600                    true
601                }
602                None => false,
603            });
604            observers
605        }
606    }
607
608    #[cfg(not(target_arch = "wasm32"))]
609    fn collect_release_observer_flows(
610        &self,
611        observers: &[Arc<dyn AuthLeaseReleaseObserver>],
612        lease_key: &LeaseKey,
613    ) -> Result<ReleasedOAuthFlows, DslTransitionError> {
614        let mut released = ReleasedOAuthFlows::empty(lease_key.clone());
615        for observer in observers {
616            let mut observed = observer.oauth_flows_for_release(lease_key)?;
617            released
618                .browser_flow_ids
619                .append(&mut observed.browser_flow_ids);
620            released
621                .device_flow_ids
622                .append(&mut observed.device_flow_ids);
623        }
624        released.dedup();
625        Ok(released)
626    }
627
628    #[cfg(not(target_arch = "wasm32"))]
629    fn notify_release_observers(
630        &self,
631        observers: &[Arc<dyn AuthLeaseReleaseObserver>],
632        released: &ReleasedOAuthFlows,
633    ) -> Result<(), DslTransitionError> {
634        for observer in observers {
635            observer.auth_lease_released(released)?;
636        }
637        Ok(())
638    }
639
640    fn apply(
641        &self,
642        lease_key: &LeaseKey,
643        input: auth_dsl::AuthMachineInput,
644        context: &'static str,
645        create_if_missing: bool,
646    ) -> Result<AuthLeaseTransition, DslTransitionError> {
647        let action = Self::audit_action_for(&input);
648        let mut guard = self
649            .machines
650            .lock()
651            .unwrap_or_else(std::sync::PoisonError::into_inner);
652        if !create_if_missing && !guard.authorities.contains_key(lease_key) {
653            return Err(DslTransitionError::no_matching(
654                context,
655                format!("no auth lease registered for lease_key `{lease_key}`"),
656            ));
657        }
658        let (from_phase, to_phase, auth_transition) = {
659            let entry = if create_if_missing {
660                guard
661                    .authorities
662                    .entry(lease_key.clone())
663                    .or_insert_with(auth_dsl::AuthMachineAuthority::new)
664            } else {
665                match guard.authorities.get_mut(lease_key) {
666                    Some(m) => m,
667                    None => {
668                        return Err(DslTransitionError::no_matching(
669                            context,
670                            format!("no auth lease registered for lease_key `{lease_key}`"),
671                        ));
672                    }
673                }
674            };
675            let from_phase = map_phase(entry.state().lifecycle_phase);
676            let transition = auth_dsl::AuthMachineMutator::apply(entry, input)
677                .map_err(|err| map_auth_machine_error(err, context))?;
678            let auth_transition = auth_lease_transition_from_generated_publication(
679                lease_key,
680                entry,
681                &transition,
682                context,
683            )?;
684            let to_phase = map_phase(entry.state().lifecycle_phase);
685            (from_phase, to_phase, auth_transition)
686        };
687        emit_audit(lease_key, action, from_phase, to_phase);
688        Ok(auth_transition)
689    }
690
691    #[cfg(not(target_arch = "wasm32"))]
692    fn oauth_global_outstanding_flow_count(registry: &AuthLeaseRegistry) -> u64 {
693        registry
694            .authorities
695            .values()
696            .map(|authority| authority.state().oauth_outstanding_flow_count)
697            .fold(0u64, u64::saturating_add)
698    }
699
700    #[cfg(not(target_arch = "wasm32"))]
701    fn attach_oauth_global_observation(
702        input: auth_dsl::AuthMachineInput,
703        observed_global_outstanding_flows: u64,
704    ) -> auth_dsl::AuthMachineInput {
705        match input {
706            auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
707                flow_id,
708                provider,
709                redirect_uri,
710                expires_at_millis,
711                max_outstanding_flows,
712                ..
713            } => auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
714                flow_id,
715                provider,
716                redirect_uri,
717                expires_at_millis,
718                max_outstanding_flows,
719                observed_global_outstanding_flows,
720            },
721            auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
722                flow_id,
723                provider,
724                expires_at_millis,
725                max_outstanding_flows,
726                ..
727            } => auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
728                flow_id,
729                provider,
730                expires_at_millis,
731                max_outstanding_flows,
732                observed_global_outstanding_flows,
733            },
734            other => other,
735        }
736    }
737
738    #[cfg(not(target_arch = "wasm32"))]
739    pub(crate) fn apply_oauth_input(
740        &self,
741        target: &AuthBindingRef,
742        input: auth_dsl::AuthMachineInput,
743        context: &'static str,
744        create_if_missing: bool,
745    ) -> Result<(), DslTransitionError> {
746        let lease_key = LeaseKey::from_auth_binding(target);
747        let mut guard = self
748            .machines
749            .lock()
750            .unwrap_or_else(std::sync::PoisonError::into_inner);
751        let input = Self::attach_oauth_global_observation(
752            input,
753            Self::oauth_global_outstanding_flow_count(&guard),
754        );
755        let action = Self::audit_action_for(&input);
756        if create_if_missing && !guard.authorities.contains_key(&lease_key) {
757            let mut authority = auth_dsl::AuthMachineAuthority::new();
758            let transition = auth_dsl::AuthMachineMutator::apply(
759                &mut authority,
760                auth_dsl::AuthMachineInput::MarkReauthRequired,
761            )
762            .map_err(|err| map_auth_machine_error(err, context))?;
763            auth_lease_transition_from_generated_publication(
764                &lease_key,
765                &authority,
766                &transition,
767                context,
768            )?;
769            guard.authorities.insert(lease_key.clone(), authority);
770        }
771        let (from_phase, to_phase) = {
772            let entry = match guard.authorities.get_mut(&lease_key) {
773                Some(m) => m,
774                None => {
775                    return Err(DslTransitionError::no_matching(
776                        context,
777                        format!("no auth machine registered for lease_key `{lease_key}`"),
778                    ));
779                }
780            };
781            let from_phase = map_phase(entry.state().lifecycle_phase);
782            let transition = auth_dsl::AuthMachineMutator::apply(entry, input)
783                .map_err(|err| map_auth_machine_error(err, context))?;
784            maybe_auth_lease_transition_from_generated_publication(
785                &lease_key,
786                entry,
787                &transition,
788                context,
789            )?;
790            let to_phase = map_phase(entry.state().lifecycle_phase);
791            (from_phase, to_phase)
792        };
793        emit_audit(&lease_key, action, from_phase, to_phase);
794        Ok(())
795    }
796
797    #[cfg(not(target_arch = "wasm32"))]
798    pub(crate) fn confirm_oauth_durable_admission(
799        &self,
800        target: &AuthBindingRef,
801        observed_global_outstanding_flows: u64,
802        max_outstanding_flows: u64,
803        context: &'static str,
804    ) -> Result<(), DslTransitionError> {
805        let lease_key = LeaseKey::from_auth_binding(target);
806        let input = auth_dsl::AuthMachineInput::ConfirmOAuthDurableAdmission {
807            observed_global_outstanding_flows,
808            max_outstanding_flows,
809        };
810        let mut guard = self
811            .machines
812            .lock()
813            .unwrap_or_else(std::sync::PoisonError::into_inner);
814        let (from_phase, to_phase) = {
815            let entry = match guard.authorities.get_mut(&lease_key) {
816                Some(m) => m,
817                None => {
818                    return Err(DslTransitionError::no_matching(
819                        context,
820                        format!("no auth machine registered for lease_key `{lease_key}`"),
821                    ));
822                }
823            };
824            let from_phase = map_phase(entry.state().lifecycle_phase);
825            let transition = auth_dsl::AuthMachineMutator::apply(entry, input)
826                .map_err(|err| map_auth_machine_error(err, context))?;
827            maybe_auth_lease_transition_from_generated_publication(
828                &lease_key,
829                entry,
830                &transition,
831                context,
832            )?;
833            let to_phase = map_phase(entry.state().lifecycle_phase);
834            (from_phase, to_phase)
835        };
836        emit_audit(
837            &lease_key,
838            "confirm_oauth_durable_admission",
839            from_phase,
840            to_phase,
841        );
842        Ok(())
843    }
844
845    #[cfg(not(target_arch = "wasm32"))]
846    pub(crate) fn has_oauth_browser_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
847        let lease_key = LeaseKey::from_auth_binding(target);
848        self.machines
849            .lock()
850            .unwrap_or_else(std::sync::PoisonError::into_inner)
851            .authorities
852            .get(&lease_key)
853            .is_some_and(|authority| authority.state().oauth_browser_flow_ids.contains(flow_id))
854    }
855
856    #[cfg(not(target_arch = "wasm32"))]
857    pub(crate) fn has_oauth_device_flow(&self, target: &AuthBindingRef, flow_id: &str) -> bool {
858        let lease_key = LeaseKey::from_auth_binding(target);
859        self.machines
860            .lock()
861            .unwrap_or_else(std::sync::PoisonError::into_inner)
862            .authorities
863            .get(&lease_key)
864            .is_some_and(|authority| authority.state().oauth_device_flow_ids.contains(flow_id))
865    }
866
867    #[cfg(test)]
868    pub(crate) fn has_oauth_browser_flow_for_test(
869        &self,
870        target: &AuthBindingRef,
871        flow_id: &str,
872    ) -> bool {
873        self.has_oauth_browser_flow(target, flow_id)
874    }
875
876    #[cfg(test)]
877    pub(crate) fn has_oauth_device_flow_for_test(
878        &self,
879        target: &AuthBindingRef,
880        flow_id: &str,
881    ) -> bool {
882        self.has_oauth_device_flow(target, flow_id)
883    }
884
885    fn audit_action_for(input: &auth_dsl::AuthMachineInput) -> &'static str {
886        match input {
887            auth_dsl::AuthMachineInput::Acquire { .. } => "acquire_lease",
888            auth_dsl::AuthMachineInput::MarkExpiring => "mark_expiring",
889            auth_dsl::AuthMachineInput::ObserveCredentialFreshness { .. } => {
890                "observe_credential_freshness"
891            }
892            auth_dsl::AuthMachineInput::BeginRefresh => "begin_refresh",
893            auth_dsl::AuthMachineInput::CompleteRefresh { .. } => "complete_refresh",
894            auth_dsl::AuthMachineInput::RefreshFailed { .. } => "refresh_failed",
895            auth_dsl::AuthMachineInput::MarkReauthRequired => "mark_reauth_required",
896            auth_dsl::AuthMachineInput::ClearCredentialLifecycle => "clear_credential_lifecycle",
897            auth_dsl::AuthMachineInput::ReleaseCredentialLifecycle => {
898                "release_credential_lifecycle"
899            }
900            auth_dsl::AuthMachineInput::BeginRelease => "begin_release_lease",
901            auth_dsl::AuthMachineInput::Release => "release_lease",
902            auth_dsl::AuthMachineInput::RestoreAuthoritySnapshot { .. } => {
903                "restore_authority_snapshot"
904            }
905            auth_dsl::AuthMachineInput::RestoreCredentialLifecycleSnapshot { .. } => {
906                "restore_credential_lifecycle_snapshot"
907            }
908            auth_dsl::AuthMachineInput::RestoreOAuthBrowserFlow { .. } => {
909                "restore_oauth_browser_flow"
910            }
911            auth_dsl::AuthMachineInput::RestoreOAuthDeviceFlow { .. } => {
912                "restore_oauth_device_flow"
913            }
914            auth_dsl::AuthMachineInput::RestoreOAuthDevicePoll { .. } => {
915                "restore_oauth_device_poll"
916            }
917            auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow { .. } => "admit_oauth_browser_flow",
918            auth_dsl::AuthMachineInput::VerifyOAuthBrowserFlow { .. } => {
919                "verify_oauth_browser_flow"
920            }
921            auth_dsl::AuthMachineInput::ConsumeOAuthBrowserFlow { .. } => {
922                "consume_oauth_browser_flow"
923            }
924            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow { .. } => {
925                "expire_oauth_browser_flow"
926            }
927            auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow { .. } => "admit_oauth_device_flow",
928            auth_dsl::AuthMachineInput::ConfirmOAuthDurableAdmission { .. } => {
929                "confirm_oauth_durable_admission"
930            }
931            auth_dsl::AuthMachineInput::VerifyOAuthDeviceFlow { .. } => "verify_oauth_device_flow",
932            auth_dsl::AuthMachineInput::BeginOAuthDevicePoll { .. } => "begin_oauth_device_poll",
933            auth_dsl::AuthMachineInput::FinishOAuthDevicePoll { .. } => "finish_oauth_device_poll",
934            auth_dsl::AuthMachineInput::ConsumeOAuthDeviceFlow { .. } => {
935                "consume_oauth_device_flow"
936            }
937            auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow { .. } => "expire_oauth_device_flow",
938            auth_dsl::AuthMachineInput::ResolveRefreshFailureDisposition { .. } => {
939                "resolve_refresh_failure_disposition"
940            }
941            auth_dsl::AuthMachineInput::ResolveCredentialUseAdmission { .. } => {
942                "resolve_credential_use_admission"
943            }
944            auth_dsl::AuthMachineInput::ResolveOAuthLoginCredentialDisposition { .. } => {
945                "resolve_oauth_login_credential_disposition"
946            }
947        }
948    }
949}
950
951/// Exhaustive 1-to-1 projection of the AuthMachine DSL's typed lifecycle
952/// phase into the cross-crate [`AuthLeasePhase`] contract. Compiler enforces
953/// completeness.
954fn map_phase(phase: auth_dsl::AuthLifecyclePhase) -> AuthLeasePhase {
955    match phase {
956        auth_dsl::AuthLifecyclePhase::Valid => AuthLeasePhase::Valid,
957        auth_dsl::AuthLifecyclePhase::Expiring => AuthLeasePhase::Expiring,
958        auth_dsl::AuthLifecyclePhase::Expired => AuthLeasePhase::Expired,
959        auth_dsl::AuthLifecyclePhase::Refreshing => AuthLeasePhase::Refreshing,
960        auth_dsl::AuthLifecyclePhase::ReauthRequired => AuthLeasePhase::ReauthRequired,
961        auth_dsl::AuthLifecyclePhase::Released => AuthLeasePhase::Released,
962    }
963}
964
965fn credential_use_intent_to_dsl(
966    intent: meerkat_core::handles::CredentialUseIntent,
967) -> auth_dsl::CredentialUseIntent {
968    match intent {
969        meerkat_core::handles::CredentialUseIntent::UseCredential => {
970            auth_dsl::CredentialUseIntent::UseCredential
971        }
972        meerkat_core::handles::CredentialUseIntent::HoldAuthority => {
973            auth_dsl::CredentialUseIntent::HoldAuthority
974        }
975        meerkat_core::handles::CredentialUseIntent::BeginRefresh => {
976            auth_dsl::CredentialUseIntent::BeginRefresh
977        }
978    }
979}
980
981fn credential_use_disposition_from_dsl(
982    disposition: auth_dsl::CredentialUseDisposition,
983) -> meerkat_core::handles::CredentialUseDisposition {
984    match disposition {
985        auth_dsl::CredentialUseDisposition::Authorized => {
986            meerkat_core::handles::CredentialUseDisposition::Authorized
987        }
988        auth_dsl::CredentialUseDisposition::RefreshRequired => {
989            meerkat_core::handles::CredentialUseDisposition::RefreshRequired
990        }
991        auth_dsl::CredentialUseDisposition::RefreshDisallowed => {
992            meerkat_core::handles::CredentialUseDisposition::RefreshDisallowed
993        }
994        auth_dsl::CredentialUseDisposition::ReauthRequired => {
995            meerkat_core::handles::CredentialUseDisposition::ReauthRequired
996        }
997        auth_dsl::CredentialUseDisposition::LeaseAbsent => {
998            meerkat_core::handles::CredentialUseDisposition::LeaseAbsent
999        }
1000        auth_dsl::CredentialUseDisposition::AlreadyRefreshing => {
1001            meerkat_core::handles::CredentialUseDisposition::AlreadyRefreshing
1002        }
1003    }
1004}
1005
1006fn refresh_failure_disposition_to_dsl(
1007    disposition: RefreshFailureDisposition,
1008) -> auth_dsl::RefreshFailureDisposition {
1009    match disposition {
1010        RefreshFailureDisposition::Transient => auth_dsl::RefreshFailureDisposition::Transient,
1011        RefreshFailureDisposition::ReauthRequired => {
1012            auth_dsl::RefreshFailureDisposition::ReauthRequired
1013        }
1014    }
1015}
1016
1017fn refresh_failure_disposition_from_dsl(
1018    disposition: auth_dsl::RefreshFailureDisposition,
1019) -> RefreshFailureDisposition {
1020    match disposition {
1021        auth_dsl::RefreshFailureDisposition::Transient => RefreshFailureDisposition::Transient,
1022        auth_dsl::RefreshFailureDisposition::ReauthRequired => {
1023            RefreshFailureDisposition::ReauthRequired
1024        }
1025    }
1026}
1027
1028fn restore_phase(phase: AuthLeasePhase) -> auth_dsl::AuthLifecyclePhase {
1029    match phase {
1030        AuthLeasePhase::Valid => auth_dsl::AuthLifecyclePhase::Valid,
1031        AuthLeasePhase::Expiring => auth_dsl::AuthLifecyclePhase::Expiring,
1032        AuthLeasePhase::Expired => auth_dsl::AuthLifecyclePhase::Expired,
1033        AuthLeasePhase::Refreshing => auth_dsl::AuthLifecyclePhase::Refreshing,
1034        AuthLeasePhase::ReauthRequired => auth_dsl::AuthLifecyclePhase::ReauthRequired,
1035        AuthLeasePhase::Released => auth_dsl::AuthLifecyclePhase::Released,
1036    }
1037}
1038
1039impl Default for RuntimeAuthLeaseHandle {
1040    fn default() -> Self {
1041        Self::new()
1042    }
1043}
1044
1045impl AuthLeaseHandle for RuntimeAuthLeaseHandle {
1046    fn acquire_lease(
1047        &self,
1048        lease_key: &LeaseKey,
1049        expires_at: u64,
1050    ) -> Result<AuthLeaseTransition, DslTransitionError> {
1051        let expires_at_ts = if expires_at == u64::MAX {
1052            None
1053        } else {
1054            Some(expires_at)
1055        };
1056        self.apply(
1057            lease_key,
1058            auth_dsl::AuthMachineInput::Acquire {
1059                expires_at_ts,
1060                credential_published_at_millis: current_time_millis(),
1061            },
1062            "AuthLeaseHandle::acquire_lease",
1063            true,
1064        )
1065    }
1066
1067    fn mark_expiring(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1068        self.apply(
1069            lease_key,
1070            auth_dsl::AuthMachineInput::MarkExpiring,
1071            "AuthLeaseHandle::mark_expiring",
1072            false,
1073        )
1074        .map(|_| ())
1075    }
1076
1077    fn observe_credential_freshness(
1078        &self,
1079        lease_key: &LeaseKey,
1080        now: u64,
1081        refresh_window_secs: u64,
1082    ) -> Result<(), DslTransitionError> {
1083        #[allow(clippy::unwrap_used)]
1084        if !self
1085            .machines
1086            .lock()
1087            .unwrap()
1088            .authorities
1089            .contains_key(lease_key)
1090        {
1091            return Ok(());
1092        }
1093        self.apply(
1094            lease_key,
1095            auth_dsl::AuthMachineInput::ObserveCredentialFreshness {
1096                now_ts: now,
1097                refresh_window_secs,
1098            },
1099            "AuthLeaseHandle::observe_credential_freshness",
1100            false,
1101        )
1102        .map(|_| ())
1103    }
1104
1105    fn begin_refresh(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1106        self.apply(
1107            lease_key,
1108            auth_dsl::AuthMachineInput::BeginRefresh,
1109            "AuthLeaseHandle::begin_refresh",
1110            false,
1111        )
1112        .map(|_| ())
1113    }
1114
1115    fn complete_refresh(
1116        &self,
1117        lease_key: &LeaseKey,
1118        new_expires_at: u64,
1119        now: u64,
1120    ) -> Result<AuthLeaseTransition, DslTransitionError> {
1121        let new_expires_at = if new_expires_at == u64::MAX {
1122            None
1123        } else {
1124            Some(new_expires_at)
1125        };
1126        self.apply(
1127            lease_key,
1128            auth_dsl::AuthMachineInput::CompleteRefresh {
1129                new_expires_at,
1130                now_ts: now,
1131                credential_published_at_millis: current_time_millis(),
1132            },
1133            "AuthLeaseHandle::complete_refresh",
1134            false,
1135        )
1136    }
1137
1138    fn refresh_failed(
1139        &self,
1140        lease_key: &LeaseKey,
1141        observation: RefreshFailureObservation,
1142    ) -> Result<(), DslTransitionError> {
1143        let disposition = self.resolve_refresh_failure_disposition(lease_key, observation)?;
1144        let input = auth_dsl::AuthMachineInput::RefreshFailed {
1145            disposition: refresh_failure_disposition_to_dsl(disposition),
1146        };
1147        self.apply(lease_key, input, "AuthLeaseHandle::refresh_failed", false)
1148            .map(|_| ())
1149    }
1150
1151    fn mark_reauth_required(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1152        self.apply(
1153            lease_key,
1154            auth_dsl::AuthMachineInput::MarkReauthRequired,
1155            "AuthLeaseHandle::mark_reauth_required",
1156            false,
1157        )
1158        .map(|_| ())
1159    }
1160
1161    fn release_lease(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1162        let context = "AuthLeaseHandle::release_lease";
1163        // Two-phase machine-owned release drain (D1).
1164        //
1165        // Phase 1 — BeginRelease: fire the machine-owned drain input to record
1166        // release intent and obtain the typed CancelOAuthFlowsForRelease
1167        // obligation carrying the in-flight membership. Only fires if a local
1168        // machine exists; the stale-authority case (no local machine) has an
1169        // empty obligation and relies entirely on the observer's durable-store
1170        // scan (phase 2) for cleanup.
1171        //
1172        // Phase 2 — observer collect: gather registry-payload-only ids from the
1173        // durable store. Merged with the machine-emitted obligation so the
1174        // observer's auth_lease_released prune covers both the machine-owned
1175        // flows and any flows admitted by a stale authority that the local
1176        // machine never saw.
1177        //
1178        // Fail-closed ordering: observer notify (phase 3) and drain discharge
1179        // (phase 4) are both fallible and both precede the Release commit
1180        // (phase 5). An error in either aborts the release — the machine stays
1181        // in release_draining (or un-created in the stale case) and the caller
1182        // retries. Retried release re-fires BeginRelease which re-emits the
1183        // remaining membership (idempotent). No machine entry is synthesized in
1184        // the registry for the stale-authority path until Release commits.
1185        #[cfg(not(target_arch = "wasm32"))]
1186        let release_observers = self.live_release_observers();
1187        #[cfg(not(target_arch = "wasm32"))]
1188        let release_permits = release_observers
1189            .iter()
1190            .map(|observer| observer.begin_auth_lease_release(lease_key))
1191            .collect::<Result<Vec<_>, _>>()?;
1192        #[cfg(not(target_arch = "wasm32"))]
1193        {
1194            // Phase 1: BeginRelease under the machines lock.
1195            // Extract the machine-owned drain obligation (empty for stale authority).
1196            let machine_drain_obligation = {
1197                let mut guard = self
1198                    .machines
1199                    .lock()
1200                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1201                if let Some(entry) = guard.authorities.get_mut(lease_key) {
1202                    let transition = auth_dsl::AuthMachineMutator::apply(
1203                        entry,
1204                        auth_dsl::AuthMachineInput::BeginRelease,
1205                    )
1206                    .map_err(|err| map_auth_machine_error(err, context))?;
1207                    // BeginRelease emits no EmitLifecycleEvent; use the
1208                    // maybe_-variant (strict variant errors on missing
1209                    // publication which BeginRelease legitimately omits).
1210                    maybe_auth_lease_transition_from_generated_publication(
1211                        lease_key,
1212                        entry,
1213                        &transition,
1214                        context,
1215                    )?;
1216                    crate::protocol_auth_release_oauth_flow_drain::extract_obligations(&transition)
1217                        .into_iter()
1218                        .next()
1219                } else {
1220                    None
1221                }
1222            };
1223
1224            // Phase 2: Observer-only durable-store scan (stale-authority
1225            // flows the local machine never admitted). Merged with the
1226            // machine-emitted obligation so observers see the full prune set.
1227            let mut released =
1228                self.collect_release_observer_flows(&release_observers, lease_key)?;
1229            if let Some(ref obligation) = machine_drain_obligation {
1230                released
1231                    .browser_flow_ids
1232                    .extend(obligation.browser_flow_ids.iter().cloned());
1233                released
1234                    .device_flow_ids
1235                    .extend(obligation.device_flow_ids.iter().cloned());
1236            }
1237            released.dedup();
1238
1239            // Phase 3: Notify observers — prune payloads from registry and
1240            // durable store. Fallible: abort on error, machine stays draining.
1241            self.notify_release_observers(&release_observers, &released)?;
1242
1243            // Phase 4: Discharge machine-owned drain obligations.
1244            // Fire typed terminal Expire* feedback per drained flow id.
1245            // Each is total (Released no-op + Absent no-op exist); Err is
1246            // a genuine fault (machine absent/poisoned), propagated.
1247            if let Some(obligation) = machine_drain_obligation {
1248                let mut guard = self
1249                    .machines
1250                    .lock()
1251                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1252                if let Some(entry) = guard.authorities.get_mut(lease_key) {
1253                    for flow_id in obligation.browser_flow_ids {
1254                        let transition = auth_dsl::AuthMachineMutator::apply(
1255                            entry,
1256                            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow { flow_id },
1257                        )
1258                        .map_err(|err| map_auth_machine_error(err, context))?;
1259                        maybe_auth_lease_transition_from_generated_publication(
1260                            lease_key,
1261                            entry,
1262                            &transition,
1263                            context,
1264                        )?;
1265                    }
1266                    for flow_id in obligation.device_flow_ids {
1267                        let transition = auth_dsl::AuthMachineMutator::apply(
1268                            entry,
1269                            auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow { flow_id },
1270                        )
1271                        .map_err(|err| map_auth_machine_error(err, context))?;
1272                        maybe_auth_lease_transition_from_generated_publication(
1273                            lease_key,
1274                            entry,
1275                            &transition,
1276                            context,
1277                        )?;
1278                    }
1279                }
1280            }
1281        }
1282        #[cfg(test)]
1283        run_release_before_commit_hook(lease_key);
1284        // Phase 5: Commit — Release transition through generated machine
1285        // authority. The oauth_release_drained guard passes (count == 0 after
1286        // the drain discharge above). For the stale-authority path (no local
1287        // machine), or_insert_with creates a fresh machine whose initial
1288        // count is already 0. Nothing fallible follows the Release commit.
1289        let (from_phase, to_phase) = {
1290            let mut guard = self
1291                .machines
1292                .lock()
1293                .unwrap_or_else(std::sync::PoisonError::into_inner);
1294            let entry = guard
1295                .authorities
1296                .entry(lease_key.clone())
1297                .or_insert_with(auth_dsl::AuthMachineAuthority::new);
1298            let from_phase = map_phase(entry.state().lifecycle_phase);
1299            let transition =
1300                auth_dsl::AuthMachineMutator::apply(entry, auth_dsl::AuthMachineInput::Release)
1301                    .map_err(|err| map_auth_machine_error(err, context))?;
1302            auth_lease_transition_from_generated_publication(
1303                lease_key,
1304                entry,
1305                &transition,
1306                context,
1307            )?;
1308            let to_phase = map_phase(entry.state().lifecycle_phase);
1309            (from_phase, to_phase)
1310        };
1311        #[cfg(not(target_arch = "wasm32"))]
1312        drop(release_permits);
1313        #[cfg(not(target_arch = "wasm32"))]
1314        drop(release_observers);
1315        emit_audit(lease_key, "release_lease", from_phase, to_phase);
1316        #[cfg(test)]
1317        run_release_after_accept_hook(lease_key);
1318        Ok(())
1319    }
1320
1321    fn release_credential_lifecycle(&self, lease_key: &LeaseKey) -> Result<(), DslTransitionError> {
1322        let context = "AuthLeaseHandle::release_credential_lifecycle";
1323        let mut guard = self
1324            .machines
1325            .lock()
1326            .unwrap_or_else(std::sync::PoisonError::into_inner);
1327        let (input, from_phase, to_phase) = {
1328            let entry = guard
1329                .authorities
1330                .entry(lease_key.clone())
1331                .or_insert_with(auth_dsl::AuthMachineAuthority::new);
1332            let input = auth_dsl::AuthMachineInput::ReleaseCredentialLifecycle;
1333            let from_phase = map_phase(entry.state().lifecycle_phase);
1334            let transition = auth_dsl::AuthMachineMutator::apply(entry, input.clone())
1335                .map_err(|err| map_auth_machine_error(err, context))?;
1336            auth_lease_transition_from_generated_publication(
1337                lease_key,
1338                entry,
1339                &transition,
1340                context,
1341            )?;
1342            let to_phase = map_phase(entry.state().lifecycle_phase);
1343            (input, from_phase, to_phase)
1344        };
1345        emit_audit(
1346            lease_key,
1347            Self::audit_action_for(&input),
1348            from_phase,
1349            to_phase,
1350        );
1351        Ok(())
1352    }
1353
1354    fn restore_auth_lifecycle_snapshot(
1355        &self,
1356        captured: &AuthLeaseRestoreSnapshot,
1357    ) -> Result<Option<AuthLeaseTransition>, DslTransitionError> {
1358        if captured.captured_by_type_id() != std::any::TypeId::of::<RuntimeAuthLeaseHandle>()
1359            || captured.captured_by_instance_id() != self.auth_lifecycle_restore_instance_id()
1360        {
1361            return Err(DslTransitionError::no_matching(
1362                "AuthLeaseHandle::restore_auth_lifecycle_snapshot",
1363                "auth lifecycle restore snapshot was not captured from this RuntimeAuthLeaseHandle",
1364            ));
1365        }
1366        let lease_key = captured.lease_key();
1367        let snapshot = captured.snapshot();
1368        let mut guard = self
1369            .machines
1370            .lock()
1371            .unwrap_or_else(std::sync::PoisonError::into_inner);
1372        let from_phase = guard
1373            .authorities
1374            .get(lease_key)
1375            .map(|authority| map_phase(authority.state().lifecycle_phase))
1376            .unwrap_or(AuthLeasePhase::Released);
1377        let (to_phase, auth_transition) = apply_restore_input_to_registry(
1378            &mut guard,
1379            lease_key,
1380            restore_credential_lifecycle_snapshot_input(
1381                snapshot.phase.map(restore_phase),
1382                snapshot.expires_at,
1383                None,
1384                0,
1385                snapshot.credential_present,
1386                snapshot.generation,
1387                snapshot.credential_published_at_millis,
1388                false,
1389            ),
1390            "AuthLeaseHandle::restore_auth_lifecycle_snapshot",
1391        )?;
1392        emit_audit(
1393            lease_key,
1394            "restore_auth_lifecycle_snapshot",
1395            from_phase,
1396            to_phase,
1397        );
1398        if snapshot.credential_present
1399            && snapshot.phase.is_some()
1400            && snapshot.phase != Some(AuthLeasePhase::Released)
1401        {
1402            Ok(Some(auth_transition))
1403        } else {
1404            Ok(None)
1405        }
1406    }
1407
1408    fn auth_lifecycle_restore_instance_id(&self) -> usize {
1409        Arc::as_ptr(&self.machines) as usize
1410    }
1411
1412    fn restore_published_credential_lifecycle(
1413        &self,
1414        lease_key: &LeaseKey,
1415        publication: &AuthLeaseDurableRestorePublication,
1416    ) -> Result<AuthLeaseTransition, DslTransitionError> {
1417        let context = "AuthLeaseHandle::restore_published_credential_lifecycle";
1418        let lease_token_key = TokenKey::new_with_profile(
1419            lease_key.realm.clone(),
1420            lease_key.binding.clone(),
1421            lease_key.profile.clone(),
1422        );
1423        if publication.token_key() != &lease_token_key {
1424            return Err(DslTransitionError::no_matching(
1425                context,
1426                "durable auth lifecycle marker identity does not match restore lease key",
1427            ));
1428        }
1429        let mut guard = self
1430            .machines
1431            .lock()
1432            .unwrap_or_else(std::sync::PoisonError::into_inner);
1433        let from_phase = guard
1434            .authorities
1435            .get(lease_key)
1436            .map(|authority| map_phase(authority.state().lifecycle_phase))
1437            .unwrap_or(AuthLeasePhase::Released);
1438        let (to_phase, auth_transition) = apply_restore_input_to_registry(
1439            &mut guard,
1440            lease_key,
1441            restore_input_from_lifecycle(
1442                restore_phase_to_dsl(publication.phase()),
1443                (publication.expires_at() != u64::MAX).then_some(publication.expires_at()),
1444                None,
1445                0,
1446                publication.phase() != AuthLeasePhase::Released,
1447                publication.generation(),
1448                Some(publication.credential_published_at_millis()),
1449            ),
1450            context,
1451        )?;
1452        emit_audit(
1453            lease_key,
1454            "restore_published_credential_lifecycle",
1455            from_phase,
1456            to_phase,
1457        );
1458        Ok(auth_transition)
1459    }
1460
1461    fn resolve_refresh_failure_disposition(
1462        &self,
1463        lease_key: &LeaseKey,
1464        observation: RefreshFailureObservation,
1465    ) -> Result<RefreshFailureDisposition, DslTransitionError> {
1466        const CONTEXT: &str = "AuthLeaseHandle::resolve_refresh_failure_disposition";
1467        let guard = self
1468            .machines
1469            .lock()
1470            .unwrap_or_else(std::sync::PoisonError::into_inner);
1471        let authority = guard.authorities.get(lease_key).ok_or_else(|| {
1472            DslTransitionError::no_matching(
1473                CONTEXT,
1474                format!("no AuthMachine authority exists for `{lease_key}`"),
1475            )
1476        })?;
1477        // Read-only classification: apply to recovered authority so neither
1478        // lifecycle state nor the registry changes before durable cleanup.
1479        let mut transient =
1480            auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
1481                .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1482        let transition = auth_dsl::AuthMachineMutator::apply(
1483            &mut transient,
1484            auth_dsl::AuthMachineInput::ResolveRefreshFailureDisposition {
1485                http_status: observation.http_status,
1486                oauth_error_code: observation.oauth_error_code,
1487                local_credential_unusable: observation.local_credential_unusable,
1488            },
1489        )
1490        .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1491
1492        let mut resolved = None;
1493        for effect in transition.effects() {
1494            if let auth_dsl::AuthMachineEffect::RefreshFailureDispositionResolved { disposition } =
1495                effect
1496                && resolved.replace(*disposition).is_some()
1497            {
1498                return Err(DslTransitionError::no_matching(
1499                    CONTEXT,
1500                    format!(
1501                        "AuthMachine emitted multiple refresh-failure dispositions for `{lease_key}`"
1502                    ),
1503                ));
1504            }
1505        }
1506
1507        resolved
1508            .map(refresh_failure_disposition_from_dsl)
1509            .ok_or_else(|| {
1510                DslTransitionError::no_matching(
1511                    CONTEXT,
1512                    format!("AuthMachine emitted no refresh-failure disposition for `{lease_key}`"),
1513                )
1514            })
1515    }
1516
1517    fn resolve_credential_use_admission(
1518        &self,
1519        lease_key: &LeaseKey,
1520        intent: meerkat_core::handles::CredentialUseIntent,
1521    ) -> Result<meerkat_core::handles::CredentialUseDisposition, DslTransitionError> {
1522        const CONTEXT: &str = "AuthLeaseHandle::resolve_credential_use_admission";
1523        let guard = self
1524            .machines
1525            .lock()
1526            .unwrap_or_else(std::sync::PoisonError::into_inner);
1527        // No registered lease for this binding mirrors the resolver's prior
1528        // credential-filtered `None` phase: the lease is absent.
1529        let Some(authority) = guard.authorities.get(lease_key) else {
1530            return Ok(meerkat_core::handles::CredentialUseDisposition::LeaseAbsent);
1531        };
1532        // Read-only classification: recover a transient authority over the live
1533        // state so the registry is never mutated and no lifecycle audit fires.
1534        let mut transient =
1535            auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
1536                .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1537        let transition = auth_dsl::AuthMachineMutator::apply(
1538            &mut transient,
1539            auth_dsl::AuthMachineInput::ResolveCredentialUseAdmission {
1540                intent: credential_use_intent_to_dsl(intent),
1541            },
1542        )
1543        .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1544
1545        let mut resolved = None;
1546        for effect in transition.effects() {
1547            if let auth_dsl::AuthMachineEffect::CredentialUseAdmissionResolved { disposition } =
1548                effect
1549                && resolved.replace(*disposition).is_some()
1550            {
1551                return Err(DslTransitionError::no_matching(
1552                    CONTEXT,
1553                    format!(
1554                        "AuthMachine emitted multiple credential-use dispositions for `{lease_key}`"
1555                    ),
1556                ));
1557            }
1558        }
1559
1560        resolved
1561            .map(credential_use_disposition_from_dsl)
1562            .ok_or_else(|| {
1563                DslTransitionError::no_matching(
1564                    CONTEXT,
1565                    format!("AuthMachine emitted no credential-use disposition for `{lease_key}`"),
1566                )
1567            })
1568    }
1569
1570    fn resolve_oauth_login_credential_disposition(
1571        &self,
1572        lease_key: &LeaseKey,
1573        facts: meerkat_core::handles::OAuthLoginCredentialFacts,
1574    ) -> Result<meerkat_core::handles::CredentialUseDisposition, DslTransitionError> {
1575        const CONTEXT: &str = "AuthLeaseHandle::resolve_oauth_login_credential_disposition";
1576        let guard = self
1577            .machines
1578            .lock()
1579            .unwrap_or_else(std::sync::PoisonError::into_inner);
1580        // No registered lease for this binding mirrors the resolver's prior
1581        // credential-filtered `None` phase: the lease is absent.
1582        let Some(authority) = guard.authorities.get(lease_key) else {
1583            return Ok(meerkat_core::handles::CredentialUseDisposition::LeaseAbsent);
1584        };
1585        // Read-only classification: recover a transient authority over the live
1586        // state so the registry is never mutated and no lifecycle audit fires.
1587        let mut transient =
1588            auth_dsl::AuthMachineAuthority::recover_from_state(authority.state().clone())
1589                .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1590        let transition = auth_dsl::AuthMachineMutator::apply(
1591            &mut transient,
1592            auth_dsl::AuthMachineInput::ResolveOAuthLoginCredentialDisposition {
1593                credential_present: facts.credential_present,
1594                force_refresh: facts.force_refresh,
1595                refresh_allowed: facts.refresh_allowed,
1596            },
1597        )
1598        .map_err(|err| map_auth_machine_error(err, CONTEXT))?;
1599
1600        let mut resolved = None;
1601        for effect in transition.effects() {
1602            if let auth_dsl::AuthMachineEffect::CredentialUseAdmissionResolved { disposition } =
1603                effect
1604                && resolved.replace(*disposition).is_some()
1605            {
1606                return Err(DslTransitionError::no_matching(
1607                    CONTEXT,
1608                    format!(
1609                        "AuthMachine emitted multiple OAuth-login credential dispositions for `{lease_key}`"
1610                    ),
1611                ));
1612            }
1613        }
1614
1615        resolved
1616            .map(credential_use_disposition_from_dsl)
1617            .ok_or_else(|| {
1618                DslTransitionError::no_matching(
1619                    CONTEXT,
1620                    format!(
1621                        "AuthMachine emitted no OAuth-login credential disposition for `{lease_key}`"
1622                    ),
1623                )
1624            })
1625    }
1626
1627    fn snapshot(&self, lease_key: &LeaseKey) -> AuthLeaseSnapshot {
1628        let guard = self
1629            .machines
1630            .lock()
1631            .unwrap_or_else(std::sync::PoisonError::into_inner);
1632        match guard.authorities.get(lease_key) {
1633            Some(machine) => {
1634                let state = machine.state();
1635                let phase = (state.lifecycle_phase != auth_dsl::AuthLifecyclePhase::Released)
1636                    .then(|| map_phase(state.lifecycle_phase));
1637                AuthLeaseSnapshot {
1638                    phase,
1639                    expires_at: state.expires_at,
1640                    credential_present: state.credential_present,
1641                    generation: state.credential_generation,
1642                    credential_published_at_millis: state
1643                        .credential_present
1644                        .then_some(state.credential_published_at_millis)
1645                        .flatten(),
1646                }
1647            }
1648            None => AuthLeaseSnapshot {
1649                phase: None,
1650                expires_at: None,
1651                credential_present: false,
1652                generation: 0,
1653                credential_published_at_millis: None,
1654            },
1655        }
1656    }
1657}
1658
1659#[cfg(test)]
1660#[allow(clippy::unwrap_used, clippy::expect_used)]
1661mod tests {
1662    use super::*;
1663    use meerkat_core::connection::{BindingId, BindingOrigin, RealmId};
1664
1665    fn lease(realm: &str, binding: &str) -> LeaseKey {
1666        LeaseKey::new(
1667            RealmId::parse(realm).expect("valid realm"),
1668            BindingId::parse(binding).expect("valid binding"),
1669            None,
1670        )
1671    }
1672
1673    #[cfg(not(target_arch = "wasm32"))]
1674    fn auth_binding(realm: &str, binding: &str) -> AuthBindingRef {
1675        AuthBindingRef {
1676            realm: RealmId::parse(realm).expect("valid realm"),
1677            binding: BindingId::parse(binding).expect("valid binding"),
1678            profile: None,
1679            origin: BindingOrigin::Configured,
1680        }
1681    }
1682
1683    fn empty_auth_state() -> auth_dsl::AuthMachineState {
1684        auth_dsl::AuthMachineAuthority::new().state().clone()
1685    }
1686
1687    #[test]
1688    fn acquire_and_snapshot_roundtrip() {
1689        let h = RuntimeAuthLeaseHandle::new();
1690        let key = lease("dev", "default_openai");
1691        h.acquire_lease(&key, 1_800_000_000).unwrap();
1692        let snap = h.snapshot(&key);
1693        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1694        assert_eq!(snap.expires_at, Some(1_800_000_000));
1695    }
1696
1697    #[test]
1698    fn lifecycle_transitions() {
1699        let h = RuntimeAuthLeaseHandle::new();
1700        let k = lease("dev", "default_anthropic");
1701
1702        h.acquire_lease(&k, 1_800_000_000).unwrap();
1703        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Valid));
1704
1705        h.mark_expiring(&k).unwrap();
1706        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1707
1708        h.begin_refresh(&k).unwrap();
1709        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1710
1711        h.complete_refresh(&k, 1_800_000_900, 1_800_000_000)
1712            .unwrap();
1713        let snap = h.snapshot(&k);
1714        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
1715        assert_eq!(snap.expires_at, Some(1_800_000_900));
1716    }
1717
1718    #[test]
1719    fn credential_use_admission_is_decided_by_authmachine() {
1720        use meerkat_core::handles::CredentialUseDisposition as Disp;
1721        use meerkat_core::handles::CredentialUseIntent as Intent;
1722
1723        let h = RuntimeAuthLeaseHandle::new();
1724        let k = lease("dev", "creduse_openai");
1725
1726        // No registered lease -> LeaseAbsent for every intent.
1727        for intent in [
1728            Intent::UseCredential,
1729            Intent::HoldAuthority,
1730            Intent::BeginRefresh,
1731        ] {
1732            assert_eq!(
1733                h.resolve_credential_use_admission(&k, intent).unwrap(),
1734                Disp::LeaseAbsent,
1735                "unregistered lease must classify LeaseAbsent for {intent:?}"
1736            );
1737        }
1738
1739        // Valid + credential present.
1740        h.acquire_lease(&k, 1_800_000_000).unwrap();
1741        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Valid));
1742        assert_eq!(
1743            h.resolve_credential_use_admission(&k, Intent::UseCredential)
1744                .unwrap(),
1745            Disp::Authorized
1746        );
1747        assert_eq!(
1748            h.resolve_credential_use_admission(&k, Intent::HoldAuthority)
1749                .unwrap(),
1750            Disp::Authorized
1751        );
1752        assert_eq!(
1753            h.resolve_credential_use_admission(&k, Intent::BeginRefresh)
1754                .unwrap(),
1755            Disp::RefreshRequired
1756        );
1757
1758        // Expiring: UseCredential -> RefreshRequired, HoldAuthority -> Authorized,
1759        // BeginRefresh -> RefreshRequired.
1760        h.mark_expiring(&k).unwrap();
1761        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
1762        assert_eq!(
1763            h.resolve_credential_use_admission(&k, Intent::UseCredential)
1764                .unwrap(),
1765            Disp::RefreshRequired
1766        );
1767        assert_eq!(
1768            h.resolve_credential_use_admission(&k, Intent::HoldAuthority)
1769                .unwrap(),
1770            Disp::Authorized
1771        );
1772        assert_eq!(
1773            h.resolve_credential_use_admission(&k, Intent::BeginRefresh)
1774                .unwrap(),
1775            Disp::RefreshRequired
1776        );
1777
1778        // Refreshing: UseCredential -> RefreshRequired, HoldAuthority -> Authorized,
1779        // BeginRefresh -> AlreadyRefreshing.
1780        h.begin_refresh(&k).unwrap();
1781        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1782        assert_eq!(
1783            h.resolve_credential_use_admission(&k, Intent::UseCredential)
1784                .unwrap(),
1785            Disp::RefreshRequired
1786        );
1787        assert_eq!(
1788            h.resolve_credential_use_admission(&k, Intent::HoldAuthority)
1789                .unwrap(),
1790            Disp::Authorized
1791        );
1792        assert_eq!(
1793            h.resolve_credential_use_admission(&k, Intent::BeginRefresh)
1794                .unwrap(),
1795            Disp::AlreadyRefreshing
1796        );
1797
1798        // Expired: every intent -> RefreshRequired.
1799        let kx = lease("dev", "creduse_expired");
1800        h.acquire_lease(&kx, 1_800_000_000).unwrap();
1801        h.observe_credential_freshness(&kx, 1_800_000_001, 60)
1802            .unwrap();
1803        assert_eq!(h.snapshot(&kx).phase, Some(AuthLeasePhase::Expired));
1804        for intent in [
1805            Intent::UseCredential,
1806            Intent::HoldAuthority,
1807            Intent::BeginRefresh,
1808        ] {
1809            assert_eq!(
1810                h.resolve_credential_use_admission(&kx, intent).unwrap(),
1811                Disp::RefreshRequired,
1812                "expired lease must classify RefreshRequired for {intent:?}"
1813            );
1814        }
1815
1816        // ReauthRequired: every intent -> ReauthRequired.
1817        let kr = lease("dev", "creduse_reauth");
1818        h.acquire_lease(&kr, 1_800_000_000).unwrap();
1819        h.mark_reauth_required(&kr).unwrap();
1820        assert_eq!(h.snapshot(&kr).phase, Some(AuthLeasePhase::ReauthRequired));
1821        for intent in [
1822            Intent::UseCredential,
1823            Intent::HoldAuthority,
1824            Intent::BeginRefresh,
1825        ] {
1826            assert_eq!(
1827                h.resolve_credential_use_admission(&kr, intent).unwrap(),
1828                Disp::ReauthRequired,
1829                "reauth-required lease must classify ReauthRequired for {intent:?}"
1830            );
1831        }
1832
1833        // Released: every intent -> LeaseAbsent.
1834        let kl = lease("dev", "creduse_released");
1835        h.acquire_lease(&kl, 1_800_000_000).unwrap();
1836        h.release_lease(&kl).unwrap();
1837        for intent in [
1838            Intent::UseCredential,
1839            Intent::HoldAuthority,
1840            Intent::BeginRefresh,
1841        ] {
1842            assert_eq!(
1843                h.resolve_credential_use_admission(&kl, intent).unwrap(),
1844                Disp::LeaseAbsent,
1845                "released lease must classify LeaseAbsent for {intent:?}"
1846            );
1847        }
1848
1849        // Classification is read-only: the live phase is unchanged after probing.
1850        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1851    }
1852
1853    // P0 Dogma Invariant 1, FOLD 2: the OAuth-login cached-vs-refresh
1854    // disposition is now owned by the AuthMachine
1855    // `ResolveOAuthLoginCredentialDisposition` classifier. This test pins EXACT
1856    // behavioral parity with the prior provider-runtime shell composite
1857    // (`lifecycle == Authorized && primary_secret.is_some() && !force_refresh`
1858    // use-cached; else `refresh_allowed ? begin-refresh : Err(RefreshRequired)`)
1859    // for every combination of phase, persisted-secret presence, force_refresh,
1860    // and refresh_allowed — including the four required cases (cached-use;
1861    // force_refresh -> refresh; lifecycle RefreshRequired -> refresh;
1862    // refresh-not-allowed -> Err).
1863    #[test]
1864    fn oauth_login_credential_disposition_matches_legacy_composite() {
1865        use meerkat_core::handles::CredentialUseDisposition as Disp;
1866        use meerkat_core::handles::OAuthLoginCredentialFacts;
1867
1868        // Drive a lease into each lifecycle phase and return its handle/key. The
1869        // machine's own `credential_present` is true in all of these (acquired
1870        // leases carry a published credential), exactly as in production where
1871        // the provider composite is only reached for credential-bearing leases.
1872        fn lease_in_phase(
1873            phase: AuthLeasePhase,
1874            binding: &str,
1875        ) -> (RuntimeAuthLeaseHandle, LeaseKey) {
1876            let h = RuntimeAuthLeaseHandle::new();
1877            let k = lease("dev", binding);
1878            h.acquire_lease(&k, 1_800_000_000).unwrap();
1879            match phase {
1880                AuthLeasePhase::Valid => {}
1881                AuthLeasePhase::Expiring => h.mark_expiring(&k).unwrap(),
1882                AuthLeasePhase::Expired => {
1883                    h.observe_credential_freshness(&k, 1_800_000_001, 60)
1884                        .unwrap();
1885                }
1886                AuthLeasePhase::Refreshing => h.begin_refresh(&k).unwrap(),
1887                AuthLeasePhase::ReauthRequired => h.mark_reauth_required(&k).unwrap(),
1888                AuthLeasePhase::Released => h.release_lease(&k).unwrap(),
1889            }
1890            (h, k)
1891        }
1892
1893        let phases = [
1894            AuthLeasePhase::Valid,
1895            AuthLeasePhase::Expiring,
1896            AuthLeasePhase::Expired,
1897            AuthLeasePhase::Refreshing,
1898            AuthLeasePhase::ReauthRequired,
1899            AuthLeasePhase::Released,
1900        ];
1901        let mut counter = 0u32;
1902        for phase in phases {
1903            for credential_present in [false, true] {
1904                for force_refresh in [false, true] {
1905                    for refresh_allowed in [false, true] {
1906                        counter += 1;
1907                        let (h, k) = lease_in_phase(phase, &format!("oauthdisp_{counter}"));
1908                        let got = h
1909                            .resolve_oauth_login_credential_disposition(
1910                                &k,
1911                                OAuthLoginCredentialFacts {
1912                                    credential_present,
1913                                    force_refresh,
1914                                    refresh_allowed,
1915                                },
1916                            )
1917                            .unwrap();
1918
1919                        // Reference composite: use-cached iff lifecycle==Authorized
1920                        // (phase Valid with the machine's published credential) AND
1921                        // a persisted secret is present AND not force-refreshing;
1922                        // else refresh_allowed ? begin-refresh : refresh-disallowed.
1923                        let use_cached =
1924                            phase == AuthLeasePhase::Valid && credential_present && !force_refresh;
1925                        let expected = if use_cached {
1926                            Disp::Authorized
1927                        } else if refresh_allowed {
1928                            Disp::RefreshRequired
1929                        } else {
1930                            Disp::RefreshDisallowed
1931                        };
1932                        assert_eq!(
1933                            got, expected,
1934                            "phase={phase:?} cred_present={credential_present} \
1935                             force_refresh={force_refresh} refresh_allowed={refresh_allowed}"
1936                        );
1937                    }
1938                }
1939            }
1940        }
1941
1942        // Unregistered lease -> LeaseAbsent regardless of observations.
1943        let h = RuntimeAuthLeaseHandle::new();
1944        let absent = lease("dev", "oauthdisp_absent");
1945        assert_eq!(
1946            h.resolve_oauth_login_credential_disposition(
1947                &absent,
1948                OAuthLoginCredentialFacts {
1949                    credential_present: true,
1950                    force_refresh: false,
1951                    refresh_allowed: true,
1952                },
1953            )
1954            .unwrap(),
1955            Disp::LeaseAbsent
1956        );
1957    }
1958
1959    #[test]
1960    fn observe_credential_freshness_marks_expired_through_authmachine() {
1961        let h = RuntimeAuthLeaseHandle::new();
1962        let k = lease("dev", "expired_openai");
1963
1964        h.acquire_lease(&k, 1_800_000_000).unwrap();
1965        h.observe_credential_freshness(&k, 1_800_000_001, 60)
1966            .unwrap();
1967
1968        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expired));
1969        h.begin_refresh(&k).unwrap();
1970        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Refreshing));
1971    }
1972
1973    #[test]
1974    fn refresh_failed_permanent_routes_to_reauth() {
1975        let h = RuntimeAuthLeaseHandle::new();
1976        let k = lease("dev", "default_google");
1977
1978        h.acquire_lease(&k, 1_800_000_000).unwrap();
1979        h.begin_refresh(&k).unwrap();
1980        let before = h.snapshot(&k);
1981        let disposition = h
1982            .resolve_refresh_failure_disposition(
1983                &k,
1984                RefreshFailureObservation::oauth_error_code("invalid_grant"),
1985            )
1986            .unwrap();
1987
1988        assert_eq!(
1989            disposition,
1990            meerkat_core::auth::RefreshFailureDisposition::ReauthRequired
1991        );
1992        assert_eq!(
1993            h.snapshot(&k),
1994            before,
1995            "the generated classifier must be read-only before durable cleanup"
1996        );
1997        h.refresh_failed(&k, RefreshFailureObservation::local_credential_unusable())
1998            .unwrap();
1999
2000        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::ReauthRequired));
2001    }
2002
2003    #[test]
2004    fn refresh_failed_transient_routes_to_expiring() {
2005        let h = RuntimeAuthLeaseHandle::new();
2006        let k = lease("dev", "foo");
2007
2008        h.acquire_lease(&k, 1_800_000_000).unwrap();
2009        h.begin_refresh(&k).unwrap();
2010        let before = h.snapshot(&k);
2011        let disposition = h
2012            .resolve_refresh_failure_disposition(&k, RefreshFailureObservation::transient())
2013            .unwrap();
2014
2015        assert_eq!(
2016            disposition,
2017            meerkat_core::auth::RefreshFailureDisposition::Transient
2018        );
2019        assert_eq!(
2020            h.snapshot(&k),
2021            before,
2022            "the generated classifier must be read-only before lifecycle commit"
2023        );
2024        h.refresh_failed(&k, RefreshFailureObservation::transient())
2025            .unwrap();
2026
2027        assert_eq!(h.snapshot(&k).phase, Some(AuthLeasePhase::Expiring));
2028    }
2029
2030    #[test]
2031    fn transient_refresh_failure_preserves_credential_marker_generation() {
2032        let h = RuntimeAuthLeaseHandle::new();
2033        let k = lease("dev", "retryable_refresh");
2034
2035        h.acquire_lease(&k, 1_800_000_000).unwrap();
2036        let before = h.snapshot(&k);
2037        h.begin_refresh(&k).unwrap();
2038        h.refresh_failed(&k, RefreshFailureObservation::transient())
2039            .unwrap();
2040        let after = h.snapshot(&k);
2041
2042        assert_eq!(after.phase, Some(AuthLeasePhase::Expiring));
2043        assert_eq!(after.expires_at, before.expires_at);
2044        assert_eq!(after.generation, before.generation);
2045        assert_eq!(
2046            after.credential_published_at_millis,
2047            before.credential_published_at_millis
2048        );
2049    }
2050
2051    #[test]
2052    fn snapshot_for_unknown_binding_is_none() {
2053        let h = RuntimeAuthLeaseHandle::new();
2054        let snap = h.snapshot(&lease("dev", "never_registered"));
2055        assert!(snap.phase.is_none());
2056        assert!(snap.expires_at.is_none());
2057    }
2058
2059    #[test]
2060    fn mark_expiring_before_acquire_errors() {
2061        let h = RuntimeAuthLeaseHandle::new();
2062        let err = h.mark_expiring(&lease("dev", "ghost")).unwrap_err();
2063        assert_eq!(err.context, "AuthLeaseHandle::mark_expiring");
2064    }
2065
2066    #[test]
2067    fn release_before_acquire_is_idempotent() {
2068        let h = RuntimeAuthLeaseHandle::new();
2069        let key = lease("dev", "ghost");
2070
2071        h.release_lease(&key).unwrap();
2072
2073        let snap = h.snapshot(&key);
2074        assert!(snap.phase.is_none());
2075        assert!(snap.expires_at.is_none());
2076    }
2077
2078    #[test]
2079    fn release_does_not_remove_concurrent_reacquire() {
2080        let h = RuntimeAuthLeaseHandle::new();
2081        let key = lease("dev", "shared");
2082        h.acquire_lease(&key, 1_800_000_000).unwrap();
2083
2084        let acquire_handle = h.clone();
2085        let acquire_key = key.clone();
2086        let hook_key = key.clone();
2087        let acquired_generation = Arc::new(Mutex::new(None));
2088        let hook_generation = Arc::clone(&acquired_generation);
2089        let _hook_guard =
2090            install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
2091                if released_key != &hook_key {
2092                    return;
2093                }
2094                let generation = acquire_handle
2095                    .acquire_lease(&acquire_key, 1_800_000_000)
2096                    .unwrap()
2097                    .generation();
2098                *hook_generation.lock().unwrap() = Some(generation);
2099            }));
2100
2101        h.release_lease(&key).unwrap();
2102
2103        let acquired_generation = acquired_generation
2104            .lock()
2105            .unwrap()
2106            .expect("release hook should reacquire the lease");
2107
2108        let snap = h.snapshot(&key);
2109        assert_eq!(
2110            snap.phase,
2111            Some(AuthLeasePhase::Valid),
2112            "accepted reacquire generation {acquired_generation} must remain visible after release completes; snapshot was {snap:?}"
2113        );
2114        assert_eq!(snap.expires_at, Some(1_800_000_000));
2115        assert_eq!(snap.generation, acquired_generation);
2116    }
2117
2118    #[cfg(not(target_arch = "wasm32"))]
2119    struct FailingReleaseObserver;
2120
2121    #[cfg(not(target_arch = "wasm32"))]
2122    impl AuthLeaseReleaseObserver for FailingReleaseObserver {
2123        fn auth_lease_released(
2124            &self,
2125            _released: &ReleasedOAuthFlows,
2126        ) -> Result<(), DslTransitionError> {
2127            Err(DslTransitionError::no_matching(
2128                "test_release_observer",
2129                "injected release observer failure",
2130            ))
2131        }
2132    }
2133
2134    /// An observer fault aborts the release BEFORE the authoritative Release
2135    /// transition: the typed fault propagates and the original live lease is
2136    /// untouched — no transition happened, so there is nothing to restore.
2137    /// (Regression pin for the deleted post-transition restore arm, which
2138    /// could resurrect a live lease over a credential the token-clear flow
2139    /// was destroying.)
2140    #[cfg(not(target_arch = "wasm32"))]
2141    #[test]
2142    fn release_observer_failure_aborts_release_fail_closed() {
2143        let h = RuntimeAuthLeaseHandle::new();
2144        let key = lease("dev", "staged_failure");
2145        let acquired = h.acquire_lease(&key, 1_800_000_000).unwrap();
2146
2147        let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
2148        h.add_release_observer(Arc::downgrade(&observer));
2149
2150        // The after-accept hook must NOT fire: the release aborts before the
2151        // Release transition is ever applied.
2152        let hook_key = key.clone();
2153        let hook_fired = Arc::new(std::sync::atomic::AtomicBool::new(false));
2154        let hook_fired_flag = Arc::clone(&hook_fired);
2155        let _hook_guard =
2156            install_release_after_accept_hook_for_test(Arc::new(move |released_key| {
2157                if released_key == &hook_key {
2158                    hook_fired_flag.store(true, std::sync::atomic::Ordering::Release);
2159                }
2160            }));
2161
2162        let err = h
2163            .release_lease(&key)
2164            .expect_err("observer fault must surface typed");
2165        assert!(
2166            err.to_string()
2167                .contains("injected release observer failure"),
2168            "typed fault must carry the observer failure, got: {err}"
2169        );
2170        assert!(
2171            !hook_fired.load(std::sync::atomic::Ordering::Acquire),
2172            "Release transition must not be applied when an observer faulted"
2173        );
2174
2175        // Fail-closed: the original lease is fully intact and retryable.
2176        let snap = h.snapshot(&key);
2177        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
2178        assert_eq!(snap.expires_at, Some(1_800_000_000));
2179        assert!(snap.credential_present);
2180        assert_eq!(snap.generation, acquired.generation());
2181    }
2182
2183    #[cfg(not(target_arch = "wasm32"))]
2184    #[test]
2185    fn oauth_global_capacity_rejection_comes_from_generated_authority() {
2186        let h = RuntimeAuthLeaseHandle::new();
2187        let first = auth_binding("dev", "oauth_a");
2188        let second = auth_binding("dev", "oauth_b");
2189
2190        h.apply_oauth_input(
2191            &first,
2192            auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
2193                flow_id: "first".to_string(),
2194                provider: "provider".to_string(),
2195                redirect_uri: "http://localhost/first".to_string(),
2196                expires_at_millis: 1_900_000_000,
2197                max_outstanding_flows: 1,
2198                observed_global_outstanding_flows: u64::MAX,
2199            },
2200            "test_admit_oauth_browser_flow",
2201            true,
2202        )
2203        .unwrap();
2204
2205        let err = h
2206            .apply_oauth_input(
2207                &second,
2208                auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
2209                    flow_id: "second".to_string(),
2210                    provider: "provider".to_string(),
2211                    redirect_uri: "http://localhost/second".to_string(),
2212                    expires_at_millis: 1_900_000_000,
2213                    max_outstanding_flows: 1,
2214                    observed_global_outstanding_flows: 0,
2215                },
2216                "test_admit_oauth_browser_flow",
2217                true,
2218            )
2219            .unwrap_err();
2220
2221        assert!(
2222            err.is_guard_rejected(),
2223            "expected generated guard rejection: {err:?}"
2224        );
2225        assert_eq!(err.context, "test_admit_oauth_browser_flow");
2226        assert!(
2227            err.reason.contains("AdmitOAuthBrowserFlow"),
2228            "guard rejection should come from the generated AuthMachine transition: {err:?}"
2229        );
2230        assert!(h.has_oauth_browser_flow_for_test(&first, "first"));
2231        assert!(!h.has_oauth_browser_flow_for_test(&second, "second"));
2232    }
2233
2234    #[test]
2235    fn restore_oauth_missing_payload_rejected_by_generated_authority() {
2236        let mut registry = AuthLeaseRegistry::default();
2237        let key = lease("dev", "restore_missing_provider");
2238        let mut state = empty_auth_state();
2239        state.lifecycle_phase = auth_dsl::AuthLifecyclePhase::ReauthRequired;
2240        state
2241            .oauth_browser_flow_ids
2242            .insert("browser-flow".to_string());
2243        state.oauth_browser_flow_redirect_uris.insert(
2244            "browser-flow".to_string(),
2245            "http://localhost/callback".to_string(),
2246        );
2247        state
2248            .oauth_browser_flow_expires_at_millis
2249            .insert("browser-flow".to_string(), 1_900_000_000);
2250        state.oauth_outstanding_flow_count = 1;
2251
2252        let err = restore_state_to_registry(
2253            &mut registry,
2254            &key,
2255            &state,
2256            "test_restore_oauth_missing_payload",
2257        )
2258        .unwrap_err();
2259
2260        assert!(
2261            err.is_guard_rejected(),
2262            "missing restored OAuth payload must be rejected by generated guards: {err:?}"
2263        );
2264        assert!(
2265            err.reason.contains("RestoreOAuthBrowserFlow"),
2266            "rejection should name the generated restore input: {err:?}"
2267        );
2268        assert!(!registry.authorities.contains_key(&key));
2269    }
2270
2271    #[test]
2272    fn restore_oauth_orphan_device_poll_rejected_by_generated_authority() {
2273        let mut registry = AuthLeaseRegistry::default();
2274        let key = lease("dev", "restore_orphan_poll");
2275        let mut state = empty_auth_state();
2276        state.lifecycle_phase = auth_dsl::AuthLifecyclePhase::ReauthRequired;
2277        state
2278            .oauth_device_poll_ids
2279            .insert("orphan-device".to_string());
2280
2281        let err = restore_state_to_registry(
2282            &mut registry,
2283            &key,
2284            &state,
2285            "test_restore_oauth_orphan_device_poll",
2286        )
2287        .unwrap_err();
2288
2289        assert!(
2290            err.is_guard_rejected(),
2291            "orphan restored OAuth poll must be rejected by generated guards: {err:?}"
2292        );
2293        assert!(
2294            err.reason.contains("RestoreOAuthDevicePoll"),
2295            "rejection should name the generated restore input: {err:?}"
2296        );
2297        assert!(!registry.authorities.contains_key(&key));
2298    }
2299
2300    #[test]
2301    fn restore_released_oauth_membership_reauths_through_generated_authority() {
2302        let mut registry = AuthLeaseRegistry::default();
2303        let key = lease("dev", "restore_released_oauth");
2304        let mut state = empty_auth_state();
2305        state.lifecycle_phase = auth_dsl::AuthLifecyclePhase::Released;
2306        state
2307            .oauth_browser_flow_ids
2308            .insert("browser-flow".to_string());
2309        state
2310            .oauth_browser_flow_providers
2311            .insert("browser-flow".to_string(), "provider".to_string());
2312        state.oauth_browser_flow_redirect_uris.insert(
2313            "browser-flow".to_string(),
2314            "http://localhost/callback".to_string(),
2315        );
2316        state
2317            .oauth_browser_flow_expires_at_millis
2318            .insert("browser-flow".to_string(), 1_900_000_000);
2319        state.oauth_outstanding_flow_count = 1;
2320
2321        let (phase, transition) = restore_state_to_registry(
2322            &mut registry,
2323            &key,
2324            &state,
2325            "test_restore_released_oauth_membership",
2326        )
2327        .unwrap();
2328
2329        assert_eq!(phase, AuthLeasePhase::ReauthRequired);
2330        assert_eq!(transition.phase(), AuthLeasePhase::ReauthRequired);
2331        let restored = registry.authorities.get(&key).unwrap().state();
2332        assert_eq!(
2333            restored.lifecycle_phase,
2334            auth_dsl::AuthLifecyclePhase::ReauthRequired
2335        );
2336        assert!(restored.oauth_browser_flow_ids.contains("browser-flow"));
2337        assert_eq!(restored.oauth_outstanding_flow_count, 1);
2338    }
2339
2340    #[test]
2341    fn repeated_acquire_updates_existing_lease() {
2342        let h = RuntimeAuthLeaseHandle::new();
2343        let key = lease("dev", "default");
2344
2345        h.acquire_lease(&key, 1_800_000_000).unwrap();
2346        h.acquire_lease(&key, 1_900_000_000).unwrap();
2347
2348        let snap = h.snapshot(&key);
2349        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
2350        assert_eq!(snap.expires_at, Some(1_900_000_000));
2351    }
2352
2353    #[test]
2354    fn restore_snapshot_preserves_publication_marker_without_lowering_generation() {
2355        let h = RuntimeAuthLeaseHandle::new();
2356        let key = lease("dev", "shared");
2357
2358        h.acquire_lease(&key, 1_800_000_000).unwrap();
2359        let before = h.snapshot(&key);
2360        let before_restore = h.capture_auth_lifecycle_restore_snapshot(&key);
2361        assert_eq!(before.phase, Some(AuthLeasePhase::Valid));
2362        assert!(before.credential_present);
2363        assert!(before.credential_published_at_millis.is_some());
2364
2365        h.acquire_lease(&key, 1_900_000_000).unwrap();
2366        let advanced = h.snapshot(&key);
2367        assert!(advanced.generation > before.generation);
2368
2369        h.restore_auth_lifecycle_snapshot(&before_restore).unwrap();
2370
2371        let restored = h.snapshot(&key);
2372        assert_eq!(restored.phase, before.phase);
2373        assert_eq!(restored.expires_at, before.expires_at);
2374        assert_eq!(restored.credential_present, before.credential_present);
2375        assert_eq!(
2376            restored.credential_published_at_millis,
2377            before.credential_published_at_millis
2378        );
2379        assert_eq!(restored.generation, advanced.generation);
2380    }
2381
2382    #[test]
2383    fn restore_empty_zero_generation_snapshot_releases_through_generated_authority() {
2384        let h = RuntimeAuthLeaseHandle::new();
2385        let key = lease("dev", "shared");
2386        let empty = h.capture_auth_lifecycle_restore_snapshot(&key);
2387        h.acquire_lease(&key, 1_800_000_000).unwrap();
2388        let acquired_generation = h.snapshot(&key).generation;
2389        assert!(acquired_generation > empty.snapshot().generation);
2390
2391        h.restore_auth_lifecycle_snapshot(&empty).unwrap();
2392
2393        let restored = h.snapshot(&key);
2394        assert_eq!(restored.phase, None);
2395        assert_eq!(restored.expires_at, None);
2396        assert!(!restored.credential_present);
2397        assert_eq!(restored.generation, acquired_generation);
2398        assert_eq!(restored.credential_published_at_millis, None);
2399    }
2400
2401    #[test]
2402    fn restore_snapshot_rejects_capture_from_different_runtime_handle() {
2403        let first = RuntimeAuthLeaseHandle::new();
2404        let second = RuntimeAuthLeaseHandle::new();
2405        let key = lease("dev", "shared");
2406        first.acquire_lease(&key, 1_800_000_000).unwrap();
2407        let captured = first.capture_auth_lifecycle_restore_snapshot(&key);
2408
2409        let err = second
2410            .restore_auth_lifecycle_snapshot(&captured)
2411            .unwrap_err();
2412
2413        assert_eq!(
2414            err.context,
2415            "AuthLeaseHandle::restore_auth_lifecycle_snapshot"
2416        );
2417        assert!(
2418            err.reason.contains("this RuntimeAuthLeaseHandle"),
2419            "restore must reject snapshots captured from a different runtime handle: {err:?}"
2420        );
2421        assert_eq!(second.snapshot(&key).phase, None);
2422    }
2423
2424    #[tokio::test]
2425    async fn restore_published_credential_lifecycle_uses_generated_authority() {
2426        struct SingleTokenStore {
2427            key: meerkat_core::auth::TokenKey,
2428            tokens: meerkat_core::auth::PersistedTokens,
2429        }
2430
2431        #[async_trait::async_trait]
2432        impl meerkat_core::auth::TokenStore for SingleTokenStore {
2433            async fn load(
2434                &self,
2435                key: &meerkat_core::auth::TokenKey,
2436            ) -> Result<
2437                Option<meerkat_core::auth::PersistedTokens>,
2438                meerkat_core::auth::TokenStoreError,
2439            > {
2440                Ok((key == &self.key).then(|| self.tokens.clone()))
2441            }
2442
2443            async fn save(
2444                &self,
2445                _key: &meerkat_core::auth::TokenKey,
2446                _tokens: &meerkat_core::auth::PersistedTokens,
2447            ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2448                Ok(())
2449            }
2450
2451            async fn clear(
2452                &self,
2453                _key: &meerkat_core::auth::TokenKey,
2454            ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2455                Ok(())
2456            }
2457
2458            async fn list(
2459                &self,
2460            ) -> Result<Vec<meerkat_core::auth::TokenKey>, meerkat_core::auth::TokenStoreError>
2461            {
2462                Ok(vec![self.key.clone()])
2463            }
2464
2465            fn backend_name(&self) -> &'static str {
2466                "single-token-test"
2467            }
2468        }
2469
2470        let source = Arc::new(RuntimeAuthLeaseHandle::new());
2471        let restored = Arc::new(RuntimeAuthLeaseHandle::new());
2472        let generated_restored =
2473            crate::protocol_auth_lease_lifecycle_publication::generated_auth_lease_handle(
2474                Arc::clone(&restored),
2475            )
2476            .expect("test AuthLeaseHandle must be generated-authority certified");
2477        let key = lease("dev", "shared");
2478        let transition = source.acquire_lease(&key, 1_800_000_000).unwrap();
2479        let published_at = transition
2480            .credential_published_at_millis()
2481            .expect("acquire transition carries publication time");
2482        let key_for_tokens = meerkat_core::auth::TokenKey::new_with_profile(
2483            key.realm.clone(),
2484            key.binding.clone(),
2485            key.profile.clone(),
2486        );
2487        let tokens = meerkat_core::auth::PersistedTokens {
2488            auth_mode: meerkat_core::auth::PersistedAuthMode::ChatgptOauth,
2489            primary_secret: Some("access-token".into()),
2490            refresh_token: Some("refresh-token".into()),
2491            id_token: None,
2492            expires_at: Some(
2493                chrono::DateTime::from_timestamp(transition.expires_at() as i64, 0)
2494                    .expect("fixture expiry is representable"),
2495            ),
2496            last_refresh: None,
2497            scopes: Vec::new(),
2498            account_id: None,
2499            metadata: serde_json::Value::Null,
2500        };
2501        let marked = meerkat_core::mark_tokens_lifecycle_published_for_transition(
2502            &key_for_tokens,
2503            &tokens,
2504            &transition,
2505        )
2506        .expect("generated transition marks durable publication");
2507
2508        let auth_binding = AuthBindingRef {
2509            realm: key.realm.clone(),
2510            binding: key.binding.clone(),
2511            profile: key.profile.clone(),
2512            origin: BindingOrigin::Configured,
2513        };
2514        let store = SingleTokenStore {
2515            key: key_for_tokens,
2516            tokens: marked,
2517        };
2518        meerkat_core::rehydrate_marked_tokens_for_status(
2519            &store,
2520            &generated_restored,
2521            &auth_binding,
2522            meerkat_core::auth::PersistedAuthMode::ChatgptOauth,
2523            chrono::Utc::now(),
2524        )
2525        .await
2526        .expect("generated marker restores through AuthMachine")
2527        .expect("marker is present");
2528
2529        let snapshot = restored.snapshot(&key);
2530        assert_eq!(snapshot.phase, Some(AuthLeasePhase::Valid));
2531        assert_eq!(snapshot.expires_at, Some(transition.expires_at()));
2532        assert_eq!(snapshot.generation, transition.generation());
2533        assert_eq!(snapshot.credential_published_at_millis, Some(published_at));
2534        assert!(snapshot.credential_present);
2535    }
2536
2537    #[test]
2538    fn per_binding_isolation() {
2539        let h = RuntimeAuthLeaseHandle::new();
2540        let openai = lease("dev", "openai");
2541        let anthropic = lease("dev", "anthropic");
2542        h.acquire_lease(&openai, 1_800_000_000).unwrap();
2543        h.acquire_lease(&anthropic, 1_900_000_000).unwrap();
2544        h.mark_expiring(&openai).unwrap();
2545
2546        assert_eq!(h.snapshot(&openai).phase, Some(AuthLeasePhase::Expiring));
2547        assert_eq!(h.snapshot(&anthropic).phase, Some(AuthLeasePhase::Valid));
2548        assert_eq!(h.snapshot(&anthropic).expires_at, Some(1_900_000_000));
2549    }
2550    /// K7 interleaving pin: the token-clear flow stages the lease release
2551    /// BEFORE the durable clear commits, and the release itself notifies
2552    /// observers before its authoritative transition. A release-observer
2553    /// fault therefore aborts the whole clear fail-closed: the typed fault
2554    /// surfaces, the durable clear never ran, and the lease is untouched —
2555    /// no interleaving can produce a live lease over a cleared credential,
2556    /// and no post-clear lease operation (the old restore arm) exists.
2557    #[cfg(not(target_arch = "wasm32"))]
2558    #[tokio::test]
2559    async fn clear_flow_observer_failure_fails_closed_and_never_clears_durable() {
2560        use std::sync::Mutex as StdMutex;
2561
2562        struct RecordingStore {
2563            tokens: StdMutex<Option<meerkat_core::auth::PersistedTokens>>,
2564            key: meerkat_core::auth::TokenKey,
2565            clear_called: std::sync::atomic::AtomicBool,
2566        }
2567
2568        #[async_trait::async_trait]
2569        impl meerkat_core::auth::TokenStore for RecordingStore {
2570            async fn load(
2571                &self,
2572                key: &meerkat_core::auth::TokenKey,
2573            ) -> Result<
2574                Option<meerkat_core::auth::PersistedTokens>,
2575                meerkat_core::auth::TokenStoreError,
2576            > {
2577                if key == &self.key {
2578                    Ok(self.tokens.lock().unwrap().clone())
2579                } else {
2580                    Ok(None)
2581                }
2582            }
2583
2584            async fn save(
2585                &self,
2586                _key: &meerkat_core::auth::TokenKey,
2587                tokens: &meerkat_core::auth::PersistedTokens,
2588            ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2589                *self.tokens.lock().unwrap() = Some(tokens.clone());
2590                Ok(())
2591            }
2592
2593            async fn clear(
2594                &self,
2595                _key: &meerkat_core::auth::TokenKey,
2596            ) -> Result<(), meerkat_core::auth::TokenStoreError> {
2597                self.clear_called
2598                    .store(true, std::sync::atomic::Ordering::Release);
2599                *self.tokens.lock().unwrap() = None;
2600                Ok(())
2601            }
2602
2603            async fn list(
2604                &self,
2605            ) -> Result<Vec<meerkat_core::auth::TokenKey>, meerkat_core::auth::TokenStoreError>
2606            {
2607                Ok(vec![self.key.clone()])
2608            }
2609
2610            fn backend_name(&self) -> &'static str {
2611                "recording-clear-test"
2612            }
2613        }
2614
2615        let handle = Arc::new(RuntimeAuthLeaseHandle::new());
2616        let generated =
2617            crate::protocol_auth_lease_lifecycle_publication::generated_auth_lease_handle(
2618                Arc::clone(&handle),
2619            )
2620            .expect("test AuthLeaseHandle must be generated-authority certified");
2621        let binding = auth_binding("dev", "clear_stage");
2622        let key = meerkat_core::auth::TokenKey::from_auth_binding(&binding);
2623
2624        let tokens = meerkat_core::auth::PersistedTokens {
2625            auth_mode: meerkat_core::auth::PersistedAuthMode::ChatgptOauth,
2626            primary_secret: Some("access-token".into()),
2627            refresh_token: Some("refresh-token".into()),
2628            id_token: None,
2629            expires_at: None,
2630            last_refresh: None,
2631            scopes: Vec::new(),
2632            account_id: None,
2633            metadata: serde_json::Value::Null,
2634        };
2635        meerkat_core::publish_token_lifecycle_acquired(&generated, &binding, &tokens)
2636            .expect("acquire lease");
2637        let store = RecordingStore {
2638            tokens: StdMutex::new(Some(tokens)),
2639            key: key.clone(),
2640            clear_called: std::sync::atomic::AtomicBool::new(false),
2641        };
2642
2643        let observer: Arc<dyn AuthLeaseReleaseObserver> = Arc::new(FailingReleaseObserver);
2644        handle.add_release_observer(Arc::downgrade(&observer));
2645
2646        let err =
2647            meerkat_core::clear_tokens_and_publish_lifecycle_released(&store, &generated, &binding)
2648                .await
2649                .expect_err("staged release observer fault must fail the clear typed");
2650        assert!(
2651            matches!(
2652                err,
2653                meerkat_core::TokenLifecycleClearError::AuthMachineRelease(_)
2654            ),
2655            "expected typed AuthMachineRelease fault, got: {err:?}"
2656        );
2657
2658        // The durable clear must never run after a staging fault: fail closed.
2659        assert!(
2660            !store
2661                .clear_called
2662                .load(std::sync::atomic::Ordering::Acquire),
2663            "durable clear must not run when release staging faulted"
2664        );
2665        assert!(store.tokens.lock().unwrap().is_some());
2666
2667        // Fail-closed consistency: durable truth still holds the credential
2668        // and the lease projection still matches it (live) — the clear is
2669        // retryable; nothing diverged. In particular no interleaving exists
2670        // where a live lease outlives a CLEARED credential: the durable clear
2671        // only commits after the release fully succeeded.
2672        let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(&binding);
2673        let snap = handle.snapshot(&lease_key);
2674        assert_eq!(snap.phase, Some(AuthLeasePhase::Valid));
2675        assert!(
2676            snap.credential_present,
2677            "aborted clear must leave the live lease aligned with the retained durable record, got {snap:?}"
2678        );
2679    }
2680
2681    // --- 0.7.2 disciplined shell inputs (lane L3 auth-release) ---
2682    //
2683    // Machine-level pins for the two-phase release drain (D1) and the
2684    // post-teardown totality of observation/cleanup-shaped OAuth inputs
2685    // (D2a). These drive the generated authority directly.
2686
2687    fn apply_machine(
2688        authority: &mut auth_dsl::AuthMachineAuthority,
2689        input: auth_dsl::AuthMachineInput,
2690    ) -> Result<auth_dsl::AuthMachineTransition, auth_dsl::AuthMachineTransitionError> {
2691        auth_dsl::AuthMachineMutator::apply(authority, input)
2692    }
2693
2694    fn admit_browser_flow_input(flow_id: &str) -> auth_dsl::AuthMachineInput {
2695        auth_dsl::AuthMachineInput::AdmitOAuthBrowserFlow {
2696            flow_id: flow_id.to_string(),
2697            provider: "openai-chatgpt".to_string(),
2698            redirect_uri: "http://127.0.0.1/callback".to_string(),
2699            expires_at_millis: u64::MAX,
2700            max_outstanding_flows: 16,
2701            observed_global_outstanding_flows: 0,
2702        }
2703    }
2704
2705    fn admit_device_flow_input(flow_id: &str) -> auth_dsl::AuthMachineInput {
2706        auth_dsl::AuthMachineInput::AdmitOAuthDeviceFlow {
2707            flow_id: flow_id.to_string(),
2708            provider: "openai-chatgpt".to_string(),
2709            expires_at_millis: u64::MAX,
2710            max_outstanding_flows: 16,
2711            observed_global_outstanding_flows: 0,
2712        }
2713    }
2714
2715    /// D1: release is two-phase. With flows in flight, Release alone is
2716    /// rejected (drain obligations open); BeginRelease records the draining
2717    /// intent and emits the typed cancellation obligation carrying exactly
2718    /// the in-flight membership; the Expire* feedback inputs close the
2719    /// obligations; only then does Release commit.
2720    #[test]
2721    fn begin_release_emits_machine_owned_drain_obligation_and_guards_release() {
2722        let mut machine = auth_dsl::AuthMachineAuthority::new();
2723        apply_machine(
2724            &mut machine,
2725            auth_dsl::AuthMachineInput::Acquire {
2726                expires_at_ts: Some(2_000_000_000),
2727                credential_published_at_millis: 1,
2728            },
2729        )
2730        .expect("acquire");
2731        apply_machine(&mut machine, admit_browser_flow_input("b-1")).expect("admit browser flow");
2732        apply_machine(&mut machine, admit_device_flow_input("d-1")).expect("admit device flow");
2733
2734        // Release with in-flight flows is guard-rejected: the drain
2735        // obligations are still open.
2736        let err = apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release)
2737            .expect_err("Release must not commit while flows are in flight");
2738        assert!(
2739            matches!(
2740                err,
2741                auth_dsl::AuthMachineTransitionError::GuardRejected { .. }
2742            ),
2743            "expected guard rejection, got: {err:?}"
2744        );
2745
2746        // BeginRelease enters the draining sub-state and emits the typed
2747        // cancellation obligation with the exact in-flight membership.
2748        let transition = apply_machine(&mut machine, auth_dsl::AuthMachineInput::BeginRelease)
2749            .expect("begin release");
2750        assert!(machine.state().release_draining);
2751        let cancel = transition
2752            .effects()
2753            .iter()
2754            .find_map(|effect| match effect {
2755                auth_dsl::AuthMachineEffect::CancelOAuthFlowsForRelease {
2756                    browser_flow_ids,
2757                    device_flow_ids,
2758                } => Some((browser_flow_ids.clone(), device_flow_ids.clone())),
2759                _ => None,
2760            })
2761            .expect("BeginRelease with in-flight flows must emit CancelOAuthFlowsForRelease");
2762        assert_eq!(
2763            cancel.0.iter().collect::<Vec<_>>(),
2764            vec!["b-1"],
2765            "cancellation obligation must carry the in-flight browser flow"
2766        );
2767        assert_eq!(
2768            cancel.1.iter().collect::<Vec<_>>(),
2769            vec!["d-1"],
2770            "cancellation obligation must carry the in-flight device flow"
2771        );
2772
2773        // New admissions are refused while the drain is in flight, so the
2774        // obligation set cannot grow behind the shell's back.
2775        let err = apply_machine(&mut machine, admit_browser_flow_input("b-2"))
2776            .expect_err("admission during release drain must be refused");
2777        assert!(
2778            matches!(
2779                err,
2780                auth_dsl::AuthMachineTransitionError::GuardRejected { .. }
2781            ),
2782            "expected guard rejection, got: {err:?}"
2783        );
2784
2785        // Typed terminal cancellation closes each obligation.
2786        apply_machine(
2787            &mut machine,
2788            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2789                flow_id: "b-1".to_string(),
2790            },
2791        )
2792        .expect("terminal browser cancellation");
2793        apply_machine(
2794            &mut machine,
2795            auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow {
2796                flow_id: "d-1".to_string(),
2797            },
2798        )
2799        .expect("terminal device cancellation");
2800        assert_eq!(machine.state().oauth_outstanding_flow_count, 0);
2801
2802        // All drain obligations closed: Release commits.
2803        apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release).expect("drained release");
2804        assert!(matches!(
2805            machine.state().lifecycle_phase,
2806            auth_dsl::AuthLifecyclePhase::Released
2807        ));
2808        assert!(!machine.state().release_draining);
2809        assert_eq!(machine.state().oauth_outstanding_flow_count, 0);
2810        assert!(machine.state().oauth_browser_flow_ids.is_empty());
2811        assert!(machine.state().oauth_device_flow_ids.is_empty());
2812    }
2813
2814    /// D1: with no flows in flight, BeginRelease records the drain intent
2815    /// without emitting a cancellation obligation, and Release commits
2816    /// immediately.
2817    #[test]
2818    fn begin_release_without_flows_emits_no_drain_obligation() {
2819        let mut machine = auth_dsl::AuthMachineAuthority::new();
2820        apply_machine(
2821            &mut machine,
2822            auth_dsl::AuthMachineInput::Acquire {
2823                expires_at_ts: Some(2_000_000_000),
2824                credential_published_at_millis: 1,
2825            },
2826        )
2827        .expect("acquire");
2828
2829        let transition = apply_machine(&mut machine, auth_dsl::AuthMachineInput::BeginRelease)
2830            .expect("begin release");
2831        assert!(machine.state().release_draining);
2832        assert!(
2833            transition.effects().iter().all(|effect| !matches!(
2834                effect,
2835                auth_dsl::AuthMachineEffect::CancelOAuthFlowsForRelease { .. }
2836            )),
2837            "no cancellation obligation without in-flight flows"
2838        );
2839
2840        apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release).expect("release");
2841        assert!(matches!(
2842            machine.state().lifecycle_phase,
2843            auth_dsl::AuthLifecyclePhase::Released
2844        ));
2845        assert!(!machine.state().release_draining);
2846    }
2847
2848    /// D2a: late poll/prune/compensation arrivals after teardown — Expire*,
2849    /// durable-admission Confirm, poll Finish, and a release retry — are
2850    /// accepted as no-ops in Released with zero state change and zero
2851    /// emitted effects, never a guard rejection (worklist entries 24-30).
2852    #[test]
2853    fn post_release_oauth_observation_inputs_are_total_noops() {
2854        let mut machine = auth_dsl::AuthMachineAuthority::new();
2855        apply_machine(&mut machine, auth_dsl::AuthMachineInput::Release).expect("release");
2856        assert!(matches!(
2857            machine.state().lifecycle_phase,
2858            auth_dsl::AuthLifecyclePhase::Released
2859        ));
2860        let before = machine.state().clone();
2861
2862        for input in [
2863            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2864                flow_id: "ghost-browser".to_string(),
2865            },
2866            auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow {
2867                flow_id: "ghost-device".to_string(),
2868            },
2869            auth_dsl::AuthMachineInput::FinishOAuthDevicePoll {
2870                flow_id: "ghost-poll".to_string(),
2871            },
2872            auth_dsl::AuthMachineInput::ConfirmOAuthDurableAdmission {
2873                observed_global_outstanding_flows: 0,
2874                max_outstanding_flows: 16,
2875            },
2876            auth_dsl::AuthMachineInput::BeginRelease,
2877        ] {
2878            let description = format!("{input:?}");
2879            let transition = apply_machine(&mut machine, input).unwrap_or_else(|err| {
2880                panic!("`{description}` must be a total no-op in Released, got: {err:?}")
2881            });
2882            assert!(
2883                transition.effects().is_empty(),
2884                "`{description}` must not emit effects in Released"
2885            );
2886            assert_eq!(
2887                machine.state(),
2888                &before,
2889                "`{description}` must not mutate Released state"
2890            );
2891        }
2892    }
2893
2894    /// D2a: stale expiry/finish observations for flows that are no longer
2895    /// members are total no-ops in live phases too — a consume or release
2896    /// drain legitimately removes the flow between a producer's membership
2897    /// observation and its input delivery.
2898    #[test]
2899    fn stale_oauth_cleanup_observations_are_total_noops_in_live_phases() {
2900        let mut machine = auth_dsl::AuthMachineAuthority::new();
2901        apply_machine(
2902            &mut machine,
2903            auth_dsl::AuthMachineInput::Acquire {
2904                expires_at_ts: Some(2_000_000_000),
2905                credential_published_at_millis: 1,
2906            },
2907        )
2908        .expect("acquire");
2909        apply_machine(&mut machine, admit_browser_flow_input("b-1")).expect("admit browser flow");
2910        let before = machine.state().clone();
2911
2912        for input in [
2913            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2914                flow_id: "ghost-browser".to_string(),
2915            },
2916            auth_dsl::AuthMachineInput::ExpireOAuthDeviceFlow {
2917                flow_id: "ghost-device".to_string(),
2918            },
2919            auth_dsl::AuthMachineInput::FinishOAuthDevicePoll {
2920                flow_id: "ghost-poll".to_string(),
2921            },
2922        ] {
2923            let description = format!("{input:?}");
2924            apply_machine(&mut machine, input).unwrap_or_else(|err| {
2925                panic!("stale `{description}` must be a total no-op, got: {err:?}")
2926            });
2927            assert_eq!(
2928                machine.state(),
2929                &before,
2930                "stale `{description}` must not mutate live-phase state"
2931            );
2932        }
2933
2934        // The genuine member is still expirable afterwards.
2935        apply_machine(
2936            &mut machine,
2937            auth_dsl::AuthMachineInput::ExpireOAuthBrowserFlow {
2938                flow_id: "b-1".to_string(),
2939            },
2940        )
2941        .expect("present flow still expires");
2942        assert_eq!(machine.state().oauth_outstanding_flow_count, 0);
2943    }
2944}