commit b9bf25b4972dc36688fb823feafb0046c86602ce
parent 028b84f7f095e2758c2ee2f7a857c03248a6d833
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Jul 2026 03:40:33 +0000
sync: preserve direct Nostr target metadata
- map SDK direct push receipts from target-aware outbox publish receipts
- preserve logical target scope and label metadata in public push target receipts
- cover scoped duplicate endpoint success and adapter transport failure receipts
- guard the direct Nostr receipt mapper against URL-level relay receipt regressions
Diffstat:
4 files changed, 321 insertions(+), 39 deletions(-)
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -43,8 +43,8 @@ use radroots_transport::{RadrootsTransportSatisfactionClass, RadrootsTransportSa
use radroots_transport_nostr::RadrootsNostrClientPublishAdapter;
#[cfg(feature = "runtime")]
use radroots_transport_nostr::{
- RadrootsOutboxPublishPolicy, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter,
- RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, publish_claimed_outbox_event,
+ RadrootsOutboxPublishPolicy, RadrootsOutboxPublishReceipt, RadrootsOutboxPublishTargetReceipt,
+ RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, publish_claimed_outbox_event,
};
#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
use radroots_transport_publish_protocol::{
@@ -821,10 +821,11 @@ impl<'sdk> SyncClient<'sdk> {
publish_now_ms,
)
.await?;
+ let final_state = push_event_final_state(&publish);
receipt.push_attempted_event(push_event_receipt(
claimed.outbox_event_id,
- push_event_final_state(&publish.publish),
- publish.publish,
+ final_state,
+ publish,
)?);
}
Ok(receipt)
@@ -1780,7 +1781,7 @@ fn push_outbox_claim_token() -> String {
}
#[cfg(feature = "runtime")]
-fn push_event_final_state(publish: &RadrootsRelayPublishReceipt) -> PushOutboxEventState {
+fn push_event_final_state(publish: &RadrootsOutboxPublishReceipt) -> PushOutboxEventState {
if publish.quorum_met {
PushOutboxEventState::Published
} else if publish.retryable_count > 0 {
@@ -1794,11 +1795,11 @@ fn push_event_final_state(publish: &RadrootsRelayPublishReceipt) -> PushOutboxEv
fn push_event_receipt(
outbox_event_id: i64,
final_state: PushOutboxEventState,
- publish: RadrootsRelayPublishReceipt,
+ publish: RadrootsOutboxPublishReceipt,
) -> Result<PushOutboxEventReceipt, RadrootsSdkError> {
let event_id = push_receipt_event_id(
publish.event_id.as_str(),
- "relay transport publish receipt event id",
+ "direct Nostr outbox publish receipt event id",
)?;
Ok(PushOutboxEventReceipt {
event_id,
@@ -1811,7 +1812,7 @@ fn push_event_receipt(
quorum: publish.quorum,
quorum_met: publish.quorum_met,
targets: publish
- .relays
+ .target_receipts
.into_iter()
.map(push_target_receipt)
.collect(),
@@ -1826,16 +1827,16 @@ fn push_receipt_event_id(value: &str, field: &str) -> Result<RadrootsEventId, Ra
}
#[cfg(feature = "runtime")]
-fn push_target_receipt(relay: RadrootsRelayPublishRelayReceipt) -> PushOutboxTargetReceipt {
+fn push_target_receipt(target: RadrootsOutboxPublishTargetReceipt) -> PushOutboxTargetReceipt {
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,
+ endpoint_uri: target.endpoint_uri,
+ target_scope: target.target_scope,
+ target_label: target.target_label,
+ outcome_kind: target.outcome.kind.into(),
+ transport_outcome_kind: Some(target.outcome.kind.transport_outcome_kind().into()),
+ attempted: target.attempted,
+ message: target.outcome.message,
}
}
diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs
@@ -1556,6 +1556,66 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() {
}
#[test]
+fn sdk_direct_nostr_push_receipts_preserve_outbox_target_metadata() {
+ let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+ let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path());
+
+ for required in [
+ "RadrootsOutboxPublishReceipt",
+ "RadrootsOutboxPublishTargetReceipt",
+ "push_event_final_state(&publish)",
+ ".target_receipts",
+ "target_scope: target.target_scope",
+ "target_label: target.target_label",
+ ] {
+ assert!(
+ sync_runtime_source.contains(required),
+ "src/sync_runtime.rs must preserve direct Nostr target-aware receipt witness `{required}`"
+ );
+ }
+
+ let direct_event_receipt_source = source_between(
+ sync_runtime_source.as_str(),
+ "fn push_event_receipt",
+ "fn push_receipt_event_id",
+ );
+ assert!(
+ !direct_event_receipt_source.contains(".relays"),
+ "push_event_receipt must not derive SDK direct publish targets from URL-level relay receipts"
+ );
+
+ let direct_target_receipt_source = source_between(
+ sync_runtime_source.as_str(),
+ "fn push_target_receipt",
+ "#[cfg(all(test",
+ );
+ for forbidden in [
+ "target_scope: None",
+ "target_label: None",
+ "RadrootsRelayPublishRelayReceipt",
+ ] {
+ assert!(
+ !direct_target_receipt_source.contains(forbidden),
+ "push_target_receipt must not hard-code or relay-derive direct Nostr metadata token `{forbidden}`"
+ );
+ }
+
+ let sync_runtime_test_source =
+ read_source(manifest_dir.join("tests/sync_runtime.rs").as_path());
+ for required in [
+ "push_outbox_with_adapter_preserves_scoped_duplicate_target_metadata",
+ "push_outbox_adapter_transport_failure_preserves_scoped_target_metadata",
+ "target.target_scope.as_deref() == Some(\"farm.a\")",
+ "target.target_scope.as_deref() == Some(\"farm.b\")",
+ ] {
+ assert!(
+ sync_runtime_test_source.contains(required),
+ "tests/sync_runtime.rs must retain SDK direct Nostr metadata coverage `{required}`"
+ );
+ }
+}
+
+#[test]
fn sdk_public_outcome_label_contracts_are_explicit() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path());
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -33,7 +33,10 @@ use radroots_sdk::{
ReticulumPreviewTryNowRequest, SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRestoreState,
StorageStatusRequest, SyncStatusRequest, SyncStatusSource, TargetPolicy, TransportProfile,
};
-use radroots_transport::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE;
+use radroots_transport::{
+ RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportKind, RadrootsTransportMeshScopeId,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel,
+};
use radroots_transport_nostr::{
RadrootsMockRelayPublishAdapter, RadrootsRelayOutcome, RadrootsRelayPublishAdapter,
RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError,
@@ -531,6 +534,36 @@ async fn enqueue_listing(sdk: &RadrootsClient, d_tag: &str, title: &str, relays:
enqueue_listing_with_policy(sdk, d_tag, title, relays, NostrRelayUrlPolicy::Public).await
}
+async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, title: &str) -> i64 {
+ let plan = sdk
+ .listings()
+ .prepare_publish(ListingPreparePublishRequest::new(
+ actor(),
+ listing(d_tag, title),
+ ))
+ .expect("prepared listing");
+ let signer = FixtureSigner::new(SELLER);
+ let signed_event = signer
+ .sign_frozen_draft(&plan.frozen_draft)
+ .expect("signed listing");
+ let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path)
+ .await
+ .expect("outbox");
+ outbox
+ .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new(
+ LISTING_PUBLISH_OPERATION_KIND,
+ plan.frozen_draft,
+ signed_event,
+ scoped_duplicate_relay_delivery_plan(RELAY_A),
+ true,
+ 1_700_000_000_000,
+ 1_700_000_000_000,
+ ))
+ .await
+ .expect("scoped duplicate enqueue")
+ .outbox_event_id
+}
+
async fn assert_local_import_observation(sdk: &RadrootsClient, outbox_event_id: i64) {
let paths = sdk.storage_paths().expect("paths");
let outbox = RadrootsOutbox::open_file(&paths.outbox_path)
@@ -578,6 +611,30 @@ where
)
}
+fn scoped_duplicate_relay_delivery_plan(
+ relay: &str,
+) -> radroots_outbox::RadrootsOutboxDeliveryPlanInput {
+ radroots_outbox::RadrootsOutboxDeliveryPlanInput::new(
+ "explicit.scoped",
+ 2,
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ vec![
+ scoped_nostr_target(relay, "farm.a", "Farm A"),
+ scoped_nostr_target(relay, "farm.b", "Farm B"),
+ ],
+ )
+}
+
+fn scoped_nostr_target(relay: &str, scope: &str, label: &str) -> RadrootsTransportTarget {
+ RadrootsTransportTarget::new_with_metadata(
+ RadrootsTransportKind::Nostr,
+ relay,
+ Some(RadrootsTransportMeshScopeId::parse(scope).expect("target scope")),
+ Some(RadrootsTransportTargetLabel::parse(label).expect("target label")),
+ )
+ .expect("scoped Nostr target")
+}
+
async fn backup_source(sdk: &RadrootsClient, root: &Path, name: &str) -> PathBuf {
let source = root.join(name);
sdk.backup(BackupRequest::new(source.clone()))
@@ -2392,6 +2449,149 @@ async fn push_outbox_with_adapter_uses_queued_targets_without_builder_relays() {
}
#[tokio::test]
+async fn push_outbox_with_adapter_preserves_scoped_duplicate_target_metadata() {
+ let (_tempdir, sdk) = directory_sdk(&[]).await;
+ let outbox_event_id =
+ enqueue_scoped_duplicate_listing(&sdk, LISTING_A_D_TAG, "Scoped Coffee").await;
+ let adapter = RecordingPublishAdapter::new(Duration::ZERO);
+
+ let receipt = sdk
+ .sync()
+ .push_outbox_with_adapter(&adapter, PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect("push");
+
+ assert_eq!(receipt.attempted_events, 1);
+ assert_eq!(receipt.published_events, 1);
+ assert_eq!(adapter.relay_batches(), vec![vec![RELAY_A.to_owned()]]);
+ let event = &receipt.events[0];
+ assert_eq!(event.outbox_event_id, outbox_event_id);
+ assert_eq!(event.final_state, PushOutboxEventState::Published);
+ assert_eq!(event.attempted_count, 2);
+ assert_eq!(event.accepted_count, 2);
+ assert_eq!(event.retryable_count, 0);
+ assert_eq!(event.terminal_count, 0);
+ assert_eq!(event.quorum, 2);
+ assert!(event.quorum_met);
+ assert_eq!(event.targets.len(), 2);
+ assert!(event.targets.iter().all(|target| {
+ target.transport_kind == "nostr"
+ && target.endpoint_uri == RELAY_A
+ && target.attempted
+ && target.outcome_kind == PushOutboxTargetOutcomeKind::Accepted
+ }));
+ assert!(event.targets.iter().any(|target| {
+ target.target_scope.as_deref() == Some("farm.a")
+ && target.target_label.as_deref() == Some("Farm A")
+ }));
+ assert!(event.targets.iter().any(|target| {
+ target.target_scope.as_deref() == Some("farm.b")
+ && target.target_label.as_deref() == Some("Farm B")
+ }));
+
+ let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path)
+ .await
+ .expect("outbox");
+ let stored = outbox
+ .get_event(outbox_event_id)
+ .await
+ .expect("stored")
+ .expect("stored");
+ assert_eq!(stored.state, RadrootsOutboxEventState::Published);
+ let targets = outbox
+ .delivery_targets(outbox_event_id)
+ .await
+ .expect("targets");
+ assert_eq!(targets.len(), 2);
+ assert!(targets.iter().all(|target| {
+ target.endpoint_uri.as_str() == RELAY_A
+ && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted
+ }));
+ assert!(targets.iter().any(|target| {
+ target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a")
+ && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm A")
+ }));
+ assert!(targets.iter().any(|target| {
+ target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b")
+ && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm B")
+ }));
+}
+
+#[tokio::test]
+async fn push_outbox_adapter_transport_failure_preserves_scoped_target_metadata() {
+ let (_tempdir, sdk) = directory_sdk(&[]).await;
+ let outbox_event_id =
+ enqueue_scoped_duplicate_listing(&sdk, LISTING_B_D_TAG, "Scoped Retry Coffee").await;
+
+ let receipt = sdk
+ .sync()
+ .push_outbox_with_adapter(
+ &TransportFailurePublishAdapter,
+ PushOutboxRequest::new().with_limit(1),
+ )
+ .await
+ .expect("push");
+
+ assert_eq!(receipt.attempted_events, 1);
+ assert_eq!(receipt.published_events, 0);
+ assert_eq!(receipt.retryable_events, 1);
+ assert_eq!(receipt.terminal_events, 0);
+ let event = &receipt.events[0];
+ assert_eq!(event.outbox_event_id, outbox_event_id);
+ assert_eq!(event.final_state, PushOutboxEventState::PublishRetryable);
+ assert_eq!(event.attempted_count, 2);
+ assert_eq!(event.accepted_count, 0);
+ assert_eq!(event.retryable_count, 2);
+ assert_eq!(event.terminal_count, 0);
+ assert_eq!(event.quorum, 2);
+ assert!(!event.quorum_met);
+ assert_eq!(event.targets.len(), 2);
+ assert!(event.targets.iter().all(|target| {
+ target.transport_kind == "nostr"
+ && target.endpoint_uri == RELAY_A
+ && target.attempted
+ && target.outcome_kind == PushOutboxTargetOutcomeKind::ConnectionFailed
+ && target.message.as_deref() == Some("adapter boundary unavailable")
+ }));
+ assert!(event.targets.iter().any(|target| {
+ target.target_scope.as_deref() == Some("farm.a")
+ && target.target_label.as_deref() == Some("Farm A")
+ }));
+ assert!(event.targets.iter().any(|target| {
+ target.target_scope.as_deref() == Some("farm.b")
+ && target.target_label.as_deref() == Some("Farm B")
+ }));
+
+ let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path)
+ .await
+ .expect("outbox");
+ let stored = outbox
+ .get_event(outbox_event_id)
+ .await
+ .expect("stored")
+ .expect("stored");
+ assert_eq!(stored.state, RadrootsOutboxEventState::PublishRetryable);
+ assert!(stored.claim_token.is_none());
+ let targets = outbox
+ .delivery_targets(outbox_event_id)
+ .await
+ .expect("targets");
+ assert_eq!(targets.len(), 2);
+ assert!(targets.iter().all(|target| {
+ target.endpoint_uri.as_str() == RELAY_A
+ && target.status == RadrootsOutboxDeliveryTargetStatus::FailedRetryable
+ }));
+ assert!(targets.iter().any(|target| {
+ target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a")
+ && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm A")
+ }));
+ assert!(targets.iter().any(|target| {
+ target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b")
+ && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm B")
+ }));
+}
+
+#[tokio::test]
async fn push_outbox_with_adapter_recovers_expired_publishing_claim_before_selecting_work() {
let tempdir = tempfile::tempdir().expect("tempdir");
let storage = tempdir.path().join("sdk");
diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs
@@ -42,15 +42,18 @@ use radroots_outbox::{
RadrootsOutboxDeliveryTargetRecord, RadrootsOutboxDeliveryTargetStatus,
RadrootsOutboxOperationInput, RadrootsOutboxSignedOperationInput,
};
-use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary};
+use radroots_outbox::{
+ RadrootsOutboxEventState, RadrootsOutboxEventStoreIngestReceipt, RadrootsOutboxStatusSummary,
+};
#[cfg(feature = "radrootsd-proxy")]
use radroots_transport::{
RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportMeshScopeId,
RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel,
};
use radroots_transport_nostr::{
- RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt,
- RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError,
+ RadrootsOutboxPublishReceipt, RadrootsOutboxPublishTargetReceipt, RadrootsRelayOutcomeKind,
+ RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest,
+ RadrootsRelayTransportError,
};
#[cfg(feature = "radrootsd-proxy")]
use radroots_transport_publish_protocol::{
@@ -379,7 +382,7 @@ fn push_event_receipt_parses_typed_event_id() {
let receipt = push_event_receipt(
1,
PushOutboxEventState::Published,
- relay_publish_receipt(event_id.as_str()).with_relay(),
+ outbox_publish_receipt(event_id.as_str()).with_target(),
)
.expect("receipt");
@@ -390,6 +393,14 @@ fn push_event_receipt_parses_typed_event_id() {
assert_eq!(receipt.targets.len(), 1);
assert_eq!(receipt.targets[0].transport_kind, "nostr");
assert_eq!(receipt.targets[0].endpoint_uri, "wss://relay.example.com");
+ assert_eq!(
+ receipt.targets[0].target_scope.as_deref(),
+ Some("farm.local")
+ );
+ assert_eq!(
+ receipt.targets[0].target_label.as_deref(),
+ Some("Farm relay")
+ );
assert!(receipt.targets[0].attempted);
}
@@ -398,19 +409,19 @@ fn push_event_receipt_returns_typed_error_for_invalid_internal_event_id() {
let error = push_event_receipt(
1,
PushOutboxEventState::Published,
- relay_publish_receipt("not-a-valid-event-id"),
+ outbox_publish_receipt("not-a-valid-event-id"),
)
.expect_err("invalid event id");
assert!(matches!(
error,
RadrootsSdkError::InvalidRequest { message }
- if message.contains("relay transport publish receipt event id is invalid")
+ if message.contains("direct Nostr outbox publish receipt event id is invalid")
));
}
#[test]
fn push_event_final_state_follows_publish_quorum_and_retryability() {
- let published = relay_publish_receipt("a".repeat(64).as_str())
+ let published = outbox_publish_receipt("a".repeat(64).as_str())
.with_quorum_met(true)
.with_retryable_count(1);
assert_eq!(
@@ -418,13 +429,13 @@ fn push_event_final_state_follows_publish_quorum_and_retryability() {
PushOutboxEventState::Published
);
- let retryable = relay_publish_receipt("b".repeat(64).as_str()).with_retryable_count(1);
+ let retryable = outbox_publish_receipt("b".repeat(64).as_str()).with_retryable_count(1);
assert_eq!(
push_event_final_state(&retryable),
PushOutboxEventState::PublishRetryable
);
- let terminal = relay_publish_receipt("c".repeat(64).as_str());
+ let terminal = outbox_publish_receipt("c".repeat(64).as_str());
assert_eq!(
push_event_final_state(&terminal),
PushOutboxEventState::FailedTerminal
@@ -1656,8 +1667,14 @@ fn push_proxy_event_receipt_returns_typed_error_for_invalid_daemon_event_id() {
));
}
-fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt {
- RadrootsRelayPublishReceipt {
+fn outbox_publish_receipt(event_id: &str) -> RadrootsOutboxPublishReceipt {
+ RadrootsOutboxPublishReceipt {
+ local_ingest: RadrootsOutboxEventStoreIngestReceipt {
+ outbox_event_id: 1,
+ event_id: event_id.to_owned(),
+ already_ingested: false,
+ event_store_inserted: true,
+ },
event_id: event_id.to_owned(),
attempted_count: 0,
accepted_count: 0,
@@ -1665,24 +1682,28 @@ fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt {
terminal_count: 0,
quorum: 0,
quorum_met: false,
- relays: Vec::new(),
+ target_receipts: Vec::new(),
+ relay_receipts: Vec::new(),
}
}
-trait RelayReceiptFixture {
- fn with_relay(self) -> Self;
+trait OutboxPublishReceiptFixture {
+ fn with_target(self) -> Self;
fn with_quorum_met(self, quorum_met: bool) -> Self;
fn with_retryable_count(self, retryable_count: usize) -> Self;
}
-impl RelayReceiptFixture for RadrootsRelayPublishReceipt {
- fn with_relay(mut self) -> Self {
- self.relays.push(
- radroots_transport_nostr::RadrootsRelayPublishRelayReceipt::attempted(
- "wss://relay.example.com",
- radroots_transport_nostr::RadrootsRelayOutcome::accepted(),
- ),
- );
+impl OutboxPublishReceiptFixture for RadrootsOutboxPublishReceipt {
+ fn with_target(mut self) -> Self {
+ self.target_receipts
+ .push(RadrootsOutboxPublishTargetReceipt {
+ delivery_target_id: 10,
+ endpoint_uri: "wss://relay.example.com".to_owned(),
+ target_scope: Some("farm.local".to_owned()),
+ target_label: Some("Farm relay".to_owned()),
+ attempted: true,
+ outcome: radroots_transport_nostr::RadrootsRelayOutcome::accepted(),
+ });
self
}