app

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

state_machine.rs (20304B)


      1 use harvestcircle_domain::{NostrIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage};
      2 
      3 use crate::{ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState};
      4 
      5 #[derive(Clone, Debug, Eq, PartialEq)]
      6 pub enum StateTransition {
      7     Bootstrap,
      8     BootstrapRegistry {
      9         identities: Vec<NostrIdentity>,
     10         selected: Option<PublicKey>,
     11     },
     12     Fatal(SafeError),
     13     ReplaceRegistry {
     14         identities: Vec<NostrIdentity>,
     15         selected: Option<PublicKey>,
     16     },
     17     ReplaceRegistryPreservingSession {
     18         identities: Vec<NostrIdentity>,
     19         selected: Option<PublicKey>,
     20     },
     21     Select(PublicKey),
     22     BeginActivation(PublicKey),
     23     ActivationSucceeded(Box<ActiveIdentitySnapshot>),
     24     ActivationFailed(SafeError),
     25     UpdateActiveIdentity {
     26         expected: PublicKey,
     27         active_identity: Box<ActiveIdentitySnapshot>,
     28         problem: Option<SafeError>,
     29     },
     30     SignOut,
     31     SetProblem(Option<SafeError>),
     32 }
     33 
     34 #[derive(Clone)]
     35 struct PreviousSession {
     36     session: SessionState,
     37     active_identity: Option<ActiveIdentitySnapshot>,
     38 }
     39 
     40 pub struct StateMachine {
     41     snapshot: AppSnapshot,
     42     pending_activation: Option<(PublicKey, PreviousSession)>,
     43 }
     44 
     45 impl StateMachine {
     46     #[must_use]
     47     pub fn booting() -> Self {
     48         Self {
     49             snapshot: AppSnapshot::booting(),
     50             pending_activation: None,
     51         }
     52     }
     53 
     54     #[must_use]
     55     pub const fn snapshot(&self) -> &AppSnapshot {
     56         &self.snapshot
     57     }
     58 
     59     /// Applies one deterministic state transition and returns the new snapshot.
     60     ///
     61     /// # Errors
     62     ///
     63     /// Returns a safe application error when the transition violates identity,
     64     /// revision, activation, or snapshot invariants.
     65     pub fn apply(
     66         &mut self,
     67         transition: StateTransition,
     68         relay_configuration: &RelayConfiguration,
     69     ) -> Result<AppSnapshot, SafeError> {
     70         let next_revision = self
     71             .snapshot
     72             .revision()
     73             .next()
     74             .ok_or_else(invalid_application_state)?;
     75 
     76         let next = match transition {
     77             StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?,
     78             StateTransition::BootstrapRegistry {
     79                 identities,
     80                 selected,
     81             } => {
     82                 self.bootstrap_registry(next_revision, relay_configuration, identities, selected)?
     83             }
     84             StateTransition::Fatal(error) => {
     85                 AppSnapshot::fatal(next_revision, relay_configuration.clone(), error)
     86             }
     87             StateTransition::ReplaceRegistry {
     88                 identities,
     89                 selected,
     90             } => self.replace_registry(next_revision, identities, selected)?,
     91             StateTransition::ReplaceRegistryPreservingSession {
     92                 identities,
     93                 selected,
     94             } => self.replace_registry_preserving_session(next_revision, identities, selected)?,
     95             StateTransition::Select(public_key) => self.select(next_revision, public_key)?,
     96             StateTransition::BeginActivation(public_key) => {
     97                 self.begin_activation(next_revision, public_key)?
     98             }
     99             StateTransition::ActivationSucceeded(active_identity) => {
    100                 self.activation_succeeded(next_revision, *active_identity)?
    101             }
    102             StateTransition::ActivationFailed(problem) => {
    103                 self.activation_failed(next_revision, problem)?
    104             }
    105             StateTransition::UpdateActiveIdentity {
    106                 expected,
    107                 active_identity,
    108                 problem,
    109             } => self.update_active_identity(next_revision, expected, *active_identity, problem)?,
    110             StateTransition::SignOut => self.sign_out(next_revision)?,
    111             StateTransition::SetProblem(problem) => self.copy_ready(
    112                 next_revision,
    113                 self.snapshot.selected_identity(),
    114                 self.snapshot.session(),
    115                 self.snapshot.active_identity().cloned(),
    116                 problem,
    117             )?,
    118         };
    119         self.snapshot = next.clone();
    120         Ok(next)
    121     }
    122 
    123     fn bootstrap(
    124         &self,
    125         revision: crate::SnapshotRevision,
    126         relay_configuration: &RelayConfiguration,
    127     ) -> Result<AppSnapshot, SafeError> {
    128         if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
    129             return Ok(self.snapshot.clone());
    130         }
    131         AppSnapshot::ready(
    132             revision,
    133             relay_configuration.clone(),
    134             Vec::new(),
    135             None,
    136             SessionState::SignedOut,
    137             None,
    138             None,
    139         )
    140     }
    141 
    142     fn bootstrap_registry(
    143         &self,
    144         revision: crate::SnapshotRevision,
    145         relay_configuration: &RelayConfiguration,
    146         identities: Vec<NostrIdentity>,
    147         selected: Option<PublicKey>,
    148     ) -> Result<AppSnapshot, SafeError> {
    149         if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) {
    150             return Ok(self.snapshot.clone());
    151         }
    152         AppSnapshot::ready(
    153             revision,
    154             relay_configuration.clone(),
    155             identities,
    156             selected,
    157             SessionState::SignedOut,
    158             None,
    159             None,
    160         )
    161     }
    162 
    163     fn replace_registry(
    164         &mut self,
    165         revision: crate::SnapshotRevision,
    166         identities: Vec<NostrIdentity>,
    167         selected: Option<PublicKey>,
    168     ) -> Result<AppSnapshot, SafeError> {
    169         self.pending_activation = None;
    170         AppSnapshot::ready(
    171             revision,
    172             self.snapshot.relay_configuration().clone(),
    173             identities,
    174             selected,
    175             SessionState::SignedOut,
    176             None,
    177             None,
    178         )
    179     }
    180 
    181     fn replace_registry_preserving_session(
    182         &mut self,
    183         revision: crate::SnapshotRevision,
    184         identities: Vec<NostrIdentity>,
    185         selected: Option<PublicKey>,
    186     ) -> Result<AppSnapshot, SafeError> {
    187         self.pending_activation = None;
    188         AppSnapshot::ready(
    189             revision,
    190             self.snapshot.relay_configuration().clone(),
    191             identities,
    192             selected,
    193             self.snapshot.session(),
    194             self.snapshot.active_identity().cloned(),
    195             None,
    196         )
    197     }
    198 
    199     fn select(
    200         &self,
    201         revision: crate::SnapshotRevision,
    202         public_key: PublicKey,
    203     ) -> Result<AppSnapshot, SafeError> {
    204         self.require_identity(public_key)?;
    205         self.copy_ready(
    206             revision,
    207             Some(public_key),
    208             self.snapshot.session(),
    209             self.snapshot.active_identity().cloned(),
    210             None,
    211         )
    212     }
    213 
    214     fn begin_activation(
    215         &mut self,
    216         revision: crate::SnapshotRevision,
    217         public_key: PublicKey,
    218     ) -> Result<AppSnapshot, SafeError> {
    219         self.require_identity(public_key)?;
    220         if self.pending_activation.is_some() {
    221             return Err(invalid_application_state());
    222         }
    223         self.pending_activation = Some((
    224             public_key,
    225             PreviousSession {
    226                 session: self.snapshot.session(),
    227                 active_identity: self.snapshot.active_identity().cloned(),
    228             },
    229         ));
    230         self.copy_ready(
    231             revision,
    232             self.snapshot.selected_identity(),
    233             SessionState::Activating(public_key),
    234             self.snapshot.active_identity().cloned(),
    235             None,
    236         )
    237     }
    238 
    239     fn activation_succeeded(
    240         &mut self,
    241         revision: crate::SnapshotRevision,
    242         active_identity: ActiveIdentitySnapshot,
    243     ) -> Result<AppSnapshot, SafeError> {
    244         let Some((target, _previous)) = self.pending_activation.as_ref() else {
    245             return Err(invalid_application_state());
    246         };
    247         if active_identity.identity().public_key() != *target {
    248             return Err(invalid_application_state());
    249         }
    250         let target = *target;
    251         let mut identities = self.snapshot.identities().to_vec();
    252         let registered = identities
    253             .iter_mut()
    254             .find(|identity| identity.public_key() == target)
    255             .ok_or_else(identity_not_found)?;
    256         *registered = active_identity.identity().clone();
    257         let next = AppSnapshot::ready(
    258             revision,
    259             self.snapshot.relay_configuration().clone(),
    260             identities,
    261             Some(target),
    262             SessionState::Active,
    263             Some(active_identity),
    264             None,
    265         )?;
    266         self.pending_activation = None;
    267         Ok(next)
    268     }
    269 
    270     fn activation_failed(
    271         &mut self,
    272         revision: crate::SnapshotRevision,
    273         problem: SafeError,
    274     ) -> Result<AppSnapshot, SafeError> {
    275         let Some((_target, previous)) = self.pending_activation.take() else {
    276             return Err(invalid_application_state());
    277         };
    278         self.copy_ready(
    279             revision,
    280             self.snapshot.selected_identity(),
    281             previous.session,
    282             previous.active_identity,
    283             Some(problem),
    284         )
    285     }
    286 
    287     fn sign_out(&mut self, revision: crate::SnapshotRevision) -> Result<AppSnapshot, SafeError> {
    288         self.pending_activation = None;
    289         self.copy_ready(
    290             revision,
    291             self.snapshot.selected_identity(),
    292             SessionState::SignedOut,
    293             None,
    294             None,
    295         )
    296     }
    297 
    298     fn update_active_identity(
    299         &self,
    300         revision: crate::SnapshotRevision,
    301         expected: PublicKey,
    302         active_identity: ActiveIdentitySnapshot,
    303         problem: Option<SafeError>,
    304     ) -> Result<AppSnapshot, SafeError> {
    305         if !matches!(self.snapshot.session(), SessionState::Active)
    306             || self
    307                 .snapshot
    308                 .active_identity()
    309                 .map(|active| active.identity().public_key())
    310                 != Some(expected)
    311             || active_identity.identity().public_key() != expected
    312         {
    313             return Err(invalid_application_state());
    314         }
    315         self.copy_ready(
    316             revision,
    317             self.snapshot.selected_identity(),
    318             SessionState::Active,
    319             Some(active_identity),
    320             problem,
    321         )
    322     }
    323 
    324     fn require_identity(&self, public_key: PublicKey) -> Result<(), SafeError> {
    325         if self
    326             .snapshot
    327             .identities()
    328             .iter()
    329             .any(|identity| identity.public_key() == public_key)
    330         {
    331             Ok(())
    332         } else {
    333             Err(identity_not_found())
    334         }
    335     }
    336 
    337     fn copy_ready(
    338         &self,
    339         revision: crate::SnapshotRevision,
    340         selected_identity: Option<PublicKey>,
    341         session: SessionState,
    342         active_identity: Option<ActiveIdentitySnapshot>,
    343         recoverable_problem: Option<SafeError>,
    344     ) -> Result<AppSnapshot, SafeError> {
    345         AppSnapshot::ready(
    346             revision,
    347             self.snapshot.relay_configuration().clone(),
    348             self.snapshot.identities().to_vec(),
    349             selected_identity,
    350             session,
    351             active_identity,
    352             recoverable_problem,
    353         )
    354     }
    355 }
    356 
    357 const fn invalid_application_state() -> SafeError {
    358     SafeError::new(
    359         SafeErrorCode::InvalidApplicationState,
    360         SafeMessage::new("The application state is invalid."),
    361     )
    362 }
    363 
    364 const fn identity_not_found() -> SafeError {
    365     SafeError::new(
    366         SafeErrorCode::IdentityNotFound,
    367         SafeMessage::new("The identity was not found."),
    368     )
    369 }
    370 
    371 #[cfg(test)]
    372 mod tests {
    373     use harvestcircle_domain::{
    374         IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, SafeError,
    375         SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp,
    376     };
    377 
    378     use crate::{
    379         ActiveIdentitySnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState,
    380         SessionState, StateMachine, StateTransition,
    381     };
    382 
    383     fn identity(key_byte: u8) -> NostrIdentity {
    384         let public_key =
    385             crate::test_support::valid_test_public_key(key_byte).expect("valid public key");
    386         NostrIdentity::new(
    387             NostrIdentityReference::derive(public_key).expect("identity"),
    388             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    389             None,
    390             IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")),
    391             None,
    392         )
    393         .expect("identity")
    394     }
    395 
    396     fn active(identity: NostrIdentity) -> ActiveIdentitySnapshot {
    397         ActiveIdentitySnapshot::new(
    398             identity,
    399             RelayConnectionState::Disconnected,
    400             ProfileLoadState::Empty,
    401             None,
    402         )
    403     }
    404 
    405     #[test]
    406     fn state_machine_command_trace_preserves_working_session_on_failed_replacement() {
    407         let first = identity(1);
    408         let second = identity(2);
    409         let mut machine = StateMachine::booting();
    410         let relays = RelayConfiguration::default();
    411         let problem = SafeError::new(
    412             SafeErrorCode::CredentialMissing,
    413             SafeMessage::new("The identity credential is missing."),
    414         );
    415 
    416         machine
    417             .apply(StateTransition::Bootstrap, &relays)
    418             .expect("bootstrap");
    419         machine
    420             .apply(
    421                 StateTransition::ReplaceRegistry {
    422                     identities: vec![first.clone(), second.clone()],
    423                     selected: Some(first.public_key()),
    424                 },
    425                 &relays,
    426             )
    427             .expect("load registry");
    428         machine
    429             .apply(
    430                 StateTransition::BeginActivation(first.public_key()),
    431                 &relays,
    432             )
    433             .expect("begin first activation");
    434         machine
    435             .apply(
    436                 StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
    437                 &relays,
    438             )
    439             .expect("activate first");
    440         machine
    441             .apply(StateTransition::Select(second.public_key()), &relays)
    442             .expect("select second");
    443         let pending = machine
    444             .apply(
    445                 StateTransition::BeginActivation(second.public_key()),
    446                 &relays,
    447             )
    448             .expect("begin replacement");
    449         let restored = machine
    450             .apply(StateTransition::ActivationFailed(problem), &relays)
    451             .expect("fail replacement");
    452 
    453         assert_eq!(
    454             pending.session(),
    455             SessionState::Activating(second.public_key())
    456         );
    457         assert_eq!(
    458             pending
    459                 .active_identity()
    460                 .map(|value| value.identity().public_key()),
    461             Some(first.public_key())
    462         );
    463         assert_eq!(restored.session(), SessionState::Active);
    464         assert_eq!(restored.selected_identity(), Some(second.public_key()));
    465         assert_eq!(
    466             restored
    467                 .active_identity()
    468                 .map(|value| value.identity().public_key()),
    469             Some(first.public_key())
    470         );
    471         assert_eq!(restored.recoverable_problem(), Some(problem));
    472         assert_eq!(restored.revision().value(), 7);
    473     }
    474 
    475     #[test]
    476     fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() {
    477         let identity = identity(1);
    478         let mut machine = StateMachine::booting();
    479         let relays = RelayConfiguration::default();
    480         machine
    481             .apply(StateTransition::Bootstrap, &relays)
    482             .expect("bootstrap");
    483         machine
    484             .apply(
    485                 StateTransition::ReplaceRegistry {
    486                     identities: vec![identity.clone()],
    487                     selected: Some(identity.public_key()),
    488                 },
    489                 &relays,
    490             )
    491             .expect("load registry");
    492 
    493         let error = machine
    494             .apply(
    495                 StateTransition::Select(
    496                     crate::test_support::valid_test_public_key(9).expect("valid public key"),
    497                 ),
    498                 &relays,
    499             )
    500             .expect_err("missing identity");
    501         assert_eq!(error.code(), SafeErrorCode::IdentityNotFound);
    502 
    503         machine
    504             .apply(
    505                 StateTransition::BeginActivation(identity.public_key()),
    506                 &relays,
    507             )
    508             .expect("begin activation");
    509         machine
    510             .apply(
    511                 StateTransition::ActivationSucceeded(Box::new(active(identity.clone()))),
    512                 &relays,
    513             )
    514             .expect("activate");
    515         let signed_out = machine
    516             .apply(StateTransition::SignOut, &relays)
    517             .expect("sign out");
    518 
    519         assert_eq!(signed_out.identities(), &[identity]);
    520         assert_eq!(signed_out.session(), SessionState::SignedOut);
    521         assert!(signed_out.active_identity().is_none());
    522     }
    523 
    524     #[test]
    525     fn activation_state_policy_rejects_every_stale_or_mismatched_transition() {
    526         let first = identity(1);
    527         let second = identity(2);
    528         let relays = RelayConfiguration::default();
    529         let problem = SafeError::new(
    530             SafeErrorCode::CredentialMissing,
    531             SafeMessage::new("The identity credential is missing."),
    532         );
    533         let mut machine = StateMachine::booting();
    534         machine
    535             .apply(
    536                 StateTransition::BootstrapRegistry {
    537                     identities: vec![first.clone(), second.clone()],
    538                     selected: Some(first.public_key()),
    539                 },
    540                 &relays,
    541             )
    542             .expect("registry");
    543         let unchanged = machine
    544             .apply(
    545                 StateTransition::BootstrapRegistry {
    546                     identities: Vec::new(),
    547                     selected: None,
    548                 },
    549                 &relays,
    550             )
    551             .expect("repeated bootstrap is idempotent");
    552         assert_eq!(unchanged.identities().len(), 2);
    553 
    554         assert!(
    555             machine
    556                 .apply(
    557                     StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
    558                     &relays,
    559                 )
    560                 .is_err()
    561         );
    562         assert!(
    563             machine
    564                 .apply(StateTransition::ActivationFailed(problem), &relays)
    565                 .is_err()
    566         );
    567         machine
    568             .apply(
    569                 StateTransition::BeginActivation(first.public_key()),
    570                 &relays,
    571             )
    572             .expect("begin activation");
    573         assert!(
    574             machine
    575                 .apply(
    576                     StateTransition::BeginActivation(second.public_key()),
    577                     &relays,
    578                 )
    579                 .is_err()
    580         );
    581         assert!(
    582             machine
    583                 .apply(
    584                     StateTransition::ActivationSucceeded(Box::new(active(second.clone()))),
    585                     &relays,
    586                 )
    587                 .is_err()
    588         );
    589         machine
    590             .apply(
    591                 StateTransition::ActivationSucceeded(Box::new(active(first.clone()))),
    592                 &relays,
    593             )
    594             .expect("activate first");
    595 
    596         for (expected, candidate) in [
    597             (second.public_key(), first.clone()),
    598             (first.public_key(), second.clone()),
    599         ] {
    600             assert!(
    601                 machine
    602                     .apply(
    603                         StateTransition::UpdateActiveIdentity {
    604                             expected,
    605                             active_identity: Box::new(active(candidate)),
    606                             problem: None,
    607                         },
    608                         &relays,
    609                     )
    610                     .is_err()
    611             );
    612         }
    613 
    614         machine
    615             .apply(StateTransition::SignOut, &relays)
    616             .expect("sign out");
    617         assert!(
    618             machine
    619                 .apply(
    620                     StateTransition::UpdateActiveIdentity {
    621                         expected: first.public_key(),
    622                         active_identity: Box::new(active(first)),
    623                         problem: None,
    624                     },
    625                     &relays,
    626                 )
    627                 .is_err()
    628         );
    629     }
    630 }