commit cc3aa7a9cc67674c1e7c249797ff6b1b2533b583
parent 16b4a15098d91660fa5df2dcf7144ce15876b41b
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 22:51:03 +0000
operations: define durable request contracts
- add bounded caller-generated idempotency keys
- model durable operation kinds phases prior state and terminal outcomes
- define recoverable terminal receipts with resulting revisions
- verify request validation and public receipt semantics
Diffstat:
2 files changed, 162 insertions(+), 6 deletions(-)
diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs
@@ -33,9 +33,10 @@ pub use config::{
pub use nostr_client::SdkNostrClient;
pub use ports::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
- AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, NostrClient,
- OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation,
- ProfileRefreshStatus, ProfileRepository,
+ AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, DurableOperationKind,
+ DurableOperationPhase, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome,
+ NostrClient, OperationDiagnostic, OperationId, OperationJournal, OperationPriorState,
+ PendingAccountOperation, ProfileRefreshStatus, ProfileRepository,
};
pub use profile_refresh::ProfileRefreshPlan;
pub use secrets::{
diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs
@@ -2,9 +2,147 @@ use std::future::Future;
use std::pin::Pin;
use radroots_studio_domain::{
- AccountSummary, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, UnixTimestamp,
+ AccountSummary, BindingAvailability, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError,
+ SafeErrorCode, SafeMessage, UnixTimestamp,
};
+const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128;
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct DurableRequestId(String);
+
+impl DurableRequestId {
+ /// Validates an opaque caller-generated idempotency key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe validation error when the value is empty, oversized, or contains anything
+ /// other than visible ASCII characters.
+ pub fn parse(value: impl Into<String>) -> Result<Self, SafeError> {
+ let value = value.into();
+ if value.is_empty()
+ || value.len() > MAX_DURABLE_REQUEST_ID_BYTES
+ || !value.bytes().all(|byte| byte.is_ascii_graphic())
+ {
+ return Err(invalid_request_id());
+ }
+ Ok(Self(value))
+ }
+
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DurableOperationKind {
+ Create,
+ Import,
+ Repair,
+ Remove,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DurableOperationPhase {
+ IntentRecorded,
+ CredentialWritten,
+ MetadataCommitted,
+ SelectionCommitted,
+ CompensationPending,
+ CredentialDeleted,
+ MetadataDeleted,
+ Finalized,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DurableTerminalOutcome {
+ Completed,
+ Cancelled,
+ Failed,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct OperationPriorState {
+ selected_account: Option<PublicKey>,
+ binding_availability: Option<BindingAvailability>,
+}
+
+impl OperationPriorState {
+ #[must_use]
+ pub const fn new(
+ selected_account: Option<PublicKey>,
+ binding_availability: Option<BindingAvailability>,
+ ) -> Self {
+ Self {
+ selected_account,
+ binding_availability,
+ }
+ }
+
+ #[must_use]
+ pub const fn selected_account(self) -> Option<PublicKey> {
+ self.selected_account
+ }
+
+ #[must_use]
+ pub const fn binding_availability(self) -> Option<BindingAvailability> {
+ self.binding_availability
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DurableOperationReceipt {
+ request_id: DurableRequestId,
+ account: PublicKey,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+}
+
+impl DurableOperationReceipt {
+ #[must_use]
+ pub const fn new(
+ request_id: DurableRequestId,
+ account: PublicKey,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+ ) -> Self {
+ Self {
+ request_id,
+ account,
+ outcome,
+ resulting_revision,
+ }
+ }
+
+ #[must_use]
+ pub const fn request_id(&self) -> &DurableRequestId {
+ &self.request_id
+ }
+
+ #[must_use]
+ pub const fn account(&self) -> PublicKey {
+ self.account
+ }
+
+ #[must_use]
+ pub const fn outcome(&self) -> DurableTerminalOutcome {
+ self.outcome
+ }
+
+ #[must_use]
+ pub const fn resulting_revision(&self) -> Option<u64> {
+ self.resulting_revision
+ }
+}
+
+const fn invalid_request_id() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The request identifier is invalid."),
+ )
+}
+
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -324,10 +462,27 @@ mod tests {
use super::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase,
AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile,
- Clock, NostrClient, OperationDiagnostic, OperationId, OperationJournal,
- PendingAccountOperation, ProfileRefreshStatus, ProfileRepository,
+ Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient,
+ OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation,
+ ProfileRefreshStatus, ProfileRepository,
};
+ #[test]
+ fn durable_request_ids_and_terminal_receipts_are_bounded_and_public() {
+ let request = DurableRequestId::parse("create:desktop:0001").expect("request id");
+ let receipt = DurableOperationReceipt::new(
+ request.clone(),
+ PublicKey::from_bytes([3; 32]),
+ DurableTerminalOutcome::Completed,
+ Some(42),
+ );
+ assert_eq!(receipt.request_id(), &request);
+ assert_eq!(receipt.resulting_revision(), Some(42));
+ for invalid in ["", "contains space", &"x".repeat(129)] {
+ assert!(DurableRequestId::parse(invalid).is_err());
+ }
+ }
+
#[derive(Default)]
struct FakePorts {
selected: Mutex<Option<PublicKey>>,