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