lib

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

commit 46ff7c47d51581b89baa84fc6e9caf06aff7d473
parent a891f09aedb6f219ed544184edcbf3e896e9e5dd
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 07:26:32 +0000

transport: seal satisfaction policy state

- hide policy variants behind validated constructors and typed accessors
- make every quorum threshold fallible and target-count bounded
- preserve strict wire compatibility with bounded required-target decoding
- migrate runtime outbox and Nostr consumers to the sealed contract

Diffstat:
Mcrates/outbox/src/store.rs | 123+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/runtime/src/transport.rs | 38++++++++++++++++++++------------------
Mcrates/transport/src/delivery.rs | 290++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/transport/src/lib.rs | 3++-
Mcrates/transport/tests/source_boundary.rs | 10+++++-----
Mcrates/transport/tests/transport.rs | 112++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/transport_nostr/src/outbox.rs | 52+++++++++++++++++++---------------------------------
Mcrates/transport_nostr/src/publish.rs | 64++++++++++++++++++++++++++++++----------------------------------
Mcrates/transport_nostr/tests/transport.rs | 8+++++---
9 files changed, 392 insertions(+), 308 deletions(-)

diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -34,9 +34,10 @@ use radroots_event_store::{ RadrootsTransportObservationType, }; use radroots_transport::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RadrootsTransportError, RadrootsTransportKind, - RadrootsTransportMeshScopeId, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RadrootsTransportError, + RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcomeKind, + RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportSatisfactionPolicyKind, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, }; use serde::Serialize; @@ -1886,22 +1887,15 @@ fn prepare_delivery_plan( fn canonical_satisfaction_policy( policy: &RadrootsTransportSatisfactionPolicy, ) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsOutboxError> { - match policy { - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => Ok( - RadrootsTransportSatisfactionPolicy::required_targets(*class, targets.clone())?, - ), - RadrootsTransportSatisfactionPolicy::NoWait - | RadrootsTransportSatisfactionPolicy::Any { .. } - | RadrootsTransportSatisfactionPolicy::All { .. } - | RadrootsTransportSatisfactionPolicy::Quorum { .. } => Ok(policy.clone()), - } + policy.required_target_count(RADROOTS_TRANSPORT_TARGET_MAX_COUNT)?; + Ok(policy.clone()) } fn validate_required_targets_belong_to_plan( policy: &RadrootsTransportSatisfactionPolicy, prepared_targets: &[PreparedDeliveryTarget], ) -> Result<(), RadrootsOutboxError> { - let RadrootsTransportSatisfactionPolicy::RequiredTargets { targets, .. } = policy else { + let Some(targets) = policy.required_target_fingerprints() else { return Ok(()); }; if targets.iter().all(|required| { @@ -2570,18 +2564,12 @@ fn delivery_plan_status_targets<'a>( policy: &RadrootsTransportSatisfactionPolicy, targets: &'a [RadrootsOutboxDeliveryTargetRecord], ) -> Vec<&'a RadrootsOutboxDeliveryTargetRecord> { - match policy { - RadrootsTransportSatisfactionPolicy::RequiredTargets { - targets: required_targets, - .. - } => targets + match policy.required_target_fingerprints() { + Some(required_targets) => targets .iter() .filter(|target| required_targets.contains(&target.endpoint_fingerprint)) .collect(), - RadrootsTransportSatisfactionPolicy::NoWait - | RadrootsTransportSatisfactionPolicy::Any { .. } - | RadrootsTransportSatisfactionPolicy::All { .. } - | RadrootsTransportSatisfactionPolicy::Quorum { .. } => targets.iter().collect(), + None => targets.iter().collect(), } } @@ -2589,23 +2577,20 @@ fn outbox_satisfied_target_count( policy: &RadrootsTransportSatisfactionPolicy, targets: &[RadrootsOutboxDeliveryTargetRecord], ) -> i64 { - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => 0, - RadrootsTransportSatisfactionPolicy::Any { class } - | RadrootsTransportSatisfactionPolicy::All { class } - | RadrootsTransportSatisfactionPolicy::Quorum { class, .. } => targets + let Some(class) = policy.target_satisfaction_class() else { + return 0; + }; + match policy.required_target_fingerprints() { + None => targets .iter() - .filter(|target| target.status.counts_as_transport_satisfaction(*class)) + .filter(|target| target.status.counts_as_transport_satisfaction(class)) .count() as i64, - RadrootsTransportSatisfactionPolicy::RequiredTargets { - class, - targets: required_targets, - } => required_targets + Some(required_targets) => required_targets .iter() .filter(|required| { targets.iter().any(|target| { target.endpoint_fingerprint == **required - && target.status.counts_as_transport_satisfaction(*class) + && target.status.counts_as_transport_satisfaction(class) }) }) .count() as i64, @@ -3176,22 +3161,32 @@ fn parse_optional_stored_mutation_id( } fn satisfaction_policy_storage_value(policy: &RadrootsTransportSatisfactionPolicy) -> String { - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => "no_wait".to_owned(), - RadrootsTransportSatisfactionPolicy::All { class } => { - format!("all_{}", satisfaction_class_storage_value(*class)) + let class = policy.target_satisfaction_class(); + match policy.kind() { + RadrootsTransportSatisfactionPolicyKind::NoWait => "no_wait".to_owned(), + RadrootsTransportSatisfactionPolicyKind::All => { + format!( + "all_{}", + satisfaction_class_storage_value(class.expect("all policy class")) + ) } - RadrootsTransportSatisfactionPolicy::Any { class } => { - format!("any_{}", satisfaction_class_storage_value(*class)) + RadrootsTransportSatisfactionPolicyKind::Any => { + format!( + "any_{}", + satisfaction_class_storage_value(class.expect("any policy class")) + ) } - RadrootsTransportSatisfactionPolicy::Quorum { class, threshold } => { + RadrootsTransportSatisfactionPolicyKind::Quorum => { format!( - "quorum_{}:{threshold}", - satisfaction_class_storage_value(*class) + "quorum_{}:{}", + satisfaction_class_storage_value(class.expect("quorum policy class")), + policy.quorum_threshold().expect("quorum threshold") ) } - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => { - let mut fingerprints = targets + RadrootsTransportSatisfactionPolicyKind::RequiredTargets => { + let mut fingerprints = policy + .required_target_fingerprints() + .expect("required-target policy fingerprints") .iter() .map(RadrootsTransportTargetFingerprint::as_str) .collect::<Vec<_>>(); @@ -3199,7 +3194,7 @@ fn satisfaction_policy_storage_value(policy: &RadrootsTransportSatisfactionPolic let fingerprints = fingerprints.join(","); format!( "required_{}:{fingerprints}", - satisfaction_class_storage_value(*class) + satisfaction_class_storage_value(class.expect("required-target policy class")) ) } } @@ -3241,13 +3236,13 @@ fn parse_satisfaction_policy( .strip_prefix("all_") .and_then(parse_satisfaction_class_storage_value) { - return Ok(RadrootsTransportSatisfactionPolicy::All { class }); + return Ok(RadrootsTransportSatisfactionPolicy::all(class)); } if let Some(class) = value .strip_prefix("any_") .and_then(parse_satisfaction_class_storage_value) { - return Ok(RadrootsTransportSatisfactionPolicy::Any { class }); + return Ok(RadrootsTransportSatisfactionPolicy::any(class)); } if let Some((class_label, threshold)) = value .strip_prefix("quorum_") @@ -3255,10 +3250,10 @@ fn parse_satisfaction_policy( && threshold == required_success_count.to_string() && let Some(class) = parse_satisfaction_class_storage_value(class_label) { - return Ok(RadrootsTransportSatisfactionPolicy::Quorum { + return Ok(RadrootsTransportSatisfactionPolicy::quorum( class, - threshold: required_count_u16(required_success_count)?, - }); + required_count_u16(required_success_count)?, + )?); } if let Some((class_label, fingerprints)) = value .strip_prefix("required_") @@ -3594,7 +3589,7 @@ mod tests { 2, ), ( - RadrootsTransportSatisfactionPolicy::quorum_seen(2), + RadrootsTransportSatisfactionPolicy::quorum_seen(2).expect("valid quorum"), "quorum_seen:2", 2, ), @@ -3604,7 +3599,7 @@ mod tests { 1, ), ( - RadrootsTransportSatisfactionPolicy::quorum_delivered(2), + RadrootsTransportSatisfactionPolicy::quorum_delivered(2).expect("valid quorum"), "quorum_delivered:2", 2, ), @@ -3621,14 +3616,16 @@ mod tests { fn required_target_policy_idempotency_is_order_independent() { let first = nostr_target("wss://required-one.example"); let second = nostr_target("wss://required-two.example"); - let first_policy = RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: RadrootsTransportSatisfactionClass::Accepted, - targets: vec![second.fingerprint().clone(), first.fingerprint().clone()], - }; - let second_policy = RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: RadrootsTransportSatisfactionClass::Accepted, - targets: vec![first.fingerprint().clone(), second.fingerprint().clone()], - }; + let first_policy = RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![second.fingerprint().clone(), first.fingerprint().clone()], + ) + .expect("first required-target policy"); + let second_policy = RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![first.fingerprint().clone(), second.fingerprint().clone()], + ) + .expect("second required-target policy"); let targets = vec![first, second]; let first_prepared = prepare_delivery_plan( @@ -6805,7 +6802,7 @@ mod tests { RadrootsOutboxDeliveryPlanInput::new( "transport.nostr.local", 7, - RadrootsTransportSatisfactionPolicy::quorum_accepted(1), + RadrootsTransportSatisfactionPolicy::quorum_accepted(1).expect("valid quorum"), vec![ nostr_target(NOSTR_PRIMARY_WSS), nostr_target(NOSTR_SECONDARY_WSS), @@ -6825,7 +6822,7 @@ mod tests { assert_eq!(plans.len(), 1); assert_eq!( plans[0].satisfaction_policy, - RadrootsTransportSatisfactionPolicy::quorum_accepted(1) + RadrootsTransportSatisfactionPolicy::quorum_accepted(1).expect("valid quorum") ); assert_eq!(plans[0].required_success_count, 1); assert_eq!(plans[0].target_policy_version, 7); @@ -7449,7 +7446,7 @@ mod tests { RadrootsOutboxDeliveryPlanInput::new( "transport.explicit.multi_target", 1, - RadrootsTransportSatisfactionPolicy::quorum_accepted(2), + RadrootsTransportSatisfactionPolicy::quorum_accepted(2).expect("valid quorum"), vec![ nostr_target(NOSTR_PRIMARY_WSS), reticulum_target("reticulum:local"), diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -382,20 +382,20 @@ fn satisfied_target_count_for_policy( policy: &RadrootsTransportSatisfactionPolicy, target_states: &[RadrootsRuntimeDeliveryTargetState], ) -> usize { - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => 0, - RadrootsTransportSatisfactionPolicy::Any { class } - | RadrootsTransportSatisfactionPolicy::All { class } - | RadrootsTransportSatisfactionPolicy::Quorum { class, .. } => target_states + let Some(class) = policy.target_satisfaction_class() else { + return 0; + }; + match policy.required_target_fingerprints() { + None => target_states .iter() - .filter(|state| state.status.counts_as_satisfied(*class)) + .filter(|state| state.status.counts_as_satisfied(class)) .count(), - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => targets + Some(targets) => targets .iter() .filter(|required| { target_states.iter().any(|state| { state.target.fingerprint() == *required - && state.status.counts_as_satisfied(*class) + && state.status.counts_as_satisfied(class) }) }) .count(), @@ -407,15 +407,15 @@ fn target_states_satisfy_policy( policy: &RadrootsTransportSatisfactionPolicy, target_states: &[RadrootsRuntimeDeliveryTargetState], ) -> Result<bool, RadrootsRuntimeTransportError> { - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => Ok(true), - RadrootsTransportSatisfactionPolicy::Any { .. } - | RadrootsTransportSatisfactionPolicy::All { .. } - | RadrootsTransportSatisfactionPolicy::Quorum { .. } => Ok(policy.is_satisfied_by( + if policy.target_satisfaction_class().is_none() { + return Ok(true); + } + match policy.required_target_fingerprints() { + None => Ok(policy.is_satisfied_by( target_states.len(), satisfied_target_count_for_policy(policy, target_states), )?), - RadrootsTransportSatisfactionPolicy::RequiredTargets { targets, .. } => { + Some(targets) => { policy.required_target_count(target_states.len())?; Ok(satisfied_target_count_for_policy(policy, target_states) == targets.len()) } @@ -427,8 +427,8 @@ fn dispatch_satisfaction_policy( policy: &RadrootsTransportSatisfactionPolicy, ) -> RadrootsTransportSatisfactionPolicy { match policy.target_satisfaction_class() { - Some(class) => RadrootsTransportSatisfactionPolicy::All { class }, - None => RadrootsTransportSatisfactionPolicy::NoWait, + Some(class) => RadrootsTransportSatisfactionPolicy::all(class), + None => RadrootsTransportSatisfactionPolicy::no_wait(), } } @@ -1403,7 +1403,8 @@ mod tests { payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, - satisfaction_policy: RadrootsTransportSatisfactionPolicy::quorum_accepted(2), + satisfaction_policy: RadrootsTransportSatisfactionPolicy::quorum_accepted(2) + .expect("valid quorum"), targets: vec![ RadrootsRuntimeDeliveryTarget::ready( 1, @@ -1459,7 +1460,8 @@ mod tests { payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, - satisfaction_policy: RadrootsTransportSatisfactionPolicy::quorum_accepted(2), + satisfaction_policy: RadrootsTransportSatisfactionPolicy::quorum_accepted(2) + .expect("valid quorum"), targets: vec![ RadrootsRuntimeDeliveryTarget::ready( 1, diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -22,9 +22,23 @@ pub enum RadrootsTransportSatisfactionClass { DurableOrObserved, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RadrootsTransportSatisfactionPolicyKind { + NoWait, + Any, + All, + Quorum, + RequiredTargets, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTransportSatisfactionPolicy { + body: RadrootsTransportSatisfactionPolicyBody, +} + #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsTransportSatisfactionPolicy { +enum RadrootsTransportSatisfactionPolicyBody { NoWait, Any { class: RadrootsTransportSatisfactionClass, @@ -44,121 +58,108 @@ pub enum RadrootsTransportSatisfactionPolicy { impl RadrootsTransportSatisfactionPolicy { pub fn no_wait() -> Self { - Self::NoWait + Self { + body: RadrootsTransportSatisfactionPolicyBody::NoWait, + } } - pub fn any_accepted() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Accepted, + pub fn any(class: RadrootsTransportSatisfactionClass) -> Self { + Self { + body: RadrootsTransportSatisfactionPolicyBody::Any { class }, } } - pub fn all_accepted() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Accepted, + pub fn all(class: RadrootsTransportSatisfactionClass) -> Self { + Self { + body: RadrootsTransportSatisfactionPolicyBody::All { class }, } } - pub fn quorum_accepted(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Accepted, - threshold, + pub fn quorum( + class: RadrootsTransportSatisfactionClass, + threshold: u16, + ) -> Result<Self, RadrootsTransportError> { + if threshold == 0 || usize::from(threshold) > RADROOTS_TRANSPORT_TARGET_MAX_COUNT { + return Err(RadrootsTransportError::InvalidSatisfactionPolicy); } + Ok(Self { + body: RadrootsTransportSatisfactionPolicyBody::Quorum { class, threshold }, + }) + } + + pub fn any_accepted() -> Self { + Self::any(RadrootsTransportSatisfactionClass::Accepted) + } + + pub fn all_accepted() -> Self { + Self::all(RadrootsTransportSatisfactionClass::Accepted) + } + + pub fn quorum_accepted(threshold: u16) -> Result<Self, RadrootsTransportError> { + Self::quorum(RadrootsTransportSatisfactionClass::Accepted, threshold) } pub fn any_forwarded() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Forwarded, - } + Self::any(RadrootsTransportSatisfactionClass::Forwarded) } pub fn all_forwarded() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Forwarded, - } + Self::all(RadrootsTransportSatisfactionClass::Forwarded) } - pub fn quorum_forwarded(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Forwarded, - threshold, - } + pub fn quorum_forwarded(threshold: u16) -> Result<Self, RadrootsTransportError> { + Self::quorum(RadrootsTransportSatisfactionClass::Forwarded, threshold) } pub fn any_stored() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Stored, - } + Self::any(RadrootsTransportSatisfactionClass::Stored) } pub fn all_stored() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Stored, - } + Self::all(RadrootsTransportSatisfactionClass::Stored) } - pub fn quorum_stored(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Stored, - threshold, - } + pub fn quorum_stored(threshold: u16) -> Result<Self, RadrootsTransportError> { + Self::quorum(RadrootsTransportSatisfactionClass::Stored, threshold) } pub fn any_seen() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Seen, - } + Self::any(RadrootsTransportSatisfactionClass::Seen) } pub fn all_seen() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Seen, - } + Self::all(RadrootsTransportSatisfactionClass::Seen) } - pub fn quorum_seen(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Seen, - threshold, - } + pub fn quorum_seen(threshold: u16) -> Result<Self, RadrootsTransportError> { + Self::quorum(RadrootsTransportSatisfactionClass::Seen, threshold) } pub fn any_delivered() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::Delivered, - } + Self::any(RadrootsTransportSatisfactionClass::Delivered) } pub fn all_delivered() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::Delivered, - } + Self::all(RadrootsTransportSatisfactionClass::Delivered) } - pub fn quorum_delivered(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::Delivered, - threshold, - } + pub fn quorum_delivered(threshold: u16) -> Result<Self, RadrootsTransportError> { + Self::quorum(RadrootsTransportSatisfactionClass::Delivered, threshold) } pub fn any_durable_or_observed() -> Self { - Self::Any { - class: RadrootsTransportSatisfactionClass::DurableOrObserved, - } + Self::any(RadrootsTransportSatisfactionClass::DurableOrObserved) } pub fn all_durable_or_observed() -> Self { - Self::All { - class: RadrootsTransportSatisfactionClass::DurableOrObserved, - } + Self::all(RadrootsTransportSatisfactionClass::DurableOrObserved) } - pub fn quorum_durable_or_observed(threshold: u16) -> Self { - Self::Quorum { - class: RadrootsTransportSatisfactionClass::DurableOrObserved, + pub fn quorum_durable_or_observed(threshold: u16) -> Result<Self, RadrootsTransportError> { + Self::quorum( + RadrootsTransportSatisfactionClass::DurableOrObserved, threshold, - } + ) } pub fn required_targets( @@ -167,23 +168,62 @@ impl RadrootsTransportSatisfactionPolicy { ) -> Result<Self, RadrootsTransportError> { validate_required_targets(&targets)?; targets.sort(); - Ok(Self::RequiredTargets { class, targets }) + Ok(Self { + body: RadrootsTransportSatisfactionPolicyBody::RequiredTargets { class, targets }, + }) + } + + pub fn kind(&self) -> RadrootsTransportSatisfactionPolicyKind { + match &self.body { + RadrootsTransportSatisfactionPolicyBody::NoWait => { + RadrootsTransportSatisfactionPolicyKind::NoWait + } + RadrootsTransportSatisfactionPolicyBody::Any { .. } => { + RadrootsTransportSatisfactionPolicyKind::Any + } + RadrootsTransportSatisfactionPolicyBody::All { .. } => { + RadrootsTransportSatisfactionPolicyKind::All + } + RadrootsTransportSatisfactionPolicyBody::Quorum { .. } => { + RadrootsTransportSatisfactionPolicyKind::Quorum + } + RadrootsTransportSatisfactionPolicyBody::RequiredTargets { .. } => { + RadrootsTransportSatisfactionPolicyKind::RequiredTargets + } + } + } + + pub fn quorum_threshold(&self) -> Option<u16> { + match &self.body { + RadrootsTransportSatisfactionPolicyBody::Quorum { threshold, .. } => Some(*threshold), + RadrootsTransportSatisfactionPolicyBody::NoWait + | RadrootsTransportSatisfactionPolicyBody::Any { .. } + | RadrootsTransportSatisfactionPolicyBody::All { .. } + | RadrootsTransportSatisfactionPolicyBody::RequiredTargets { .. } => None, + } } pub fn target_satisfaction_class(&self) -> Option<RadrootsTransportSatisfactionClass> { - match self { - Self::NoWait => None, - Self::Any { class } - | Self::All { class } - | Self::Quorum { class, .. } - | Self::RequiredTargets { class, .. } => Some(*class), + match &self.body { + RadrootsTransportSatisfactionPolicyBody::NoWait => None, + RadrootsTransportSatisfactionPolicyBody::Any { class } + | RadrootsTransportSatisfactionPolicyBody::All { class } + | RadrootsTransportSatisfactionPolicyBody::Quorum { class, .. } + | RadrootsTransportSatisfactionPolicyBody::RequiredTargets { class, .. } => { + Some(*class) + } } } pub fn required_target_fingerprints(&self) -> Option<&[RadrootsTransportTargetFingerprint]> { - match self { - Self::RequiredTargets { targets, .. } => Some(targets), - Self::NoWait | Self::Any { .. } | Self::All { .. } | Self::Quorum { .. } => None, + match &self.body { + RadrootsTransportSatisfactionPolicyBody::RequiredTargets { targets, .. } => { + Some(targets) + } + RadrootsTransportSatisfactionPolicyBody::NoWait + | RadrootsTransportSatisfactionPolicyBody::Any { .. } + | RadrootsTransportSatisfactionPolicyBody::All { .. } + | RadrootsTransportSatisfactionPolicyBody::Quorum { .. } => None, } } @@ -191,20 +231,22 @@ impl RadrootsTransportSatisfactionPolicy { &self, total_targets: usize, ) -> Result<usize, RadrootsTransportError> { - if total_targets == 0 && !matches!(self, Self::NoWait) { + if total_targets == 0 && self.kind() != RadrootsTransportSatisfactionPolicyKind::NoWait { return Err(RadrootsTransportError::InvalidSatisfactionPolicy); } - match self { - Self::NoWait => Ok(0), - Self::All { .. } => Ok(total_targets), - Self::Any { .. } => Ok(1), - Self::Quorum { threshold, .. } + match &self.body { + RadrootsTransportSatisfactionPolicyBody::NoWait => Ok(0), + RadrootsTransportSatisfactionPolicyBody::All { .. } => Ok(total_targets), + RadrootsTransportSatisfactionPolicyBody::Any { .. } => Ok(1), + RadrootsTransportSatisfactionPolicyBody::Quorum { threshold, .. } if *threshold > 0 && usize::from(*threshold) <= total_targets => { Ok(usize::from(*threshold)) } - Self::Quorum { .. } => Err(RadrootsTransportError::InvalidSatisfactionPolicy), - Self::RequiredTargets { targets, .. } => { + RadrootsTransportSatisfactionPolicyBody::Quorum { .. } => { + Err(RadrootsTransportError::InvalidSatisfactionPolicy) + } + RadrootsTransportSatisfactionPolicyBody::RequiredTargets { targets, .. } => { validate_required_targets(targets)?; if targets.len() > total_targets { return Err(RadrootsTransportError::InvalidSatisfactionPolicy); @@ -219,7 +261,7 @@ impl RadrootsTransportSatisfactionPolicy { total_targets: usize, satisfied_targets: usize, ) -> Result<bool, RadrootsTransportError> { - if matches!(self, Self::RequiredTargets { .. }) { + if self.kind() == RadrootsTransportSatisfactionPolicyKind::RequiredTargets { return Err(RadrootsTransportError::InvalidSatisfactionPolicy); } let required = self.required_target_count(total_targets)?; @@ -231,7 +273,8 @@ impl RadrootsTransportSatisfactionPolicy { target_set: &RadrootsTransportTargetSet, ) -> Result<(), RadrootsTransportError> { self.required_target_count(target_set.len())?; - if let Self::RequiredTargets { targets, .. } = self { + if let RadrootsTransportSatisfactionPolicyBody::RequiredTargets { targets, .. } = &self.body + { for required in targets { if !target_set .targets() @@ -247,7 +290,18 @@ impl RadrootsTransportSatisfactionPolicy { } #[cfg(feature = "serde")] +impl serde::Serialize for RadrootsTransportSatisfactionPolicy { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.body.serialize(serializer) + } +} + +#[cfg(feature = "serde")] #[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] enum RadrootsTransportSatisfactionPolicyWire { NoWait, Any { @@ -262,6 +316,7 @@ enum RadrootsTransportSatisfactionPolicyWire { }, RequiredTargets { class: RadrootsTransportSatisfactionClass, + #[serde(deserialize_with = "deserialize_required_target_fingerprints")] targets: Vec<RadrootsTransportTargetFingerprint>, }, } @@ -273,17 +328,12 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportSatisfactionPolicy { D: serde::Deserializer<'de>, { match RadrootsTransportSatisfactionPolicyWire::deserialize(deserializer)? { - RadrootsTransportSatisfactionPolicyWire::NoWait => Ok(Self::NoWait), - RadrootsTransportSatisfactionPolicyWire::Any { class } => Ok(Self::Any { class }), - RadrootsTransportSatisfactionPolicyWire::All { class } => Ok(Self::All { class }), - RadrootsTransportSatisfactionPolicyWire::Quorum { class, threshold } - if threshold > 0 => - { - Ok(Self::Quorum { class, threshold }) + RadrootsTransportSatisfactionPolicyWire::NoWait => Ok(Self::no_wait()), + RadrootsTransportSatisfactionPolicyWire::Any { class } => Ok(Self::any(class)), + RadrootsTransportSatisfactionPolicyWire::All { class } => Ok(Self::all(class)), + RadrootsTransportSatisfactionPolicyWire::Quorum { class, threshold } => { + Self::quorum(class, threshold).map_err(serde::de::Error::custom) } - RadrootsTransportSatisfactionPolicyWire::Quorum { .. } => Err( - serde::de::Error::custom(RadrootsTransportError::InvalidSatisfactionPolicy), - ), RadrootsTransportSatisfactionPolicyWire::RequiredTargets { class, targets } => { Self::required_targets(class, targets).map_err(serde::de::Error::custom) } @@ -291,6 +341,20 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportSatisfactionPolicy { } } +#[cfg(feature = "serde")] +fn deserialize_required_target_fingerprints<'de, D>( + deserializer: D, +) -> Result<Vec<RadrootsTransportTargetFingerprint>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_vec( + deserializer, + "required_target_count", + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + ) +} + fn validate_required_targets( targets: &[RadrootsTransportTargetFingerprint], ) -> Result<(), RadrootsTransportError> { @@ -604,23 +668,23 @@ impl RadrootsTransportDeliveryReceipt { policy: &RadrootsTransportSatisfactionPolicy, ) -> Result<bool, RadrootsTransportError> { policy.validate_for_target_set(&self.target_set)?; - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => Ok(true), - RadrootsTransportSatisfactionPolicy::Any { class } - | RadrootsTransportSatisfactionPolicy::All { class } - | RadrootsTransportSatisfactionPolicy::Quorum { class, .. } => { - policy.is_satisfied_by(self.target_set.len(), self.satisfied_target_count(*class)) - } - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => { - validate_required_targets(targets)?; - Ok(targets.iter().all(|required| { - self.target_receipts.iter().any(|receipt| { - receipt.target.fingerprint() == required - && receipt.status.counts_as_satisfied(*class) - }) - })) - } - } + if policy.kind() == RadrootsTransportSatisfactionPolicyKind::NoWait { + return Ok(true); + } + let class = policy + .target_satisfaction_class() + .ok_or(RadrootsTransportError::InvalidSatisfactionPolicy)?; + let Some(targets) = policy.required_target_fingerprints() else { + return policy + .is_satisfied_by(self.target_set.len(), self.satisfied_target_count(class)); + }; + validate_required_targets(targets)?; + Ok(targets.iter().all(|required| { + self.target_receipts.iter().any(|receipt| { + receipt.target.fingerprint() == required + && receipt.status.counts_as_satisfied(class) + }) + })) } } diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -20,7 +20,8 @@ mod transport; pub use delivery::{ RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetReceipt, + RadrootsTransportSatisfactionPolicy, RadrootsTransportSatisfactionPolicyKind, + RadrootsTransportTargetReceipt, }; pub use error::RadrootsTransportError; pub use kind::{ diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -725,11 +725,11 @@ fn required_target_semantics_stay_fingerprint_exact() { let nostr_publish_source = read_source(crates_root.join("transport_nostr/src/publish.rs").as_path()); for required in [ - "RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } =>", + "let Some(targets) = policy.required_target_fingerprints() else", "let mut satisfied_required_targets = BTreeSet::new();", "targets.contains(target.fingerprint())", - "counts_as_satisfied(*class)", - "targets\n .iter()\n .all(|target| satisfied_required_targets.contains(target))", + "counts_as_satisfied(class)", + "targets\n .iter()\n .all(|target| satisfied_required_targets.contains(target))", ] { assert!( nostr_publish_source.contains(required), @@ -741,11 +741,11 @@ fn required_target_semantics_stay_fingerprint_exact() { read_source(crates_root.join("transport_nostr/src/outbox.rs").as_path()); let publishable_relays_source = source_between( nostr_outbox_source.as_str(), - "let required_targets = match &plan.satisfaction_policy", + "let required_targets = plan", "Ok(PublishableRelays {", ); for required in [ - "RadrootsTransportSatisfactionPolicy::RequiredTargets { targets, .. } =>", + ".required_target_fingerprints()", ".is_none_or(|required| required.contains(&target.endpoint_fingerprint))", ".is_some_and(|required| required.contains(&target.endpoint_fingerprint))", "required_targets.is_none() || required_for_satisfaction", diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -11,8 +11,9 @@ use radroots_transport::{ RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, - RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, + RadrootsTransportSatisfactionPolicy, RadrootsTransportSatisfactionPolicyKind, + RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, + RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, @@ -314,14 +315,20 @@ fn satisfaction_policy_counts_target_statuses() { let no_wait = RadrootsTransportSatisfactionPolicy::no_wait(); let all = RadrootsTransportSatisfactionPolicy::all_accepted(); let any = RadrootsTransportSatisfactionPolicy::any_accepted(); - let two = RadrootsTransportSatisfactionPolicy::quorum_accepted(2); - let delivered = RadrootsTransportSatisfactionPolicy::quorum_delivered(2); + let two = RadrootsTransportSatisfactionPolicy::quorum_accepted(2).expect("valid quorum"); + let delivered = RadrootsTransportSatisfactionPolicy::quorum_delivered(2).expect("valid quorum"); let forwarded = RadrootsTransportSatisfactionPolicy::any_forwarded(); let stored = RadrootsTransportSatisfactionPolicy::all_stored(); - let seen = RadrootsTransportSatisfactionPolicy::quorum_seen(2); + let seen = RadrootsTransportSatisfactionPolicy::quorum_seen(2).expect("valid quorum"); let durable_or_observed = RadrootsTransportSatisfactionPolicy::any_durable_or_observed(); assert_eq!(no_wait.required_target_count(0).expect("no wait"), 0); + assert_eq!( + no_wait.kind(), + RadrootsTransportSatisfactionPolicyKind::NoWait + ); + assert_eq!(two.kind(), RadrootsTransportSatisfactionPolicyKind::Quorum); + assert_eq!(two.quorum_threshold(), Some(2)); assert_eq!(no_wait.required_target_count(3).expect("no wait"), 0); assert!(no_wait.is_satisfied_by(0, 0).expect("no wait")); assert_ne!(no_wait, all); @@ -360,7 +367,7 @@ fn satisfaction_policy_counts_target_statuses() { RadrootsTransportSatisfactionClass::Forwarded, ), ( - RadrootsTransportSatisfactionPolicy::quorum_forwarded(2), + RadrootsTransportSatisfactionPolicy::quorum_forwarded(2).expect("valid quorum"), RadrootsTransportSatisfactionClass::Forwarded, ), ( @@ -368,7 +375,7 @@ fn satisfaction_policy_counts_target_statuses() { RadrootsTransportSatisfactionClass::Stored, ), ( - RadrootsTransportSatisfactionPolicy::quorum_stored(2), + RadrootsTransportSatisfactionPolicy::quorum_stored(2).expect("valid quorum"), RadrootsTransportSatisfactionClass::Stored, ), ( @@ -384,7 +391,8 @@ fn satisfaction_policy_counts_target_statuses() { RadrootsTransportSatisfactionClass::DurableOrObserved, ), ( - RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2), + RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2) + .expect("valid quorum"), RadrootsTransportSatisfactionClass::DurableOrObserved, ), ] { @@ -395,9 +403,7 @@ fn satisfaction_policy_counts_target_statuses() { RadrootsTransportError::InvalidSatisfactionPolicy ); assert_eq!( - RadrootsTransportSatisfactionPolicy::quorum_accepted(0) - .is_satisfied_by(3, 0) - .expect_err("zero required targets"), + RadrootsTransportSatisfactionPolicy::quorum_accepted(0).expect_err("zero required targets"), RadrootsTransportError::InvalidSatisfactionPolicy ); } @@ -890,6 +896,7 @@ fn satisfaction_and_target_status_cover_all_contract_states() { ); assert_eq!( RadrootsTransportSatisfactionPolicy::quorum_accepted(4) + .expect("bounded quorum") .required_target_count(3) .expect_err("at least too high"), RadrootsTransportError::InvalidSatisfactionPolicy @@ -1346,7 +1353,8 @@ fn delivery_contract_covers_every_policy_and_receipt_path() { RadrootsTransportSatisfactionClass::Delivered, ), ( - RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2), + RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2) + .expect("valid quorum"), RadrootsTransportSatisfactionClass::DurableOrObserved, ), ] { @@ -1356,7 +1364,7 @@ fn delivery_contract_covers_every_policy_and_receipt_path() { RadrootsTransportSatisfactionPolicy::no_wait(), RadrootsTransportSatisfactionPolicy::any_accepted(), RadrootsTransportSatisfactionPolicy::all_accepted(), - RadrootsTransportSatisfactionPolicy::quorum_accepted(1), + RadrootsTransportSatisfactionPolicy::quorum_accepted(1).expect("valid quorum"), ] { assert!(policy.required_target_fingerprints().is_none()); } @@ -1402,38 +1410,25 @@ fn delivery_contract_covers_every_policy_and_receipt_path() { .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::all_accepted()) .expect("all") ); - assert!( - receipt - .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::quorum_accepted(1)) - .expect("quorum") - ); + let quorum = RadrootsTransportSatisfactionPolicy::quorum_accepted(1).expect("valid quorum"); + assert!(receipt.is_satisfied_by(&quorum).expect("quorum")); assert!(!receipt.is_satisfied_by(&required).expect("required")); - let invalid_empty = RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: RadrootsTransportSatisfactionClass::Accepted, - targets: Vec::new(), - }; assert_eq!( - invalid_empty - .required_target_count(2) - .expect_err("empty required set"), - RadrootsTransportError::EmptyRequiredTargetSet - ); - assert_eq!( - receipt - .is_satisfied_by(&invalid_empty) - .expect_err("empty required receipt policy"), + RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + Vec::new(), + ) + .expect_err("empty required set"), RadrootsTransportError::EmptyRequiredTargetSet ); - let duplicate = RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: RadrootsTransportSatisfactionClass::Accepted, - targets: vec![one.fingerprint().clone(), one.fingerprint().clone()], - }; assert_eq!( - duplicate - .required_target_count(2) - .expect_err("duplicate required set"), + RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![one.fingerprint().clone(), one.fingerprint().clone()], + ) + .expect_err("duplicate required set"), RadrootsTransportError::DuplicateRequiredTargetFingerprint ); } @@ -1711,6 +1706,47 @@ fn delivery_request_and_receipt_deserialization_revalidates_invariants() { })) .is_err() ); + let exact_required = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| format!("{index:064x}")) + .collect::<Vec<_>>(); + let exact_required_wire = serde_json::json!({ + "RequiredTargets": { + "class": "Accepted", + "targets": exact_required, + } + }); + assert_eq!( + serde_json::from_value::<RadrootsTransportSatisfactionPolicy>(exact_required_wire) + .expect("decode exact required-target policy") + .required_target_fingerprints() + .expect("required targets") + .len(), + RADROOTS_TRANSPORT_TARGET_MAX_COUNT + ); + let one_over_required = (0..=RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| format!("{index:064x}")) + .collect::<Vec<_>>(); + let one_over_required_wire = serde_json::json!({ + "RequiredTargets": { + "class": "Accepted", + "targets": one_over_required, + } + }); + assert!( + serde_json::from_value::<RadrootsTransportSatisfactionPolicy>(one_over_required_wire) + .expect_err("reject one-over required-target wire") + .to_string() + .contains("required_target_count") + ); + assert!( + serde_json::from_value::<RadrootsTransportSatisfactionPolicy>(serde_json::json!({ + "Any": { + "class": "Accepted", + "unknown": true, + } + })) + .is_err() + ); } #[test] diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -181,7 +181,7 @@ where let target_strings = targets.relay_strings(); let active_delivery_plan_id = publishable.active_delivery_plan_id; let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms)? - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait) + .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::no_wait()) .try_with_idempotency_key(outbox_publish_idempotency_key( claimed.outbox_event_id, claimed.attempt_count, @@ -885,15 +885,10 @@ async fn publishable_relays( )) })?; let satisfaction_required_count = plan.required_success_count as usize; - let required_targets = match &plan.satisfaction_policy { - RadrootsTransportSatisfactionPolicy::RequiredTargets { targets, .. } => { - Some(targets.clone()) - } - RadrootsTransportSatisfactionPolicy::NoWait - | RadrootsTransportSatisfactionPolicy::Any { .. } - | RadrootsTransportSatisfactionPolicy::All { .. } - | RadrootsTransportSatisfactionPolicy::Quorum { .. } => None, - }; + let required_targets = plan + .satisfaction_policy + .required_target_fingerprints() + .map(<[_]>::to_vec); let active_targets = targets .iter() .filter(|target| target.delivery_plan_id == active_delivery_plan_id) @@ -1050,33 +1045,26 @@ fn satisfaction_policy_for_remaining_count( exact_required_targets: Option<&[RadrootsTransportTargetFingerprint]>, ) -> RadrootsTransportSatisfactionPolicy { if let Some(targets) = exact_required_targets { - return RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: satisfaction_class, - targets: targets.to_vec(), - }; + return RadrootsTransportSatisfactionPolicy::required_targets( + satisfaction_class, + targets.to_vec(), + ) + .expect("remaining required targets retain a validated nonempty unique set"); } if remaining_satisfaction_count >= target_count { - return RadrootsTransportSatisfactionPolicy::All { - class: satisfaction_class, - }; + return RadrootsTransportSatisfactionPolicy::all(satisfaction_class); } if remaining_satisfaction_count == 0 { - return RadrootsTransportSatisfactionPolicy::NoWait; + return RadrootsTransportSatisfactionPolicy::no_wait(); } if remaining_satisfaction_count == 1 { - return RadrootsTransportSatisfactionPolicy::Any { - class: satisfaction_class, - }; + return RadrootsTransportSatisfactionPolicy::any(satisfaction_class); } let Ok(count) = u16::try_from(remaining_satisfaction_count) else { - return RadrootsTransportSatisfactionPolicy::All { - class: satisfaction_class, - }; + return RadrootsTransportSatisfactionPolicy::all(satisfaction_class); }; - RadrootsTransportSatisfactionPolicy::Quorum { - class: satisfaction_class, - threshold: count, - } + RadrootsTransportSatisfactionPolicy::quorum(satisfaction_class, count) + .expect("remaining quorum is bounded by the validated target set") } async fn ingest_publish_observation( @@ -1151,7 +1139,7 @@ mod tests { 3, None ), - RadrootsTransportSatisfactionPolicy::quorum_delivered(2) + RadrootsTransportSatisfactionPolicy::quorum_delivered(2).expect("valid quorum") ); let required_target = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("required target"); @@ -1175,9 +1163,7 @@ mod tests { usize::from(u16::MAX) + 2, None, ), - RadrootsTransportSatisfactionPolicy::All { - class: RadrootsTransportSatisfactionClass::Accepted, - } + RadrootsTransportSatisfactionPolicy::all(RadrootsTransportSatisfactionClass::Accepted,) ); assert_eq!( satisfaction_policy_for_remaining_count( @@ -1186,7 +1172,7 @@ mod tests { 3, None, ), - RadrootsTransportSatisfactionPolicy::NoWait + RadrootsTransportSatisfactionPolicy::no_wait() ); assert!(counts_as_accepted_for_plan( diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -266,7 +266,7 @@ where let publish_request = RadrootsRelayPublishRequest::new(signed_event, targets, request.now_ms()) .map_err(nostr_error_to_transport_error)? - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait) + .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::no_wait()) .try_with_idempotency_key(request.request_id()) .map_err(nostr_error_to_transport_error)?; let relay_receipts = match publish_signed_event(&self.adapter, publish_request).await { @@ -757,45 +757,41 @@ fn relay_publish_satisfies_policy( target_count: usize, relays: &[RadrootsRelayPublishRelayReceipt], ) -> Result<bool, RadrootsRelayTransportError> { - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => Ok(true), - RadrootsTransportSatisfactionPolicy::Any { class } - | RadrootsTransportSatisfactionPolicy::All { class } - | RadrootsTransportSatisfactionPolicy::Quorum { class, .. } => { - let satisfied_count = relays - .iter() - .filter(|receipt| { - relay_receipt_counts_toward_quorum(receipt) - && receipt - .outcome - .to_transport_outcome() - .status - .counts_as_satisfied(*class) - }) - .count(); - Ok(policy.is_satisfied_by(target_count, satisfied_count)?) - } - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => { - policy.required_target_count(target_count)?; - let mut satisfied_required_targets = BTreeSet::new(); - for receipt in relays { - let target = RadrootsTransportTarget::nostr_relay(&receipt.relay_url)?; - if targets.contains(target.fingerprint()) - && relay_receipt_counts_toward_quorum(receipt) + let Some(class) = policy.target_satisfaction_class() else { + return Ok(true); + }; + let Some(targets) = policy.required_target_fingerprints() else { + let satisfied_count = relays + .iter() + .filter(|receipt| { + relay_receipt_counts_toward_quorum(receipt) && receipt .outcome .to_transport_outcome() .status - .counts_as_satisfied(*class) - { - satisfied_required_targets.insert(target.fingerprint().clone()); - } - } - Ok(targets - .iter() - .all(|target| satisfied_required_targets.contains(target))) + .counts_as_satisfied(class) + }) + .count(); + return Ok(policy.is_satisfied_by(target_count, satisfied_count)?); + }; + policy.required_target_count(target_count)?; + let mut satisfied_required_targets = BTreeSet::new(); + for receipt in relays { + let target = RadrootsTransportTarget::nostr_relay(&receipt.relay_url)?; + if targets.contains(target.fingerprint()) + && relay_receipt_counts_toward_quorum(receipt) + && receipt + .outcome + .to_transport_outcome() + .status + .counts_as_satisfied(class) + { + satisfied_required_targets.insert(target.fingerprint().clone()); } } + Ok(targets + .iter() + .all(|target| satisfied_required_targets.contains(target))) } fn relay_receipt_matches_target( diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1124,7 +1124,9 @@ async fn mock_publish_preserves_exact_raw_json_and_counts_outcomes() { 1_000, ) .expect("publish request") - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::quorum_accepted(2)), + .with_satisfaction_policy( + RadrootsTransportSatisfactionPolicy::quorum_accepted(2).expect("valid quorum"), + ), ) .await .expect("publish"); @@ -1662,7 +1664,7 @@ async fn publish_all_policy_uses_requested_target_count() { &PartialPublishAdapter, RadrootsRelayPublishRequest::new(verified_signed_event(signed), targets, 1_081) .expect("publish request") - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait), + .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::no_wait()), ) .await .expect("no-wait publish"); @@ -4790,7 +4792,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::quorum_accepted(2), + RadrootsTransportSatisfactionPolicy::quorum_accepted(2).expect("valid quorum"), )) .await .expect("enqueue");