lib

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

commit e86ac0f3532d0513a8f61ec05287bec2ac0fd5cc
parent a4eb86587fdf71fa8080e5a64aa28eeafa113924
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Jul 2026 01:05:30 +0000

sdk: remove target-bound default idempotency

- derive SDK default idempotency from operation identity only
- keep target policy differences in delivery-plan idempotency
- add regression coverage for repeated workflow enqueue targets
- verify the SDK check, test, source boundary, and xtask gates

Diffstat:
Mcrates/sdk/src/idempotency.rs | 3---
Mcrates/sdk/src/workflow_runtime.rs | 35+----------------------------------
Mcrates/sdk/tests/unit/idempotency_tests.rs | 8++------
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 98 insertions(+), 49 deletions(-)

diff --git a/crates/sdk/src/idempotency.rs b/crates/sdk/src/idempotency.rs @@ -45,13 +45,11 @@ impl SdkIdempotencyKey { operation_kind: &'static str, expected_event_id: &str, expected_pubkey: &str, - target_policy: &[String], ) -> Self { let input = SdkIdempotencyDerivationInput { operation_kind, expected_event_id, expected_pubkey, - target_policy, }; let bytes = serde_json::to_vec(&input).expect("idempotency derivation input serializes"); let digest = hex::encode(Sha256::digest(bytes)); @@ -86,7 +84,6 @@ struct SdkIdempotencyDerivationInput<'a> { operation_kind: &'static str, expected_event_id: &'a str, expected_pubkey: &'a str, - target_policy: &'a [String], } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -18,8 +18,6 @@ use radroots_outbox::{ RadrootsOutboxReticulumPreviewBehavior, RadrootsOutboxSignedOperationInput, }; use radroots_transport::{RadrootsTransportKind, RadrootsTransportSatisfactionPolicy}; -#[cfg(test)] -use sha2::{Digest, Sha256}; const SDK_LOCAL_EVENT_ENDPOINT_URI: &str = "local:sdk"; @@ -82,7 +80,6 @@ async fn enqueue_signed_workflow_event( request.operation_kind, request.frozen_draft.expected_event_id.as_str(), request.frozen_draft.expected_pubkey.as_str(), - delivery_plan.canonical_targets.as_slice(), ), }; let observed_at_ms = sdk_now_ms(sdk)?; @@ -161,7 +158,6 @@ async fn enqueue_signed_workflow_event( struct SdkResolvedDeliveryPlan { delivery_plan: RadrootsOutboxDeliveryPlanInput, - canonical_targets: Vec<String>, } fn resolved_delivery_plan( @@ -197,7 +193,6 @@ fn delivery_plan_from_target_set( satisfaction_policy: SatisfactionPolicy, reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior, ) -> Result<SdkResolvedDeliveryPlan, RadrootsSdkError> { - let canonical_targets = target_set.canonical_targets().to_vec(); let delivery_plan = RadrootsOutboxDeliveryPlanInput::new( transport_profile_id, 1, @@ -205,10 +200,7 @@ fn delivery_plan_from_target_set( target_set.into_targets(), ) .with_reticulum_preview_behavior(reticulum_preview_behavior); - Ok(SdkResolvedDeliveryPlan { - delivery_plan, - canonical_targets, - }) + Ok(SdkResolvedDeliveryPlan { delivery_plan }) } fn transport_satisfaction_policy( @@ -256,31 +248,6 @@ fn reticulum_preview_behavior( } } -#[derive(serde::Serialize)] -#[cfg(test)] -struct SdkWorkflowOutboxDigestInput<'a> { - operation_kind: &'static str, - expected_pubkey: &'a str, - draft: &'a RadrootsFrozenEventDraft, - target_policy: &'a [String], -} - -#[cfg(test)] -fn outbox_idempotency_digest_prefix( - operation_kind: &'static str, - frozen_draft: &RadrootsFrozenEventDraft, - target_policy: &[String], -) -> String { - let input = SdkWorkflowOutboxDigestInput { - operation_kind, - expected_pubkey: frozen_draft.expected_pubkey.as_str(), - draft: frozen_draft, - target_policy, - }; - let bytes = serde_json::to_vec(&input).expect("workflow digest input serializes"); - digest_prefix(hex::encode(Sha256::digest(bytes)).as_str()) -} - fn digest_prefix(digest: &str) -> String { digest.chars().take(12).collect() } diff --git a/crates/sdk/tests/unit/idempotency_tests.rs b/crates/sdk/tests/unit/idempotency_tests.rs @@ -33,12 +33,8 @@ fn empty_key_is_rejected_before_redacted_storage() { #[test] fn derived_key_is_deterministic_and_consumable() { - let relays = vec![ - "wss://relay-b.example.com".to_owned(), - "wss://relay-a.example.com".to_owned(), - ]; - let first = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a", &relays); - let second = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a", &relays); + let first = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a"); + let second = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a"); assert_eq!(first.as_str(), second.as_str()); assert!(first.into_string().starts_with("listing.publish.v1:")); diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -119,12 +119,6 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { )); let draft = frozen_draft(); - let digest = outbox_idempotency_digest_prefix( - "workflow.test.v1", - &draft, - &["wss://relay.example.com".to_owned()], - ); - assert_eq!(digest.len(), 12); let signed = signed_event(); let event = event_from_signed(&signed); @@ -150,6 +144,101 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { } #[tokio::test] +async fn default_operation_idempotency_ignores_target_policy() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_012, + )) + .build() + .await + .expect("sdk"); + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let signer = WorkflowSigner::new(); + let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-target-policy"); + let first_target_policy = TargetPolicy::try_nostr_relays( + ["wss://relay-a.example.com"], + crate::NostrRelayUrlPolicy::Public, + ) + .expect("first target policy"); + let second_target_policy = TargetPolicy::try_nostr_relays( + ["wss://relay-b.example.com"], + crate::NostrRelayUrlPolicy::Public, + ) + .expect("second target policy"); + + let first = enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_policy: first_target_policy, + satisfaction_policy: SatisfactionPolicy::AllTargets, + idempotency_key: None, + }, + &signer, + ) + .await + .expect("first enqueue"); + let second = enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_policy: second_target_policy, + satisfaction_policy: SatisfactionPolicy::AllTargets, + idempotency_key: None, + }, + &signer, + ) + .await + .expect("second enqueue"); + + assert_eq!( + first.state, + radroots_outbox::RadrootsOutboxEnqueueStatus::Inserted + ); + assert_eq!( + second.state, + radroots_outbox::RadrootsOutboxEnqueueStatus::Inserted + ); + assert_eq!(first.outbox_operation_id, second.outbox_operation_id); + assert_eq!(first.outbox_event_id, second.outbox_event_id); + assert_eq!( + first.idempotency_digest_prefix, + second.idempotency_digest_prefix + ); + let plans = sdk + ._outbox + .delivery_plans(first.outbox_event_id) + .await + .expect("delivery plans"); + assert_eq!(plans.len(), 2); + assert_ne!( + plans[0].delivery_plan_idempotency_digest, + plans[1].delivery_plan_idempotency_digest + ); + let targets = sdk + ._outbox + .delivery_targets(first.outbox_event_id) + .await + .expect("delivery targets"); + let target_uris = targets + .iter() + .map(|target| target.endpoint_uri.as_str()) + .collect::<std::collections::BTreeSet<_>>(); + assert_eq!( + target_uris, + std::collections::BTreeSet::from([ + "wss://relay-a.example.com", + "wss://relay-b.example.com", + ]) + ); +} + +#[tokio::test] async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_conflicts() { let sdk = crate::RadrootsClient::builder() .transport_profile(nostr_profile("wss://relay.example.com"))