app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 55ec47b39675805716e348b0b20f674f5a598929
parent 014e231fdba7e23b7e1b08dd10a1ce57e312aaaf
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:02:25 +0000

operations: reconcile interrupted account writes

- inspect durable phases before restoring public application state
- complete metadata and selection commits when credential state is valid
- compensate orphan credentials and restore prior repair availability
- retain failed terminal receipts without deleting pre-existing accounts

Diffstat:
Mcore/crates/application/src/recovery.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/storage/src/application_adapter.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 254 insertions(+), 4 deletions(-)

diff --git a/core/crates/application/src/recovery.rs b/core/crates/application/src/recovery.rs @@ -2,10 +2,38 @@ use radroots_studio_domain::{PublicKey, SafeError}; use crate::{ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, - Clock, OperationJournal, SecretStore, + Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore, }; impl AppCore { + /// Reconciles durable request operations before public state is restored. + /// + /// # Errors + /// + /// Returns a safe credential, persistence, or recovery error while retaining the operation + /// at its last durable phase for a later retry. + pub fn recover_durable_operations( + &self, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + for operation in operations.list_unfinished_durable_operations()? { + match operation.kind() { + DurableOperationKind::Create + | DurableOperationKind::Import + | DurableOperationKind::Repair => recover_durable_addition( + &operation, accounts, app_state, secrets, operations, clock, + )?, + DurableOperationKind::Remove => {} + } + } + Ok(()) + } + /// Reconciles non-secret cross-resource journal entries before bootstrap. /// /// An empty journal does not access the credential store. @@ -36,6 +64,148 @@ impl AppCore { } } +fn recover_durable_addition( + 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(); + match operation.phase() { + DurableOperationPhase::IntentRecorded => { + if secrets.contains(account)? { + secrets.delete(account)?; + } + operations.finalize_durable_operation( + request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Failed, + None, + clock.now(), + )?; + } + DurableOperationPhase::CredentialWritten => { + let metadata = accounts.find_account(account)?; + let committed = metadata.as_ref().is_some_and(|saved| { + saved.signer().availability() + == radroots_studio_domain::BindingAvailability::Available + }); + if committed { + operations.advance_durable_operation( + request, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + finish_durable_selection(operation, app_state, operations, clock)?; + } else { + operations.advance_durable_operation( + request, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::CompensationPending, + clock.now(), + None, + )?; + compensate_durable_addition( + operation, accounts, app_state, secrets, operations, clock, + )?; + } + } + DurableOperationPhase::MetadataCommitted => { + finish_durable_selection(operation, app_state, operations, clock)?; + } + DurableOperationPhase::SelectionCommitted => { + operations.finalize_durable_operation( + request, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + None, + clock.now(), + )?; + } + DurableOperationPhase::CompensationPending => { + compensate_durable_addition( + operation, accounts, app_state, secrets, operations, clock, + )?; + } + DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => { + operations.finalize_durable_operation( + request, + operation.phase(), + DurableTerminalOutcome::Failed, + None, + clock.now(), + )?; + } + DurableOperationPhase::Finalized => {} + } + Ok(()) +} + +fn finish_durable_selection( + operation: &DurableAccountOperation, + app_state: &(impl AppStateRepository + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + app_state.save_selected_account(Some(operation.account()))?; + operations.advance_durable_operation( + operation.request_id(), + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + operations.finalize_durable_operation( + operation.request_id(), + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + None, + clock.now(), + )?; + Ok(()) +} + +fn compensate_durable_addition( + 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> { + if secrets.contains(operation.account())? { + secrets.delete(operation.account())?; + } + if let Some(availability) = operation.prior().binding_availability() { + if let Some(previous) = accounts.find_account(operation.account())? { + accounts.update_account(&previous.with_binding_availability(availability))?; + } + } else if accounts.find_account(operation.account())?.is_some() { + accounts.remove_account(operation.account())?; + } + app_state.save_selected_account(operation.prior().selected_account())?; + operations.advance_durable_operation( + operation.request_id(), + DurableOperationPhase::CompensationPending, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + operations.finalize_durable_operation( + operation.request_id(), + DurableOperationPhase::CredentialDeleted, + DurableTerminalOutcome::Failed, + None, + clock.now(), + )?; + Ok(()) +} + fn recover_removal( operation: &crate::PendingAccountOperation, accounts: &(impl AccountRepository + ?Sized), diff --git a/core/crates/storage/src/application_adapter.rs b/core/crates/storage/src/application_adapter.rs @@ -49,6 +49,13 @@ impl PersistentAppCore { secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { + self.core.recover_durable_operations( + &self.database, + &self.database, + secrets, + &self.database, + clock, + )?; self.core.recover_pending_operations( &self.database, &self.database, @@ -237,9 +244,10 @@ mod tests { use radroots_studio_application::{ AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, - AppStateRepository, Clock, DurableOperationRepository, DurableRequestId, - FailureSecretStore, InMemorySecretStore, OperationJournal, RelayConfiguration, SecretStore, - SecretStoreOperation, SessionState, + AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, + InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, + SecretStore, SecretStoreOperation, SessionState, }; use radroots_studio_domain::{ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, @@ -396,6 +404,78 @@ mod tests { } #[test] + fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let secrets = InMemorySecretStore::default(); + let missing = account().with_binding_availability(BindingAvailability::CredentialMissing); + adapter + .database() + .insert_account(&missing) + .expect("account"); + adapter + .database() + .save_selected_account(Some(missing.public_key())) + .expect("selection"); + let request = DurableRequestId::parse("repair:recovery:1").expect("request"); + adapter + .database() + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + missing.public_key(), + Some(0), + OperationPriorState::new( + Some(missing.public_key()), + Some(BindingAvailability::CredentialMissing), + ), + FixedClock.now(), + ) + .expect("intent"); + secrets + .put( + missing.public_key(), + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + ) + .expect("credential"); + adapter + .database() + .advance_durable_operation( + &request, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential phase"); + + adapter.bootstrap(&secrets, &FixedClock).expect("recovery"); + let repaired = adapter + .database() + .find_account(missing.public_key()) + .expect("lookup") + .expect("preserved account"); + assert_eq!( + repaired.signer().availability(), + BindingAvailability::CredentialMissing + ); + assert!(!secrets.contains(missing.public_key()).expect("credential")); + assert_eq!( + adapter + .database() + .load_durable_operation(&request) + .expect("operation") + .expect("record") + .terminal() + .expect("receipt") + .outcome(), + DurableTerminalOutcome::Failed + ); + } + + #[test] fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { let directory = tempdir().expect("directory"); let path = directory.path().join("studio.sqlite3");