app

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

commit 8529800d2263ca7e9904b5b0884971376c505ab8
parent b7922dee64ab48343a19341d735219fa463432e1
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:44:11 +0000

core(storage): add account operation journal

- model explicit add import and removal recovery phases
- persist only public operation metadata and safe diagnostics
- retain journal entries independently of account metadata
- verify deterministic loading advancement and finalization

Diffstat:
Mcore/crates/application/src/lib.rs | 7++++---
Mcore/crates/application/src/ports.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acore/crates/storage/migrations/V5__operation_journal.sql | 8++++++++
Mcore/crates/storage/src/db.rs | 2+-
Acore/crates/storage/src/journal.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/storage/src/lib.rs | 1+
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
Mdocs/security/key-management.md | 5+++++
8 files changed, 449 insertions(+), 8 deletions(-)

diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -7,9 +7,10 @@ pub mod state_machine; pub use app_core::{AppCore, AppObserver, ObserverHandle}; pub use ports::{ - AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, - BoxFuture, CachedProfile, Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, - SecretStore, + AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, + AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, NostrClient, + OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, + ProfileRefreshStatus, ProfileRepository, SecretStore, }; pub use snapshot::{ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, diff --git a/core/crates/application/src/ports.rs b/core/crates/application/src/ports.rs @@ -26,6 +26,102 @@ pub enum AccountPreferenceKey { NamespaceProbe, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountOperationKind { + Add, + Import, + Remove, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountOperationPhase { + IntentRecorded, + CredentialWritten, + MetadataCommitted, + CompensationPending, + CredentialDeleted, + MetadataDeleted, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OperationDiagnostic { + StorageUnavailable, + KeyringUnavailable, + CredentialMissing, + CompensationFailed, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct OperationId(u64); + +impl OperationId { + #[must_use] + pub const fn from_raw(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn as_raw(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAccountOperation { + id: OperationId, + kind: AccountOperationKind, + subject: PublicKey, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, +} + +impl PendingAccountOperation { + #[must_use] + pub const fn new( + id: OperationId, + kind: AccountOperationKind, + subject: PublicKey, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Self { + Self { + id, + kind, + subject, + phase, + updated_at, + diagnostic, + } + } + + #[must_use] + pub const fn id(&self) -> OperationId { + self.id + } + #[must_use] + pub const fn kind(&self) -> AccountOperationKind { + self.kind + } + #[must_use] + pub const fn subject(&self) -> PublicKey { + self.subject + } + #[must_use] + pub const fn phase(&self) -> AccountOperationPhase { + self.phase + } + #[must_use] + pub const fn updated_at(&self) -> UnixTimestamp { + self.updated_at + } + #[must_use] + pub const fn diagnostic(&self) -> Option<OperationDiagnostic> { + self.diagnostic + } +} + impl CachedProfile { #[must_use] pub const fn new( @@ -167,6 +263,44 @@ pub trait AppStateRepository: Send + Sync { fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>; } +pub trait OperationJournal: Send + Sync { + /// Records one cross-resource account operation intent. + /// + /// # Errors + /// + /// Returns a safe storage error when the entry cannot be committed. + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError>; + /// Advances an operation to a durable recovery phase. + /// + /// # Errors + /// + /// Returns a safe storage error when the entry cannot be updated. + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError>; + /// Loads all unfinished operations in deterministic order. + /// + /// # Errors + /// + /// Returns a safe storage error when entries cannot be read. + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>; + /// Deletes one fully reconciled operation entry. + /// + /// # Errors + /// + /// Returns a safe storage error when finalization cannot be committed. + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>; +} + pub trait SecretStore: Send + Sync {} pub trait NostrClient: Send + Sync { @@ -190,9 +324,10 @@ mod tests { }; use super::{ - AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, - BoxFuture, CachedProfile, Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, - SecretStore, + AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, + AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, + Clock, NostrClient, OperationDiagnostic, OperationId, OperationJournal, + PendingAccountOperation, ProfileRefreshStatus, ProfileRepository, SecretStore, }; #[derive(Default)] @@ -282,6 +417,35 @@ mod tests { } } + impl OperationJournal for FakePorts { + fn begin_operation( + &self, + _kind: AccountOperationKind, + _subject: PublicKey, + _updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError> { + Ok(OperationId::from_raw(1)) + } + + fn update_operation( + &self, + _id: OperationId, + _phase: AccountOperationPhase, + _updated_at: UnixTimestamp, + _diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + Ok(Vec::new()) + } + + fn finalize_operation(&self, _id: OperationId) -> Result<(), SafeError> { + Ok(()) + } + } + impl SecretStore for FakePorts {} impl NostrClient for FakePorts { diff --git a/core/crates/storage/migrations/V5__operation_journal.sql b/core/crates/storage/migrations/V5__operation_journal.sql @@ -0,0 +1,8 @@ +CREATE TABLE operation_journal ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('add', 'import', 'remove')), + subject_pubkey TEXT NOT NULL, + phase TEXT NOT NULL CHECK (phase IN ('intent_recorded', 'credential_written', 'metadata_committed', 'compensation_pending', 'credential_deleted', 'metadata_deleted')), + updated_at INTEGER NOT NULL, + diagnostic_code TEXT CHECK (diagnostic_code IN ('storage_unavailable', 'keyring_unavailable', 'credential_missing', 'compensation_failed')) +) STRICT; diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs @@ -7,7 +7,7 @@ use refinery::embed_migrations; use rusqlite::{Connection, OpenFlags}; #[cfg(test)] -const LATEST_SCHEMA_VERSION: u32 = 4; +const LATEST_SCHEMA_VERSION: u32 = 5; mod migrations { use super::embed_migrations; diff --git a/core/crates/storage/src/journal.rs b/core/crates/storage/src/journal.rs @@ -0,0 +1,262 @@ +use radroots_studio_application::{ + AccountOperationKind, AccountOperationPhase, OperationDiagnostic, OperationId, + OperationJournal, PendingAccountOperation, +}; +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; +use rusqlite::{Row, params}; + +use crate::Database; + +impl OperationJournal for Database { + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<OperationId, SafeError> { + let connection = self.connection(); + connection + .execute( + "INSERT INTO operation_journal (operation_kind, subject_pubkey, phase, \ + updated_at) VALUES (?1, ?2, 'intent_recorded', ?3)", + params![encode_kind(kind), subject.to_hex(), updated_at.as_seconds()], + ) + .map_err(|_| storage_error())?; + let id = + u64::try_from(connection.last_insert_rowid()).map_err(|_| corrupt_storage_error())?; + Ok(OperationId::from_raw(id)) + } + + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; + match self.connection().execute( + "UPDATE operation_journal SET phase = ?2, updated_at = ?3, diagnostic_code = ?4 \ + WHERE operation_id = ?1", + params![ + encoded_id, + encode_phase(phase), + updated_at.as_seconds(), + diagnostic.map(encode_diagnostic) + ], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(operation_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT operation_id, operation_kind, subject_pubkey, phase, updated_at, \ + diagnostic_code FROM operation_journal ORDER BY operation_id ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_operation) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { + let encoded_id = i64::try_from(id.as_raw()).map_err(|_| corrupt_storage_error())?; + self.connection() + .execute( + "DELETE FROM operation_journal WHERE operation_id = ?1", + [encoded_id], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> { + let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?; + let kind = decode_kind(row.get::<_, String>(1)?.as_str())?; + let subject = + PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; + let phase = decode_phase(row.get::<_, String>(3)?.as_str())?; + let updated_at = UnixTimestamp::from_seconds(row.get(4)?).ok_or_else(|| invalid_column(4))?; + let diagnostic = row + .get::<_, Option<String>>(5)? + .map(|value| decode_diagnostic(&value)) + .transpose()?; + Ok(PendingAccountOperation::new( + OperationId::from_raw(id), + kind, + subject, + phase, + updated_at, + diagnostic, + )) +} + +const fn encode_kind(value: AccountOperationKind) -> &'static str { + match value { + AccountOperationKind::Add => "add", + AccountOperationKind::Import => "import", + AccountOperationKind::Remove => "remove", + } +} + +fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> { + match value { + "add" => Ok(AccountOperationKind::Add), + "import" => Ok(AccountOperationKind::Import), + "remove" => Ok(AccountOperationKind::Remove), + _ => Err(invalid_column(1)), + } +} + +const fn encode_phase(value: AccountOperationPhase) -> &'static str { + match value { + AccountOperationPhase::IntentRecorded => "intent_recorded", + AccountOperationPhase::CredentialWritten => "credential_written", + AccountOperationPhase::MetadataCommitted => "metadata_committed", + AccountOperationPhase::CompensationPending => "compensation_pending", + AccountOperationPhase::CredentialDeleted => "credential_deleted", + AccountOperationPhase::MetadataDeleted => "metadata_deleted", + } +} + +fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> { + match value { + "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded), + "credential_written" => Ok(AccountOperationPhase::CredentialWritten), + "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted), + "compensation_pending" => Ok(AccountOperationPhase::CompensationPending), + "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted), + "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted), + _ => Err(invalid_column(3)), + } +} + +const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str { + match value { + OperationDiagnostic::StorageUnavailable => "storage_unavailable", + OperationDiagnostic::KeyringUnavailable => "keyring_unavailable", + OperationDiagnostic::CredentialMissing => "credential_missing", + OperationDiagnostic::CompensationFailed => "compensation_failed", + } +} + +fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { + match value { + "storage_unavailable" => Ok(OperationDiagnostic::StorageUnavailable), + "keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable), + "credential_missing" => Ok(OperationDiagnostic::CredentialMissing), + "compensation_failed" => Ok(OperationDiagnostic::CompensationFailed), + _ => Err(invalid_column(5)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "account operation journal".to_owned(), + rusqlite::types::Type::Text, + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The account recovery journal is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The account recovery journal could not be read."), + ) +} + +const fn operation_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("The account recovery operation was not found."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{ + AccountOperationKind, AccountOperationPhase, OperationDiagnostic, OperationJournal, + }; + use radroots_studio_domain::{PublicKey, UnixTimestamp}; + + use crate::Database; + + #[test] + fn journal_creates_advances_loads_and_finalizes_pending_operations() { + let database = Database::in_memory().expect("database"); + let subject = PublicKey::from_bytes([7; 32]); + let id = database + .begin_operation( + AccountOperationKind::Import, + subject, + UnixTimestamp::from_seconds(10).expect("time"), + ) + .expect("begin"); + database + .update_operation( + id, + AccountOperationPhase::CompensationPending, + UnixTimestamp::from_seconds(11).expect("time"), + Some(OperationDiagnostic::KeyringUnavailable), + ) + .expect("advance"); + + let pending = database.list_pending_operations().expect("pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].subject(), subject); + assert_eq!(pending[0].kind(), AccountOperationKind::Import); + assert_eq!( + pending[0].phase(), + AccountOperationPhase::CompensationPending + ); + assert_eq!( + pending[0].diagnostic(), + Some(OperationDiagnostic::KeyringUnavailable) + ); + + database.finalize_operation(id).expect("finalize"); + assert!( + database + .list_pending_operations() + .expect("pending") + .is_empty() + ); + } + + #[test] + fn journal_schema_and_rows_exclude_secret_payload_columns() { + let database = Database::in_memory().expect("database"); + database + .begin_operation( + AccountOperationKind::Remove, + PublicKey::from_bytes([8; 32]), + UnixTimestamp::from_seconds(12).expect("time"), + ) + .expect("begin"); + let connection = database.connection(); + let schema: String = connection + .query_row( + "SELECT sql FROM sqlite_master WHERE name = 'operation_journal'", + [], + |row| row.get(0), + ) + .expect("schema"); + assert!(!schema.contains("secret")); + assert!(!schema.contains("payload")); + } +} diff --git a/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs @@ -3,6 +3,7 @@ pub mod account_namespace; pub mod accounts; pub mod db; +pub mod journal; pub mod profiles; pub use db::Database; diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -478,7 +478,7 @@ handoff commit sequence. - [x] 16. Implement account and selected-account persistence. - [x] 17. Implement profile cache persistence. - [x] 18. Implement typed account-scoped namespace persistence. -- [ ] 19. Add operation journal persistence. +- [x] 19. Add operation journal persistence. - [ ] 20. Implement storage adapter wiring for AppCore bootstrap. ### RCLD-05 diff --git a/docs/security/key-management.md b/docs/security/key-management.md @@ -23,3 +23,8 @@ crosses an unavoidable JVM String boundary once and is cleared immediately. Keyring unavailability is a safe recoverable error. Cross-resource operations publish no partial success and use a non-secret journal for restart recovery. + +The journal contains only an operation identifier, operation kind, canonical +public key, phase, safe timestamp, and optional safe diagnostic code. It cannot +store credential text or arbitrary payloads, and it survives account metadata +removal until cleanup has been finalized.