lib

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

commit c2ffa039818df2cade989a96eef943625d0d4d34
parent 226acf73a2610c2c5c1abb269935135d99218e4e
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 22:59:24 +0000

operations: coordinate durable account writes

- persist durable intent before every credential mutation
- advance credential metadata selection and terminal phases explicitly
- bind operations to expected snapshot revisions and prior binding state
- route actor create and import commands through the durable coordinator

Diffstat:
Mcrates/studio_application/src/accounts.rs | 206++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/studio_storage/src/application_adapter.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/studio_storage/src/runtime_actor.rs | 55+++++++++++++++++++++++++++++++++++++++++++++----------
3 files changed, 334 insertions(+), 15 deletions(-)

diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -8,7 +8,9 @@ use radroots_studio_nostr::{generate_local_keypair, import_secret}; use crate::{ AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, - Clock, OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, + Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, + DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, + OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, RemovalConfirmationToken, SecretStore, StateTransition, }; @@ -42,6 +44,201 @@ impl GenerateAccountReceipt { } impl AppCore { + /// Generates and commits one account under a durable caller request. + /// + /// # Errors + /// + /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport + /// replaces this transitional generated-secret receipt in the custody phase. + #[allow(clippy::too_many_arguments)] + pub fn generate_account_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.require_revision(expected_revision)?; + let generated = generate_local_keypair()?; + let (public_key, npub, secret, nsec) = generated.into_parts(); + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(clock.now()), + None, + )?; + self.persist_account_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &account, + secret, + None, + accounts, + app_state, + secrets, + operations, + clock, + )?; + Ok(GenerateAccountReceipt { + account, + generated_nsec: nsec, + }) + } + + /// Imports or explicitly repairs one local account under a durable caller request. + /// + /// # Errors + /// + /// Returns a safe conflict, validation, keyring, persistence, or state error. + #[allow(clippy::too_many_arguments)] + pub fn import_secret_key_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + input: SecretKeyInput, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.require_revision(expected_revision)?; + let imported = import_secret(input)?; + let (public_key, npub, secret) = imported.into_parts(); + let previous = accounts.find_account(public_key)?; + if let Some(existing) = &previous + && (existing.signer().availability() != BindingAvailability::CredentialMissing + || secrets.contains(public_key)?) + { + return Err(account_exists()); + } + if previous.is_none() && secrets.contains(public_key)? { + return Err(account_exists()); + } + let account = if let Some(existing) = &previous { + existing.with_binding_availability(BindingAvailability::Available) + } else { + AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(clock.now()), + None, + )? + }; + let kind = if previous.is_some() { + DurableOperationKind::Repair + } else { + DurableOperationKind::Import + }; + self.persist_account_durable( + request_id, + kind, + expected_revision, + &account, + secret, + previous.as_ref(), + accounts, + app_state, + secrets, + operations, + clock, + )?; + Ok(ImportAccountReceipt { account }) + } + + fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> { + if self.snapshot().revision().value() != expected_revision { + return Err(operation_conflict()); + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn persist_account_durable( + &self, + request_id: &DurableRequestId, + kind: DurableOperationKind, + expected_revision: u64, + account: &AccountSummary, + secret: SecretKeyInput, + previous: Option<&AccountSummary>, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + let prior = OperationPriorState::new( + app_state.load_selected_account()?, + previous.map(|account| account.signer().availability()), + ); + match operations.begin_durable_operation( + request_id, + kind, + account.public_key(), + Some(expected_revision), + prior, + clock.now(), + )? { + DurableOperationStart::Started(_) => {} + DurableOperationStart::Existing(operation) => { + return if operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + Ok(()) + } else { + Err(recovery_required()) + }; + } + } + secrets.put(account.public_key(), secret)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + clock.now(), + None, + )?; + previous.map_or_else( + || accounts.insert_account(account), + |_| accounts.update_account(account), + )?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + app_state.save_selected_account(Some(account.public_key()))?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + let snapshot = self.apply_transition(StateTransition::ReplaceRegistry { + accounts: accounts.list_accounts()?, + selected: Some(account.public_key()), + })?; + operations.finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + )?; + Ok(()) + } + /// Issues a single-use confirmation bound to the target and current revision. /// /// # Errors @@ -558,6 +755,13 @@ const fn recovery_required() -> SafeError { ) } +const fn operation_conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The account operation conflicts with the current application state."), + ) +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs @@ -1,8 +1,8 @@ use std::path::Path; use radroots_studio_application::{ - AppCore, AppSnapshot, Clock, GenerateAccountReceipt, ImportAccountReceipt, RelayConfiguration, - RemovalConfirmationToken, SecretStore, + AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, + RelayConfiguration, RemovalConfirmationToken, SecretStore, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput}; @@ -99,6 +99,54 @@ impl PersistentAppCore { ) } + /// Generates an account through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, credential, storage, or application-state error. + pub fn generate_account_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateAccountReceipt, SafeError> { + self.core.generate_account_durable( + request_id, + expected_revision, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + + /// Imports or repairs an account through the durable request coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, validation, credential, storage, or state error. + pub fn import_secret_key_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + input: SecretKeyInput, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.import_secret_key_durable( + request_id, + expected_revision, + input, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + /// Persists and publishes one saved-account selection without activation. /// /// # Errors @@ -189,8 +237,9 @@ mod tests { use radroots_studio_application::{ AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, - AppStateRepository, Clock, FailureSecretStore, InMemorySecretStore, OperationJournal, - RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, + AppStateRepository, Clock, DurableOperationRepository, DurableRequestId, + FailureSecretStore, InMemorySecretStore, OperationJournal, RelayConfiguration, SecretStore, + SecretStoreOperation, SessionState, }; use radroots_studio_domain::{ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, @@ -316,6 +365,37 @@ mod tests { } #[test] + fn durable_import_commits_each_phase_and_recovers_the_terminal_receipt() { + let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); + let secrets = InMemorySecretStore::default(); + let snapshot = adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let request = DurableRequestId::parse("import:adapter:1").expect("request"); + let imported = adapter + .import_secret_key_durable( + &request, + snapshot.revision().value(), + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + &secrets, + &FixedClock, + ) + .expect("durable import"); + let operation = adapter + .database() + .load_durable_operation(&request) + .expect("operation") + .expect("durable record"); + let receipt = operation.terminal().expect("terminal receipt"); + assert_eq!(receipt.account(), imported.account().public_key()); + assert_eq!( + receipt.resulting_revision(), + Some(adapter.core().snapshot().revision().value()) + ); + } + + #[test] fn bootstrap_recovery_completes_credential_deleted_removal_and_fallback() { let directory = tempdir().expect("directory"); let path = directory.path().join("studio.sqlite3"); diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs @@ -81,6 +81,7 @@ struct RuntimeActor { profile_tasks: BTreeMap<RequestId, PendingProfileTask>, changes: OrderedSnapshotChanges, published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, + durable_request_namespace: String, } struct PendingProfileTask { @@ -192,6 +193,13 @@ impl RuntimeActorHandle { let session_generation = Arc::new(AtomicU64::new(SessionGeneration::initial().value())); let foreground_session = Arc::new(Mutex::new(None)); let changes = OrderedSnapshotChanges::new(adapter.core().snapshot()); + let durable_request_namespace = format!( + "runtime:{}:{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) + ); let actor = RuntimeActor { adapter: Arc::clone(&adapter), secrets, @@ -204,6 +212,7 @@ impl RuntimeActorHandle { profile_tasks: BTreeMap::new(), changes, published_foreground_session: Arc::clone(&foreground_session), + durable_request_namespace, }; drop(runtime.spawn(actor.run(receiver))); Ok(Self { @@ -551,7 +560,7 @@ impl RuntimeActor { | RuntimeCommand::SignOut | RuntimeCommand::ConfirmAccountRemoval(_) ); - let result = self.execute_sync(command); + let result = self.execute_sync(context, command); if changes_session && matches!(result, CommandResult::Completed(_)) { self.advance_session_generation(); self.synchronize_foreground_session(); @@ -592,19 +601,45 @@ impl RuntimeActor { None } - fn execute_sync(&mut self, command: RuntimeCommand) -> CommandResult<RuntimeCommandValue> { + fn execute_sync( + &mut self, + context: CommandContext, + command: RuntimeCommand, + ) -> CommandResult<RuntimeCommandValue> { + let durable_request = radroots_studio_application::DurableRequestId::parse(format!( + "{}:{}", + self.durable_request_namespace, + context.request_id().get() + )); + let expected_revision = context + .expected_revision() + .unwrap_or_else(|| self.adapter.core().snapshot().revision()) + .value(); let result = match command { RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new( self.adapter.core().snapshot(), ))), - RuntimeCommand::GenerateAccount => self - .adapter - .generate_account(self.secrets.as_ref(), self.clock.as_ref()) - .map(RuntimeCommandValue::Generated), - RuntimeCommand::ImportSecretKey(input) => self - .adapter - .import_secret_key(input, self.secrets.as_ref(), self.clock.as_ref()) - .map(RuntimeCommandValue::Imported), + RuntimeCommand::GenerateAccount => durable_request.and_then(|request| { + self.adapter + .generate_account_durable( + &request, + expected_revision, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(RuntimeCommandValue::Generated) + }), + RuntimeCommand::ImportSecretKey(input) => durable_request.and_then(|request| { + self.adapter + .import_secret_key_durable( + &request, + expected_revision, + input, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(RuntimeCommandValue::Imported) + }), RuntimeCommand::SelectAccount(public_key) => self .adapter .select_account(public_key)