commit 5ba42d6b2ff0f3d618d4c79478554850c2ef10a2
parent faacecec47f2727159ba4d53b0fe9d53e8786732
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:
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"))