lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 8f77ddd88a6bb1aab96fc63cb8cc1fc864435ee5
parent e9d0d8af43950e156e6015663ad84c6a32601e30
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 05:27:54 +0000

transport: harden shared delivery semantics

- replace coarse transport target statuses with explicit delivery, preview, and failure states
- model satisfaction policies with accepted and delivered policy classes
- align outbox, Nostr, and Reticulum mappings with shared transport semantics
- validate targeted transport, outbox, Nostr, and Reticulum package lanes

Diffstat:
Mcrates/outbox/src/model.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/outbox/src/store.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/transport/src/delivery.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/transport/src/lib.rs | 3++-
Mcrates/transport/src/status.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/transport/tests/transport.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/transport_nostr/src/outbox.rs | 31++++++++++++++++++++++++-------
Mcrates/transport_nostr/src/outcome.rs | 4++--
Mcrates/transport_nostr/src/publish.rs | 2+-
Mcrates/transport_nostr/tests/transport.rs | 34+++++++++++++++++-----------------
Mcrates/transport_reticulum/src/lib.rs | 8++++----
Mcrates/transport_reticulum/tests/reticulum_preview.rs | 30++++++++++++++++++++----------
12 files changed, 396 insertions(+), 117 deletions(-)

diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs @@ -3,8 +3,8 @@ use crate::RadrootsOutboxError; use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; use radroots_transport::{ - RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, - RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri, + RadrootsTransportKind, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -137,6 +137,7 @@ pub enum RadrootsOutboxDeliveryTargetStatus { StoredByGateway, Seen, DeferredUntilImplemented, + PreviewUnavailable, SkippedPolicyDenied, FailedRetryable, FailedTerminal, @@ -152,6 +153,7 @@ impl RadrootsOutboxDeliveryTargetStatus { Self::StoredByGateway => "stored_by_gateway", Self::Seen => "seen", Self::DeferredUntilImplemented => "deferred_until_implemented", + Self::PreviewUnavailable => "preview_unavailable", Self::SkippedPolicyDenied => "skipped_policy_denied", Self::FailedRetryable => "failed_retryable", Self::FailedTerminal => "failed_terminal", @@ -167,6 +169,7 @@ impl RadrootsOutboxDeliveryTargetStatus { "stored_by_gateway" => Ok(Self::StoredByGateway), "seen" => Ok(Self::Seen), "deferred_until_implemented" => Ok(Self::DeferredUntilImplemented), + "preview_unavailable" => Ok(Self::PreviewUnavailable), "skipped_policy_denied" => Ok(Self::SkippedPolicyDenied), "failed_retryable" => Ok(Self::FailedRetryable), "failed_terminal" => Ok(Self::FailedTerminal), @@ -181,16 +184,46 @@ impl RadrootsOutboxDeliveryTargetStatus { matches!(self, Self::Pending | Self::FailedRetryable) } - pub fn counts_as_satisfied(self) -> bool { + pub fn counts_as_transport_satisfaction( + self, + satisfaction_class: RadrootsTransportSatisfactionClass, + ) -> bool { + match satisfaction_class { + RadrootsTransportSatisfactionClass::Accepted => matches!( + self, + Self::Accepted + | Self::Delivered + | Self::Forwarded + | Self::StoredByGateway + | Self::Seen + ), + RadrootsTransportSatisfactionClass::Delivered => matches!( + self, + Self::Delivered | Self::Forwarded | Self::StoredByGateway | Self::Seen + ), + } + } + + pub fn is_deferred_preview(self) -> bool { matches!( self, - Self::Accepted | Self::Delivered | Self::Forwarded | Self::StoredByGateway | Self::Seen + Self::DeferredUntilImplemented | Self::PreviewUnavailable ) } + pub fn is_retryable_failure(self) -> bool { + matches!(self, Self::FailedRetryable) + } + pub fn is_terminal_failure(self) -> bool { matches!(self, Self::SkippedPolicyDenied | Self::FailedTerminal) } + + pub fn is_completed(self) -> bool { + self.counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted) + || self.is_deferred_preview() + || self.is_terminal_failure() + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -462,13 +495,15 @@ mod tests { ); } - for (status, expected, ready, satisfied, terminal_failure) in [ + for (status, expected, ready, satisfied, delivered, deferred_preview, terminal_failure) in [ ( RadrootsOutboxDeliveryTargetStatus::Pending, "pending", true, false, false, + false, + false, ), ( RadrootsOutboxDeliveryTargetStatus::Accepted, @@ -476,12 +511,16 @@ mod tests { false, true, false, + false, + false, ), ( RadrootsOutboxDeliveryTargetStatus::Delivered, "delivered", false, true, + true, + false, false, ), ( @@ -489,6 +528,8 @@ mod tests { "forwarded", false, true, + true, + false, false, ), ( @@ -496,6 +537,8 @@ mod tests { "stored_by_gateway", false, true, + true, + false, false, ), ( @@ -503,6 +546,8 @@ mod tests { "seen", false, true, + true, + false, false, ), ( @@ -511,12 +556,25 @@ mod tests { false, false, false, + true, + false, + ), + ( + RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable, + "preview_unavailable", + false, + false, + false, + true, + false, ), ( RadrootsOutboxDeliveryTargetStatus::SkippedPolicyDenied, "skipped_policy_denied", false, false, + false, + false, true, ), ( @@ -525,12 +583,16 @@ mod tests { true, false, false, + false, + false, ), ( RadrootsOutboxDeliveryTargetStatus::FailedTerminal, "failed_terminal", false, false, + false, + false, true, ), ] { @@ -540,7 +602,19 @@ mod tests { status ); assert_eq!(status.is_ready_for_attempt(), ready); - assert_eq!(status.counts_as_satisfied(), satisfied); + assert_eq!( + status.counts_as_transport_satisfaction( + radroots_transport::RadrootsTransportSatisfactionClass::Accepted + ), + satisfied + ); + assert_eq!( + status.counts_as_transport_satisfaction( + radroots_transport::RadrootsTransportSatisfactionClass::Delivered + ), + delivered + ); + assert_eq!(status.is_deferred_preview(), deferred_preview); assert_eq!(status.is_terminal_failure(), terminal_failure); } } diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -18,8 +18,8 @@ use radroots_events::draft::{ RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, validate_signed_nostr_event_matches_draft, }; use radroots_transport::{ - RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, - RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri, + RadrootsTransportKind, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri, }; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -985,10 +985,7 @@ impl RadrootsOutbox { ) -> Result<(), RadrootsOutboxError> { let mut tx = self.pool.begin().await?; claimed_event_identity_tx(&mut tx, outbox_event_id, claim_token).await?; - let completed_at_ms = (status.counts_as_satisfied() - || status.is_terminal_failure() - || status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented) - .then_some(attempted_at_ms); + let completed_at_ms = status.is_completed().then_some(attempted_at_ms); let changed = sqlx::query( "UPDATE outbox_delivery_target SET status = ?, attempt_count = attempt_count + 1, last_attempt_at_ms = ?, completed_at_ms = ?, last_error = ? WHERE delivery_target_id = ? AND delivery_plan_id IN (SELECT delivery_plan_id FROM outbox_delivery_plan WHERE outbox_event_id = ?)", ) @@ -1499,7 +1496,11 @@ async fn evaluate_delivery_plans( let targets = delivery_targets_for_plan_tx(tx, plan.delivery_plan_id).await?; let satisfied_count = targets .iter() - .filter(|target| target.status.counts_as_satisfied()) + .filter(|target| { + target + .status + .counts_as_transport_satisfaction(plan.satisfaction_policy.class()) + }) .count() as i64; let ready_count = targets .iter() @@ -1507,9 +1508,7 @@ async fn evaluate_delivery_plans( .count(); let deferred_count = targets .iter() - .filter(|target| { - target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - }) + .filter(|target| target.status.is_deferred_preview()) .count(); let terminal_failure_count = targets .iter() @@ -1781,9 +1780,25 @@ fn sha256_json<T: Serialize>(value: &T) -> String { fn satisfaction_policy_storage_value(policy: &RadrootsTransportSatisfactionPolicy) -> String { match policy { - RadrootsTransportSatisfactionPolicy::AllTargets => "all_targets".to_owned(), - RadrootsTransportSatisfactionPolicy::AnyTarget => "any_target".to_owned(), - RadrootsTransportSatisfactionPolicy::AtLeast(count) => format!("at_least:{count}"), + RadrootsTransportSatisfactionPolicy::All { class } => { + format!("all_{}", satisfaction_class_storage_value(*class)) + } + RadrootsTransportSatisfactionPolicy::Any { class } => { + format!("any_{}", satisfaction_class_storage_value(*class)) + } + RadrootsTransportSatisfactionPolicy::Quorum { class, threshold } => { + format!( + "quorum_{}:{threshold}", + satisfaction_class_storage_value(*class) + ) + } + } +} + +fn satisfaction_class_storage_value(class: RadrootsTransportSatisfactionClass) -> &'static str { + match class { + RadrootsTransportSatisfactionClass::Accepted => "accepted", + RadrootsTransportSatisfactionClass::Delivered => "delivered", } } @@ -1792,16 +1807,19 @@ fn parse_satisfaction_policy( required_success_count: i64, ) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsOutboxError> { match value { - "all_targets" => Ok(RadrootsTransportSatisfactionPolicy::AllTargets), - "any_target" => Ok(RadrootsTransportSatisfactionPolicy::AnyTarget), - stored if stored == format!("at_least:{required_success_count}") => { - let count = u16::try_from(required_success_count).map_err(|_| { - RadrootsOutboxError::IntegerRange { - field: "required_success_count", - value: required_success_count, - } - })?; - Ok(RadrootsTransportSatisfactionPolicy::AtLeast(count)) + "all_accepted" => Ok(RadrootsTransportSatisfactionPolicy::all_accepted()), + "any_accepted" => Ok(RadrootsTransportSatisfactionPolicy::any_accepted()), + "all_delivered" => Ok(RadrootsTransportSatisfactionPolicy::all_delivered()), + "any_delivered" => Ok(RadrootsTransportSatisfactionPolicy::any_delivered()), + stored if stored == format!("quorum_accepted:{required_success_count}") => { + Ok(RadrootsTransportSatisfactionPolicy::quorum_accepted( + required_count_u16(required_success_count)?, + )) + } + stored if stored == format!("quorum_delivered:{required_success_count}") => { + Ok(RadrootsTransportSatisfactionPolicy::quorum_delivered( + required_count_u16(required_success_count)?, + )) } _ => Err(RadrootsOutboxError::InvalidStoredEnum { field: "outbox_delivery_plan.satisfaction_policy", @@ -1810,6 +1828,13 @@ fn parse_satisfaction_policy( } } +fn required_count_u16(required_success_count: i64) -> Result<u16, RadrootsOutboxError> { + u16::try_from(required_success_count).map_err(|_| RadrootsOutboxError::IntegerRange { + field: "required_success_count", + value: required_success_count, + }) +} + fn bool_i64(value: bool) -> i64 { if value { 1 } else { 0 } } @@ -1862,7 +1887,7 @@ mod tests { RadrootsOutboxDeliveryPlanInput::new( "transport.nostr.local", 1, - RadrootsTransportSatisfactionPolicy::AllTargets, + RadrootsTransportSatisfactionPolicy::all_accepted(), targets, ) } @@ -2116,7 +2141,7 @@ mod tests { } #[tokio::test] - async fn at_least_delivery_plan_round_trips_and_completes_after_required_target() { + async fn quorum_accepted_delivery_plan_round_trips_and_completes_after_required_target() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "at least"); let signed_event = @@ -2129,7 +2154,7 @@ mod tests { RadrootsOutboxDeliveryPlanInput::new( "transport.nostr.local", 7, - RadrootsTransportSatisfactionPolicy::AtLeast(1), + RadrootsTransportSatisfactionPolicy::quorum_accepted(1), vec![ nostr_target(NOSTR_PRIMARY_WSS), nostr_target(NOSTR_SECONDARY_WSS), @@ -2149,7 +2174,7 @@ mod tests { assert_eq!(plans.len(), 1); assert_eq!( plans[0].satisfaction_policy, - RadrootsTransportSatisfactionPolicy::AtLeast(1) + RadrootsTransportSatisfactionPolicy::quorum_accepted(1) ); assert_eq!(plans[0].required_success_count, 1); assert_eq!(plans[0].target_policy_version, 7); @@ -2239,7 +2264,7 @@ mod tests { RadrootsOutboxDeliveryPlanInput::new( "transport.reticulum.preview", 1, - RadrootsTransportSatisfactionPolicy::AllTargets, + RadrootsTransportSatisfactionPolicy::all_accepted(), vec![reticulum_target("reticulum:preview-target")], ), true, diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -6,14 +6,72 @@ use alloc::string::String; use alloc::vec::Vec; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RadrootsTransportSatisfactionClass { + Accepted, + Delivered, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsTransportSatisfactionPolicy { - AllTargets, - AnyTarget, - AtLeast(u16), + Any { + class: RadrootsTransportSatisfactionClass, + }, + All { + class: RadrootsTransportSatisfactionClass, + }, + Quorum { + class: RadrootsTransportSatisfactionClass, + threshold: u16, + }, } impl RadrootsTransportSatisfactionPolicy { + pub fn any_accepted() -> Self { + Self::Any { + class: RadrootsTransportSatisfactionClass::Accepted, + } + } + + pub fn all_accepted() -> Self { + Self::All { + class: RadrootsTransportSatisfactionClass::Accepted, + } + } + + pub fn quorum_accepted(threshold: u16) -> Self { + Self::Quorum { + class: RadrootsTransportSatisfactionClass::Accepted, + threshold, + } + } + + pub fn any_delivered() -> Self { + Self::Any { + class: RadrootsTransportSatisfactionClass::Delivered, + } + } + + pub fn all_delivered() -> Self { + Self::All { + class: RadrootsTransportSatisfactionClass::Delivered, + } + } + + pub fn quorum_delivered(threshold: u16) -> Self { + Self::Quorum { + class: RadrootsTransportSatisfactionClass::Delivered, + threshold, + } + } + + pub fn class(&self) -> RadrootsTransportSatisfactionClass { + match self { + Self::Any { class } | Self::All { class } | Self::Quorum { class, .. } => *class, + } + } + pub fn required_target_count( &self, total_targets: usize, @@ -22,12 +80,14 @@ impl RadrootsTransportSatisfactionPolicy { return Err(RadrootsTransportError::InvalidSatisfactionPolicy); } match self { - Self::AllTargets => Ok(total_targets), - Self::AnyTarget => Ok(1), - Self::AtLeast(count) if *count > 0 && usize::from(*count) <= total_targets => { - Ok(usize::from(*count)) + Self::All { .. } => Ok(total_targets), + Self::Any { .. } => Ok(1), + Self::Quorum { threshold, .. } + if *threshold > 0 && usize::from(*threshold) <= total_targets => + { + Ok(usize::from(*threshold)) } - Self::AtLeast(_) => Err(RadrootsTransportError::InvalidSatisfactionPolicy), + Self::Quorum { .. } => Err(RadrootsTransportError::InvalidSatisfactionPolicy), } } @@ -92,10 +152,13 @@ pub struct RadrootsTransportDeliveryReceipt { } impl RadrootsTransportDeliveryReceipt { - pub fn satisfied_target_count(&self) -> usize { + pub fn satisfied_target_count( + &self, + satisfaction_class: RadrootsTransportSatisfactionClass, + ) -> usize { self.target_receipts .iter() - .filter(|receipt| receipt.status.counts_as_satisfied()) + .filter(|receipt| receipt.status.counts_as_satisfied(satisfaction_class)) .count() } } diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -11,7 +11,8 @@ mod target; pub use delivery::{ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetReceipt, + RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportTargetReceipt, }; pub use error::RadrootsTransportError; pub use kind::{RadrootsTransportImplementationState, RadrootsTransportKind}; diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -1,3 +1,4 @@ +use crate::delivery::RadrootsTransportSatisfactionClass; use alloc::string::String; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -5,19 +6,61 @@ use alloc::string::String; pub enum RadrootsTransportDeliveryTargetStatus { Pending, Accepted, - Deferred, - Rejected, - Failed, - Unavailable, + Delivered, + Forwarded, + StoredByGateway, + Seen, + DeferredUntilImplemented, + PreviewUnavailable, + SkippedPolicyDenied, + FailedRetryable, + FailedTerminal, } impl RadrootsTransportDeliveryTargetStatus { - pub fn is_terminal(self) -> bool { - !matches!(self, Self::Pending) + pub fn is_ready_for_attempt(self) -> bool { + matches!(self, Self::Pending | Self::FailedRetryable) } - pub fn counts_as_satisfied(self) -> bool { - matches!(self, Self::Accepted) + pub fn counts_as_accepted_satisfaction(self) -> bool { + matches!( + self, + Self::Accepted | Self::Delivered | Self::Forwarded | Self::StoredByGateway | Self::Seen + ) + } + + pub fn counts_as_delivered_satisfaction(self) -> bool { + matches!( + self, + Self::Delivered | Self::Forwarded | Self::StoredByGateway | Self::Seen + ) + } + + pub fn counts_as_satisfied( + self, + satisfaction_class: RadrootsTransportSatisfactionClass, + ) -> bool { + match satisfaction_class { + RadrootsTransportSatisfactionClass::Accepted => self.counts_as_accepted_satisfaction(), + RadrootsTransportSatisfactionClass::Delivered => { + self.counts_as_delivered_satisfaction() + } + } + } + + pub fn is_retryable_failure(self) -> bool { + matches!(self, Self::FailedRetryable) + } + + pub fn is_terminal_failure(self) -> bool { + matches!(self, Self::SkippedPolicyDenied | Self::FailedTerminal) + } + + pub fn is_deferred_preview(self) -> bool { + matches!( + self, + Self::DeferredUntilImplemented | Self::PreviewUnavailable + ) } } diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,7 +1,8 @@ use radroots_transport::{ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportKind, - RadrootsTransportOutcome, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RadrootsTransportOutcome, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, }; @@ -80,20 +81,26 @@ fn target_set_rejects_duplicate_fingerprints() { #[test] fn satisfaction_policy_counts_target_statuses() { - let all = RadrootsTransportSatisfactionPolicy::AllTargets; - let any = RadrootsTransportSatisfactionPolicy::AnyTarget; - let two = RadrootsTransportSatisfactionPolicy::AtLeast(2); + let all = RadrootsTransportSatisfactionPolicy::all_accepted(); + let any = RadrootsTransportSatisfactionPolicy::any_accepted(); + let two = RadrootsTransportSatisfactionPolicy::quorum_accepted(2); + let delivered = RadrootsTransportSatisfactionPolicy::quorum_delivered(2); assert!(all.is_satisfied_by(2, 2).expect("all")); assert!(!all.is_satisfied_by(2, 1).expect("all incomplete")); assert!(any.is_satisfied_by(3, 1).expect("any")); assert!(two.is_satisfied_by(3, 2).expect("two")); + assert_eq!(all.class(), RadrootsTransportSatisfactionClass::Accepted); + assert_eq!( + delivered.class(), + RadrootsTransportSatisfactionClass::Delivered + ); assert_eq!( any.is_satisfied_by(0, 0).expect_err("zero target set"), RadrootsTransportError::InvalidSatisfactionPolicy ); assert_eq!( - RadrootsTransportSatisfactionPolicy::AtLeast(0) + RadrootsTransportSatisfactionPolicy::quorum_accepted(0) .is_satisfied_by(3, 0) .expect_err("zero required targets"), RadrootsTransportError::InvalidSatisfactionPolicy @@ -109,15 +116,23 @@ fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { request_id: "reticulum-preview".to_owned(), target_receipts: vec![RadrootsTransportTargetReceipt::new( target, - RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Deferred), + RadrootsTransportOutcome::new( + RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented, + ), )], }; - assert!(RadrootsTransportDeliveryTargetStatus::Deferred.is_terminal()); - assert_eq!(receipt.satisfied_target_count(), 0); + assert!(RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented.is_deferred_preview()); + assert_eq!( + receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), + 0 + ); assert!( - !RadrootsTransportSatisfactionPolicy::AnyTarget - .is_satisfied_by(1, receipt.satisfied_target_count()) + !RadrootsTransportSatisfactionPolicy::any_accepted() + .is_satisfied_by( + 1, + receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted) + ) .expect("satisfaction check") ); } @@ -131,7 +146,7 @@ fn request_models_round_trip_with_serde() { "req-1", "sha256:payload", target_set, - RadrootsTransportSatisfactionPolicy::AnyTarget, + RadrootsTransportSatisfactionPolicy::any_accepted(), ); let json = serde_json::to_string(&request).expect("serialize request"); @@ -267,19 +282,19 @@ fn target_fingerprints_and_sets_cover_accessors_and_validation() { #[test] fn satisfaction_and_target_status_cover_all_contract_states() { assert_eq!( - RadrootsTransportSatisfactionPolicy::AllTargets + RadrootsTransportSatisfactionPolicy::all_accepted() .required_target_count(3) .expect("all targets"), 3 ); assert_eq!( - RadrootsTransportSatisfactionPolicy::AnyTarget + RadrootsTransportSatisfactionPolicy::any_accepted() .required_target_count(3) .expect("any target"), 1 ); assert_eq!( - RadrootsTransportSatisfactionPolicy::AtLeast(4) + RadrootsTransportSatisfactionPolicy::quorum_accepted(4) .required_target_count(3) .expect_err("at least too high"), RadrootsTransportError::InvalidSatisfactionPolicy @@ -288,18 +303,49 @@ fn satisfaction_and_target_status_cover_all_contract_states() { let statuses = [ RadrootsTransportDeliveryTargetStatus::Pending, RadrootsTransportDeliveryTargetStatus::Accepted, - RadrootsTransportDeliveryTargetStatus::Deferred, - RadrootsTransportDeliveryTargetStatus::Rejected, - RadrootsTransportDeliveryTargetStatus::Failed, - RadrootsTransportDeliveryTargetStatus::Unavailable, + RadrootsTransportDeliveryTargetStatus::Delivered, + RadrootsTransportDeliveryTargetStatus::Forwarded, + RadrootsTransportDeliveryTargetStatus::StoredByGateway, + RadrootsTransportDeliveryTargetStatus::Seen, + RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented, + RadrootsTransportDeliveryTargetStatus::PreviewUnavailable, + RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied, + RadrootsTransportDeliveryTargetStatus::FailedRetryable, + RadrootsTransportDeliveryTargetStatus::FailedTerminal, ]; - assert!(!statuses[0].is_terminal()); - assert!(statuses[1..].iter().all(|status| status.is_terminal())); - assert!(RadrootsTransportDeliveryTargetStatus::Accepted.counts_as_satisfied()); + assert!(RadrootsTransportDeliveryTargetStatus::Pending.is_ready_for_attempt()); + assert!(RadrootsTransportDeliveryTargetStatus::FailedRetryable.is_ready_for_attempt()); + assert!( + RadrootsTransportDeliveryTargetStatus::Accepted + .counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted) + ); + assert!( + !RadrootsTransportDeliveryTargetStatus::Accepted + .counts_as_satisfied(RadrootsTransportSatisfactionClass::Delivered) + ); + for status in [ + RadrootsTransportDeliveryTargetStatus::Delivered, + RadrootsTransportDeliveryTargetStatus::Forwarded, + RadrootsTransportDeliveryTargetStatus::StoredByGateway, + RadrootsTransportDeliveryTargetStatus::Seen, + ] { + assert!(status.counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted)); + assert!(status.counts_as_satisfied(RadrootsTransportSatisfactionClass::Delivered)); + } assert!( statuses .iter() - .filter(|status| **status != RadrootsTransportDeliveryTargetStatus::Accepted) - .all(|status| !status.counts_as_satisfied()) + .filter(|status| !matches!( + status, + RadrootsTransportDeliveryTargetStatus::Accepted + | RadrootsTransportDeliveryTargetStatus::Delivered + | RadrootsTransportDeliveryTargetStatus::Forwarded + | RadrootsTransportDeliveryTargetStatus::StoredByGateway + | RadrootsTransportDeliveryTargetStatus::Seen + )) + .all(|status| !status.counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted)) ); + assert!(RadrootsTransportDeliveryTargetStatus::PreviewUnavailable.is_deferred_preview()); + assert!(RadrootsTransportDeliveryTargetStatus::FailedRetryable.is_retryable_failure()); + assert!(RadrootsTransportDeliveryTargetStatus::FailedTerminal.is_terminal_failure()); } diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -15,7 +15,9 @@ use radroots_outbox::{ RadrootsOutbox, RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetRecord, RadrootsOutboxDeliveryTargetStatus, RadrootsOutboxEventStoreIngestReceipt, }; -use radroots_transport::{RadrootsTransportKind, RadrootsTransportSatisfactionPolicy}; +use radroots_transport::{ + RadrootsTransportKind, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOutboxPublishPolicy { @@ -256,7 +258,11 @@ async fn publishable_relays( let satisfied_count = targets .iter() .filter(|target| target.delivery_plan_id == plan.delivery_plan_id) - .filter(|target| target.status.counts_as_satisfied()) + .filter(|target| { + target + .status + .counts_as_transport_satisfaction(plan.satisfaction_policy.class()) + }) .count(); (plan.required_success_count as usize).saturating_sub(satisfied_count) }) @@ -267,7 +273,10 @@ async fn publishable_relays( if !is_nostr_target(&target) { continue; } - if target.status.counts_as_satisfied() { + if target + .status + .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted) + { accepted_count += 1; } if required_accept_count > 0 @@ -298,7 +307,15 @@ fn satisfaction_policy_for_required_accept_count( target_count: usize, ) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsRelayTransportError> { if required_accept_count >= target_count { - return Ok(RadrootsTransportSatisfactionPolicy::AllTargets); + return Ok(RadrootsTransportSatisfactionPolicy::all_accepted()); + } + if required_accept_count == 0 { + return Err(RadrootsRelayTransportError::Transport( + "required Nostr relay acceptance count must be greater than zero".to_owned(), + )); + } + if required_accept_count == 1 { + return Ok(RadrootsTransportSatisfactionPolicy::any_accepted()); } let count = u16::try_from(required_accept_count).map_err(|_| { RadrootsRelayTransportError::Transport( @@ -306,7 +323,7 @@ fn satisfaction_policy_for_required_accept_count( .to_owned(), ) })?; - Ok(RadrootsTransportSatisfactionPolicy::AtLeast(count)) + Ok(RadrootsTransportSatisfactionPolicy::quorum_accepted(count)) } async fn ingest_publish_observation( @@ -354,11 +371,11 @@ mod tests { fn internal_outbox_publish_helpers_cover_policy_edges() { assert_eq!( satisfaction_policy_for_required_accept_count(2, 2).expect("all targets"), - RadrootsTransportSatisfactionPolicy::AllTargets + RadrootsTransportSatisfactionPolicy::all_accepted() ); assert_eq!( satisfaction_policy_for_required_accept_count(1, 3).expect("at least one"), - RadrootsTransportSatisfactionPolicy::AtLeast(1) + RadrootsTransportSatisfactionPolicy::any_accepted() ); assert!( satisfaction_policy_for_required_accept_count( diff --git a/crates/transport_nostr/src/outcome.rs b/crates/transport_nostr/src/outcome.rs @@ -182,9 +182,9 @@ impl RadrootsRelayOutcome { let status = if self.counts_toward_quorum() { RadrootsTransportDeliveryTargetStatus::Accepted } else if self.is_retryable() { - RadrootsTransportDeliveryTargetStatus::Failed + RadrootsTransportDeliveryTargetStatus::FailedRetryable } else { - RadrootsTransportDeliveryTargetStatus::Rejected + RadrootsTransportDeliveryTargetStatus::FailedTerminal }; let mut outcome = RadrootsTransportOutcome::new(status); outcome.code = Some(self.kind.as_str().to_owned()); diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -37,7 +37,7 @@ impl RadrootsRelayPublishRequest { Self { signed_event, targets, - satisfaction_policy: RadrootsTransportSatisfactionPolicy::AllTargets, + satisfaction_policy: RadrootsTransportSatisfactionPolicy::all_accepted(), now_ms, } } diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -189,7 +189,7 @@ where ) } -fn all_targets_outbox_operation_input<I, S>( +fn all_accepted_outbox_operation_input<I, S>( draft: RadrootsFrozenEventDraft, relays: I, ) -> RadrootsOutboxOperationInput @@ -200,7 +200,7 @@ where outbox_operation_input( draft, relays, - RadrootsTransportSatisfactionPolicy::AllTargets, + RadrootsTransportSatisfactionPolicy::all_accepted(), ) } @@ -557,13 +557,13 @@ fn outcome_prefix_classification_covers_required_kinds() { RadrootsRelayOutcome::timeout("timeout: no OK") .to_transport_outcome() .status, - radroots_transport::RadrootsTransportDeliveryTargetStatus::Failed + radroots_transport::RadrootsTransportDeliveryTargetStatus::FailedRetryable ); assert_eq!( RadrootsRelayOutcome::classify("restricted: denied") .to_transport_outcome() .status, - radroots_transport::RadrootsTransportDeliveryTargetStatus::Rejected + radroots_transport::RadrootsTransportDeliveryTargetStatus::FailedTerminal ); assert_eq!( RadrootsRelayOutcome::connection_failed("offline") @@ -612,7 +612,7 @@ async fn mock_publish_preserves_exact_raw_json_and_counts_outcomes() { let receipt = publish_signed_event( &adapter, radroots_transport_nostr::RadrootsRelayPublishRequest::new(signed.clone(), targets, 1_000) - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::AtLeast(2)), + .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::quorum_accepted(2)), ) .await .expect("publish"); @@ -641,7 +641,7 @@ async fn publish_receipts_track_terminal_skipped_and_adapter_errors() { let receipt = publish_signed_event( &adapter, RadrootsRelayPublishRequest::new(signed.clone(), targets, 1_050) - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::AllTargets), + .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::all_accepted()), ) .await .expect("publish"); @@ -1288,7 +1288,7 @@ async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![ RELAY_PRIMARY_WSS.to_owned(), @@ -1429,7 +1429,7 @@ async fn outbox_transport_publish_failure_releases_retryable_claim() { ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()], )) @@ -1522,7 +1522,7 @@ async fn outbox_publish_marks_published_without_adapter_when_all_relays_already_ ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()], )) @@ -1611,7 +1611,7 @@ async fn outbox_publish_ignores_unknown_adapter_receipts() { ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned()], )) @@ -1678,7 +1678,7 @@ async fn outbox_publish_skips_non_nostr_targets() { RadrootsOutboxDeliveryPlanInput::new( "transport.mixed.local", 1, - RadrootsTransportSatisfactionPolicy::AllTargets, + RadrootsTransportSatisfactionPolicy::all_accepted(), vec![ nostr_target(RELAY_PRIMARY_WSS), RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, "reticulum:a") @@ -1755,7 +1755,7 @@ async fn outbox_publish_marks_published_when_delivery_plan_satisfaction_is_met_w RELAY_SECONDARY_WSS.to_owned(), RELAY_TERTIARY_WSS.to_owned(), ], - RadrootsTransportSatisfactionPolicy::AtLeast(2), + RadrootsTransportSatisfactionPolicy::quorum_accepted(2), )) .await .expect("enqueue"); @@ -1854,7 +1854,7 @@ async fn outbox_publish_republishes_accepted_relays_when_policy_requests_it() { ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()], )) @@ -1938,7 +1938,7 @@ async fn outbox_publish_republish_policy_keeps_terminal_targets_excluded() { ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()], )) @@ -2019,7 +2019,7 @@ async fn outbox_publish_requires_claimed_signed_event() { ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned()], )) @@ -2066,7 +2066,7 @@ async fn outbox_publish_propagates_non_transport_adapter_errors_after_target_fil ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()], )) @@ -2124,7 +2124,7 @@ async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish( ) .expect("draft"); let receipt = outbox - .enqueue_operation(all_targets_outbox_operation_input( + .enqueue_operation(all_accepted_outbox_operation_input( draft, vec!["ws://127.0.0.1:9999".to_owned()], )) diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -288,11 +288,11 @@ fn ensure_reticulum_targets( fn preview_outcome(behavior: RadrootsReticulumPreviewBehavior) -> RadrootsTransportOutcome { let mut outcome = match behavior { RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => { - RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Unavailable) - } - RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => { - RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Deferred) + RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::PreviewUnavailable) } + RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => RadrootsTransportOutcome::new( + RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented, + ), }; outcome.code = Some( match behavior { diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -1,7 +1,8 @@ use radroots_transport::{ RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet, + RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportTarget, RadrootsTransportTargetSet, }; use radroots_transport_reticulum::{ RadrootsReticulumPreviewBehavior, RadrootsReticulumPreviewEndpoint, @@ -24,7 +25,7 @@ fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportD "reticulum-preview-delivery", "sha256:preview-payload", RadrootsTransportTargetSet::new(targets).expect("target set"), - RadrootsTransportSatisfactionPolicy::AnyTarget, + RadrootsTransportSatisfactionPolicy::any_accepted(), ) } @@ -125,12 +126,15 @@ fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing let receipt = transport.deliver(request).expect("delivery receipt"); assert_eq!(receipt.target_receipts.len(), 2); - assert_eq!(receipt.satisfied_target_count(), 0); + assert_eq!( + receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), + 0 + ); for target_receipt in receipt.target_receipts { assert_eq!(target_receipt.target.kind, RadrootsTransportKind::Reticulum); assert_eq!( target_receipt.status, - RadrootsTransportDeliveryTargetStatus::Unavailable + RadrootsTransportDeliveryTargetStatus::PreviewUnavailable ); assert_eq!( target_receipt.outcome.code.as_deref(), @@ -149,18 +153,24 @@ fn deferred_delivery_plan_mode_never_counts_as_satisfied() { let receipt = transport.deliver(request).expect("delivery receipt"); assert_eq!(receipt.target_receipts.len(), 1); - assert_eq!(receipt.satisfied_target_count(), 0); + assert_eq!( + receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted), + 0 + ); assert_eq!( receipt.target_receipts[0].status, - RadrootsTransportDeliveryTargetStatus::Deferred + RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); assert_eq!( receipt.target_receipts[0].outcome.code.as_deref(), Some("deferred_until_implemented") ); assert!( - !RadrootsTransportSatisfactionPolicy::AnyTarget - .is_satisfied_by(1, receipt.satisfied_target_count()) + !RadrootsTransportSatisfactionPolicy::any_accepted() + .is_satisfied_by( + 1, + receipt.satisfied_target_count(RadrootsTransportSatisfactionClass::Accepted) + ) .expect("satisfaction check") ); } @@ -199,7 +209,7 @@ fn fetch_reports_preview_unavailable_without_observed_events() { ); assert_eq!( receipt.outcome.status, - RadrootsTransportDeliveryTargetStatus::Unavailable + RadrootsTransportDeliveryTargetStatus::PreviewUnavailable ); assert_eq!( RadrootsReticulumPreviewFetchRequest::new("fetch-0", 0).expect_err("zero limit"), @@ -223,7 +233,7 @@ fn fetch_reports_preview_unavailable_without_observed_events() { .expect("fetch receipt"); assert_eq!( deferred.outcome.status, - RadrootsTransportDeliveryTargetStatus::Deferred + RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); }