lib

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

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:
Mcrates/studio_application/src/lib.rs | 1+
Acrates/studio_application/src/recovery.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/application_adapter.rs | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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(&registry, 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); + } }