commit f87f676a6938ddc9aff63788b54ad6df26628f8d
parent d54cb234c3c14ce45f09cecf0e03ce5ee0a82ab2
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:13:15 +0000
core(recovery): resume pending account removals
- inspect the non-secret journal before public-state bootstrap
- avoid credential-store access when no recovery is pending
- resume irreversible credential and metadata deletion phases
- retain failed intents and finalize deterministic fallback state
Diffstat:
3 files changed, 250 insertions(+), 7 deletions(-)
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -3,6 +3,7 @@
pub mod accounts;
pub mod app_core;
pub mod ports;
+pub mod recovery;
pub mod secrets;
pub mod session;
pub mod snapshot;
diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs
@@ -0,0 +1,126 @@
+use radroots_studio_domain::{PublicKey, SafeError};
+
+use crate::{
+ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
+ Clock, OperationJournal, SecretStore,
+};
+
+impl AppCore {
+ /// Reconciles non-secret cross-resource journal entries before bootstrap.
+ ///
+ /// An empty journal does not access the credential store.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe credential, persistence, or recovery error while retaining
+ /// the unfinished journal entry for a later retry.
+ pub fn recover_pending_operations(
+ &self,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<(), SafeError> {
+ for operation in journal.list_pending_operations()? {
+ match operation.kind() {
+ AccountOperationKind::Remove => {
+ recover_removal(&operation, accounts, app_state, secrets, journal, clock)?;
+ }
+ AccountOperationKind::Add | AccountOperationKind::Import => {
+ recover_addition(&operation, accounts, secrets, journal, clock)?;
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+fn recover_removal(
+ operation: &crate::PendingAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ app_state: &(impl AppStateRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let public_key = operation.subject();
+ if operation.phase() == AccountOperationPhase::IntentRecorded {
+ match secrets.delete(public_key) {
+ Ok(()) => {}
+ Err(error)
+ if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {}
+ Err(error) => return Err(error),
+ }
+ journal.update_operation(
+ operation.id(),
+ AccountOperationPhase::CredentialDeleted,
+ clock.now(),
+ None,
+ )?;
+ }
+ if matches!(
+ operation.phase(),
+ AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted
+ ) {
+ let registry = accounts.list_accounts()?;
+ let selected = removal_fallback(®istry, app_state.load_selected_account()?, public_key);
+ accounts.remove_account(public_key)?;
+ app_state.save_selected_account(selected)?;
+ journal.update_operation(
+ operation.id(),
+ AccountOperationPhase::MetadataDeleted,
+ clock.now(),
+ None,
+ )?;
+ }
+ journal.finalize_operation(operation.id())
+}
+
+fn recover_addition(
+ operation: &crate::PendingAccountOperation,
+ accounts: &(impl AccountRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ let has_metadata = accounts.find_account(operation.subject())?.is_some();
+ match operation.phase() {
+ AccountOperationPhase::CredentialWritten | AccountOperationPhase::CompensationPending
+ if !has_metadata =>
+ {
+ match secrets.delete(operation.subject()) {
+ Ok(()) => {}
+ Err(error)
+ if error.code() == radroots_studio_domain::SafeErrorCode::CredentialMissing => {
+ }
+ Err(error) => return Err(error),
+ }
+ journal.update_operation(
+ operation.id(),
+ AccountOperationPhase::MetadataDeleted,
+ clock.now(),
+ None,
+ )?;
+ }
+ _ => {}
+ }
+ journal.finalize_operation(operation.id())
+}
+
+fn removal_fallback(
+ registry: &[radroots_studio_domain::AccountSummary],
+ selected: Option<PublicKey>,
+ removed: PublicKey,
+) -> Option<PublicKey> {
+ if selected != Some(removed) {
+ return selected;
+ }
+ let index = registry
+ .iter()
+ .position(|account| account.public_key() == removed)?;
+ registry
+ .get(index + 1)
+ .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before)))
+ .map(radroots_studio_domain::AccountSummary::public_key)
+}
diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs
@@ -44,7 +44,18 @@ impl PersistentAppCore {
///
/// Returns a safe storage or application-state error after publishing a fatal
/// snapshot when durable state cannot be restored.
- pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
+ pub fn bootstrap(
+ &self,
+ secrets: &(impl SecretStore + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<AppSnapshot, SafeError> {
+ self.core.recover_pending_operations(
+ &self.database,
+ &self.database,
+ secrets,
+ &self.database,
+ clock,
+ )?;
self.core.bootstrap_from(&self.database, &self.database)
}
@@ -177,8 +188,9 @@ mod tests {
use std::fs;
use radroots_studio_application::{
- AccountRepository, AppLifecycle, AppStateRepository, Clock, InMemorySecretStore,
- RelayConfiguration, SecretStore, SessionState,
+ AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle,
+ AppStateRepository, Clock, FailureSecretStore, InMemorySecretStore, OperationJournal,
+ RelayConfiguration, SecretStore, SecretStoreOperation, SessionState,
};
use radroots_studio_domain::{
AccountCreatedAt, AccountSummary, KeyAvailability, Npub, PublicKey, SafeErrorCode,
@@ -215,10 +227,13 @@ mod tests {
let directory = tempdir().expect("directory");
let path = directory.path().join("studio.sqlite3");
let public_key = account().public_key();
+ let secrets = InMemorySecretStore::default();
{
let adapter = PersistentAppCore::open(&path, RelayConfiguration::default())
.expect("open adapter");
- let fresh = adapter.bootstrap().expect("fresh bootstrap");
+ let fresh = adapter
+ .bootstrap(&secrets, &FixedClock)
+ .expect("fresh bootstrap");
assert!(fresh.accounts().is_empty());
adapter
.database()
@@ -232,7 +247,7 @@ mod tests {
let adapter =
PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter");
- let restored = adapter.bootstrap().expect("restore");
+ let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore");
assert_eq!(restored.lifecycle(), AppLifecycle::Ready);
assert_eq!(restored.accounts().len(), 1);
assert_eq!(restored.selected_account(), Some(public_key));
@@ -265,7 +280,7 @@ mod tests {
{
let adapter =
PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
- adapter.bootstrap().expect("bootstrap");
+ adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
let generated = adapter
.generate_account(&secrets, &FixedClock)
.expect("generate");
@@ -296,9 +311,110 @@ mod tests {
}));
let reopened =
PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
- let restored = reopened.bootstrap().expect("restore");
+ let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore");
assert_eq!(restored.accounts().len(), 2);
assert_eq!(restored.selected_account(), Some(selected));
assert_eq!(restored.session(), SessionState::SignedOut);
}
+
+ #[test]
+ fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() {
+ let directory = tempdir().expect("directory");
+ let path = directory.path().join("studio.sqlite3");
+ let secrets = InMemorySecretStore::default();
+ let first;
+ let removed;
+ {
+ let adapter =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter");
+ adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap");
+ first = adapter
+ .generate_account(&secrets, &FixedClock)
+ .expect("first")
+ .account()
+ .public_key();
+ removed = adapter
+ .generate_account(&secrets, &FixedClock)
+ .expect("removed")
+ .account()
+ .public_key();
+ let operation = adapter
+ .database()
+ .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now())
+ .expect("intent");
+ secrets.delete(removed).expect("credential deletion");
+ adapter
+ .database()
+ .update_operation(
+ operation,
+ AccountOperationPhase::CredentialDeleted,
+ FixedClock.now(),
+ None,
+ )
+ .expect("phase");
+ }
+
+ let reopened =
+ PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen");
+ let restored = reopened
+ .bootstrap(&secrets, &FixedClock)
+ .expect("recover and bootstrap");
+ assert_eq!(restored.accounts().len(), 1);
+ assert_eq!(restored.selected_account(), Some(first));
+ assert_eq!(restored.session(), SessionState::SignedOut);
+ assert!(
+ reopened
+ .database()
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ assert!(
+ reopened
+ .database()
+ .find_account(removed)
+ .expect("removed")
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn bootstrap_skips_keyring_when_journal_empty_and_retains_failed_intent() {
+ let empty = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("empty");
+ let unavailable = FailureSecretStore::default();
+ unavailable.fail_next(SecretStoreOperation::Delete);
+ empty
+ .bootstrap(&unavailable, &FixedClock)
+ .expect("empty journal does not access keyring");
+
+ let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter");
+ adapter
+ .database()
+ .insert_account(&account())
+ .expect("account");
+ adapter
+ .database()
+ .save_selected_account(Some(account().public_key()))
+ .expect("selection");
+ adapter
+ .database()
+ .begin_operation(
+ AccountOperationKind::Remove,
+ account().public_key(),
+ FixedClock.now(),
+ )
+ .expect("intent");
+ let failing = FailureSecretStore::default();
+ failing.fail_next(SecretStoreOperation::Delete);
+ let error = adapter
+ .bootstrap(&failing, &FixedClock)
+ .expect_err("keyring unavailable");
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ let pending = adapter
+ .database()
+ .list_pending_operations()
+ .expect("pending");
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded);
+ }
}