lib

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

commit e5f6aac45c3357d6457eb8c1822bda90ad4123c3
parent 8b8ee1758467a3d0cae14c547cd5b9d7cc9f45a6
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Jul 2026 08:09:23 +0000

transport: preserve required target publish semantics

- canonicalize required target fingerprints in the transport policy model
- add first-class required target delivery policy validation to transport publish protocol
- include target scope in protocol target fingerprints and membership checks
- cover optional-target and stale-required-target protocol behavior with tests

Diffstat:
Mcrates/transport/src/delivery.rs | 3++-
Mcrates/transport/tests/transport.rs | 14++++++++++++++
Mcrates/transport_publish_protocol/Cargo.toml | 2+-
Mcrates/transport_publish_protocol/src/lib.rs | 376+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
4 files changed, 366 insertions(+), 29 deletions(-)

diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -78,9 +78,10 @@ impl RadrootsTransportSatisfactionPolicy { pub fn required_targets( class: RadrootsTransportSatisfactionClass, - targets: Vec<RadrootsTransportTargetFingerprint>, + mut targets: Vec<RadrootsTransportTargetFingerprint>, ) -> Result<Self, RadrootsTransportError> { validate_required_targets(&targets)?; + targets.sort(); Ok(Self::RequiredTargets { class, targets }) } diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -741,6 +741,20 @@ fn required_target_satisfaction_uses_fingerprints_not_target_counts() { .expect("required targets"), &[required.fingerprint.clone()] ); + let unordered_policy = RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![optional.fingerprint.clone(), required.fingerprint.clone()], + ) + .expect("unordered required targets"); + let mut expected_required_targets = + vec![required.fingerprint.clone(), optional.fingerprint.clone()]; + expected_required_targets.sort(); + assert_eq!( + unordered_policy + .required_target_fingerprints() + .expect("canonical required targets"), + expected_required_targets.as_slice() + ); let optional_only = RadrootsTransportDeliveryReceipt { request_id: "required-target".to_owned(), diff --git a/crates/transport_publish_protocol/Cargo.toml b/crates/transport_publish_protocol/Cargo.toml @@ -14,7 +14,7 @@ readme = "README" [features] default = ["std", "serde"] std = [] -serde = ["dep:serde"] +serde = ["dep:serde", "radroots_transport/serde"] [dependencies] radroots_transport = { workspace = true, default-features = false } diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -5,9 +5,9 @@ extern crate alloc; #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::{collections::BTreeSet, string::String, vec::Vec}; #[cfg(feature = "std")] -use std::{string::String, vec::Vec}; +use std::{collections::BTreeSet, string::String, vec::Vec}; use core::fmt; use radroots_transport::{ @@ -77,6 +77,16 @@ pub enum TransportPublishProtocolError { }, EmptyTargetSet, InvalidQuorum, + EmptyRequiredTargetSet, + InvalidRequiredTargetFingerprint { + index: usize, + }, + DuplicateRequiredTargetFingerprint { + index: usize, + }, + RequiredTargetNotInTargetSet { + index: usize, + }, EmptyPrincipalId, EmptyJobId, InvalidJobTargetCount { @@ -173,6 +183,24 @@ impl fmt::Display for TransportPublishProtocolError { } Self::EmptyTargetSet => f.write_str("transport publish target set must not be empty"), Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"), + Self::EmptyRequiredTargetSet => { + f.write_str("delivery required target set must not be empty") + } + Self::InvalidRequiredTargetFingerprint { index } => { + write!(f, "delivery required target {index} fingerprint is invalid") + } + Self::DuplicateRequiredTargetFingerprint { index } => { + write!( + f, + "delivery required target {index} duplicates an earlier fingerprint" + ) + } + Self::RequiredTargetNotInTargetSet { index } => { + write!( + f, + "delivery required target {index} is not in the target set" + ) + } Self::EmptyPrincipalId => f.write_str("principal id must not be empty"), Self::EmptyJobId => f.write_str("job id must not be empty"), Self::InvalidJobTargetCount { expected, actual } => write!( @@ -358,13 +386,25 @@ impl TransportPublishTarget { ) -> Result<RadrootsTransportTargetFingerprint, TransportPublishProtocolError> { let transport_kind = RadrootsTransportKind::parse_canonical(self.transport_kind.as_str()) .map_err(|error| transport_kind_error(error, index))?; - validate_target_metadata( - self.target_scope.as_deref(), - self.target_label.as_deref(), - index, - )?; - let target = RadrootsTransportTarget::new(transport_kind, self.endpoint_uri.as_str()) - .map_err(|error| target_fingerprint_error(error, index))?; + let scope = self + .target_scope + .as_deref() + .map(RadrootsTransportMeshScopeId::parse) + .transpose() + .map_err(|error| target_metadata_error(error, index))?; + let label = self + .target_label + .as_deref() + .map(RadrootsTransportTargetLabel::parse) + .transpose() + .map_err(|error| target_metadata_error(error, index))?; + let target = RadrootsTransportTarget::new_with_metadata( + transport_kind, + self.endpoint_uri.as_str(), + scope, + label, + ) + .map_err(|error| target_fingerprint_error(error, index))?; Ok(target.fingerprint) } @@ -530,15 +570,27 @@ fn validate_explicit_target_uniqueness( pub enum TransportPublishDeliveryPolicy { Any, All, - Quorum { quorum: usize }, + Quorum { + quorum: usize, + }, + RequiredTargets { + targets: Vec<RadrootsTransportTargetFingerprint>, + }, } impl TransportPublishDeliveryPolicy { + pub fn required_targets( + targets: Vec<RadrootsTransportTargetFingerprint>, + ) -> Result<Self, TransportPublishProtocolError> { + validate_required_target_fingerprints(&targets)?; + Ok(Self::RequiredTargets { targets }) + } + pub fn validate(&self) -> Result<(), TransportPublishProtocolError> { - if matches!(self, Self::Quorum { quorum: 0 }) { - Err(TransportPublishProtocolError::InvalidQuorum) - } else { - Ok(()) + match self { + Self::Quorum { quorum: 0 } => Err(TransportPublishProtocolError::InvalidQuorum), + Self::RequiredTargets { targets } => validate_required_target_fingerprints(targets), + Self::Any | Self::All | Self::Quorum { .. } => Ok(()), } } @@ -547,8 +599,48 @@ impl TransportPublishDeliveryPolicy { Self::Any => usize::from(target_count > 0), Self::All => target_count, Self::Quorum { quorum } => *quorum, + Self::RequiredTargets { targets } => targets.len(), } } + + pub fn validate_target_membership( + &self, + target_fingerprints: &[RadrootsTransportTargetFingerprint], + ) -> Result<(), TransportPublishProtocolError> { + let Self::RequiredTargets { targets } = self else { + return Ok(()); + }; + validate_required_target_fingerprints(targets)?; + for (index, required) in targets.iter().enumerate() { + if !target_fingerprints + .iter() + .any(|fingerprint| fingerprint == required) + { + return Err(TransportPublishProtocolError::RequiredTargetNotInTargetSet { index }); + } + } + Ok(()) + } +} + +fn validate_required_target_fingerprints( + targets: &[RadrootsTransportTargetFingerprint], +) -> Result<(), TransportPublishProtocolError> { + if targets.is_empty() { + return Err(TransportPublishProtocolError::EmptyRequiredTargetSet); + } + let mut seen = BTreeSet::new(); + for (index, target) in targets.iter().enumerate() { + if RadrootsTransportTargetFingerprint::parse(target.as_str()).is_err() { + return Err(TransportPublishProtocolError::InvalidRequiredTargetFingerprint { index }); + } + if !seen.insert(target.as_str()) { + return Err( + TransportPublishProtocolError::DuplicateRequiredTargetFingerprint { index }, + ); + } + } + Ok(()) } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -575,6 +667,15 @@ impl TransportPublishEventRequest { self.event.validate()?; self.target_policy.validate(max_targets)?; self.delivery_policy.validate()?; + if let TransportPublishTargetPolicy::ExplicitTargets { targets } = &self.target_policy { + let target_fingerprints = targets + .iter() + .enumerate() + .map(|(index, target)| target.fingerprint(index)) + .collect::<Result<Vec<_>, _>>()?; + self.delivery_policy + .validate_target_membership(&target_fingerprints)?; + } if self .idempotency_key .as_ref() @@ -784,6 +885,19 @@ impl TransportPublishJobView { actual: self.target_count, }); } + if matches!( + self.delivery_policy, + TransportPublishDeliveryPolicy::RequiredTargets { .. } + ) { + let target_fingerprints = self + .targets + .iter() + .enumerate() + .map(|(index, target)| target_outcome_fingerprint(target, index)) + .collect::<Result<Vec<_>, _>>()?; + self.delivery_policy + .validate_target_membership(&target_fingerprints)?; + } } let acknowledged_count = self .targets @@ -866,6 +980,7 @@ impl TransportPublishCapabilities { TransportPublishDeliveryPolicyName::Any, TransportPublishDeliveryPolicyName::Quorum, TransportPublishDeliveryPolicyName::All, + TransportPublishDeliveryPolicyName::RequiredTargets, ], target_policy_modes: vec![ TransportPublishTargetPolicyName::ExplicitTargets, @@ -948,6 +1063,7 @@ pub enum TransportPublishDeliveryPolicyName { Any, Quorum, All, + RequiredTargets, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -1048,6 +1164,58 @@ fn validate_target_outcome( Ok(()) } +fn target_outcome_fingerprint( + target: &TransportPublishTargetOutcome, + index: usize, +) -> Result<RadrootsTransportTargetFingerprint, TransportPublishProtocolError> { + let transport_kind = RadrootsTransportKind::parse_canonical(target.transport_kind.as_str()) + .map_err(|error| transport_kind_error(error, index))?; + let scope = target + .target_scope + .as_deref() + .map(RadrootsTransportMeshScopeId::parse) + .transpose() + .map_err(|error| target_metadata_error(error, index))?; + let label = target + .target_label + .as_deref() + .map(RadrootsTransportTargetLabel::parse) + .transpose() + .map_err(|error| target_metadata_error(error, index))?; + let target = RadrootsTransportTarget::new_with_metadata( + transport_kind, + target.endpoint_uri.as_str(), + scope, + label, + ) + .map_err(|error| target_fingerprint_error(error, index))?; + Ok(target.fingerprint) +} + +fn required_policy_outcomes<'a>( + required_targets: &[RadrootsTransportTargetFingerprint], + outcomes: &'a [TransportPublishTargetOutcome], +) -> Result<Vec<&'a TransportPublishTargetOutcome>, TransportPublishProtocolError> { + required_targets + .iter() + .enumerate() + .map(|(required_index, required)| { + outcomes + .iter() + .enumerate() + .find_map(|(outcome_index, outcome)| { + let fingerprint = target_outcome_fingerprint(outcome, outcome_index).ok()?; + (fingerprint == *required).then_some(outcome) + }) + .ok_or( + TransportPublishProtocolError::RequiredTargetNotInTargetSet { + index: required_index, + }, + ) + }) + .collect() +} + fn validate_job_target_policy_outcomes( target_policy: &TransportPublishTargetPolicy, outcomes: &[TransportPublishTargetOutcome], @@ -1109,36 +1277,76 @@ fn validate_job_status_state( return Err(TransportPublishProtocolError::InvalidJobStatusState); } let required_count = job.delivery_policy.required_target_count(job.target_count); - let satisfied = required_count > 0 && acknowledged_count >= required_count; + let (satisfied, retryable_status_count, terminal_status_count, has_deferred, has_preview) = + match &job.delivery_policy { + TransportPublishDeliveryPolicy::RequiredTargets { targets } => { + let required_outcomes = required_policy_outcomes(targets, &job.targets)?; + let satisfied = targets.iter().all(|required| { + required_outcomes.iter().any(|outcome| { + target_outcome_fingerprint(outcome, 0).is_ok_and(|fingerprint| { + fingerprint == *required + && outcome.outcome_kind.counts_toward_satisfaction() + }) + }) + }); + ( + satisfied, + required_outcomes + .iter() + .filter(|outcome| outcome.outcome_kind.is_retryable()) + .count(), + required_outcomes + .iter() + .filter(|outcome| outcome.outcome_kind.is_terminal_failure()) + .count(), + required_outcomes.iter().any(|outcome| { + outcome.outcome_kind + == TransportPublishOutcomeKind::DeferredUntilImplemented + }), + required_outcomes.iter().any(|outcome| { + outcome.outcome_kind == TransportPublishOutcomeKind::PreviewUnavailable + }), + ) + } + TransportPublishDeliveryPolicy::Any + | TransportPublishDeliveryPolicy::All + | TransportPublishDeliveryPolicy::Quorum { .. } => ( + required_count > 0 && acknowledged_count >= required_count, + retryable_count, + terminal_count, + job.targets.iter().any(|target| { + target.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented + }), + job.targets.iter().any(|target| { + target.outcome_kind == TransportPublishOutcomeKind::PreviewUnavailable + }), + ), + }; match job.status { TransportPublishJobStatus::DeliverySatisfied if satisfied => Ok(()), TransportPublishJobStatus::DeliveryUnsatisfiedRetryable - if !satisfied && retryable_count > 0 => + if !satisfied && retryable_status_count > 0 => { Ok(()) } TransportPublishJobStatus::DeliveryUnsatisfiedTerminal - if !satisfied && retryable_count == 0 && terminal_count > 0 => + if !satisfied && retryable_status_count == 0 && terminal_status_count > 0 => { Ok(()) } TransportPublishJobStatus::DeliveryDeferred if !satisfied - && terminal_count == 0 - && retryable_count == 0 - && job.targets.iter().any(|target| { - target.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented - }) => + && terminal_status_count == 0 + && retryable_status_count == 0 + && has_deferred => { Ok(()) } TransportPublishJobStatus::DeliveryPreviewUnavailable if !satisfied - && terminal_count == 0 - && retryable_count == 0 - && job.targets.iter().any(|target| { - target.outcome_kind == TransportPublishOutcomeKind::PreviewUnavailable - }) => + && terminal_status_count == 0 + && retryable_status_count == 0 + && has_preview => { Ok(()) } @@ -1608,6 +1816,72 @@ mod tests { } #[test] + fn required_target_job_status_uses_required_membership() { + let required_target = TransportPublishTarget::nostr("wss://required.example"); + let optional_target = TransportPublishTarget::nostr("wss://optional.example"); + let required_fingerprint = required_target + .fingerprint(0) + .expect("required fingerprint"); + let target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ + required_target.clone(), + optional_target, + ]); + + let mut optional_success = job_from_targets( + TransportPublishJobStatus::DeliverySatisfied, + target_policy.clone(), + vec![ + nostr_outcome_for( + "wss://required.example", + TransportPublishOutcomeKind::ConnectionFailed, + ), + nostr_outcome_for( + "wss://optional.example", + TransportPublishOutcomeKind::Accepted, + ), + ], + ); + optional_success.delivery_policy = + TransportPublishDeliveryPolicy::required_targets(vec![required_fingerprint.clone()]) + .expect("required policy"); + optional_success.acknowledged_count = 1; + optional_success.retryable_count = 1; + assert_eq!( + optional_success.validate(), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + optional_success.status = TransportPublishJobStatus::DeliveryUnsatisfiedRetryable; + optional_success.terminal = false; + optional_success.delivery_satisfied = false; + optional_success + .validate() + .expect("required target retryable remains unsatisfied"); + + let mut optional_failure = job_from_targets( + TransportPublishJobStatus::DeliverySatisfied, + target_policy, + vec![ + nostr_outcome_for( + "wss://required.example", + TransportPublishOutcomeKind::Accepted, + ), + nostr_outcome_for( + "wss://optional.example", + TransportPublishOutcomeKind::Timeout, + ), + ], + ); + optional_failure.delivery_policy = + TransportPublishDeliveryPolicy::required_targets(vec![required_fingerprint]) + .expect("required policy"); + optional_failure.acknowledged_count = 1; + optional_failure.retryable_count = 1; + optional_failure + .validate() + .expect("optional retryable target does not block required satisfaction"); + } + + #[test] fn job_view_validation_matches_explicit_target_outcomes_by_identity() { job_from_targets( TransportPublishJobStatus::DeliverySatisfied, @@ -2106,6 +2380,54 @@ mod tests { TransportPublishDeliveryPolicy::Quorum { quorum: 2 }.required_target_count(3), 2 ); + + let required_target = + TransportPublishTarget::nostr("wss://relay.example").with_scope("farm.local"); + let required_fingerprint = required_target + .fingerprint(0) + .expect("required fingerprint"); + let required_policy = + TransportPublishDeliveryPolicy::required_targets(vec![required_fingerprint.clone()]) + .expect("required policy"); + assert_eq!(required_policy.required_target_count(3), 1); + let required_request = TransportPublishEventRequest { + event: event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + required_target.clone(), + TransportPublishTarget::nostr("wss://relay.example").with_scope("farm.remote"), + ]), + delivery_policy: required_policy, + idempotency_key: None, + timeout_ms: None, + }; + required_request + .validate(2) + .expect("required target belongs to scoped target set"); + + let unscoped_fingerprint = TransportPublishTarget::nostr("wss://relay.example") + .fingerprint(0) + .expect("unscoped fingerprint"); + assert_ne!(required_fingerprint, unscoped_fingerprint); + let mut stale_required = required_request; + stale_required.delivery_policy = + TransportPublishDeliveryPolicy::required_targets(vec![unscoped_fingerprint]) + .expect("stale required policy"); + assert_eq!( + stale_required.validate(2), + Err(TransportPublishProtocolError::RequiredTargetNotInTargetSet { index: 0 }) + ); + + assert_eq!( + TransportPublishDeliveryPolicy::required_targets(Vec::new()), + Err(TransportPublishProtocolError::EmptyRequiredTargetSet) + ); + assert_eq!( + TransportPublishDeliveryPolicy::required_targets(vec![ + required_fingerprint.clone(), + required_fingerprint + ]), + Err(TransportPublishProtocolError::DuplicateRequiredTargetFingerprint { index: 1 }) + ); } #[test]