lib

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

commit 47938c2d6e7702401014c2cad72b33acf2c077d5
parent 993a633408a9db3f2a82ee6da33f9f5767db39c9
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Jul 2026 21:03:11 +0000

transport: align SDK satisfaction and preview workflow contracts

- expose accepted, delivered, quorum, and required-target satisfaction policies without legacy aliases
- propagate configured Reticulum preview behavior through explicit and mesh target planning
- add Reticulum preview try-now unavailable operation and strict agent endpoint parsing
- surface target scope, label, and typed transport outcomes in sync status and push receipts

Diffstat:
Mcrates/sdk/src/dvm_runtime.rs | 4++--
Mcrates/sdk/src/farms_runtime.rs | 4++--
Mcrates/sdk/src/lib.rs | 12++++++------
Mcrates/sdk/src/listings_runtime.rs | 4++--
Mcrates/sdk/src/orders_runtime.rs | 98+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/sdk/src/sync_runtime.rs | 163++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/transport.rs | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/src/workflow_runtime.rs | 57+++++++++++++++++++++++++++++++--------------------------
Mcrates/sdk/tests/source_boundary.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/sync_runtime.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/sdk/tests/trade_product_publish_runtime.rs | 2+-
Mcrates/sdk/tests/unit/orders_runtime_tests.rs | 11++++++-----
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 8+++++++-
Mcrates/sdk/tests/unit/transport_tests.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 18+++++++++---------
15 files changed, 576 insertions(+), 145 deletions(-)

