commit 0a12dcf67290ae49285f4a46991b4e2a479382bf
parent 2cdf57bd4376803470f5c43e81c2bf470fcab72c
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 22:17:44 +0000
transport: scope nostr outbox publishes to active plans
- Add relay publish request idempotency metadata for outbox attempts.
- Derive direct Nostr publish targets and satisfaction from the claimed active delivery plan.
- Reject ready non-Nostr targets in active Nostr plans before adapter publishing.
- Update transport tests for typed preview state after plan-scoped publishing.
Diffstat:
3 files changed, 69 insertions(+), 20 deletions(-)
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -113,8 +113,15 @@ where
publishable.required_accept_count,
publishable.relays.len(),
)?;
+ let active_delivery_plan_id = publishable.active_delivery_plan_id;
let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms)
- .with_satisfaction_policy(satisfaction_policy);
+ .with_satisfaction_policy(satisfaction_policy)
+ .with_idempotency_key(outbox_publish_idempotency_key(
+ claimed.outbox_event_id,
+ claimed.attempt_count,
+ signed_event.id.as_str(),
+ active_delivery_plan_id,
+ ));
let publish = match publish_signed_event(adapter, request).await {
Ok(receipt) => receipt,
Err(RadrootsRelayTransportError::Transport(message)) => adapter_transport_failure_receipt(
@@ -222,6 +229,7 @@ fn adapter_transport_failure_receipt(
}
struct PublishableRelays {
+ active_delivery_plan_id: i64,
relays: Vec<PublishableRelay>,
accepted_count: usize,
satisfaction_required_count: usize,
@@ -246,31 +254,53 @@ async fn publishable_relays(
claimed: &RadrootsOutboxClaimedEvent,
republish_accepted_relays: bool,
) -> Result<PublishableRelays, RadrootsRelayTransportError> {
+ let active_delivery_plan_id = claimed.active_delivery_plan_id.ok_or_else(|| {
+ RadrootsRelayTransportError::Transport(format!(
+ "outbox event {} has no active delivery plan for Nostr publish",
+ claimed.outbox_event_id
+ ))
+ })?;
let targets = outbox.delivery_targets(claimed.outbox_event_id).await?;
let plans = outbox.delivery_plans(claimed.outbox_event_id).await?;
- let satisfaction_required_count = plans
+ let plan = plans
.iter()
- .map(|plan| plan.required_success_count as usize)
- .sum::<usize>();
- let required_accept_count = plans
+ .find(|plan| plan.delivery_plan_id == active_delivery_plan_id)
+ .ok_or_else(|| {
+ RadrootsRelayTransportError::Transport(format!(
+ "outbox event {} active delivery plan {} was not found for Nostr publish",
+ claimed.outbox_event_id, active_delivery_plan_id
+ ))
+ })?;
+ let satisfaction_required_count = plan.required_success_count as usize;
+ let active_targets = targets
+ .iter()
+ .filter(|target| target.delivery_plan_id == active_delivery_plan_id)
+ .collect::<Vec<_>>();
+ if let Some(target) = active_targets
.iter()
- .map(|plan| {
- let satisfied_count = targets
- .iter()
- .filter(|target| target.delivery_plan_id == plan.delivery_plan_id)
- .filter(|target| {
- target
- .status
- .counts_as_transport_satisfaction(plan.satisfaction_policy.class())
- })
- .count();
- (plan.required_success_count as usize).saturating_sub(satisfied_count)
+ .find(|target| !is_nostr_target(target) && target.status.is_ready_for_attempt())
+ {
+ return Err(RadrootsRelayTransportError::Transport(format!(
+ "direct Nostr outbox publish does not accept {} target {} in active delivery plan {}",
+ target.transport_kind.canonical_label(),
+ target.endpoint_uri.as_str(),
+ active_delivery_plan_id
+ )));
+ }
+ let satisfied_count = active_targets
+ .iter()
+ .filter(|target| {
+ target
+ .status
+ .counts_as_transport_satisfaction(plan.satisfaction_policy.class())
})
- .sum::<usize>();
+ .count();
+ let required_accept_count =
+ (plan.required_success_count as usize).saturating_sub(satisfied_count);
let mut relays = Vec::new();
let mut accepted_count = 0usize;
- for target in targets {
- if !is_nostr_target(&target) {
+ for target in active_targets {
+ if !is_nostr_target(target) {
continue;
}
if target
@@ -291,6 +321,7 @@ async fn publishable_relays(
}
}
Ok(PublishableRelays {
+ active_delivery_plan_id,
relays,
accepted_count,
satisfaction_required_count,
@@ -298,6 +329,17 @@ async fn publishable_relays(
})
}
+fn outbox_publish_idempotency_key(
+ outbox_event_id: i64,
+ attempt_count: i64,
+ event_id: &str,
+ active_delivery_plan_id: i64,
+) -> String {
+ format!(
+ "radroots-nostr-outbox-{outbox_event_id}-{attempt_count}-{event_id}-{active_delivery_plan_id}"
+ )
+}
+
fn is_nostr_target(target: &RadrootsOutboxDeliveryTargetRecord) -> bool {
target.transport_kind == RadrootsTransportKind::Nostr
}
diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs
@@ -25,6 +25,7 @@ pub struct RadrootsRelayPublishRequest {
pub signed_event: RadrootsSignedNostrEvent,
pub targets: RadrootsRelayTargetSet,
pub satisfaction_policy: RadrootsTransportSatisfactionPolicy,
+ pub idempotency_key: Option<String>,
pub now_ms: i64,
}
@@ -38,6 +39,7 @@ impl RadrootsRelayPublishRequest {
signed_event,
targets,
satisfaction_policy: RadrootsTransportSatisfactionPolicy::all_accepted(),
+ idempotency_key: None,
now_ms,
}
}
@@ -49,6 +51,11 @@ impl RadrootsRelayPublishRequest {
self.satisfaction_policy = satisfaction_policy;
self
}
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -1724,7 +1724,7 @@ async fn outbox_publish_skips_non_nostr_targets() {
.await
.expect("event")
.expect("event");
- assert_eq!(event.state, RadrootsOutboxEventState::Signed);
+ assert_eq!(event.state, RadrootsOutboxEventState::PreviewUnavailable);
let targets = outbox
.delivery_targets(receipt.outbox_event_id)
.await