commit 043739cceb8045877ba05fab24b7282723d6bcd7
parent 75f07b7bc0ca2afc276ab279651ce1cf2c46a75a
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 08:50:10 +0000
outbox: model Reticulum preview policy state
Diffstat:
4 files changed, 255 insertions(+), 19 deletions(-)
diff --git a/crates/outbox/migrations/0001_outbox.up.sql b/crates/outbox/migrations/0001_outbox.up.sql
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS outbox_delivery_plan (
satisfaction_policy TEXT NOT NULL,
required_success_count INTEGER NOT NULL,
delivery_plan_idempotency_digest TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued', 'complete', 'deferred_until_implemented', 'failed_terminal', 'cancelled')),
+ status TEXT NOT NULL CHECK (status IN ('queued', 'complete', 'deferred_until_implemented', 'preview_unavailable', 'failed_terminal', 'cancelled')),
satisfied_at_ms INTEGER,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS outbox_delivery_target (
transport_kind TEXT NOT NULL,
endpoint_uri TEXT NOT NULL,
endpoint_fingerprint TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'delivered', 'forwarded', 'stored_by_gateway', 'seen', 'deferred_until_implemented', 'skipped_policy_denied', 'failed_retryable', 'failed_terminal')),
+ status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'delivered', 'forwarded', 'stored_by_gateway', 'seen', 'deferred_until_implemented', 'preview_unavailable', 'skipped_policy_denied', 'failed_retryable', 'failed_terminal')),
attempt_count INTEGER NOT NULL,
last_attempt_at_ms INTEGER,
completed_at_ms INTEGER,
diff --git a/crates/outbox/src/lib.rs b/crates/outbox/src/lib.rs
@@ -16,6 +16,7 @@ pub use model::{
RadrootsOutboxEventRecord, RadrootsOutboxEventState, RadrootsOutboxEventStoreIngestReceipt,
RadrootsOutboxIdempotencyPreflight, RadrootsOutboxOperationInput,
RadrootsOutboxOperationRecord, RadrootsOutboxOperationStatus,
- RadrootsOutboxSignedOperationInput, RadrootsOutboxStatusSummary,
+ RadrootsOutboxReticulumPreviewBehavior, RadrootsOutboxSignedOperationInput,
+ RadrootsOutboxStatusSummary,
};
pub use store::RadrootsOutbox;
diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs
@@ -98,6 +98,7 @@ pub enum RadrootsOutboxDeliveryPlanStatus {
Queued,
Complete,
DeferredUntilImplemented,
+ PreviewUnavailable,
FailedTerminal,
Cancelled,
}
@@ -108,6 +109,7 @@ impl RadrootsOutboxDeliveryPlanStatus {
Self::Queued => "queued",
Self::Complete => "complete",
Self::DeferredUntilImplemented => "deferred_until_implemented",
+ Self::PreviewUnavailable => "preview_unavailable",
Self::FailedTerminal => "failed_terminal",
Self::Cancelled => "cancelled",
}
@@ -118,6 +120,7 @@ impl RadrootsOutboxDeliveryPlanStatus {
"queued" => Ok(Self::Queued),
"complete" => Ok(Self::Complete),
"deferred_until_implemented" => Ok(Self::DeferredUntilImplemented),
+ "preview_unavailable" => Ok(Self::PreviewUnavailable),
"failed_terminal" => Ok(Self::FailedTerminal),
"cancelled" => Ok(Self::Cancelled),
_ => Err(RadrootsOutboxError::InvalidStoredEnum {
@@ -226,12 +229,34 @@ impl RadrootsOutboxDeliveryTargetStatus {
}
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsOutboxReticulumPreviewBehavior {
+ RejectDeliveryAttempts,
+ DeferDeliveryPlans,
+}
+
+impl RadrootsOutboxReticulumPreviewBehavior {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::RejectDeliveryAttempts => "reject_delivery_attempts",
+ Self::DeferDeliveryPlans => "defer_delivery_plans",
+ }
+ }
+}
+
+impl Default for RadrootsOutboxReticulumPreviewBehavior {
+ fn default() -> Self {
+ Self::RejectDeliveryAttempts
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOutboxDeliveryPlanInput {
pub transport_profile_id: String,
pub target_policy_version: u32,
pub satisfaction_policy: RadrootsTransportSatisfactionPolicy,
pub targets: Vec<RadrootsTransportTarget>,
+ pub reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior,
}
impl RadrootsOutboxDeliveryPlanInput {
@@ -246,8 +271,17 @@ impl RadrootsOutboxDeliveryPlanInput {
target_policy_version,
satisfaction_policy,
targets,
+ reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior::default(),
}
}
+
+ pub fn with_reticulum_preview_behavior(
+ mut self,
+ behavior: RadrootsOutboxReticulumPreviewBehavior,
+ ) -> Self {
+ self.reticulum_preview_behavior = behavior;
+ self
+ }
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -483,6 +517,10 @@ mod tests {
"deferred_until_implemented",
),
(
+ RadrootsOutboxDeliveryPlanStatus::PreviewUnavailable,
+ "preview_unavailable",
+ ),
+ (
RadrootsOutboxDeliveryPlanStatus::FailedTerminal,
"failed_terminal",
),
@@ -495,6 +533,23 @@ mod tests {
);
}
+ for (behavior, expected) in [
+ (
+ RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts,
+ "reject_delivery_attempts",
+ ),
+ (
+ RadrootsOutboxReticulumPreviewBehavior::DeferDeliveryPlans,
+ "defer_delivery_plans",
+ ),
+ ] {
+ assert_eq!(behavior.as_str(), expected);
+ }
+ assert_eq!(
+ RadrootsOutboxReticulumPreviewBehavior::default(),
+ RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts
+ );
+
for (status, expected, ready, satisfied, delivered, deferred_preview, terminal_failure) in [
(
RadrootsOutboxDeliveryTargetStatus::Pending,
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -10,7 +10,8 @@ use crate::model::{
RadrootsOutboxEventRecord, RadrootsOutboxEventState, RadrootsOutboxEventStoreIngestReceipt,
RadrootsOutboxIdempotencyPreflight, RadrootsOutboxOperationInput,
RadrootsOutboxOperationRecord, RadrootsOutboxOperationStatus,
- RadrootsOutboxSignedOperationInput, RadrootsOutboxStatusSummary,
+ RadrootsOutboxReticulumPreviewBehavior, RadrootsOutboxSignedOperationInput,
+ RadrootsOutboxStatusSummary,
};
use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore};
use radroots_events::RadrootsNostrEvent;
@@ -1121,26 +1122,20 @@ fn prepare_delivery_plan(
let prepared_targets = targets
.into_iter()
.map(|target| {
- let initial_status = if target.kind == RadrootsTransportKind::Reticulum {
- RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
- } else {
- RadrootsOutboxDeliveryTargetStatus::Pending
- };
+ let initial_status =
+ initial_delivery_target_status(&target, input.reticulum_preview_behavior);
PreparedDeliveryTarget {
target,
initial_status,
}
})
.collect::<Vec<_>>();
- let initial_status = if prepared_targets.iter().all(|target| {
- target.initial_status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
- }) {
- RadrootsOutboxDeliveryPlanStatus::DeferredUntilImplemented
- } else {
- RadrootsOutboxDeliveryPlanStatus::Queued
- };
- let target_policy_fingerprint =
- target_policy_fingerprint(&input.satisfaction_policy, &prepared_targets);
+ let initial_status = initial_delivery_plan_status(&prepared_targets);
+ let target_policy_fingerprint = target_policy_fingerprint(
+ &input.satisfaction_policy,
+ input.reticulum_preview_behavior,
+ &prepared_targets,
+ );
let delivery_plan_idempotency_digest = delivery_plan_idempotency_digest(
event_id,
input.transport_profile_id.as_str(),
@@ -1159,6 +1154,45 @@ fn prepare_delivery_plan(
})
}
+fn initial_delivery_target_status(
+ target: &RadrootsTransportTarget,
+ reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior,
+) -> RadrootsOutboxDeliveryTargetStatus {
+ if target.kind != RadrootsTransportKind::Reticulum {
+ return RadrootsOutboxDeliveryTargetStatus::Pending;
+ }
+ match reticulum_preview_behavior {
+ RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts => {
+ RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable
+ }
+ RadrootsOutboxReticulumPreviewBehavior::DeferDeliveryPlans => {
+ RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
+ }
+ }
+}
+
+fn initial_delivery_plan_status(
+ prepared_targets: &[PreparedDeliveryTarget],
+) -> RadrootsOutboxDeliveryPlanStatus {
+ if prepared_targets
+ .iter()
+ .any(|target| target.initial_status.is_ready_for_attempt())
+ {
+ return RadrootsOutboxDeliveryPlanStatus::Queued;
+ }
+ if prepared_targets.iter().all(|target| {
+ target.initial_status == RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable
+ }) {
+ return RadrootsOutboxDeliveryPlanStatus::PreviewUnavailable;
+ }
+ if prepared_targets.iter().all(|target| {
+ target.initial_status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
+ }) {
+ return RadrootsOutboxDeliveryPlanStatus::DeferredUntilImplemented;
+ }
+ RadrootsOutboxDeliveryPlanStatus::Queued
+}
+
async fn existing_idempotent_operation(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
operation_kind: &str,
@@ -1510,6 +1544,12 @@ async fn evaluate_delivery_plans(
.iter()
.filter(|target| target.status.is_deferred_preview())
.count();
+ let preview_unavailable_count = targets
+ .iter()
+ .filter(|target| {
+ target.status == RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable
+ })
+ .count();
let terminal_failure_count = targets
.iter()
.filter(|target| target.status.is_terminal_failure())
@@ -1518,7 +1558,11 @@ async fn evaluate_delivery_plans(
RadrootsOutboxDeliveryPlanStatus::Complete
} else if ready_count > 0 {
RadrootsOutboxDeliveryPlanStatus::Queued
- } else if deferred_count > 0 && terminal_failure_count == 0 {
+ } else if terminal_failure_count > 0 {
+ RadrootsOutboxDeliveryPlanStatus::FailedTerminal
+ } else if preview_unavailable_count > 0 {
+ RadrootsOutboxDeliveryPlanStatus::PreviewUnavailable
+ } else if deferred_count > 0 {
RadrootsOutboxDeliveryPlanStatus::DeferredUntilImplemented
} else {
RadrootsOutboxDeliveryPlanStatus::FailedTerminal
@@ -1717,6 +1761,7 @@ fn operation_idempotency_digest(
#[derive(Serialize)]
struct TargetPolicyDigestInput<'a> {
satisfaction_policy: String,
+ reticulum_preview_behavior: &'a str,
targets: Vec<TargetPolicyDigestTarget<'a>>,
}
@@ -1729,6 +1774,7 @@ struct TargetPolicyDigestTarget<'a> {
fn target_policy_fingerprint(
satisfaction_policy: &RadrootsTransportSatisfactionPolicy,
+ reticulum_preview_behavior: RadrootsOutboxReticulumPreviewBehavior,
targets: &[PreparedDeliveryTarget],
) -> String {
let mut target_inputs = targets
@@ -1747,6 +1793,7 @@ fn target_policy_fingerprint(
});
sha256_json(&TargetPolicyDigestInput {
satisfaction_policy: satisfaction_policy_storage_value(satisfaction_policy),
+ reticulum_preview_behavior: reticulum_preview_behavior.as_str(),
targets: target_inputs,
})
}
@@ -2251,6 +2298,57 @@ mod tests {
}
#[tokio::test]
+ async fn reticulum_reject_targets_are_preview_unavailable_and_not_ready() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum rejected");
+ 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,
+ RadrootsOutboxDeliveryPlanInput::new(
+ "transport.reticulum.preview",
+ 1,
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ vec![reticulum_target("reticulum:preview-unavailable")],
+ ),
+ true,
+ 1_007,
+ 1_000,
+ ))
+ .await
+ .expect("enqueue");
+ let targets = outbox
+ .delivery_targets(receipt.outbox_event_id)
+ .await
+ .expect("targets");
+ assert_eq!(targets.len(), 1);
+ assert_eq!(
+ targets[0].status,
+ RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable
+ );
+ let plans = outbox
+ .delivery_plans(receipt.outbox_event_id)
+ .await
+ .expect("plans");
+ assert_eq!(
+ plans[0].status,
+ RadrootsOutboxDeliveryPlanStatus::PreviewUnavailable
+ );
+ let summary = outbox.status_summary(1_000).await.expect("summary");
+ assert_eq!(summary.ready_signed_events, 0);
+ assert!(
+ outbox
+ .claim_next_ready_signed_event("publisher", "claim-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .is_none()
+ );
+ }
+
+ #[tokio::test]
async fn reticulum_deferred_targets_do_not_retry_or_satisfy_delivery() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred");
@@ -2266,6 +2364,9 @@ mod tests {
1,
RadrootsTransportSatisfactionPolicy::all_accepted(),
vec![reticulum_target("reticulum:preview-unavailable")],
+ )
+ .with_reticulum_preview_behavior(
+ RadrootsOutboxReticulumPreviewBehavior::DeferDeliveryPlans,
),
true,
1_007,
@@ -2299,6 +2400,85 @@ mod tests {
.expect("claim")
.is_none()
);
+ let default_behavior_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred");
+ let default_behavior_signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &default_behavior_draft)
+ .expect("signed event");
+ let default_behavior_preflight = outbox
+ .preflight_signed_operation_idempotency(&RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ default_behavior_draft,
+ default_behavior_signed_event,
+ RadrootsOutboxDeliveryPlanInput::new(
+ "transport.reticulum.preview",
+ 1,
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ vec![reticulum_target("reticulum:preview-unavailable")],
+ ),
+ true,
+ 1_007,
+ 1_000,
+ ))
+ .await
+ .expect("preflight");
+ assert_ne!(
+ receipt.delivery_plan_idempotency_digest,
+ default_behavior_preflight.delivery_plan_idempotency_digest
+ );
+ }
+
+ #[tokio::test]
+ async fn hybrid_nostr_and_reticulum_preview_preserves_ready_nostr_work() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "hybrid preview");
+ 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,
+ RadrootsOutboxDeliveryPlanInput::new(
+ "transport.hybrid",
+ 1,
+ RadrootsTransportSatisfactionPolicy::any_accepted(),
+ vec![
+ nostr_target(NOSTR_PRIMARY_WSS),
+ reticulum_target("reticulum:preview-unavailable"),
+ ],
+ ),
+ true,
+ 1_007,
+ 1_000,
+ ))
+ .await
+ .expect("enqueue");
+ let targets = outbox
+ .delivery_targets(receipt.outbox_event_id)
+ .await
+ .expect("targets");
+ assert_eq!(targets.len(), 2);
+ assert!(
+ targets
+ .iter()
+ .any(|target| target.status == RadrootsOutboxDeliveryTargetStatus::Pending)
+ );
+ assert!(targets.iter().any(|target| {
+ target.status == RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable
+ }));
+ let plans = outbox
+ .delivery_plans(receipt.outbox_event_id)
+ .await
+ .expect("plans");
+ assert_eq!(plans[0].status, RadrootsOutboxDeliveryPlanStatus::Queued);
+ assert_eq!(
+ outbox
+ .status_summary(1_000)
+ .await
+ .expect("summary")
+ .ready_signed_events,
+ 1
+ );
}
#[tokio::test]