commit 7d6a2a51131babfcf658ebf201abccc03af1ba8e
parent f02ee0da6ea3b91ae6749eec6abc5ed0ba21fd6e
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:03:25 +0000
core(accounts): add recoverable account write transactions
- journal credential and metadata phases before publication
- compensate credential writes when account persistence fails
- retain a non-secret recovery entry when compensation fails
- keep public snapshots unchanged across every failed boundary
Diffstat:
2 files changed, 357 insertions(+), 18 deletions(-)
diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs
@@ -6,7 +6,11 @@ use radroots_studio_domain::{
};
use radroots_studio_nostr::{generate_local_keypair, import_secret};
-use crate::{AccountRepository, AppCore, AppStateRepository, Clock, SecretStore, StateTransition};
+use crate::{
+ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository,
+ Clock, OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation,
+ SecretStore, StateTransition,
+};
pub struct GenerateAccountReceipt {
account: AccountSummary,
@@ -48,6 +52,7 @@ impl AppCore {
accounts: &(impl AccountRepository + ?Sized),
app_state: &(impl AppStateRepository + ?Sized),
secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
clock: &(impl Clock + ?Sized),
) -> Result<GenerateAccountReceipt, SafeError> {
let generated = generate_local_keypair()?;
@@ -61,8 +66,16 @@ impl AppCore {
AccountCreatedAt::new(clock.now()),
None,
);
- secrets.put(public_key, secret)?;
- accounts.insert_account(&account)?;
+ Self::persist_account_transaction(
+ AccountOperationKind::Add,
+ &account,
+ secret,
+ None,
+ accounts,
+ secrets,
+ journal,
+ clock,
+ )?;
app_state.save_selected_account(Some(public_key))?;
let registry = accounts.list_accounts()?;
self.apply_transition(StateTransition::ReplaceRegistry {
@@ -86,6 +99,7 @@ impl AppCore {
accounts: &(impl AccountRepository + ?Sized),
app_state: &(impl AppStateRepository + ?Sized),
secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
clock: &(impl Clock + ?Sized),
) -> Result<ImportAccountReceipt, SafeError> {
let imported = import_secret(input)?;
@@ -96,9 +110,17 @@ impl AppCore {
{
return Err(account_exists());
}
- secrets.put(public_key, secret)?;
let repaired = existing.with_key_availability(KeyAvailability::Available);
- accounts.update_account(&repaired)?;
+ Self::persist_account_transaction(
+ AccountOperationKind::Import,
+ &repaired,
+ secret,
+ Some(&existing),
+ accounts,
+ secrets,
+ journal,
+ clock,
+ )?;
app_state.save_selected_account(Some(public_key))?;
self.apply_transition(StateTransition::ReplaceRegistry {
accounts: accounts.list_accounts()?,
@@ -118,8 +140,16 @@ impl AppCore {
AccountCreatedAt::new(clock.now()),
None,
);
- secrets.put(public_key, secret)?;
- accounts.insert_account(&account)?;
+ Self::persist_account_transaction(
+ AccountOperationKind::Import,
+ &account,
+ secret,
+ None,
+ accounts,
+ secrets,
+ journal,
+ clock,
+ )?;
app_state.save_selected_account(Some(public_key))?;
self.apply_transition(StateTransition::ReplaceRegistry {
accounts: accounts.list_accounts()?,
@@ -127,6 +157,161 @@ impl AppCore {
})?;
Ok(ImportAccountReceipt { account })
}
+
+ #[allow(clippy::too_many_arguments)]
+ fn persist_account_transaction(
+ kind: AccountOperationKind,
+ account: &AccountSummary,
+ secret: SecretKeyInput,
+ previous: Option<&AccountSummary>,
+ accounts: &(impl AccountRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+ ) -> Result<(), SafeError> {
+ let public_key = account.public_key();
+ let operation = journal.begin_operation(kind, public_key, clock.now())?;
+ if let Err(error) = secrets.put(public_key, secret) {
+ let _ = journal.finalize_operation(operation);
+ return Err(error);
+ }
+ if let Err(error) = journal.update_operation(
+ operation,
+ AccountOperationPhase::CredentialWritten,
+ clock.now(),
+ None,
+ ) {
+ return compensate_account_write(
+ operation, public_key, error, None, accounts, secrets, journal, clock,
+ );
+ }
+ let metadata_result = previous.map_or_else(
+ || accounts.insert_account(account),
+ |_| accounts.update_account(account),
+ );
+ if let Err(error) = metadata_result {
+ return compensate_account_write(
+ operation, public_key, error, previous, accounts, secrets, journal, clock,
+ );
+ }
+ journal.update_operation(
+ operation,
+ AccountOperationPhase::MetadataCommitted,
+ clock.now(),
+ None,
+ )?;
+ journal.finalize_operation(operation)
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn compensate_account_write(
+ operation: OperationId,
+ public_key: PublicKey,
+ original_error: SafeError,
+ previous: Option<&AccountSummary>,
+ accounts: &(impl AccountRepository + ?Sized),
+ secrets: &(impl SecretStore + ?Sized),
+ journal: &(impl OperationJournal + ?Sized),
+ clock: &(impl Clock + ?Sized),
+) -> Result<(), SafeError> {
+ if let Some(previous) = previous {
+ let _ = accounts.update_account(previous);
+ } else {
+ let _ = accounts.remove_account(public_key);
+ }
+ if secrets.delete(public_key).is_err() {
+ let _ = journal.update_operation(
+ operation,
+ AccountOperationPhase::CompensationPending,
+ clock.now(),
+ Some(OperationDiagnostic::CompensationFailed),
+ );
+ return Err(recovery_required());
+ }
+ let _ = journal.finalize_operation(operation);
+ Err(original_error)
+}
+
+#[derive(Default)]
+pub struct InMemoryOperationJournal {
+ state: Mutex<InMemoryJournalState>,
+}
+
+#[derive(Default)]
+struct InMemoryJournalState {
+ next_id: u64,
+ pending: Vec<PendingAccountOperation>,
+}
+
+impl OperationJournal for InMemoryOperationJournal {
+ fn begin_operation(
+ &self,
+ kind: AccountOperationKind,
+ subject: PublicKey,
+ updated_at: radroots_studio_domain::UnixTimestamp,
+ ) -> Result<OperationId, SafeError> {
+ let mut state = self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?;
+ let id = OperationId::from_raw(state.next_id);
+ state.pending.push(PendingAccountOperation::new(
+ id,
+ kind,
+ subject,
+ AccountOperationPhase::IntentRecorded,
+ updated_at,
+ None,
+ ));
+ Ok(id)
+ }
+
+ fn update_operation(
+ &self,
+ id: OperationId,
+ phase: AccountOperationPhase,
+ updated_at: radroots_studio_domain::UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<(), SafeError> {
+ let mut state = self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let operation = state
+ .pending
+ .iter_mut()
+ .find(|operation| operation.id() == id)
+ .ok_or_else(recovery_required)?;
+ *operation = PendingAccountOperation::new(
+ id,
+ operation.kind(),
+ operation.subject(),
+ phase,
+ updated_at,
+ diagnostic,
+ );
+ Ok(())
+ }
+
+ fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> {
+ Ok(self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .pending
+ .clone())
+ }
+
+ fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> {
+ self.state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .pending
+ .retain(|operation| operation.id() != id);
+ Ok(())
+ }
}
#[derive(Default)]
@@ -235,17 +420,25 @@ const fn account_not_found() -> SafeError {
)
}
+const fn recovery_required() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::PendingOperationRecoveryRequired,
+ SafeMessage::new("Account recovery is required before this operation can continue."),
+ )
+}
+
#[cfg(test)]
mod tests {
use radroots_studio_domain::{
- AccountCreatedAt, AccountSummary, KeyAvailability, SafeErrorCode, SecretKeyInput,
- SignerKind, UnixTimestamp,
+ AccountCreatedAt, AccountSummary, KeyAvailability, PublicKey, SafeError, SafeErrorCode,
+ SafeMessage, SecretKeyInput, SignerKind, UnixTimestamp,
};
use super::InMemoryAccountRepository;
use crate::{
- AccountRepository, AppCore, AppStateRepository, Clock, InMemorySecretStore,
- RelayConfiguration, SecretStore, SessionState, StateTransition,
+ AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock,
+ FailureSecretStore, InMemoryOperationJournal, InMemorySecretStore, OperationJournal,
+ RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition,
};
struct FixedClock;
@@ -256,15 +449,56 @@ mod tests {
}
}
+ #[derive(Default)]
+ struct FailingInsertRepository {
+ inner: InMemoryAccountRepository,
+ }
+
+ impl AccountRepository for FailingInsertRepository {
+ fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> {
+ self.inner.list_accounts()
+ }
+
+ fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> {
+ self.inner.find_account(public_key)
+ }
+
+ fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> {
+ Err(SafeError::new(
+ SafeErrorCode::StorageUnavailable,
+ SafeMessage::new("The test account repository is unavailable."),
+ ))
+ }
+
+ fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> {
+ self.inner.update_account(account)
+ }
+
+ fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ self.inner.remove_account(public_key)
+ }
+ }
+
+ impl AppStateRepository for FailingInsertRepository {
+ fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> {
+ self.inner.load_selected_account()
+ }
+
+ fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> {
+ self.inner.save_selected_account(public_key)
+ }
+ }
+
#[test]
fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() {
let core = AppCore::in_memory(RelayConfiguration::default());
let accounts = InMemoryAccountRepository::default();
let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
core.bootstrap().expect("bootstrap");
let receipt = core
- .generate_account(&accounts, &accounts, &secrets, &FixedClock)
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
.expect("generate");
let public_key = receipt.account().public_key();
assert_eq!(public_key.to_hex().len(), 64);
@@ -289,6 +523,7 @@ mod tests {
let core = AppCore::in_memory(RelayConfiguration::default());
let accounts = InMemoryAccountRepository::default();
let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
core.bootstrap().expect("bootstrap");
let receipt = core
.import_secret_key(
@@ -296,6 +531,7 @@ mod tests {
&accounts,
&accounts,
&secrets,
+ &journal,
&FixedClock,
)
.expect("import");
@@ -312,13 +548,14 @@ mod tests {
let core = AppCore::in_memory(RelayConfiguration::default());
let accounts = InMemoryAccountRepository::default();
let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
core.bootstrap().expect("bootstrap");
let input = SecretKeyInput::parse(
"nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(),
)
.expect("domain shape");
let error = core
- .import_secret_key(input, &accounts, &accounts, &secrets, &FixedClock)
+ .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock)
.expect_err("invalid import");
assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey);
assert!(core.snapshot().accounts().is_empty());
@@ -329,6 +566,7 @@ mod tests {
let core = AppCore::in_memory(RelayConfiguration::default());
let accounts = InMemoryAccountRepository::default();
let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
core.bootstrap().expect("bootstrap");
let import = || {
SecretKeyInput::parse(
@@ -336,11 +574,25 @@ mod tests {
)
.expect("input")
};
- core.import_secret_key(import(), &accounts, &accounts, &secrets, &FixedClock)
- .expect("first import");
+ core.import_secret_key(
+ import(),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
+ .expect("first import");
let before = core.snapshot();
let error = core
- .import_secret_key(import(), &accounts, &accounts, &secrets, &FixedClock)
+ .import_secret_key(
+ import(),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
.expect_err("duplicate");
assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists);
assert_eq!(core.snapshot(), before);
@@ -352,6 +604,7 @@ mod tests {
let core = AppCore::in_memory(RelayConfiguration::default());
let accounts = InMemoryAccountRepository::default();
let secrets = InMemorySecretStore::default();
+ let journal = InMemoryOperationJournal::default();
core.bootstrap().expect("bootstrap");
let input = || {
SecretKeyInput::parse(
@@ -381,7 +634,14 @@ mod tests {
.expect("registry");
let receipt = core
- .import_secret_key(input(), &accounts, &accounts, &secrets, &FixedClock)
+ .import_secret_key(
+ input(),
+ &accounts,
+ &accounts,
+ &secrets,
+ &journal,
+ &FixedClock,
+ )
.expect("repair");
assert_eq!(
receipt.account().key_availability(),
@@ -390,4 +650,80 @@ mod tests {
assert!(secrets.contains(public_key).expect("credential"));
assert_eq!(core.snapshot().accounts().len(), 1);
}
+
+ #[test]
+ fn account_transaction_publishes_nothing_when_credential_write_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = InMemoryAccountRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ secrets.fail_next(SecretStoreOperation::Put);
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("credential failure");
+ assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable);
+ assert!(core.snapshot().accounts().is_empty());
+ assert!(
+ journal
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn account_transaction_removes_written_credential_when_metadata_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = FailingInsertRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("metadata failure");
+ assert_eq!(error.code(), SafeErrorCode::StorageUnavailable);
+ let calls = secrets.calls();
+ assert_eq!(calls[0].operation(), SecretStoreOperation::Put);
+ assert_eq!(calls[1].operation(), SecretStoreOperation::Delete);
+ assert_eq!(calls[0].public_key(), calls[1].public_key());
+ assert!(core.snapshot().accounts().is_empty());
+ assert!(
+ journal
+ .list_pending_operations()
+ .expect("journal")
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn account_transaction_retains_non_secret_journal_when_compensation_fails() {
+ let core = AppCore::in_memory(RelayConfiguration::default());
+ let accounts = FailingInsertRepository::default();
+ let secrets = FailureSecretStore::default();
+ let journal = InMemoryOperationJournal::default();
+ core.bootstrap().expect("bootstrap");
+ secrets.fail_next(SecretStoreOperation::Delete);
+
+ let error = core
+ .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock)
+ .err()
+ .expect("recovery required");
+ assert_eq!(
+ error.code(),
+ SafeErrorCode::PendingOperationRecoveryRequired
+ );
+ let pending = journal.list_pending_operations().expect("journal");
+ assert_eq!(pending.len(), 1);
+ assert_eq!(
+ pending[0].phase(),
+ AccountOperationPhase::CompensationPending
+ );
+ assert!(!format!("{pending:?}").contains("nsec1"));
+ assert!(core.snapshot().accounts().is_empty());
+ }
}
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -7,7 +7,10 @@ pub mod secrets;
pub mod snapshot;
pub mod state_machine;
-pub use accounts::{GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository};
+pub use accounts::{
+ GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository,
+ InMemoryOperationJournal,
+};
pub use app_core::{AppCore, AppObserver, ObserverHandle};
pub use ports::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,