commit e0ac766b313379aa8d7160d517643d1207b9646b
parent c0075ad7554a32d7cfed30713d490974e594fc01
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 18:21:37 +0000
core(state): add deterministic transition traces
- centralize registry, selection, activation, and sign-out updates
- preserve a working session during replacement activation
- restore prior state with safe problems after replacement failure
- publish observers only for successful revision changes
Diffstat:
5 files changed, 441 insertions(+), 41 deletions(-)
diff --git a/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs
@@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex, MutexGuard};
use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage};
-use crate::{AppSnapshot, RelayConfiguration, SessionState};
+use crate::{AppSnapshot, RelayConfiguration, StateMachine, StateTransition};
pub trait AppObserver: Send + Sync {
fn on_snapshot_changed(&self, snapshot: AppSnapshot);
@@ -20,7 +20,7 @@ impl ObserverHandle {
}
struct CoreState {
- snapshot: AppSnapshot,
+ state_machine: StateMachine,
observers: BTreeMap<ObserverHandle, Arc<dyn AppObserver>>,
next_observer: u64,
}
@@ -36,7 +36,7 @@ impl AppCore {
Self {
relay_configuration,
state: Mutex::new(CoreState {
- snapshot: AppSnapshot::booting(),
+ state_machine: StateMachine::booting(),
observers: BTreeMap::new(),
next_observer: 1,
}),
@@ -50,32 +50,12 @@ impl AppCore {
/// Returns a safe application-state error if the ready snapshot invariant
/// cannot be constructed.
pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> {
- let next_snapshot = {
- let state = self.lock_state();
- if !matches!(state.snapshot.lifecycle(), crate::AppLifecycle::Booting) {
- return Ok(state.snapshot.clone());
- }
- AppSnapshot::ready(
- state
- .snapshot
- .revision()
- .next()
- .ok_or_else(invalid_application_state)?,
- self.relay_configuration.clone(),
- Vec::new(),
- None,
- SessionState::SignedOut,
- None,
- None,
- )?
- };
- self.publish(&next_snapshot);
- Ok(next_snapshot)
+ self.apply_transition(StateTransition::Bootstrap)
}
#[must_use]
pub fn snapshot(&self) -> AppSnapshot {
- self.lock_state().snapshot.clone()
+ self.lock_state().state_machine.snapshot().clone()
}
/// Registers an observer and immediately supplies the current snapshot.
@@ -93,7 +73,7 @@ impl AppCore {
.ok_or_else(observer_registration_failed)?;
let registered = Arc::clone(&observer);
state.observers.insert(handle, observer);
- (handle, state.snapshot.clone(), registered)
+ (handle, state.state_machine.snapshot().clone(), registered)
};
observer.on_snapshot_changed(snapshot);
Ok(handle)
@@ -104,15 +84,24 @@ impl AppCore {
self.lock_state().observers.remove(&handle).is_some()
}
- fn publish(&self, snapshot: &AppSnapshot) {
- let observers = {
+ fn apply_transition(&self, transition: StateTransition) -> Result<AppSnapshot, SafeError> {
+ let (snapshot, observers) = {
let mut state = self.lock_state();
- state.snapshot.clone_from(snapshot);
- state.observers.values().cloned().collect::<Vec<_>>()
+ let previous_revision = state.state_machine.snapshot().revision();
+ let snapshot = state
+ .state_machine
+ .apply(transition, &self.relay_configuration)?;
+ let observers = if snapshot.revision() == previous_revision {
+ Vec::new()
+ } else {
+ state.observers.values().cloned().collect::<Vec<_>>()
+ };
+ (snapshot, observers)
};
for observer in observers {
observer.on_snapshot_changed(snapshot.clone());
}
+ Ok(snapshot)
}
fn lock_state(&self) -> MutexGuard<'_, CoreState> {
@@ -122,13 +111,6 @@ impl AppCore {
}
}
-const fn invalid_application_state() -> SafeError {
- SafeError::new(
- SafeErrorCode::InvalidApplicationState,
- SafeMessage::new("The application state is invalid."),
- )
-}
-
const fn observer_registration_failed() -> SafeError {
SafeError::new(
SafeErrorCode::ObserverRegistrationFailed,
diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs
@@ -3,6 +3,7 @@
pub mod app_core;
pub mod ports;
pub mod snapshot;
+pub mod state_machine;
pub use app_core::{AppCore, AppObserver, ObserverHandle};
pub use ports::{
@@ -13,3 +14,4 @@ pub use snapshot::{
ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration,
RelayConnectionState, SessionState, SnapshotRevision,
};
+pub use state_machine::{StateMachine, StateTransition};
diff --git a/core/crates/application/src/snapshot.rs b/core/crates/application/src/snapshot.rs
@@ -242,7 +242,8 @@ fn validate_snapshot(
|| selected_account.is_some_and(|key| !unique_accounts.contains(&key))
|| active_account
.is_some_and(|active| !unique_accounts.contains(&active.account().public_key()))
- || (matches!(session, SessionState::Active) != active_account.is_some())
+ || (matches!(session, SessionState::Active) && active_account.is_none())
+ || (matches!(session, SessionState::SignedOut) && active_account.is_some())
{
return Err(invalid_snapshot());
}
diff --git a/core/crates/application/src/state_machine.rs b/core/crates/application/src/state_machine.rs
@@ -0,0 +1,415 @@
+use radroots_studio_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage};
+
+use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum StateTransition {
+ Bootstrap,
+ ReplaceRegistry {
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ },
+ Select(PublicKey),
+ BeginActivation(PublicKey),
+ ActivationSucceeded(Box<ActiveAccountSnapshot>),
+ ActivationFailed(SafeError),
+ SignOut,
+ SetProblem(Option<SafeError>),
+}
+
+#[derive(Clone)]
+struct PreviousSession {
+ session: SessionState,
+ active_account: Option<ActiveAccountSnapshot>,
+}
+
+pub struct StateMachine {
+ snapshot: AppSnapshot,
+ pending_activation: Option<(PublicKey, PreviousSession)>,
+}
+
+impl StateMachine {
+ #[must_use]
+ pub fn booting() -> Self {
+ Self {
+ snapshot: AppSnapshot::booting(),
+ pending_activation: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn snapshot(&self) -> &AppSnapshot {
+ &self.snapshot
+ }
+
+ /// Applies one deterministic state transition and returns the new snapshot.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application error when the transition violates account,
+ /// revision, activation, or snapshot invariants.
+ pub fn apply(
+ &mut self,
+ transition: StateTransition,
+ relay_configuration: &RelayConfiguration,
+ ) -> Result<AppSnapshot, SafeError> {
+ let next_revision = self
+ .snapshot
+ .revision()
+ .next()
+ .ok_or_else(invalid_application_state)?;
+
+ let next = match transition {
+ StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?,
+ StateTransition::ReplaceRegistry { accounts, selected } => {
+ self.replace_registry(next_revision, accounts, selected)?
+ }
+ StateTransition::Select(public_key) => self.select(next_revision, public_key)?,
+ StateTransition::BeginActivation(public_key) => {
+ self.begin_activation(next_revision, public_key)?
+ }
+ StateTransition::ActivationSucceeded(active_account) => {
+ self.activation_succeeded(next_revision, *active_account)?
+ }
+ StateTransition::ActivationFailed(problem) => {
+ self.activation_failed(next_revision, problem)?
+ }
+ StateTransition::SignOut => self.sign_out(next_revision)?,
+ StateTransition::SetProblem(problem) => self.copy_ready(
+ next_revision,
+ self.snapshot.selected_account(),
+ self.snapshot.session(),
+ self.snapshot.active_account().cloned(),
+ problem,
+ )?,
+ };
+ self.snapshot = next.clone();
+ Ok(next)
+ }
+
+ fn bootstrap(
+ &self,
+ revision: crate::SnapshotRevision,
+ relay_configuration: &RelayConfiguration,
+ ) -> Result<AppSnapshot, SafeError> {
+ if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
+ return Ok(self.snapshot.clone());
+ }
+ AppSnapshot::ready(
+ revision,
+ relay_configuration.clone(),
+ Vec::new(),
+ None,
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn replace_registry(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ accounts: Vec<AccountSummary>,
+ selected: Option<PublicKey>,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.pending_activation = None;
+ AppSnapshot::ready(
+ revision,
+ self.snapshot.relay_configuration().clone(),
+ accounts,
+ selected,
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn select(
+ &self,
+ revision: crate::SnapshotRevision,
+ public_key: PublicKey,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.require_account(public_key)?;
+ self.copy_ready(
+ revision,
+ Some(public_key),
+ self.snapshot.session(),
+ self.snapshot.active_account().cloned(),
+ None,
+ )
+ }
+
+ fn begin_activation(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ public_key: PublicKey,
+ ) -> Result<AppSnapshot, SafeError> {
+ self.require_account(public_key)?;
+ if self.pending_activation.is_some() {
+ return Err(invalid_application_state());
+ }
+ self.pending_activation = Some((
+ public_key,
+ PreviousSession {
+ session: self.snapshot.session(),
+ active_account: self.snapshot.active_account().cloned(),
+ },
+ ));
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ SessionState::Activating(public_key),
+ self.snapshot.active_account().cloned(),
+ None,
+ )
+ }
+
+ fn activation_succeeded(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ active_account: ActiveAccountSnapshot,
+ ) -> Result<AppSnapshot, SafeError> {
+ let Some((target, _previous)) = self.pending_activation.as_ref() else {
+ return Err(invalid_application_state());
+ };
+ if active_account.account().public_key() != *target {
+ return Err(invalid_application_state());
+ }
+ let target = *target;
+ self.pending_activation = None;
+ self.copy_ready(
+ revision,
+ Some(target),
+ SessionState::Active,
+ Some(active_account),
+ None,
+ )
+ }
+
+ fn activation_failed(
+ &mut self,
+ revision: crate::SnapshotRevision,
+ problem: SafeError,
+ ) -> Result<AppSnapshot, SafeError> {
+ let Some((_target, previous)) = self.pending_activation.take() else {
+ return Err(invalid_application_state());
+ };
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ previous.session,
+ previous.active_account,
+ Some(problem),
+ )
+ }
+
+ fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> {
+ self.pending_activation = None;
+ self.copy_ready(
+ revision,
+ self.snapshot.selected_account(),
+ SessionState::SignedOut,
+ None,
+ None,
+ )
+ }
+
+ fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> {
+ if self
+ .snapshot
+ .accounts()
+ .iter()
+ .any(|account| account.public_key() == public_key)
+ {
+ Ok(())
+ } else {
+ Err(account_not_found())
+ }
+ }
+
+ fn copy_ready(
+ &self,
+ revision: crate::SnapshotRevision,
+ selected_account: Option<PublicKey>,
+ session: SessionState,
+ active_account: Option<ActiveAccountSnapshot>,
+ recoverable_problem: Option<SafeError>,
+ ) -> Result<AppSnapshot, SafeError> {
+ AppSnapshot::ready(
+ revision,
+ self.snapshot.relay_configuration().clone(),
+ self.snapshot.accounts().to_vec(),
+ selected_account,
+ session,
+ active_account,
+ recoverable_problem,
+ )
+ }
+}
+
+const fn invalid_application_state() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The application state is invalid."),
+ )
+}
+
+const fn account_not_found() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::AccountNotFound,
+ SafeMessage::new("The account was not found."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::{
+ AccountCreatedAt, AccountSummary, KeyAvailability, Npub, PublicKey, SafeError,
+ SafeErrorCode, SafeMessage, SignerKind, UnixTimestamp,
+ };
+
+ use crate::{
+ ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState,
+ SessionState, StateMachine, StateTransition,
+ };
+
+ const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
+
+ fn account(key_byte: u8) -> AccountSummary {
+ AccountSummary::new(
+ PublicKey::from_bytes([key_byte; 32]),
+ Npub::from_encoded(NPUB.to_owned()).expect("valid npub"),
+ SignerKind::LocalSecret,
+ KeyAvailability::Available,
+ None,
+ AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
+ None,
+ )
+ }
+
+ fn active(account: AccountSummary) -> ActiveAccountSnapshot {
+ ActiveAccountSnapshot::new(
+ account,
+ RelayConnectionState::Disconnected,
+ ProfileLoadState::Empty,
+ None,
+ )
+ }
+
+ #[test]
+ fn state_machine_command_trace_preserves_working_session_on_failed_replacement() {
+ let first = account(1);
+ let second = account(2);
+ let mut machine = StateMachine::booting();
+ let relays = RelayConfiguration::default();
+ let problem = SafeError::new(
+ SafeErrorCode::CredentialMissing,
+ SafeMessage::new("The account credential is missing."),
+ );
+
+ machine
+ .apply(StateTransition::Bootstrap, &relays)
+ .expect("bootstrap");
+ machine
+ .apply(
+ StateTransition::ReplaceRegistry {
+ accounts: vec![first.clone(), second.clone()],
+ selected: Some(first.public_key()),
+ },
+ &relays,
+ )
+ .expect("load registry");
+ machine
+ .apply(
+ StateTransition::BeginActivation(first.public_key()),
+ &relays,
+ )
+ .expect("begin first activation");
+ machine
+ .apply(
+ StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
+ &relays,
+ )
+ .expect("activate first");
+ machine
+ .apply(StateTransition::Select(second.public_key()), &relays)
+ .expect("select second");
+ let pending = machine
+ .apply(
+ StateTransition::BeginActivation(second.public_key()),
+ &relays,
+ )
+ .expect("begin replacement");
+ let restored = machine
+ .apply(StateTransition::ActivationFailed(problem), &relays)
+ .expect("fail replacement");
+
+ assert_eq!(
+ pending.session(),
+ SessionState::Activating(second.public_key())
+ );
+ assert_eq!(
+ pending
+ .active_account()
+ .map(|value| value.account().public_key()),
+ Some(first.public_key())
+ );
+ assert_eq!(restored.session(), SessionState::Active);
+ assert_eq!(restored.selected_account(), Some(second.public_key()));
+ assert_eq!(
+ restored
+ .active_account()
+ .map(|value| value.account().public_key()),
+ Some(first.public_key())
+ );
+ assert_eq!(restored.recoverable_problem(), Some(problem));
+ assert_eq!(restored.revision().value(), 7);
+ }
+
+ #[test]
+ fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() {
+ let account = account(1);
+ let mut machine = StateMachine::booting();
+ let relays = RelayConfiguration::default();
+ machine
+ .apply(StateTransition::Bootstrap, &relays)
+ .expect("bootstrap");
+ machine
+ .apply(
+ StateTransition::ReplaceRegistry {
+ accounts: vec![account.clone()],
+ selected: Some(account.public_key()),
+ },
+ &relays,
+ )
+ .expect("load registry");
+
+ let error = machine
+ .apply(
+ StateTransition::Select(PublicKey::from_bytes([9_u8; 32])),
+ &relays,
+ )
+ .expect_err("missing account");
+ assert_eq!(error.code(), SafeErrorCode::AccountNotFound);
+
+ machine
+ .apply(
+ StateTransition::BeginActivation(account.public_key()),
+ &relays,
+ )
+ .expect("begin activation");
+ machine
+ .apply(
+ StateTransition::ActivationSucceeded(Box::new(active(account.clone()))),
+ &relays,
+ )
+ .expect("activate");
+ let signed_out = machine
+ .apply(StateTransition::SignOut, &relays)
+ .expect("sign out");
+
+ assert_eq!(signed_out.accounts(), &[account]);
+ assert_eq!(signed_out.session(), SessionState::SignedOut);
+ assert!(signed_out.active_account().is_none());
+ }
+}
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -278,7 +278,7 @@ and focused domain tests.
### RCLD-03: Application state machine
-Status: in progress.
+Status: completed.
Scope: checkpoints 12 through 14. Define repository, secret, clock, and Nostr
ports; implement in-memory AppCore bootstrap, command serialization, monotonic
@@ -470,7 +470,7 @@ handoff commit sequence.
- [x] 12. Add application ports for repositories, secrets, clock, and Nostr
client.
- [x] 13. Implement in-memory AppCore bootstrap and observer registry.
-- [ ] 14. Add state transition helpers and command trace tests.
+- [x] 14. Add state transition helpers and command trace tests.
### RCLD-04
@@ -553,7 +553,7 @@ handoff commit sequence.
- [x] RCLD-01: Authority and dependency baseline.
- [x] RCLD-02: Rust workspace and domain.
-- [ ] RCLD-03: Application state machine.
+- [x] RCLD-03: Application state machine.
- [ ] RCLD-04: SQLite persistence.
- [ ] RCLD-05: Credential boundary.
- [ ] RCLD-06: Account generation and import.