commit ef9b2138adebd0e620184e122d725517edd55209
parent 89f5bf05516d24cd57007e676e5bd8c8889e5325
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 21:04:38 +0000
transport_reticulum: add preview transport
- add deterministic Reticulum preview profile and status model
- reject delivery attempts by default and defer only by explicit behavior
- keep deferred transport outcomes terminal but unsatisfied
Diffstat:
10 files changed, 559 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4876,6 +4876,15 @@ dependencies = [
]
[[package]]
+name = "radroots_transport_reticulum"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "radroots_transport",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
name = "radroots_types"
version = "0.1.0-alpha.2"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -20,6 +20,7 @@ members = [
"crates/outbox",
"crates/publish_proxy_protocol",
"crates/transport_nostr",
+ "crates/transport_reticulum",
"crates/runtime",
"crates/secret_vault",
"crates/simplex_app_store",
@@ -87,6 +88,7 @@ radroots_nostr_runtime = { path = "crates/nostr_runtime", version = "0.1.0-alpha
radroots_outbox = { path = "crates/outbox", version = "0.1.0-alpha.2", default-features = false }
radroots_publish_proxy_protocol = { path = "crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false }
radroots_transport_nostr = { path = "crates/transport_nostr", version = "0.1.0-alpha.2", default-features = false }
+radroots_transport_reticulum = { path = "crates/transport_reticulum", version = "0.1.0-alpha.2", default-features = false }
radroots_simplex_agent_proto = { path = "crates/simplex_agent_proto", version = "0.1.0-alpha.2", default-features = false }
radroots_simplex_agent_runtime = { path = "crates/simplex_agent_runtime", version = "0.1.0-alpha.2", default-features = false }
radroots_simplex_agent_store = { path = "crates/simplex_agent_store", version = "0.1.0-alpha.2", default-features = false }
diff --git a/contracts/coverage.toml b/contracts/coverage.toml
@@ -54,6 +54,7 @@ crates = [
"radroots_protected_store",
"radroots_publish_proxy_protocol",
"radroots_transport_nostr",
+ "radroots_transport_reticulum",
"radroots_replica_db",
"radroots_replica_db_schema",
"radroots_replica_sync",
diff --git a/contracts/manifest.toml b/contracts/manifest.toml
@@ -46,6 +46,7 @@ deferred_publication = [
"radroots_outbox",
"radroots_publish_proxy_protocol",
"radroots_transport_nostr",
+ "radroots_transport_reticulum",
"radroots_transport",
"radroots_mesh",
"radroots_mesh_agent_proto",
diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs
@@ -17,7 +17,7 @@ impl RadrootsTransportDeliveryTargetStatus {
}
pub fn counts_as_satisfied(self) -> bool {
- matches!(self, Self::Accepted | Self::Deferred)
+ matches!(self, Self::Accepted)
}
}
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -1,7 +1,9 @@
use radroots_transport::{
- RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportKind,
- RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
- RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
+ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportKind,
+ RadrootsTransportOutcome, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
+ RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet,
+ RadrootsTransportTargetUri,
};
#[test]
@@ -91,6 +93,28 @@ fn satisfaction_policy_counts_target_statuses() {
}
#[test]
+fn deferred_transport_outcomes_are_terminal_but_not_satisfied() {
+ let target =
+ RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, "reticulum:preview")
+ .expect("target");
+ let receipt = RadrootsTransportDeliveryReceipt {
+ request_id: "reticulum-preview".to_owned(),
+ target_receipts: vec![RadrootsTransportTargetReceipt::new(
+ target,
+ RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Deferred),
+ )],
+ };
+
+ assert!(RadrootsTransportDeliveryTargetStatus::Deferred.is_terminal());
+ assert_eq!(receipt.satisfied_target_count(), 0);
+ assert!(
+ !RadrootsTransportSatisfactionPolicy::AnyTarget
+ .is_satisfied_by(1, receipt.satisfied_target_count())
+ .expect("satisfaction check")
+ );
+}
+
+#[test]
fn request_models_round_trip_with_serde() {
let target = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example")
.expect("target");
diff --git a/crates/transport_reticulum/Cargo.toml b/crates/transport_reticulum/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "radroots_transport_reticulum"
+publish = false
+version = "0.1.0-alpha.2"
+edition.workspace = true
+authors = ["Tyson Lupul <tyson@radroots.org>"]
+rust-version.workspace = true
+license.workspace = true
+description = "Reticulum preview transport layer for Radroots"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[features]
+default = ["serde"]
+serde = ["dep:serde", "radroots_transport/serde"]
+
+[dependencies]
+radroots_transport = { workspace = true, default-features = false }
+serde = { workspace = true, optional = true, features = ["derive"] }
+
+[dev-dependencies]
+serde_json = { workspace = true, features = ["std"] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/transport_reticulum/README b/crates/transport_reticulum/README
@@ -0,0 +1,3 @@
+# radroots_transport_reticulum
+
+Deterministic Reticulum preview transport substrate for unavailable delivery, deferred delivery-plan modeling, and non-networked fetch status.
diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs
@@ -0,0 +1,312 @@
+#![no_std]
+#![forbid(unsafe_code)]
+
+extern crate alloc;
+
+use alloc::borrow::ToOwned;
+use alloc::string::String;
+use alloc::vec::Vec;
+use core::fmt;
+use radroots_transport::{
+ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportImplementationState,
+ RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportTarget,
+ RadrootsTransportTargetReceipt,
+};
+
+const DEFAULT_PROFILE_ID: &str = "transport.reticulum.preview";
+const DEFAULT_ENDPOINT_URI: &str = "reticulum:preview-unavailable";
+const UNAVAILABLE_CODE: &str = "transport_unavailable";
+const DEFERRED_CODE: &str = "deferred_until_implemented";
+const PREVIEW_UNAVAILABLE_MESSAGE: &str =
+ "Reticulum preview transport is configured but unavailable";
+const DEFERRED_MESSAGE: &str = "Reticulum preview delivery is deferred until implementation";
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsReticulumPreviewBehavior {
+ RejectDeliveryAttempts,
+ DeferDeliveryPlans,
+}
+
+impl Default for RadrootsReticulumPreviewBehavior {
+ fn default() -> Self {
+ Self::RejectDeliveryAttempts
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsReticulumPreviewReadiness {
+ PreviewUnavailable,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsReticulumPreviewEndpoint {
+ uri: String,
+}
+
+impl RadrootsReticulumPreviewEndpoint {
+ pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsReticulumPreviewError> {
+ let uri = raw.as_ref().trim().to_ascii_lowercase();
+ if uri.is_empty() || !uri.starts_with("reticulum:") || uri == "reticulum:" {
+ return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
+ }
+ if uri
+ .chars()
+ .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
+ {
+ return Err(RadrootsReticulumPreviewError::InvalidEndpoint);
+ }
+ Ok(Self { uri })
+ }
+
+ pub fn as_str(&self) -> &str {
+ self.uri.as_str()
+ }
+
+ pub fn into_string(self) -> String {
+ self.uri
+ }
+}
+
+impl Default for RadrootsReticulumPreviewEndpoint {
+ fn default() -> Self {
+ Self::parse(DEFAULT_ENDPOINT_URI).expect("default Reticulum preview endpoint")
+ }
+}
+
+impl fmt::Display for RadrootsReticulumPreviewEndpoint {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.uri.as_str())
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsReticulumPreviewProfile {
+ profile_id: String,
+ endpoint: RadrootsReticulumPreviewEndpoint,
+ behavior: RadrootsReticulumPreviewBehavior,
+}
+
+impl RadrootsReticulumPreviewProfile {
+ pub fn new(
+ profile_id: impl Into<String>,
+ endpoint: RadrootsReticulumPreviewEndpoint,
+ behavior: RadrootsReticulumPreviewBehavior,
+ ) -> Result<Self, RadrootsReticulumPreviewError> {
+ let profile_id = profile_id.into();
+ if profile_id.trim().is_empty() || profile_id.chars().any(char::is_whitespace) {
+ return Err(RadrootsReticulumPreviewError::InvalidProfileId);
+ }
+ Ok(Self {
+ profile_id,
+ endpoint,
+ behavior,
+ })
+ }
+
+ pub fn preview_unavailable() -> Self {
+ Self {
+ profile_id: DEFAULT_PROFILE_ID.to_owned(),
+ endpoint: RadrootsReticulumPreviewEndpoint::default(),
+ behavior: RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts,
+ }
+ }
+
+ pub fn with_behavior(mut self, behavior: RadrootsReticulumPreviewBehavior) -> Self {
+ self.behavior = behavior;
+ self
+ }
+
+ pub fn profile_id(&self) -> &str {
+ self.profile_id.as_str()
+ }
+
+ pub fn endpoint(&self) -> &RadrootsReticulumPreviewEndpoint {
+ &self.endpoint
+ }
+
+ pub fn behavior(&self) -> RadrootsReticulumPreviewBehavior {
+ self.behavior
+ }
+
+ pub fn status(&self) -> RadrootsReticulumPreviewStatus {
+ RadrootsReticulumPreviewStatus {
+ profile_id: self.profile_id.clone(),
+ endpoint_uri: self.endpoint.as_str().to_owned(),
+ behavior: self.behavior,
+ readiness: RadrootsReticulumPreviewReadiness::PreviewUnavailable,
+ implementation_state: RadrootsTransportImplementationState::PreviewUnavailable,
+ }
+ }
+}
+
+impl Default for RadrootsReticulumPreviewProfile {
+ fn default() -> Self {
+ Self::preview_unavailable()
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsReticulumPreviewStatus {
+ pub profile_id: String,
+ pub endpoint_uri: String,
+ pub behavior: RadrootsReticulumPreviewBehavior,
+ pub readiness: RadrootsReticulumPreviewReadiness,
+ pub implementation_state: RadrootsTransportImplementationState,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsReticulumPreviewTransport {
+ profile: RadrootsReticulumPreviewProfile,
+}
+
+impl RadrootsReticulumPreviewTransport {
+ pub fn new(profile: RadrootsReticulumPreviewProfile) -> Self {
+ Self { profile }
+ }
+
+ pub fn profile(&self) -> &RadrootsReticulumPreviewProfile {
+ &self.profile
+ }
+
+ pub fn status(&self) -> RadrootsReticulumPreviewStatus {
+ self.profile.status()
+ }
+
+ pub fn deliver(
+ &self,
+ request: RadrootsTransportDeliveryRequest,
+ ) -> Result<RadrootsTransportDeliveryReceipt, RadrootsReticulumPreviewError> {
+ ensure_reticulum_targets(request.target_set.targets())?;
+ let outcome = preview_outcome(self.profile.behavior);
+ let target_receipts = request
+ .target_set
+ .targets()
+ .iter()
+ .cloned()
+ .map(|target| RadrootsTransportTargetReceipt::new(target, outcome.clone()))
+ .collect::<Vec<_>>();
+ Ok(RadrootsTransportDeliveryReceipt {
+ request_id: request.request_id,
+ target_receipts,
+ })
+ }
+
+ pub fn fetch(
+ &self,
+ request: RadrootsReticulumPreviewFetchRequest,
+ ) -> Result<RadrootsReticulumPreviewFetchReceipt, RadrootsReticulumPreviewError> {
+ if request.max_events == 0 {
+ return Err(RadrootsReticulumPreviewError::InvalidFetchLimit);
+ }
+ Ok(RadrootsReticulumPreviewFetchReceipt {
+ request_id: request.request_id,
+ endpoint_uri: self.profile.endpoint.as_str().to_owned(),
+ outcome: preview_outcome(self.profile.behavior),
+ observed_event_count: 0,
+ implementation_state: RadrootsTransportImplementationState::PreviewUnavailable,
+ })
+ }
+}
+
+impl Default for RadrootsReticulumPreviewTransport {
+ fn default() -> Self {
+ Self::new(RadrootsReticulumPreviewProfile::default())
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsReticulumPreviewFetchRequest {
+ pub request_id: String,
+ pub max_events: u16,
+}
+
+impl RadrootsReticulumPreviewFetchRequest {
+ pub fn new(
+ request_id: impl Into<String>,
+ max_events: u16,
+ ) -> Result<Self, RadrootsReticulumPreviewError> {
+ if max_events == 0 {
+ return Err(RadrootsReticulumPreviewError::InvalidFetchLimit);
+ }
+ Ok(Self {
+ request_id: request_id.into(),
+ max_events,
+ })
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsReticulumPreviewFetchReceipt {
+ pub request_id: String,
+ pub endpoint_uri: String,
+ pub outcome: RadrootsTransportOutcome,
+ pub observed_event_count: usize,
+ pub implementation_state: RadrootsTransportImplementationState,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsReticulumPreviewError {
+ InvalidEndpoint,
+ InvalidProfileId,
+ InvalidFetchLimit,
+ NonReticulumTarget,
+}
+
+impl fmt::Display for RadrootsReticulumPreviewError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(match self {
+ Self::InvalidEndpoint => "invalid Reticulum preview endpoint",
+ Self::InvalidProfileId => "invalid Reticulum preview profile id",
+ Self::InvalidFetchLimit => "Reticulum preview fetch limit must be greater than zero",
+ Self::NonReticulumTarget => {
+ "Reticulum preview transport received a non-Reticulum target"
+ }
+ })
+ }
+}
+
+fn ensure_reticulum_targets(
+ targets: &[RadrootsTransportTarget],
+) -> Result<(), RadrootsReticulumPreviewError> {
+ if targets
+ .iter()
+ .all(|target| target.kind == RadrootsTransportKind::Reticulum)
+ {
+ return Ok(());
+ }
+ Err(RadrootsReticulumPreviewError::NonReticulumTarget)
+}
+
+fn preview_outcome(behavior: RadrootsReticulumPreviewBehavior) -> RadrootsTransportOutcome {
+ let mut outcome = match behavior {
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => {
+ RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Unavailable)
+ }
+ RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => {
+ RadrootsTransportOutcome::new(RadrootsTransportDeliveryTargetStatus::Deferred)
+ }
+ };
+ outcome.code = Some(
+ match behavior {
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => UNAVAILABLE_CODE,
+ RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => DEFERRED_CODE,
+ }
+ .to_owned(),
+ );
+ outcome.message = Some(
+ match behavior {
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts => PREVIEW_UNAVAILABLE_MESSAGE,
+ RadrootsReticulumPreviewBehavior::DeferDeliveryPlans => DEFERRED_MESSAGE,
+ }
+ .to_owned(),
+ );
+ outcome
+}
diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs
@@ -0,0 +1,177 @@
+use radroots_transport::{
+ RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
+ RadrootsTransportImplementationState, RadrootsTransportKind,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet,
+};
+use radroots_transport_reticulum::{
+ RadrootsReticulumPreviewBehavior, RadrootsReticulumPreviewEndpoint,
+ RadrootsReticulumPreviewError, RadrootsReticulumPreviewFetchRequest,
+ RadrootsReticulumPreviewProfile, RadrootsReticulumPreviewReadiness,
+ RadrootsReticulumPreviewTransport,
+};
+
+fn reticulum_target(uri: &str) -> RadrootsTransportTarget {
+ RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, uri).expect("reticulum target")
+}
+
+fn nostr_target() -> RadrootsTransportTarget {
+ RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example")
+ .expect("nostr target")
+}
+
+fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportDeliveryRequest {
+ RadrootsTransportDeliveryRequest::new(
+ "reticulum-preview-delivery",
+ "sha256:preview-payload",
+ RadrootsTransportTargetSet::new(targets).expect("target set"),
+ RadrootsTransportSatisfactionPolicy::AnyTarget,
+ )
+}
+
+#[test]
+fn default_profile_is_configured_preview_unavailable_and_rejecting() {
+ let profile = RadrootsReticulumPreviewProfile::default();
+ let status = profile.status();
+
+ assert_eq!(profile.profile_id(), "transport.reticulum.preview");
+ assert_eq!(profile.endpoint().as_str(), "reticulum:preview-unavailable");
+ assert_eq!(
+ profile.behavior(),
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts
+ );
+ assert_eq!(
+ status.implementation_state,
+ RadrootsTransportImplementationState::PreviewUnavailable
+ );
+ assert_eq!(
+ status.readiness,
+ RadrootsReticulumPreviewReadiness::PreviewUnavailable
+ );
+}
+
+#[test]
+fn endpoint_and_profile_validation_are_strict_and_canonical() {
+ let endpoint =
+ RadrootsReticulumPreviewEndpoint::parse(" RETICULUM:Preview-Target ").expect("endpoint");
+ assert_eq!(endpoint.as_str(), "reticulum:preview-target");
+ assert_eq!(endpoint.to_string(), "reticulum:preview-target");
+ assert_eq!(endpoint.clone().into_string(), "reticulum:preview-target");
+
+ assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse("reticulum:").expect_err("empty endpoint"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse("https://target").expect_err("wrong scheme"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse("reticulum:bad target")
+ .expect_err("whitespace endpoint"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
+ RadrootsReticulumPreviewProfile::new(
+ "transport reticulum",
+ endpoint,
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts,
+ )
+ .expect_err("profile id whitespace"),
+ RadrootsReticulumPreviewError::InvalidProfileId
+ );
+}
+
+#[test]
+fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing() {
+ let transport = RadrootsReticulumPreviewTransport::default();
+ let request = delivery_request(vec![
+ reticulum_target("reticulum:preview-a"),
+ reticulum_target("reticulum:preview-b"),
+ ]);
+ let receipt = transport.deliver(request).expect("delivery receipt");
+
+ assert_eq!(receipt.target_receipts.len(), 2);
+ assert_eq!(receipt.satisfied_target_count(), 0);
+ for target_receipt in receipt.target_receipts {
+ assert_eq!(target_receipt.target.kind, RadrootsTransportKind::Reticulum);
+ assert_eq!(
+ target_receipt.status,
+ RadrootsTransportDeliveryTargetStatus::Unavailable
+ );
+ assert_eq!(
+ target_receipt.outcome.code.as_deref(),
+ Some("transport_unavailable")
+ );
+ }
+}
+
+#[test]
+fn deferred_delivery_plan_mode_never_counts_as_satisfied() {
+ let transport = RadrootsReticulumPreviewTransport::new(
+ RadrootsReticulumPreviewProfile::default()
+ .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans),
+ );
+ let request = delivery_request(vec![reticulum_target("reticulum:preview-deferred")]);
+ let receipt = transport.deliver(request).expect("delivery receipt");
+
+ assert_eq!(receipt.target_receipts.len(), 1);
+ assert_eq!(receipt.satisfied_target_count(), 0);
+ assert_eq!(
+ receipt.target_receipts[0].status,
+ RadrootsTransportDeliveryTargetStatus::Deferred
+ );
+ assert_eq!(
+ receipt.target_receipts[0].outcome.code.as_deref(),
+ Some("deferred_until_implemented")
+ );
+ assert!(
+ !RadrootsTransportSatisfactionPolicy::AnyTarget
+ .is_satisfied_by(1, receipt.satisfied_target_count())
+ .expect("satisfaction check")
+ );
+}
+
+#[test]
+fn non_reticulum_targets_are_rejected_without_nostr_routing() {
+ let transport = RadrootsReticulumPreviewTransport::default();
+ let err = transport
+ .deliver(delivery_request(vec![nostr_target()]))
+ .expect_err("non-reticulum target");
+
+ assert_eq!(err, RadrootsReticulumPreviewError::NonReticulumTarget);
+}
+
+#[test]
+fn fetch_reports_preview_unavailable_without_observed_events() {
+ let transport = RadrootsReticulumPreviewTransport::default();
+ let receipt = transport
+ .fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-1", 10).expect("fetch request"))
+ .expect("fetch receipt");
+
+ assert_eq!(receipt.request_id, "fetch-1");
+ assert_eq!(receipt.endpoint_uri, "reticulum:preview-unavailable");
+ assert_eq!(receipt.observed_event_count, 0);
+ assert_eq!(
+ receipt.implementation_state,
+ RadrootsTransportImplementationState::PreviewUnavailable
+ );
+ assert_eq!(
+ receipt.outcome.status,
+ RadrootsTransportDeliveryTargetStatus::Unavailable
+ );
+ assert_eq!(
+ RadrootsReticulumPreviewFetchRequest::new("fetch-0", 0).expect_err("zero limit"),
+ RadrootsReticulumPreviewError::InvalidFetchLimit
+ );
+}
+
+#[test]
+fn public_models_round_trip_through_serde() {
+ let profile = RadrootsReticulumPreviewProfile::default()
+ .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans);
+ let json = serde_json::to_string(&profile).expect("profile json");
+ let decoded: RadrootsReticulumPreviewProfile =
+ serde_json::from_str(&json).expect("profile decode");
+
+ assert_eq!(decoded, profile);
+}