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:
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 = ®istry[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)