diff --git a/crates/sdk/src/dvm_runtime.rs b/crates/sdk/src/dvm_runtime.rs @@ -430,7 +430,7 @@ impl<'sdk> DvmClient<'sdk> { actor: &actor, frozen_draft: &plan.frozen_draft, target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key, }, ) @@ -454,7 +454,7 @@ impl<'sdk> DvmClient<'sdk> { actor: &actor, frozen_draft: &plan.frozen_draft, target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key, }, signer, diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -481,7 +481,7 @@ impl<'sdk> FarmsClient<'sdk> { actor, frozen_draft: &plan.frozen_draft, target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key, }, ) @@ -504,7 +504,7 @@ impl<'sdk> FarmsClient<'sdk> { actor, frozen_draft: &plan.frozen_draft, target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key, }, signer, diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -218,11 +218,11 @@ pub use crate::sync_runtime::{ PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind, - PushOutboxTargetReceipt, SYNC_PROJECTION_REFRESH_DEFAULT_LIMIT, - SYNC_PROJECTION_REFRESH_MAX_LIMIT, SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, - SyncProjectionRefreshReceipt, SyncProjectionRefreshRequest, SyncStatusReceipt, - SyncStatusRequest, SyncStatusSource, SyncTransportProfileSummary, SyncTransportStatusSummary, - SyncTransportTargetSummary, + PushOutboxTargetReceipt, PushOutboxTransportOutcomeKind, ReticulumPreviewTryNowRequest, + SYNC_PROJECTION_REFRESH_DEFAULT_LIMIT, SYNC_PROJECTION_REFRESH_MAX_LIMIT, SdkRelayAuthPolicy, + SyncEventStoreStatus, SyncOutboxStatus, SyncProjectionRefreshReceipt, + SyncProjectionRefreshRequest, SyncStatusReceipt, SyncStatusRequest, SyncStatusSource, + SyncTransportProfileSummary, SyncTransportStatusSummary, SyncTransportTargetSummary, }; #[cfg(feature = "runtime")] pub use crate::trade_storage::{ @@ -235,7 +235,7 @@ pub use crate::transport::{ PublishMode, ReticulumPreviewAgentEndpoint, ReticulumPreviewBehavior, ReticulumPreviewProfile, SDK_TRANSPORT_TARGET_MAX_COUNT, SatisfactionPolicy, TargetPolicy, TargetSet, TransportDeliveryReceipt, TransportDeliveryTargetStatus, TransportKind, TransportOutcome, - TransportProfile, TransportReceipt, TransportTargetReceipt, + TransportProfile, TransportReceipt, TransportSatisfactionClass, TransportTargetReceipt, }; #[cfg(feature = "runtime")] pub use radroots_trade::dvm::RadrootsTradeInventoryBinWitnessDto; diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -251,7 +251,7 @@ impl<'sdk> ListingsClient<'sdk> { actor, frozen_draft: &plan.frozen_draft, target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key, }, ) @@ -274,7 +274,7 @@ impl<'sdk> ListingsClient<'sdk> { actor, frozen_draft: &plan.frozen_draft, target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key, }, signer, diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -2578,7 +2578,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeSubmitPrepareRequest { actor: actor.clone(), listing_event, @@ -2613,7 +2613,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeSubmitPrepareRequest { actor: actor.clone(), listing_event, @@ -2643,7 +2643,7 @@ impl<'sdk> TradesClient<'sdk> { satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let enqueue = enqueue_configured_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -2670,7 +2670,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -2717,7 +2717,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeDecisionPrepareRequest { actor: actor.clone(), request_event, @@ -2752,7 +2752,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeDecisionPrepareRequest { actor: actor.clone(), request_event, @@ -2782,7 +2782,7 @@ impl<'sdk> TradesClient<'sdk> { satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -2815,7 +2815,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -2870,7 +2870,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeRevisionProposalPrepareRequest { actor: actor.clone(), root_event, @@ -2907,7 +2907,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeRevisionProposalPrepareRequest { actor: actor.clone(), root_event, @@ -2938,7 +2938,7 @@ impl<'sdk> TradesClient<'sdk> { satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -2971,7 +2971,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -3026,7 +3026,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeRevisionDecisionPrepareRequest { actor: actor.clone(), root_event, @@ -3063,7 +3063,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeRevisionDecisionPrepareRequest { actor: actor.clone(), root_event, @@ -3094,7 +3094,7 @@ impl<'sdk> TradesClient<'sdk> { satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -3127,7 +3127,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -3182,7 +3182,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeCancellationPrepareRequest { actor: actor.clone(), root_event, @@ -3219,7 +3219,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key, created_at, } = request; - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; let prepare_request = TradeCancellationPrepareRequest { actor: actor.clone(), root_event, @@ -3233,7 +3233,7 @@ impl<'sdk> TradesClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, signer, ) @@ -3250,7 +3250,7 @@ impl<'sdk> TradesClient<'sdk> { satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -3283,7 +3283,7 @@ impl<'sdk> TradesClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, satisfaction_policy)?; + validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -5292,7 +5292,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { &self, request: TradeProposeRequest, ) -> Result<TradeMutationOutcome<TradeSubmitPlan, TradeSubmitReceipt>, RadrootsSdkError> { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; require_trade_product_privacy_preflight( "trade.propose", trade_propose_privacy_fields(&request), @@ -5338,7 +5338,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -5359,7 +5359,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { TradeMutationOutcome<TradeCancellationPlan, TradeCancellationReceipt>, RadrootsSdkError, > { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; let TradeCancelRequest { actor, locator, @@ -5403,7 +5403,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -5445,7 +5445,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { TradeMutationOutcome<TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt>, RadrootsSdkError, > { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; let TradeRevisionDecisionRequest { actor, locator, @@ -5499,7 +5499,7 @@ impl<'sdk> TradeBuyerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -5594,7 +5594,7 @@ impl<'sdk> TradeSellerClient<'sdk> { request: TradeAcceptRequest, ) -> Result<TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, RadrootsSdkError> { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; let TradeAcceptRequest { actor, locator, @@ -5639,7 +5639,7 @@ impl<'sdk> TradeSellerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -5662,7 +5662,7 @@ impl<'sdk> TradeSellerClient<'sdk> { where A: RadrootsRelayFetchAdapter, { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; let TradeAcceptRequest { actor, locator, @@ -5713,7 +5713,7 @@ impl<'sdk> TradeSellerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -5733,7 +5733,7 @@ impl<'sdk> TradeSellerClient<'sdk> { request: TradeDeclineRequest, ) -> Result<TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, RadrootsSdkError> { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; let TradeDeclineRequest { actor, locator, @@ -5776,7 +5776,7 @@ impl<'sdk> TradeSellerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -5798,7 +5798,7 @@ impl<'sdk> TradeSellerClient<'sdk> { TradeMutationOutcome<TradeRevisionProposalPlan, TradeRevisionProposalReceipt>, RadrootsSdkError, > { - validate_trade_product_publish_policy(request.publish_mode, request.satisfaction_policy)?; + validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; let TradeRevisionProposalRequest { actor, locator, @@ -5851,7 +5851,7 @@ impl<'sdk> TradeSellerClient<'sdk> { plan, target_policy, publish_mode, - satisfaction_policy, + satisfaction_policy.clone(), idempotency_key, ) .await?; @@ -6148,32 +6148,29 @@ fn push_request_for_satisfaction_policy( outbox_event_id: i64, ) -> Result<PushOutboxRequest, RadrootsSdkError> { let request = PushOutboxRequest::new().with_outbox_event_id(outbox_event_id); - match satisfaction_policy { - SatisfactionPolicy::NoWait => Err(RadrootsSdkError::InvalidRequest { + if satisfaction_policy.is_no_wait() { + Err(RadrootsSdkError::InvalidRequest { message: "trade enqueue-and-publish requires a transport satisfaction policy" .to_owned(), - }), - SatisfactionPolicy::AtLeastOneTarget - | SatisfactionPolicy::AllTargets - | SatisfactionPolicy::AtLeast { .. } => Ok(request), + }) + } else { + Ok(request) } } #[cfg(feature = "signer-adapters")] fn validate_trade_product_publish_policy( publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, + satisfaction_policy: &SatisfactionPolicy, ) -> Result<(), RadrootsSdkError> { match publish_mode { - PublishMode::DryRun | PublishMode::EnqueueOnly - if satisfaction_policy != SatisfactionPolicy::NoWait => - { + PublishMode::DryRun | PublishMode::EnqueueOnly if !satisfaction_policy.is_no_wait() => { Err(RadrootsSdkError::InvalidRequest { message: "trade dry-run and enqueue-only modes require no-wait satisfaction" .to_owned(), }) } - PublishMode::EnqueueAndPublish if satisfaction_policy == SatisfactionPolicy::NoWait => { + PublishMode::EnqueueAndPublish if satisfaction_policy.is_no_wait() => { Err(RadrootsSdkError::InvalidRequest { message: "trade enqueue-and-publish requires a transport satisfaction policy" .to_owned(), @@ -6279,23 +6276,20 @@ fn trade_reason_contains_private_coordination(reason: &str) -> bool { #[cfg(any(feature = "signer-adapters", test))] fn validate_trade_enqueue_policy( publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, + satisfaction_policy: &SatisfactionPolicy, ) -> Result<(), RadrootsSdkError> { if publish_mode == PublishMode::DryRun { return Err(RadrootsSdkError::InvalidRequest { message: "trade dry-run publish mode must use a prepare request".to_owned(), }); } - if publish_mode == PublishMode::EnqueueOnly && satisfaction_policy != SatisfactionPolicy::NoWait - { + if publish_mode == PublishMode::EnqueueOnly && !satisfaction_policy.is_no_wait() { return Err(RadrootsSdkError::InvalidRequest { message: "trade enqueue-only publish mode only supports no-wait satisfaction" .to_owned(), }); } - if publish_mode == PublishMode::EnqueueAndPublish - && satisfaction_policy == SatisfactionPolicy::NoWait - { + if publish_mode == PublishMode::EnqueueAndPublish && satisfaction_policy.is_no_wait() { return Err(RadrootsSdkError::InvalidRequest { message: "trade enqueue-and-publish requires a transport satisfaction policy" .to_owned(), diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -7,7 +7,7 @@ use crate::adapters::radrootsd::{ use crate::{ NostrRelayUrlPolicy, RadrootsSdkError, SyncClient, runtime::{RadrootsClient, sdk_now_ms}, - transport::TransportProfile, + transport::{ReticulumPreviewProfile, TransportProfile}, }; #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] use crate::{ProxyAuth, ProxyProfile}; @@ -36,7 +36,8 @@ use radroots_transport::RadrootsTransportSatisfactionPolicy; #[cfg(feature = "runtime")] use radroots_transport::{ RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportImplementationState, - RadrootsTransportKind, RadrootsTransportStatus, RadrootsTransportTarget, + RadrootsTransportKind, RadrootsTransportOutcomeKind, RadrootsTransportStatus, + RadrootsTransportTarget, }; #[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] use radroots_transport_nostr::RadrootsNostrClientPublishAdapter; @@ -81,6 +82,18 @@ impl SyncStatusRequest { } #[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] +pub struct ReticulumPreviewTryNowRequest {} + +#[cfg(feature = "runtime")] +impl ReticulumPreviewTryNowRequest { + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SyncStatusReceipt { pub source: SyncStatusSource, @@ -191,6 +204,8 @@ impl SyncTransportProfileSummary { pub struct SyncTransportTargetSummary { pub transport_kind: String, pub endpoint_uri: String, + pub target_scope: Option<String>, + pub target_label: Option<String>, pub endpoint_fingerprint: String, } @@ -200,6 +215,8 @@ impl SyncTransportTargetSummary { Self { transport_kind: target.kind.canonical_label(), endpoint_uri: target.uri.as_str().to_owned(), + target_scope: target.scope.as_ref().map(|scope| scope.as_str().to_owned()), + target_label: target.label.as_ref().map(|label| label.as_str().to_owned()), endpoint_fingerprint: target.fingerprint.as_str().to_owned(), } } @@ -465,7 +482,10 @@ pub struct PushOutboxEventReceipt { pub struct PushOutboxTargetReceipt { pub transport_kind: String, pub endpoint_uri: String, + pub target_scope: Option<String>, + pub target_label: Option<String>, pub outcome_kind: PushOutboxTargetOutcomeKind, + pub transport_outcome_kind: Option<PushOutboxTransportOutcomeKind>, pub attempted: bool, pub message: Option<String>, } @@ -534,6 +554,51 @@ pub enum PushOutboxTargetOutcomeKind { } #[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum PushOutboxTransportOutcomeKind { + Accepted, + DuplicateAccepted, + Delivered, + Forwarded, + StoredByGateway, + Seen, + DeferredUntilImplemented, + Rejected, + RouteUnavailable, + PayloadTooLarge, + PolicyDenied, + Timeout, + ConnectionFailed, + TransportUnavailable, +} + +#[cfg(feature = "runtime")] +impl From<RadrootsTransportOutcomeKind> for PushOutboxTransportOutcomeKind { + fn from(kind: RadrootsTransportOutcomeKind) -> Self { + match kind { + RadrootsTransportOutcomeKind::Accepted => Self::Accepted, + RadrootsTransportOutcomeKind::DuplicateAccepted => Self::DuplicateAccepted, + RadrootsTransportOutcomeKind::Delivered => Self::Delivered, + RadrootsTransportOutcomeKind::Forwarded => Self::Forwarded, + RadrootsTransportOutcomeKind::StoredByGateway => Self::StoredByGateway, + RadrootsTransportOutcomeKind::Seen => Self::Seen, + RadrootsTransportOutcomeKind::DeferredUntilImplemented => { + Self::DeferredUntilImplemented + } + RadrootsTransportOutcomeKind::Rejected => Self::Rejected, + RadrootsTransportOutcomeKind::RouteUnavailable => Self::RouteUnavailable, + RadrootsTransportOutcomeKind::PayloadTooLarge => Self::PayloadTooLarge, + RadrootsTransportOutcomeKind::PolicyDenied => Self::PolicyDenied, + RadrootsTransportOutcomeKind::Timeout => Self::Timeout, + RadrootsTransportOutcomeKind::ConnectionFailed => Self::ConnectionFailed, + RadrootsTransportOutcomeKind::TransportUnavailable => Self::TransportUnavailable, + } + } +} + +#[cfg(feature = "runtime")] impl From<RadrootsRelayOutcomeKind> for PushOutboxTargetOutcomeKind { fn from(kind: RadrootsRelayOutcomeKind) -> Self { match kind { @@ -634,6 +699,25 @@ impl<'sdk> SyncClient<'sdk> { } } + pub async fn try_reticulum_preview_now( + &self, + _request: ReticulumPreviewTryNowRequest, + ) -> Result<(), RadrootsSdkError> { + let profile = + active_reticulum_preview_profile(self.sdk.transport_profile()).ok_or_else(|| { + RadrootsSdkError::InvalidRequest { + message: + "sync.try_reticulum_preview_now requires a Reticulum preview transport profile" + .to_owned(), + } + })?; + Err(RadrootsSdkError::ReticulumPreviewTransportUnavailable { + operation: "sync.try_reticulum_preview_now".to_owned(), + endpoint_uri: profile.endpoint_uri().to_owned(), + behavior: profile.behavior(), + }) + } + async fn push_outbox_has_no_ready_signed_work( &self, request: &PushOutboxRequest, @@ -863,7 +947,16 @@ fn reticulum_preview_target_receipt( PushOutboxTargetReceipt { transport_kind: target.transport_kind.canonical_label(), endpoint_uri: target.endpoint_uri.as_str().to_owned(), + target_scope: target + .target_scope + .as_ref() + .map(|scope| scope.as_str().to_owned()), + target_label: target + .target_label + .as_ref() + .map(|label| label.as_str().to_owned()), outcome_kind: reticulum_preview_target_outcome_kind(target.status), + transport_outcome_kind: target.last_outcome_kind.map(Into::into), attempted: false, message: Some( target @@ -874,6 +967,19 @@ fn reticulum_preview_target_receipt( } #[cfg(feature = "runtime")] +fn active_reticulum_preview_profile( + profile: &TransportProfile, +) -> Option<&ReticulumPreviewProfile> { + match profile { + TransportProfile::ReticulumPreview { profile } => Some(profile), + TransportProfile::Hybrid { profile } => Some(profile.reticulum_preview()), + TransportProfile::LocalOnly + | TransportProfile::Nostr { .. } + | TransportProfile::Proxy { .. } => None, + } +} + +#[cfg(feature = "runtime")] fn reticulum_preview_target_outcome_kind( status: RadrootsOutboxDeliveryTargetStatus, ) -> PushOutboxTargetOutcomeKind { @@ -1076,6 +1182,9 @@ fn proxy_delivery_policy_from_remaining( RadrootsTransportSatisfactionPolicy::NoWait => TransportPublishDeliveryPolicy::Any, RadrootsTransportSatisfactionPolicy::Any { .. } => TransportPublishDeliveryPolicy::Any, RadrootsTransportSatisfactionPolicy::All { .. } => TransportPublishDeliveryPolicy::All, + RadrootsTransportSatisfactionPolicy::RequiredTargets { .. } => { + TransportPublishDeliveryPolicy::All + } RadrootsTransportSatisfactionPolicy::Quorum { .. } => { if required_remaining >= ready_target_count { TransportPublishDeliveryPolicy::All @@ -1434,7 +1543,16 @@ fn proxy_transport_error_receipt( .map(|target| PushOutboxTargetReceipt { transport_kind: target.transport_kind.canonical_label(), endpoint_uri: target.endpoint_uri.as_str().to_owned(), + target_scope: target + .target_scope + .as_ref() + .map(|scope| scope.as_str().to_owned()), + target_label: target + .target_label + .as_ref() + .map(|label| label.as_str().to_owned()), outcome_kind: PushOutboxTargetOutcomeKind::ConnectionFailed, + transport_outcome_kind: Some(PushOutboxTransportOutcomeKind::ConnectionFailed), attempted: false, message: Some(message.clone()), }) @@ -1496,7 +1614,10 @@ fn push_proxy_target_receipt(outcome: TransportPublishTargetOutcome) -> PushOutb PushOutboxTargetReceipt { transport_kind: outcome.transport_kind, endpoint_uri: outcome.endpoint_uri, + target_scope: None, + target_label: None, outcome_kind: push_proxy_target_outcome_kind(outcome.outcome_kind), + transport_outcome_kind: Some(push_proxy_transport_outcome_kind(outcome.outcome_kind)), attempted: outcome.attempted, message: outcome.message, } @@ -1543,6 +1664,41 @@ fn push_proxy_target_outcome_kind( } } +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn push_proxy_transport_outcome_kind( + outcome_kind: TransportPublishOutcomeKind, +) -> PushOutboxTransportOutcomeKind { + match outcome_kind { + TransportPublishOutcomeKind::Accepted => PushOutboxTransportOutcomeKind::Accepted, + TransportPublishOutcomeKind::DuplicateAccepted + | TransportPublishOutcomeKind::SkippedAlreadyAccepted => { + PushOutboxTransportOutcomeKind::DuplicateAccepted + } + TransportPublishOutcomeKind::DeferredUntilImplemented => { + PushOutboxTransportOutcomeKind::DeferredUntilImplemented + } + TransportPublishOutcomeKind::Blocked + | TransportPublishOutcomeKind::Invalid + | TransportPublishOutcomeKind::Restricted + | TransportPublishOutcomeKind::Muted + | TransportPublishOutcomeKind::Unsupported + | TransportPublishOutcomeKind::TargetRejected => PushOutboxTransportOutcomeKind::Rejected, + TransportPublishOutcomeKind::PaymentRequired + | TransportPublishOutcomeKind::PowRequired + | TransportPublishOutcomeKind::AuthRequired => PushOutboxTransportOutcomeKind::PolicyDenied, + TransportPublishOutcomeKind::Timeout => PushOutboxTransportOutcomeKind::Timeout, + TransportPublishOutcomeKind::ConnectionFailed => { + PushOutboxTransportOutcomeKind::ConnectionFailed + } + TransportPublishOutcomeKind::RateLimited + | TransportPublishOutcomeKind::Error + | TransportPublishOutcomeKind::PreviewUnavailable + | TransportPublishOutcomeKind::Unknown => { + PushOutboxTransportOutcomeKind::TransportUnavailable + } + } +} + #[cfg(feature = "runtime")] fn push_outbox_claim_token() -> String { format!("radroots-sdk-sync-{}", uuid::Uuid::now_v7()) @@ -1599,7 +1755,10 @@ fn push_target_receipt(relay: RadrootsRelayPublishRelayReceipt) -> PushOutboxTar PushOutboxTargetReceipt { transport_kind: RadrootsTransportKind::Nostr.canonical_label(), endpoint_uri: relay.relay_url, + target_scope: None, + target_label: None, outcome_kind: relay.outcome.kind.into(), + transport_outcome_kind: Some(relay.outcome.kind.transport_outcome_kind().into()), attempted: relay.attempted, message: relay.outcome.message, } diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -2,8 +2,9 @@ use crate::RadrootsSdkError; use radroots_transport::{ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportDeliveryReceipt, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportMeshScopeId, RadrootsTransportSatisfactionClass, RadrootsTransportStatus, - RadrootsTransportTarget, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, + RadrootsTransportMeshScopeId, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, + RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, }; use radroots_transport_nostr::{RadrootsRelayUrl, RadrootsRelayUrlPolicy}; use serde::ser::{SerializeStruct, Serializer}; @@ -13,10 +14,12 @@ pub use radroots_transport::{ RadrootsTransportDeliveryReceipt as TransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus as TransportDeliveryTargetStatus, RadrootsTransportKind as TransportKind, RadrootsTransportOutcome as TransportOutcome, + RadrootsTransportSatisfactionClass as TransportSatisfactionClass, RadrootsTransportTargetReceipt as TransportTargetReceipt, }; pub const SDK_TRANSPORT_TARGET_MAX_COUNT: usize = 20; +pub const RETICULUM_AGENT_ENDPOINT_PREFIX: &str = "reticulum-agent:"; #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] @@ -27,25 +30,120 @@ pub enum PublishMode { EnqueueAndPublish, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum SatisfactionPolicy { NoWait, - AtLeastOneTarget, - AllTargets, - AtLeast { required: u16 }, + AnyAccepted, + AllAccepted, + QuorumAccepted { + threshold: u16, + }, + AnyDelivered, + AllDelivered, + QuorumDelivered { + threshold: u16, + }, + RequiredAcceptedTargets { + target_fingerprints: Vec<RadrootsTransportTargetFingerprint>, + }, + RequiredDeliveredTargets { + target_fingerprints: Vec<RadrootsTransportTargetFingerprint>, + }, } impl SatisfactionPolicy { - pub fn at_least(required: u16) -> Result<Self, RadrootsSdkError> { - if required == 0 { - return Err(RadrootsSdkError::InvalidRequest { - message: "satisfaction policy must require at least one target".to_owned(), - }); - } - Ok(Self::AtLeast { required }) + pub fn quorum_accepted(threshold: u16) -> Result<Self, RadrootsSdkError> { + validate_satisfaction_threshold(threshold)?; + Ok(Self::QuorumAccepted { threshold }) + } + + pub fn quorum_delivered(threshold: u16) -> Result<Self, RadrootsSdkError> { + validate_satisfaction_threshold(threshold)?; + Ok(Self::QuorumDelivered { threshold }) + } + + pub fn required_accepted_targets<I, S>(targets: I) -> Result<Self, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + Ok(Self::RequiredAcceptedTargets { + target_fingerprints: required_target_fingerprints(targets)?, + }) + } + + pub fn required_delivered_targets<I, S>(targets: I) -> Result<Self, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + Ok(Self::RequiredDeliveredTargets { + target_fingerprints: required_target_fingerprints(targets)?, + }) + } + + pub fn is_no_wait(&self) -> bool { + matches!(self, Self::NoWait) + } + + pub(crate) fn transport_satisfaction_policy( + &self, + ) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsSdkError> { + Ok(match self { + Self::NoWait => RadrootsTransportSatisfactionPolicy::no_wait(), + Self::AnyAccepted => RadrootsTransportSatisfactionPolicy::any_accepted(), + Self::AllAccepted => RadrootsTransportSatisfactionPolicy::all_accepted(), + Self::QuorumAccepted { threshold } => { + RadrootsTransportSatisfactionPolicy::quorum_accepted(*threshold) + } + Self::AnyDelivered => RadrootsTransportSatisfactionPolicy::any_delivered(), + Self::AllDelivered => RadrootsTransportSatisfactionPolicy::all_delivered(), + Self::QuorumDelivered { threshold } => { + RadrootsTransportSatisfactionPolicy::quorum_delivered(*threshold) + } + Self::RequiredAcceptedTargets { + target_fingerprints, + } => RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + target_fingerprints.clone(), + )?, + Self::RequiredDeliveredTargets { + target_fingerprints, + } => RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Delivered, + target_fingerprints.clone(), + )?, + }) + } +} + +fn validate_satisfaction_threshold(threshold: u16) -> Result<(), RadrootsSdkError> { + if threshold == 0 { + return Err(RadrootsSdkError::InvalidRequest { + message: "satisfaction policy threshold must require at least one target".to_owned(), + }); } + Ok(()) +} + +fn required_target_fingerprints<I, S>( + targets: I, +) -> Result<Vec<RadrootsTransportTargetFingerprint>, RadrootsSdkError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let fingerprints = targets + .into_iter() + .map(|target| RadrootsTransportTargetFingerprint::parse(target.as_ref())) + .collect::<Result<Vec<_>, _>>()?; + RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + fingerprints.clone(), + )?; + Ok(fingerprints) } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] @@ -387,12 +485,17 @@ pub struct ReticulumPreviewAgentEndpoint(String); impl ReticulumPreviewAgentEndpoint { pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsSdkError> { let uri = raw.as_ref(); + let Some(suffix) = uri.strip_prefix(RETICULUM_AGENT_ENDPOINT_PREFIX) else { + return Err(RadrootsSdkError::InvalidRequest { + message: "Reticulum preview agent endpoint is invalid".to_owned(), + }); + }; if uri.is_empty() || uri != uri.trim() + || suffix.is_empty() || uri .chars() .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) - || uri.find(':').is_none() { return Err(RadrootsSdkError::InvalidRequest { message: "Reticulum preview agent endpoint is invalid".to_owned(), @@ -695,16 +798,20 @@ pub struct TransportReceipt { } impl TransportReceipt { - pub fn satisfied_target_count(&self) -> usize { + pub fn satisfied_target_count(&self, satisfaction_class: TransportSatisfactionClass) -> usize { self.target_receipts .iter() - .filter(|receipt| { - receipt - .status - .counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted) - }) + .filter(|receipt| receipt.status.counts_as_satisfied(satisfaction_class)) .count() } + + pub fn is_satisfied_by(&self, policy: &SatisfactionPolicy) -> Result<bool, RadrootsSdkError> { + Ok(RadrootsTransportDeliveryReceipt { + request_id: self.request_id.clone(), + target_receipts: self.target_receipts.clone(), + } + .is_satisfied_by(&policy.transport_satisfaction_policy()?)?) + } } fn normalized_nostr_relay_url( diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -18,8 +18,7 @@ use radroots_outbox::{ RadrootsOutboxReticulumPreviewBehavior, RadrootsOutboxSignedOperationInput, }; use radroots_transport::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportTarget, }; const SDK_LOCAL_EVENT_ENDPOINT_URI: &str = "local:sdk"; @@ -48,7 +47,7 @@ pub(crate) async fn enqueue_signed_workflow( signer: &dyn RadrootsEventSigner, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { let delivery_plan = - resolved_delivery_plan(sdk, &request.target_policy, request.satisfaction_policy)?; + resolved_delivery_plan(sdk, &request.target_policy, &request.satisfaction_policy)?; let signed_event = sign_authorized_draft(request.actor, signer, request.frozen_draft)?; enqueue_signed_workflow_event(sdk, request, signed_event, delivery_plan).await } @@ -59,7 +58,7 @@ pub(crate) async fn enqueue_configured_signed_workflow( request: SdkWorkflowEnqueueRequest<'_>, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { let delivery_plan = - resolved_delivery_plan(sdk, &request.target_policy, request.satisfaction_policy)?; + resolved_delivery_plan(sdk, &request.target_policy, &request.satisfaction_policy)?; let signed_event = sdk .sign_with_configured_signer(RadrootsSdkSignRequest::new( request.operation_kind, @@ -166,22 +165,27 @@ struct SdkResolvedDeliveryPlan { fn resolved_delivery_plan( sdk: &RadrootsClient, target_policy: &TargetPolicy, - satisfaction_policy: SatisfactionPolicy, + satisfaction_policy: &SatisfactionPolicy, ) -> Result<SdkResolvedDeliveryPlan, RadrootsSdkError> { match target_policy { - TargetPolicy::Explicit(target_policy) => delivery_plan_from_targets( - "explicit", - target_policy.clone().into_targets(), - satisfaction_policy, - RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts, - ), + TargetPolicy::Explicit(target_policy) => { + let targets = target_policy.clone().into_targets(); + let reticulum_preview_behavior = + reticulum_preview_behavior_for_targets(sdk.transport_profile(), &targets); + delivery_plan_from_targets( + "explicit", + targets, + satisfaction_policy, + reticulum_preview_behavior, + ) + } TargetPolicy::DefaultProfile => { let transport_profile = sdk.transport_profile(); let targets = transport_profile .target_set()? .map(TargetSet::into_targets) .unwrap_or_default(); - if targets.is_empty() && satisfaction_policy != SatisfactionPolicy::NoWait { + if targets.is_empty() && !satisfaction_policy.is_no_wait() { return Err(RadrootsSdkError::empty_transport_targets( "publish transport profile", )); @@ -194,7 +198,7 @@ fn resolved_delivery_plan( ) } TargetPolicy::LocalOnly => { - if satisfaction_policy != SatisfactionPolicy::NoWait { + if !satisfaction_policy.is_no_wait() { return Err(RadrootsSdkError::InvalidRequest { message: "local-only target policy requires no_wait satisfaction policy" .to_owned(), @@ -219,7 +223,7 @@ fn resolved_delivery_plan( "mesh_scope", target_set.into_targets(), satisfaction_policy, - RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts, + outbox_reticulum_preview_behavior(sdk.transport_profile()), ) } } @@ -228,29 +232,30 @@ fn resolved_delivery_plan( fn delivery_plan_from_targets( transport_profile_id: impl Into<String>, targets: Vec<RadrootsTransportTarget>, - satisfaction_policy: SatisfactionPolicy, + satisfaction_policy: &SatisfactionPolicy, reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior, ) -> Result<SdkResolvedDeliveryPlan, RadrootsSdkError> { let delivery_plan = RadrootsOutboxDeliveryPlanInput::new( transport_profile_id, 1, - transport_satisfaction_policy(satisfaction_policy), + satisfaction_policy.transport_satisfaction_policy()?, targets, ) .with_reticulum_preview_behavior(reticulum_preview_behavior); Ok(SdkResolvedDeliveryPlan { delivery_plan }) } -fn transport_satisfaction_policy( - satisfaction_policy: SatisfactionPolicy, -) -> RadrootsTransportSatisfactionPolicy { - match satisfaction_policy { - SatisfactionPolicy::NoWait => RadrootsTransportSatisfactionPolicy::no_wait(), - SatisfactionPolicy::AllTargets => RadrootsTransportSatisfactionPolicy::all_accepted(), - SatisfactionPolicy::AtLeastOneTarget => RadrootsTransportSatisfactionPolicy::any_accepted(), - SatisfactionPolicy::AtLeast { required } => { - RadrootsTransportSatisfactionPolicy::quorum_accepted(required) - } +fn reticulum_preview_behavior_for_targets( + transport_profile: &TransportProfile, + targets: &[RadrootsTransportTarget], +) -> RadrootsOutboxReticulumPreviewBehavior { + if targets + .iter() + .any(|target| target.kind == RadrootsTransportKind::Reticulum) + { + outbox_reticulum_preview_behavior(transport_profile) + } else { + RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts } } diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -1537,6 +1537,10 @@ fn sdk_sync_status_sources_reject_retired_relay_shaped_generic_fields() { "pub transport_statuses: Vec<SyncTransportStatusSummary>,", ".configured_transport_targets()?", ".transport_statuses()", + "pub target_scope: Option<String>,", + "pub target_label: Option<String>,", + "target.scope.as_ref()", + "target.label.as_ref()", "pub transport: String,", "pub configured: bool,", "pub implementation: String,", @@ -1593,6 +1597,50 @@ fn sdk_sync_status_sources_reject_retired_relay_shaped_generic_fields() { } #[test] +fn sdk_transport_sources_expose_full_satisfaction_policy_without_legacy_aliases() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let transport_source = read_source(manifest_dir.join("src/transport.rs").as_path()); + let workflow_runtime = read_source(manifest_dir.join("src/workflow_runtime.rs").as_path()); + + for required in [ + "pub enum SatisfactionPolicy", + "AnyAccepted", + "AllAccepted", + "QuorumAccepted", + "AnyDelivered", + "AllDelivered", + "QuorumDelivered", + "RequiredAcceptedTargets", + "RequiredDeliveredTargets", + "pub fn quorum_accepted(", + "pub fn quorum_delivered(", + "pub fn required_accepted_targets", + "pub fn required_delivered_targets", + "RadrootsTransportSatisfactionPolicy::required_targets", + "RadrootsTransportSatisfactionClass::Accepted", + "RadrootsTransportSatisfactionClass::Delivered", + ] { + assert!( + transport_source.contains(required), + "src/transport.rs must retain full SDK satisfaction policy witness `{required}`" + ); + } + + for forbidden in [ + "AtLeastOneTarget", + "AllTargets", + "pub fn at_least", + concat!("at_least", "_one_target"), + concat!("all", "_targets"), + ] { + assert!( + !transport_source.contains(forbidden) && !workflow_runtime.contains(forbidden), + "SDK transport/workflow sources must not retain legacy satisfaction alias `{forbidden}`" + ); + } +} + +#[test] fn sdk_workflow_runtime_records_local_import_observations() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let workflow_runtime = read_source(manifest_dir.join("src/workflow_runtime.rs").as_path()); @@ -1649,6 +1697,14 @@ fn sdk_transport_sources_keep_reticulum_preview_push_boundary() { "reticulum_preview_event_receipt", "push_reported_event", "RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE", + "ReticulumPreviewTryNowRequest", + "try_reticulum_preview_now", + "\"sync.try_reticulum_preview_now\"", + "RadrootsSdkError::ReticulumPreviewTransportUnavailable", + "pub target_scope: Option<String>,", + "pub target_label: Option<String>,", + "pub transport_outcome_kind: Option<PushOutboxTransportOutcomeKind>,", + "PushOutboxTransportOutcomeKind::TransportUnavailable", ] { assert!( sync_runtime.contains(required), diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -28,10 +28,10 @@ use radroots_sdk::{ PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind, - PushOutboxTargetReceipt, RadrootsClient, RadrootsSdkError, RadrootsSdkTimestamp, - RestoreRequest, ReticulumPreviewBehavior, ReticulumPreviewProfile, SdkBackupManifestKind, - SdkRelayAuthPolicy, SdkRestoreState, StorageStatusRequest, SyncStatusRequest, SyncStatusSource, - TargetPolicy, TransportProfile, + PushOutboxTargetReceipt, PushOutboxTransportOutcomeKind, RadrootsClient, RadrootsSdkError, + RadrootsSdkTimestamp, RestoreRequest, ReticulumPreviewBehavior, ReticulumPreviewProfile, + ReticulumPreviewTryNowRequest, SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRestoreState, + StorageStatusRequest, SyncStatusRequest, SyncStatusSource, TargetPolicy, TransportProfile, }; use radroots_transport::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; use radroots_transport_nostr::{ @@ -699,11 +699,15 @@ async fn sync_status_empty_store_reports_canonical_sources_and_transport_targets { "transport_kind": "nostr", "endpoint_uri": RELAY_B, + "target_scope": null, + "target_label": null, "endpoint_fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05" }, { "transport_kind": "nostr", "endpoint_uri": RELAY_A, + "target_scope": null, + "target_label": null, "endpoint_fingerprint": "fc957b234632cc52e2be19cba88bc85c69966ee5a2df61742b5875ff717fd6fa" } ], @@ -741,12 +745,24 @@ async fn sync_status_reports_hybrid_transport_targets_and_statuses() { .transport_profile .configured_transport_targets .iter() - .map(|target| { (target.transport_kind.as_str(), target.endpoint_uri.as_str(),) }) + .map(|target| { + ( + target.transport_kind.as_str(), + target.endpoint_uri.as_str(), + target.target_scope.as_deref(), + target.target_label.as_deref(), + ) + }) .collect::<Vec<_>>(), vec![ - ("nostr", RELAY_A), - ("nostr", RELAY_B), - ("reticulum", "reticulum:preview-unavailable") + ("nostr", RELAY_A, None, None), + ("nostr", RELAY_B, None, None), + ( + "reticulum", + "reticulum:preview-unavailable", + Some("local_preview"), + None + ) ] ); assert_eq!( @@ -2075,7 +2091,10 @@ fn push_outbox_contract_dtos_serialize_deterministically() { targets: vec![PushOutboxTargetReceipt { transport_kind: "nostr".to_owned(), endpoint_uri: RELAY_A.to_owned(), + target_scope: None, + target_label: None, outcome_kind: PushOutboxTargetOutcomeKind::DuplicateAccepted, + transport_outcome_kind: Some(PushOutboxTransportOutcomeKind::DuplicateAccepted), attempted: true, message: Some("duplicate".to_owned()), }], @@ -2101,7 +2120,10 @@ fn push_outbox_contract_dtos_serialize_deterministically() { "targets": [{ "transport_kind": "nostr", "endpoint_uri": RELAY_A, + "target_scope": null, + "target_label": null, "outcome_kind": "duplicate_accepted", + "transport_outcome_kind": "duplicate_accepted", "attempted": true, "message": "duplicate" }] @@ -2150,15 +2172,17 @@ async fn sync_runtime_product_push_outbox_reticulum_preview_reports_zero_attempt ReticulumPreviewBehavior::RejectDeliveryAttempts, PushOutboxEventState::PreviewUnavailable, PushOutboxTargetOutcomeKind::PreviewUnavailable, + PushOutboxTransportOutcomeKind::TransportUnavailable, ), ( ReticulumPreviewBehavior::DeferDeliveryPlans, PushOutboxEventState::DeferredUntilImplemented, PushOutboxTargetOutcomeKind::DeferredUntilImplemented, + PushOutboxTransportOutcomeKind::DeferredUntilImplemented, ), ]; - for (behavior, expected_state, expected_outcome) in cases { + for (behavior, expected_state, expected_outcome, expected_transport_outcome) in cases { let (_tempdir, sdk) = reticulum_preview_directory_sdk(behavior).await; let empty = sdk .sync() @@ -2204,7 +2228,13 @@ async fn sync_runtime_product_push_outbox_reticulum_preview_reports_zero_attempt let target = &event.targets[0]; assert_eq!(target.transport_kind, "reticulum"); assert_eq!(target.endpoint_uri, "reticulum:preview-unavailable"); + assert_eq!(target.target_scope.as_deref(), Some("local_preview")); + assert_eq!(target.target_label.as_deref(), None); assert_eq!(target.outcome_kind, expected_outcome); + assert_eq!( + target.transport_outcome_kind, + Some(expected_transport_outcome) + ); assert!(!target.attempted); assert_eq!( target.message.as_deref(), @@ -2233,6 +2263,12 @@ async fn sync_runtime_product_push_outbox_reticulum_preview_reports_zero_attempt status.outbox.deferred_until_implemented_events, expected_deferred ); + assert_eq!( + status.transport_profile.configured_transport_targets[0] + .target_scope + .as_deref(), + Some("local_preview") + ); assert_eq!(status.outbox.total_events, 1); assert_eq!(enqueue.outbox_event_id, 1); @@ -2248,6 +2284,28 @@ async fn sync_runtime_product_push_outbox_reticulum_preview_reports_zero_attempt } #[tokio::test] +async fn sync_runtime_try_reticulum_preview_now_returns_explicit_unavailable_error() { + let (_tempdir, sdk) = + reticulum_preview_directory_sdk(ReticulumPreviewBehavior::DeferDeliveryPlans).await; + + let error = sdk + .sync() + .try_reticulum_preview_now(ReticulumPreviewTryNowRequest::new()) + .await + .expect_err("Reticulum preview unavailable"); + + assert!(matches!( + error, + RadrootsSdkError::ReticulumPreviewTransportUnavailable { + ref operation, + ref endpoint_uri, + behavior: ReticulumPreviewBehavior::DeferDeliveryPlans, + } if operation == "sync.try_reticulum_preview_now" + && endpoint_uri == "reticulum:preview-unavailable" + )); +} + +#[tokio::test] async fn push_outbox_rejects_invalid_limits_before_claiming() { let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await; let adapter = RadrootsMockRelayPublishAdapter::new(); diff --git a/crates/sdk/tests/trade_product_publish_runtime.rs b/crates/sdk/tests/trade_product_publish_runtime.rs @@ -254,7 +254,7 @@ async fn trade_product_propose_enqueue_and_publish_uses_ack_policy() { trade_propose_request( "trade-product-publish", PublishMode::EnqueueAndPublish, - SatisfactionPolicy::AtLeastOneTarget, + SatisfactionPolicy::AnyAccepted, ) .try_with_idempotency_key("trade-product-publish") .expect("idempotency"), diff --git a/crates/sdk/tests/unit/orders_runtime_tests.rs b/crates/sdk/tests/unit/orders_runtime_tests.rs @@ -2212,27 +2212,28 @@ fn order_enqueue_request_mutators_reject_invalid_relays_and_idempotency_keys() { #[test] fn trade_enqueue_policy_rejects_publish_modes_without_matching_side_effects() { assert!(matches!( - validate_trade_enqueue_policy(PublishMode::DryRun, SatisfactionPolicy::NoWait), + validate_trade_enqueue_policy(PublishMode::DryRun, &SatisfactionPolicy::NoWait), Err(RadrootsSdkError::InvalidRequest { ref message }) if message == "trade dry-run publish mode must use a prepare request" )); assert!(matches!( - validate_trade_enqueue_policy(PublishMode::EnqueueOnly, SatisfactionPolicy::AtLeastOneTarget), + validate_trade_enqueue_policy(PublishMode::EnqueueOnly, &SatisfactionPolicy::AnyAccepted), Err(RadrootsSdkError::InvalidRequest { ref message }) if message == "trade enqueue-only publish mode only supports no-wait satisfaction" )); assert!(matches!( - validate_trade_enqueue_policy(PublishMode::EnqueueAndPublish, SatisfactionPolicy::NoWait), + validate_trade_enqueue_policy(PublishMode::EnqueueAndPublish, &SatisfactionPolicy::NoWait), Err(RadrootsSdkError::InvalidRequest { ref message }) if message == "trade enqueue-and-publish requires a transport satisfaction policy" )); assert!( - validate_trade_enqueue_policy(PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait).is_ok() + validate_trade_enqueue_policy(PublishMode::EnqueueOnly, &SatisfactionPolicy::NoWait) + .is_ok() ); assert!( validate_trade_enqueue_policy( PublishMode::EnqueueAndPublish, - SatisfactionPolicy::AtLeastOneTarget + &SatisfactionPolicy::AnyAccepted ) .is_ok() ); diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -165,7 +165,7 @@ async fn claimed_proxy_event(d_tag: &str) -> (crate::RadrootsClient, RadrootsOut crate::NostrRelayUrlPolicy::Public, ) .expect("target relays"), - satisfaction_policy: crate::SatisfactionPolicy::AllTargets, + satisfaction_policy: crate::SatisfactionPolicy::AllAccepted, idempotency_key: None, }, &ProxyFixtureSigner::new(), @@ -747,8 +747,11 @@ fn proxy_outbox_target_conversion_rejects_reticulum_targets_before_behavior_loss delivery_plan_id: 1, transport_kind: target.kind.clone(), endpoint_uri: target.uri.clone(), + target_scope: target.scope.clone(), + target_label: target.label.clone(), endpoint_fingerprint: target.fingerprint.clone(), status: RadrootsOutboxDeliveryTargetStatus::Pending, + last_outcome_kind: None, attempt_count: 0, last_attempt_at_ms: None, completed_at_ms: None, @@ -778,8 +781,11 @@ fn proxy_outbox_target_conversion_rejects_proxy_targets_before_daemon_explicit_t delivery_plan_id: 1, transport_kind: target.kind.clone(), endpoint_uri: target.uri.clone(), + target_scope: target.scope.clone(), + target_label: target.label.clone(), endpoint_fingerprint: target.fingerprint.clone(), status: RadrootsOutboxDeliveryTargetStatus::Pending, + last_outcome_kind: None, attempt_count: 0, last_attempt_at_ms: None, completed_at_ms: None, diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs @@ -33,22 +33,47 @@ fn publish_mode_and_ack_policy_serialize_explicit_product_contracts() { serde_json::json!("no_wait") ); assert_eq!( - serde_json::to_value(SatisfactionPolicy::AtLeastOneTarget).expect("json"), - serde_json::json!("at_least_one_target") + serde_json::to_value(SatisfactionPolicy::AnyAccepted).expect("json"), + serde_json::json!("any_accepted") ); assert_eq!( - serde_json::to_value(SatisfactionPolicy::AllTargets).expect("json"), - serde_json::json!("all_targets") + serde_json::to_value(SatisfactionPolicy::AllAccepted).expect("json"), + serde_json::json!("all_accepted") ); assert_eq!( - serde_json::to_value(SatisfactionPolicy::at_least(2).expect("satisfaction policy")) + serde_json::to_value(SatisfactionPolicy::quorum_accepted(2).expect("satisfaction policy")) .expect("json"), - serde_json::json!({ "at_least": { "required": 2 } }) + serde_json::json!({ "quorum_accepted": { "threshold": 2 } }) + ); + assert_eq!( + serde_json::to_value(SatisfactionPolicy::AnyDelivered).expect("json"), + serde_json::json!("any_delivered") + ); + assert_eq!( + serde_json::to_value(SatisfactionPolicy::AllDelivered).expect("json"), + serde_json::json!("all_delivered") + ); + assert_eq!( + serde_json::to_value(SatisfactionPolicy::quorum_delivered(3).expect("satisfaction policy")) + .expect("json"), + serde_json::json!({ "quorum_delivered": { "threshold": 3 } }) + ); + assert_eq!( + serde_json::to_value( + SatisfactionPolicy::required_accepted_targets(["a".repeat(64)]) + .expect("satisfaction policy") + ) + .expect("json"), + serde_json::json!({ + "required_accepted_targets": { + "target_fingerprints": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + } + }) ); assert!(matches!( - SatisfactionPolicy::at_least(0), + SatisfactionPolicy::quorum_accepted(0), Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "satisfaction policy must require at least one target" + if message == "satisfaction policy threshold must require at least one target" )); } @@ -262,6 +287,26 @@ fn reticulum_preview_profile_preserves_explicit_scope_and_agent_endpoint() { } #[test] +fn reticulum_preview_agent_endpoint_rejects_non_agent_endpoint_families() { + for invalid in [ + "", + "reticulum-agent:", + " reticulum-agent:local", + "reticulum-agent:local ", + "RETICULUM-AGENT:local", + "reticulum:local", + "https://reticulum.example.com", + "ws://127.0.0.1:9735", + ] { + assert!(matches!( + ReticulumPreviewAgentEndpoint::parse(invalid), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message == "Reticulum preview agent endpoint is invalid" + )); + } +} + +#[test] fn explicit_target_sets_reject_noncanonical_reticulum_preview_endpoints() { for invalid in [ " reticulum:preview-unavailable", diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -174,7 +174,7 @@ async fn default_operation_idempotency_ignores_target_policy() { actor: &actor, frozen_draft: &draft, target_policy: first_target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, }, &signer, @@ -188,7 +188,7 @@ async fn default_operation_idempotency_ignores_target_policy() { actor: &actor, frozen_draft: &draft, target_policy: second_target_policy, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, }, &signer, @@ -334,7 +334,7 @@ async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_con actor: &actor, frozen_draft: &first_draft, target_policy: TargetPolicy::default_profile(), - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: Some(idempotency_key.clone()), }, &signer, @@ -375,7 +375,7 @@ async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_con actor: &actor, frozen_draft: &second_draft, target_policy: TargetPolicy::default_profile(), - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: Some(idempotency_key), }, &signer, @@ -438,7 +438,7 @@ async fn enqueue_configured_signed_workflow_uses_sdk_signer_provider() { actor: &actor, frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, }, ) @@ -473,7 +473,7 @@ async fn enqueue_signed_workflow_reports_outbox_preflight_failure_without_mutati actor: &actor, frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, }; @@ -509,7 +509,7 @@ async fn enqueue_signed_workflow_reports_store_failures() { actor: &actor, frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, }; assert!(matches!( @@ -539,7 +539,7 @@ async fn enqueue_signed_workflow_reports_clock_failures() { actor: &actor, frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, }; assert!(matches!( @@ -559,7 +559,7 @@ async fn enqueue_signed_workflow_rejects_transport_profile_targets_without_proxy actor: &actor, frozen_draft: &draft, target_policy: TargetPolicy::DefaultProfile, - satisfaction_policy: SatisfactionPolicy::AllTargets, + satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: None, };