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