lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit a754582df78234acc162356e04f2531f1384eb41
parent 0325901ae28277991bb7a8cfe6bdc708214f22c2
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:06:29 +0000

operations: make removal plans durable and expiring

- attach expiry revision and binding-aware impact to removal preflight
- support explicit cancellation before irreversible work begins
- persist credential metadata selection and terminal removal phases
- resume interrupted removals deterministically during startup recovery

Diffstat:
Mcrates/studio_application/src/accounts.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/studio_application/src/app_core.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/studio_application/src/lib.rs | 2+-
Mcrates/studio_application/src/recovery.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/studio_storage/src/application_adapter.rs | 26+++++++++++++++++++++++++-
Mcrates/studio_storage/src/runtime_actor.rs | 18++++++++++++------
6 files changed, 350 insertions(+), 21 deletions(-)

diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -247,8 +247,13 @@ impl AppCore { pub fn request_account_removal( &self, public_key: PublicKey, + clock: &(impl Clock + ?Sized), ) -> Result<RemovalConfirmationToken, SafeError> { - self.issue_removal_token(public_key) + self.issue_removal_token(public_key, clock.now()) + } + + pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool { + self.cancel_removal_token(token) } /// Permanently removes a confirmed account and selects a deterministic fallback. @@ -265,7 +270,7 @@ impl AppCore { journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<crate::AppSnapshot, SafeError> { - let public_key = self.consume_removal_token(token)?; + let public_key = self.consume_removal_token(token, clock.now())?; let registry = accounts.list_accounts()?; let index = registry .iter() @@ -318,6 +323,112 @@ impl AppCore { }) } + /// Confirms and executes an expiring removal plan as a durable request. + /// + /// # Errors + /// + /// Returns a safe expiry, conflict, credential, persistence, or recovery error. + #[allow(clippy::too_many_arguments)] + pub fn confirm_account_removal_durable( + &self, + request_id: &DurableRequestId, + token: RemovalConfirmationToken, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + let expected_revision = token.revision().value(); + let public_key = self.consume_removal_token(token, clock.now())?; + self.require_revision(expected_revision)?; + let registry = accounts.list_accounts()?; + let index = registry + .iter() + .position(|account| account.public_key() == public_key) + .ok_or_else(account_not_found)?; + let selected = if self.snapshot().selected_account() == Some(public_key) { + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(AccountSummary::public_key) + } else { + self.snapshot().selected_account() + }; + let account = &registry[index]; + match operations.begin_durable_operation( + request_id, + DurableOperationKind::Remove, + public_key, + Some(expected_revision), + OperationPriorState::new(selected, Some(account.signer().availability())), + clock.now(), + )? { + DurableOperationStart::Started(_) => {} + DurableOperationStart::Existing(operation) => { + return if operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + Ok(self.snapshot()) + } else { + Err(recovery_required()) + }; + } + } + if self + .snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key) + { + self.sign_out()?; + } + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == SafeErrorCode::CredentialMissing + && account.signer().availability() + == BindingAvailability::CredentialMissing => {} + Err(error) => return Err(error), + } + operations.advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + accounts.remove_account(public_key)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + app_state.save_selected_account(selected)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::MetadataDeleted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + let snapshot = + self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { + accounts: accounts.list_accounts()?, + selected, + })?; + operations.finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + )?; + Ok(snapshot) + } + /// Persists and publishes a saved account selection without activating it. /// /// # Errors @@ -786,6 +897,14 @@ mod tests { } } + struct LateClock; + + impl Clock for LateClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(311).expect("time") + } + } + #[derive(Default)] struct FailingInsertRepository { inner: InMemoryAccountRepository, @@ -1188,7 +1307,9 @@ mod tests { .public_key(); core.select_account(first, &accounts, &accounts) .expect("select first"); - let stale = core.request_account_removal(first).expect("stale token"); + let stale = core + .request_account_removal(first, &FixedClock) + .expect("stale token"); core.select_account(second, &accounts, &accounts) .expect("change revision"); let stale_error = core @@ -1199,7 +1320,9 @@ mod tests { core.select_account(first, &accounts, &accounts) .expect("reselect first"); - let token = core.request_account_removal(first).expect("token"); + let token = core + .request_account_removal(first, &FixedClock) + .expect("token"); let removed = core .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) .expect("remove"); @@ -1208,4 +1331,34 @@ mod tests { assert!(!secrets.contains(first).expect("credential removed")); assert_eq!(removed.session(), SessionState::SignedOut); } + + #[test] + fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let account = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("account") + .account() + .public_key(); + let expired = core + .request_account_removal(account, &FixedClock) + .expect("plan"); + assert!(expired.impact().deletes_local_credential()); + assert!(!expired.impact().signs_out()); + assert!( + core.confirm_account_removal( + expired, &accounts, &accounts, &secrets, &journal, &LateClock, + ) + .is_err() + ); + let cancelled = core + .request_account_removal(account, &FixedClock) + .expect("replacement plan"); + assert!(core.cancel_account_removal(cancelled)); + assert_eq!(core.snapshot().accounts().len(), 1); + } } diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::sync::{Mutex, MutexGuard}; -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; use crate::{ AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, SnapshotRevision, @@ -10,13 +10,59 @@ use crate::{ pub struct RemovalConfirmationToken { id: u64, - public_key: radroots_studio_domain::PublicKey, + public_key: PublicKey, revision: SnapshotRevision, + expires_at: UnixTimestamp, + impact: RemovalImpact, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RemovalImpact { + deletes_local_credential: bool, + signs_out: bool, +} + +impl RemovalImpact { + #[must_use] + pub const fn deletes_local_credential(self) -> bool { + self.deletes_local_credential + } + #[must_use] + pub const fn signs_out(self) -> bool { + self.signs_out + } +} + +impl RemovalConfirmationToken { + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + #[must_use] + pub const fn revision(&self) -> SnapshotRevision { + self.revision + } + #[must_use] + pub const fn expires_at(&self) -> UnixTimestamp { + self.expires_at + } + #[must_use] + pub const fn impact(&self) -> RemovalImpact { + self.impact + } +} + +#[derive(Clone, Copy)] +struct RemovalTokenState { + public_key: PublicKey, + revision: SnapshotRevision, + expires_at: UnixTimestamp, + impact: RemovalImpact, } struct CoreState { state_machine: StateMachine, - removal_tokens: BTreeMap<u64, (radroots_studio_domain::PublicKey, SnapshotRevision)>, + removal_tokens: BTreeMap<u64, RemovalTokenState>, next_removal_token: u64, } @@ -91,7 +137,8 @@ impl AppCore { pub(crate) fn issue_removal_token( &self, - public_key: radroots_studio_domain::PublicKey, + public_key: PublicKey, + now: UnixTimestamp, ) -> Result<RemovalConfirmationToken, SafeError> { let mut state = self.lock_state(); if !state @@ -106,11 +153,35 @@ impl AppCore { let id = state.next_removal_token; state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; let revision = state.state_machine.snapshot().revision(); - state.removal_tokens.insert(id, (public_key, revision)); + let expires_at = UnixTimestamp::from_seconds( + now.as_seconds() + .checked_add(300) + .ok_or_else(invalid_application_state)?, + ) + .ok_or_else(invalid_application_state)?; + let impact = RemovalImpact { + deletes_local_credential: true, + signs_out: state + .state_machine + .snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key), + }; + state.removal_tokens.insert( + id, + RemovalTokenState { + public_key, + revision, + expires_at, + impact, + }, + ); Ok(RemovalConfirmationToken { id, public_key, revision, + expires_at, + impact, }) } @@ -118,22 +189,35 @@ impl AppCore { pub(crate) fn consume_removal_token( &self, token: RemovalConfirmationToken, - ) -> Result<radroots_studio_domain::PublicKey, SafeError> { + now: UnixTimestamp, + ) -> Result<PublicKey, SafeError> { let RemovalConfirmationToken { id, public_key, revision, + expires_at, + impact, } = token; let mut state = self.lock_state(); let stored = state.removal_tokens.remove(&id); - if stored != Some((public_key, revision)) - || state.state_machine.snapshot().revision() != revision + if stored.is_none_or(|stored| { + stored.public_key != public_key + || stored.revision != revision + || stored.expires_at != expires_at + || stored.impact != impact + }) || state.state_machine.snapshot().revision() != revision + || now.as_seconds() > expires_at.as_seconds() { return Err(invalid_application_state()); } Ok(public_key) } + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn cancel_removal_token(&self, token: RemovalConfirmationToken) -> bool { + self.lock_state().removal_tokens.remove(&token.id).is_some() + } + fn lock_state(&self) -> MutexGuard<'_, CoreState> { self.state .lock() diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -23,7 +23,7 @@ pub use actor::{ CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation, }; -pub use app_core::{AppCore, RemovalConfirmationToken}; +pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact}; pub use change_stream::{ ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver, }; diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs @@ -28,7 +28,9 @@ impl AppCore { | DurableOperationKind::Repair => recover_durable_addition( &operation, accounts, app_state, secrets, operations, clock, )?, - DurableOperationKind::Remove => {} + DurableOperationKind::Remove => recover_durable_removal( + &operation, accounts, app_state, secrets, operations, clock, + )?, } } Ok(()) @@ -64,6 +66,66 @@ impl AppCore { } } +fn recover_durable_removal( + operation: &DurableAccountOperation, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let request = operation.request_id(); + let account = operation.account(); + let mut phase = operation.phase(); + if phase == DurableOperationPhase::IntentRecorded { + if secrets.contains(account)? { + secrets.delete(account)?; + } + operations.advance_durable_operation( + request, + phase, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + phase = DurableOperationPhase::CredentialDeleted; + } + if phase == DurableOperationPhase::CredentialDeleted { + if accounts.find_account(account)?.is_some() { + accounts.remove_account(account)?; + } + operations.advance_durable_operation( + request, + phase, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + phase = DurableOperationPhase::MetadataDeleted; + } + if phase == DurableOperationPhase::MetadataDeleted { + app_state.save_selected_account(operation.prior().selected_account())?; + operations.advance_durable_operation( + request, + phase, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + phase = DurableOperationPhase::SelectionCommitted; + } + if phase == DurableOperationPhase::SelectionCommitted { + operations.finalize_durable_operation( + request, + phase, + DurableTerminalOutcome::Completed, + None, + clock.now(), + )?; + } + Ok(()) +} + fn recover_durable_addition( operation: &DurableAccountOperation, accounts: &(impl AccountRepository + ?Sized), diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs @@ -202,8 +202,9 @@ impl PersistentAppCore { pub fn request_account_removal( &self, public_key: PublicKey, + clock: &(impl Clock + ?Sized), ) -> Result<RemovalConfirmationToken, SafeError> { - self.core.request_account_removal(public_key) + self.core.request_account_removal(public_key, clock) } /// Permanently removes one confirmed account and its credential. @@ -227,6 +228,29 @@ impl PersistentAppCore { ) } + /// Executes a confirmed removal through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. + pub fn confirm_account_removal_durable( + &self, + request_id: &DurableRequestId, + token: RemovalConfirmationToken, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + self.core.confirm_account_removal_durable( + request_id, + token, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + #[must_use] pub const fn core(&self) -> &AppCore { &self.core diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs @@ -657,13 +657,19 @@ impl RuntimeActor { .map(RuntimeCommandValue::Snapshot), RuntimeCommand::RequestAccountRemoval(public_key) => self .adapter - .request_account_removal(public_key) + .request_account_removal(public_key, self.clock.as_ref()) .map(RuntimeCommandValue::RemovalRequest), - RuntimeCommand::ConfirmAccountRemoval(token) => self - .adapter - .confirm_account_removal(token, self.secrets.as_ref(), self.clock.as_ref()) - .map(Box::new) - .map(RuntimeCommandValue::Snapshot), + RuntimeCommand::ConfirmAccountRemoval(token) => durable_request.and_then(|request| { + self.adapter + .confirm_account_removal_durable( + &request, + token, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(Box::new) + .map(RuntimeCommandValue::Snapshot) + }), RuntimeCommand::SubscribeChanges(capacity) => self .changes .subscribe(capacity)