commit 79964de9a20412169666d1e492e1e3e200b1ee9c
parent 76529e0da67e19a6570fbc1ddcd435182e3ca791
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 09:02:03 +0000
outbox: allow delegated publish targets
- add explicit empty-target opt-in for outbox enqueue inputs
- relax the outbox quorum schema invariant for delegated publishing
- preserve default rejection of accidental empty relay sets
- cover draft and signed delegated target enqueue behavior
Diffstat:
3 files changed, 99 insertions(+), 3 deletions(-)
diff --git a/crates/outbox/migrations/0001_outbox.up.sql b/crates/outbox/migrations/0001_outbox.up.sql
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS outbox_event (
signed_event_json TEXT,
raw_event_json TEXT,
state TEXT NOT NULL CHECK (state IN ('draft_queued', 'signing', 'signed', 'publishing', 'published', 'sign_retryable', 'publish_retryable', 'failed_terminal', 'cancelled')),
- accepted_quorum INTEGER NOT NULL CHECK (accepted_quorum > 0),
+ accepted_quorum INTEGER NOT NULL CHECK (accepted_quorum >= 0),
attempt_count INTEGER NOT NULL,
claim_token TEXT,
claim_owner TEXT,
diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs
@@ -127,6 +127,7 @@ pub struct RadrootsOutboxOperationInput {
pub draft: RadrootsFrozenEventDraft,
pub target_relays: Vec<String>,
pub idempotency_key: Option<String>,
+ pub allow_empty_target_relays: bool,
pub created_at_ms: i64,
}
@@ -142,6 +143,7 @@ impl RadrootsOutboxOperationInput {
draft,
target_relays,
idempotency_key: None,
+ allow_empty_target_relays: false,
created_at_ms,
}
}
@@ -150,6 +152,11 @@ impl RadrootsOutboxOperationInput {
self.idempotency_key = Some(idempotency_key.into());
self
}
+
+ pub fn allow_empty_target_relays(mut self) -> Self {
+ self.allow_empty_target_relays = true;
+ self
+ }
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -159,6 +166,7 @@ pub struct RadrootsOutboxSignedOperationInput {
pub signed_event: RadrootsSignedNostrEvent,
pub target_relays: Vec<String>,
pub idempotency_key: Option<String>,
+ pub allow_empty_target_relays: bool,
pub event_store_inserted: bool,
pub event_store_ingested_at_ms: i64,
pub created_at_ms: i64,
@@ -180,6 +188,7 @@ impl RadrootsOutboxSignedOperationInput {
signed_event,
target_relays,
idempotency_key: None,
+ allow_empty_target_relays: false,
event_store_inserted,
event_store_ingested_at_ms,
created_at_ms,
@@ -190,6 +199,11 @@ impl RadrootsOutboxSignedOperationInput {
self.idempotency_key = Some(idempotency_key.into());
self
}
+
+ pub fn allow_empty_target_relays(mut self) -> Self {
+ self.allow_empty_target_relays = true;
+ self
+ }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -118,7 +118,7 @@ impl RadrootsOutbox {
input: RadrootsOutboxOperationInput,
) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> {
let target_relays = ordered_unique_relays(input.target_relays);
- if target_relays.is_empty() {
+ if target_relays.is_empty() && !input.allow_empty_target_relays {
return Err(RadrootsOutboxError::EmptyTargetRelays);
}
let digest_relays = digest_relays(target_relays.as_slice());
@@ -216,7 +216,7 @@ impl RadrootsOutbox {
) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> {
validate_signed_nostr_event_matches_draft(&input.signed_event, &input.draft)?;
let target_relays = ordered_unique_relays(input.target_relays);
- if target_relays.is_empty() {
+ if target_relays.is_empty() && !input.allow_empty_target_relays {
return Err(RadrootsOutboxError::EmptyTargetRelays);
}
let digest_relays = digest_relays(target_relays.as_slice());
@@ -1638,6 +1638,88 @@ mod tests {
}
#[tokio::test]
+ async fn enqueue_allows_explicitly_delegated_empty_target_relays() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(hex_64('a').as_str(), "delegated empty");
+
+ let receipt = outbox
+ .enqueue_operation(
+ RadrootsOutboxOperationInput::new("publish_post", draft, Vec::new(), 1_000)
+ .allow_empty_target_relays(),
+ )
+ .await
+ .expect("delegated empty relays");
+
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.accepted_quorum, 0);
+ assert_eq!(
+ outbox
+ .relay_statuses(receipt.outbox_event_id)
+ .await
+ .expect("relay statuses"),
+ Vec::new()
+ );
+
+ let claimed = outbox
+ .claim_next_ready_event("worker-a", "claim-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+ assert_eq!(claimed.target_relays, Vec::<String>::new());
+ }
+
+ #[tokio::test]
+ async fn enqueue_signed_allows_explicitly_delegated_empty_target_relays() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed delegated empty");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event");
+
+ let receipt = outbox
+ .enqueue_signed_operation(
+ RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft,
+ signed_event.clone(),
+ Vec::new(),
+ false,
+ 1_007,
+ 1_000,
+ )
+ .allow_empty_target_relays(),
+ )
+ .await
+ .expect("delegated signed empty relays");
+
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.accepted_quorum, 0);
+ assert_eq!(event.signed_event, Some(signed_event.clone()));
+ assert_eq!(
+ outbox
+ .relay_statuses(receipt.outbox_event_id)
+ .await
+ .expect("relay statuses"),
+ Vec::new()
+ );
+
+ let claimed = outbox
+ .claim_next_ready_signed_event("publisher-a", "claim-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+ assert_eq!(claimed.signed_event, Some(signed_event));
+ assert_eq!(claimed.target_relays, Vec::<String>::new());
+ }
+
+ #[tokio::test]
async fn claim_next_ready_event_returns_none_when_no_work_is_ready() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");