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:
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");