app

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

commit d9ed403689a8864e2b4cae7fb7b1e5a69fe99e27
parent 4ae94599df6b4cde3e3c200617d8d0ca568d512d
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:12:17 +0000

core(state): define immutable application snapshots

- separate lifecycle, selection, and active-session state
- expose revisioned account, relay, and profile snapshots
- enforce unique accounts and consistent active invariants
- prove boot and ready snapshots contain public data only

Diffstat:
Mcore/Cargo.lock | 3+++
Mcore/crates/application/Cargo.toml | 3+++
Mcore/crates/application/src/lib.rs | 13++++++-------
Acore/crates/application/src/snapshot.rs | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/domain/src/error.rs | 1+
Mdocs/implementation/nostr-runtime-rcld.md | 6+++---
6 files changed, 384 insertions(+), 10 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -167,6 +167,9 @@ dependencies = [ [[package]] name = "radroots-studio-application" version = "0.1.0-alpha.0" +dependencies = [ + "radroots-studio-domain", +] [[package]] name = "radroots-studio-domain" diff --git a/core/crates/application/Cargo.toml b/core/crates/application/Cargo.toml @@ -6,5 +6,8 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[dependencies] +radroots-studio-domain = { path = "../domain" } + [lints] workspace = true diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -1,9 +1,8 @@ #![doc = "Radroots Studio application runtime."] -#[cfg(test)] -mod tests { - #[test] - fn crate_is_available_to_the_workspace() { - assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-application"); - } -} +pub mod snapshot; + +pub use snapshot::{ + ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, + RelayConnectionState, SessionState, SnapshotRevision, +}; diff --git a/core/crates/application/src/snapshot.rs b/core/crates/application/src/snapshot.rs @@ -0,0 +1,368 @@ +use std::collections::HashSet; + +use radroots_studio_domain::{ + AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +pub struct SnapshotRevision(u64); + +impl SnapshotRevision { + #[must_use] + pub const fn initial() -> Self { + Self(0) + } + + #[must_use] + pub const fn from_value(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } + + #[must_use] + pub const fn next(self) -> Option<Self> { + match self.0.checked_add(1) { + Some(value) => Some(Self(value)), + None => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppLifecycle { + Booting, + Ready, + Fatal(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionState { + SignedOut, + Activating(PublicKey), + Active, + SigningOut, + Failed(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RelayConnectionState { + Disconnected, + Connecting, + Connected, + Degraded, + Error(SafeError), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProfileLoadState { + Empty, + Loading, + Cached, + Fresh, + Error(SafeError), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RelayConfiguration(Vec<RelayUrl>); + +impl RelayConfiguration { + #[must_use] + pub fn new(relays: Vec<RelayUrl>) -> Self { + Self(relays) + } + + #[must_use] + pub fn relays(&self) -> &[RelayUrl] { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActiveAccountSnapshot { + account: AccountSummary, + relay_state: RelayConnectionState, + profile_state: ProfileLoadState, + profile: Option<ProfileMetadata>, +} + +impl ActiveAccountSnapshot { + #[must_use] + pub const fn new( + account: AccountSummary, + relay_state: RelayConnectionState, + profile_state: ProfileLoadState, + profile: Option<ProfileMetadata>, + ) -> Self { + Self { + account, + relay_state, + profile_state, + profile, + } + } + + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + #[must_use] + pub const fn relay_state(&self) -> RelayConnectionState { + self.relay_state + } + + #[must_use] + pub const fn profile_state(&self) -> ProfileLoadState { + self.profile_state + } + + #[must_use] + pub const fn profile(&self) -> Option<&ProfileMetadata> { + self.profile.as_ref() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppSnapshot { + revision: SnapshotRevision, + lifecycle: AppLifecycle, + relay_configuration: RelayConfiguration, + accounts: Vec<AccountSummary>, + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<ActiveAccountSnapshot>, + recoverable_problem: Option<SafeError>, +} + +impl AppSnapshot { + #[must_use] + pub fn booting() -> Self { + Self { + revision: SnapshotRevision::initial(), + lifecycle: AppLifecycle::Booting, + relay_configuration: RelayConfiguration::default(), + accounts: Vec::new(), + selected_account: None, + session: SessionState::SignedOut, + active_account: None, + recoverable_problem: None, + } + } + + /// Constructs a ready immutable snapshot after validating state invariants. + /// + /// # Errors + /// + /// Returns a safe invalid-state error for duplicate accounts, invalid + /// selection, or inconsistent active-session state. + pub fn ready( + revision: SnapshotRevision, + relay_configuration: RelayConfiguration, + accounts: Vec<AccountSummary>, + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<ActiveAccountSnapshot>, + recoverable_problem: Option<SafeError>, + ) -> Result<Self, SafeError> { + validate_snapshot( + &accounts, + selected_account, + session, + active_account.as_ref(), + )?; + Ok(Self { + revision, + lifecycle: AppLifecycle::Ready, + relay_configuration, + accounts, + selected_account, + session, + active_account, + recoverable_problem, + }) + } + + #[must_use] + pub const fn revision(&self) -> SnapshotRevision { + self.revision + } + + #[must_use] + pub const fn lifecycle(&self) -> AppLifecycle { + self.lifecycle + } + + #[must_use] + pub const fn relay_configuration(&self) -> &RelayConfiguration { + &self.relay_configuration + } + + #[must_use] + pub fn accounts(&self) -> &[AccountSummary] { + &self.accounts + } + + #[must_use] + pub const fn selected_account(&self) -> Option<PublicKey> { + self.selected_account + } + + #[must_use] + pub const fn session(&self) -> SessionState { + self.session + } + + #[must_use] + pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> { + self.active_account.as_ref() + } + + #[must_use] + pub const fn recoverable_problem(&self) -> Option<SafeError> { + self.recoverable_problem + } +} + +fn validate_snapshot( + accounts: &[AccountSummary], + selected_account: Option<PublicKey>, + session: SessionState, + active_account: Option<&ActiveAccountSnapshot>, +) -> Result<(), SafeError> { + let unique_accounts = accounts + .iter() + .map(AccountSummary::public_key) + .collect::<HashSet<_>>(); + if unique_accounts.len() != accounts.len() + || (accounts.is_empty() != selected_account.is_none()) + || 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()) + { + return Err(invalid_snapshot()); + } + Ok(()) +} + +const fn invalid_snapshot() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The application state is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{ + AccountCreatedAt, AccountSummary, KeyAvailability, Npub, PublicKey, SignerKind, + UnixTimestamp, + }; + + use super::{ + ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, + RelayConnectionState, SessionState, SnapshotRevision, + }; + + 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, + ) + } + + #[test] + fn snapshot_boots_empty_and_secret_free() { + let snapshot = AppSnapshot::booting(); + let debug = format!("{snapshot:?}"); + + assert_eq!(snapshot.revision(), SnapshotRevision::initial()); + assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting); + assert_eq!(snapshot.session(), SessionState::SignedOut); + assert!(snapshot.accounts().is_empty()); + assert!(snapshot.selected_account().is_none()); + assert!(snapshot.active_account().is_none()); + assert!(snapshot.relay_configuration().relays().is_empty()); + assert!(snapshot.recoverable_problem().is_none()); + assert!(!debug.contains("nsec1")); + assert!(!debug.contains(&"11".repeat(32))); + } + + #[test] + fn revision_helper_is_monotonic_and_checked() { + assert_eq!( + SnapshotRevision::initial() + .next() + .map(SnapshotRevision::value), + Some(1) + ); + assert_eq!(SnapshotRevision::from_value(u64::MAX).next(), None); + } + + #[test] + fn ready_snapshot_requires_valid_selection_and_active_session() { + let first = account(1); + let second = account(2); + let active = ActiveAccountSnapshot::new( + second.clone(), + RelayConnectionState::Disconnected, + ProfileLoadState::Empty, + None, + ); + let valid = AppSnapshot::ready( + SnapshotRevision::from_value(1), + RelayConfiguration::default(), + vec![first.clone(), second.clone()], + Some(first.public_key()), + SessionState::Active, + Some(active), + None, + ) + .expect("valid ready snapshot"); + + assert_eq!(valid.lifecycle(), AppLifecycle::Ready); + assert_eq!(valid.selected_account(), Some(first.public_key())); + assert_eq!( + valid + .active_account() + .map(|value| value.account().public_key()), + Some(second.public_key()) + ); + + assert!( + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![first.clone(), first], + Some(second.public_key()), + SessionState::SignedOut, + None, + None, + ) + .is_err() + ); + assert!( + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![second.clone()], + Some(second.public_key()), + SessionState::Active, + None, + None, + ) + .is_err() + ); + } +} diff --git a/core/crates/domain/src/error.rs b/core/crates/domain/src/error.rs @@ -8,6 +8,7 @@ pub enum SafeErrorCode { InvalidSecretKey, InvalidAccountMetadata, InvalidProfileMetadata, + InvalidApplicationState, AccountAlreadyExists, AccountNotFound, KeyringUnavailable, diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -263,7 +263,7 @@ checks, documentation link/path validation, and diff review. ### RCLD-02: Rust workspace and domain -Status: in progress. +Status: completed. Scope: checkpoints 2 through 11. Establish the Rust workspace and implement safe errors, public keys, secret input/redaction, NIP-19 types, relay URLs, @@ -463,7 +463,7 @@ handoff commit sequence. - [x] 08. Implement relay URL parser and policy. - [x] 09. Add account public metadata value types. - [x] 10. Add profile metadata model and kind-0 selection rules. -- [ ] 11. Define immutable AppSnapshot and state enums. +- [x] 11. Define immutable AppSnapshot and state enums. ### RCLD-03 @@ -552,7 +552,7 @@ handoff commit sequence. ## Unfinished RCLD ledger - [x] RCLD-01: Authority and dependency baseline. -- [ ] RCLD-02: Rust workspace and domain. +- [x] RCLD-02: Rust workspace and domain. - [ ] RCLD-03: Application state machine. - [ ] RCLD-04: SQLite persistence. - [ ] RCLD-05: Credential boundary.