commit a314092543bd2eeb6fe0fef033790651083969b1
parent 516fb4231e6227193ca79385797d339f1a8f3dad
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:
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,
};