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:
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
);
}