commit de4fccb0203c665e2f2caf5e3c852956c5ecfa15
parent 1fc418f66d354eea57ad45b1e6f935f6756aa975
Author: triesap <tyson@radroots.org>
Date: Wed, 15 Jul 2026 15:08:18 +0000
transport: harden Reticulum V1 contract
Add explicit ReticulumDestinationV1, capability, payload, gateway, privacy, and fragmentation contract types; expose them through Reticulum status without reporting fake delivery success; align publish protocol operation capabilities; and add Reticulum outbox idempotency coverage.
Diffstat:
9 files changed, 517 insertions(+), 7 deletions(-)
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -4548,6 +4548,75 @@ mod tests {
}
#[tokio::test]
+ async fn repeated_reticulum_signed_delivery_reuses_one_logical_operation_and_plan() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum repeated");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event");
+ let first = outbox
+ .enqueue_signed_operation(
+ RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft.clone(),
+ signed_event.clone(),
+ RadrootsOutboxDeliveryPlanInput::new(
+ "transport.reticulum.default",
+ 1,
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ vec![reticulum_target("reticulum:local")],
+ ),
+ true,
+ 1_007,
+ 1_000,
+ )
+ .with_idempotency_key("idem-reticulum-repeated"),
+ )
+ .await
+ .expect("first enqueue");
+ let second = outbox
+ .enqueue_signed_operation(
+ RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft,
+ signed_event,
+ RadrootsOutboxDeliveryPlanInput::new(
+ "transport.reticulum.default",
+ 1,
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ vec![reticulum_target("reticulum:local")],
+ ),
+ true,
+ 1_017,
+ 1_010,
+ )
+ .with_idempotency_key("idem-reticulum-repeated"),
+ )
+ .await
+ .expect("second enqueue");
+
+ assert_eq!(first.status, RadrootsOutboxEnqueueStatus::Inserted);
+ assert_eq!(second.status, RadrootsOutboxEnqueueStatus::Existing);
+ assert_eq!(first.operation_id, second.operation_id);
+ assert_eq!(first.outbox_event_id, second.outbox_event_id);
+ assert_eq!(first.delivery_plan_id, second.delivery_plan_id);
+ assert_eq!(
+ first.operation_idempotency_digest,
+ second.operation_idempotency_digest
+ );
+ assert_eq!(
+ first.delivery_plan_idempotency_digest,
+ second.delivery_plan_idempotency_digest
+ );
+ assert_eq!(table_count(&outbox, "outbox_operations").await, 1);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 1);
+ assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 1);
+ assert_eq!(table_count(&outbox, "outbox_delivery_target").await, 1);
+ let records = outbox.reticulum_events(None, 10).await.expect("records");
+ assert_eq!(records.len(), 1);
+ assert_eq!(records[0].event.outbox_event_id, first.outbox_event_id);
+ }
+
+ #[tokio::test]
async fn reticulum_events_report_deferred_records() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let reject_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum record");
diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs
@@ -8,6 +8,7 @@ mod error;
mod kind;
mod message;
mod payload;
+mod reticulum;
mod status;
mod target;
mod transport;
@@ -27,6 +28,12 @@ pub use message::{
RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
};
pub use payload::RadrootsTransportPayload;
+pub use reticulum::{
+ ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1,
+ ReticulumFragmentIntegrityV1, ReticulumFragmentPolicyV1, ReticulumFragmentationModeV1,
+ ReticulumGatewaySemanticsV1, ReticulumPayloadPolicyV1, ReticulumPrivacySemanticsV1,
+ ReticulumRoutingMetadataV1,
+};
pub use status::{
RadrootsTransportCapabilities, RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome,
RadrootsTransportOutcomeKind, RadrootsTransportStatus,
diff --git a/crates/transport/src/reticulum.rs b/crates/transport/src/reticulum.rs
@@ -0,0 +1,194 @@
+use crate::{
+ RADROOTS_RETICULUM_ENDPOINT_URI, RadrootsTransportError, RadrootsTransportKind,
+ RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint,
+ RadrootsTransportTargetLabel, RadrootsTransportTargetUri,
+};
+
+pub const RETICULUM_V1_MAX_PAYLOAD_BYTES: usize = 64 * 1024;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ReticulumFragmentationModeV1 {
+ Unsupported,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ReticulumDuplicateFragmentBehaviorV1 {
+ Reject,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ReticulumFragmentIntegrityV1 {
+ PayloadDigest,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ReticulumFragmentPolicyV1 {
+ pub mode: ReticulumFragmentationModeV1,
+ pub max_fragment_count: u16,
+ pub max_reassembled_bytes: usize,
+ pub duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1,
+ pub integrity_verification: ReticulumFragmentIntegrityV1,
+}
+
+impl ReticulumFragmentPolicyV1 {
+ pub const fn unsupported() -> Self {
+ Self {
+ mode: ReticulumFragmentationModeV1::Unsupported,
+ max_fragment_count: 1,
+ max_reassembled_bytes: RETICULUM_V1_MAX_PAYLOAD_BYTES,
+ duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1::Reject,
+ integrity_verification: ReticulumFragmentIntegrityV1::PayloadDigest,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ReticulumPayloadPolicyV1 {
+ pub max_payload_bytes: usize,
+ pub fragment_policy: ReticulumFragmentPolicyV1,
+}
+
+impl ReticulumPayloadPolicyV1 {
+ pub const fn v1() -> Self {
+ Self {
+ max_payload_bytes: RETICULUM_V1_MAX_PAYLOAD_BYTES,
+ fragment_policy: ReticulumFragmentPolicyV1::unsupported(),
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ReticulumGatewaySemanticsV1 {
+ NoGatewayForwarding,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ReticulumPrivacySemanticsV1 {
+ CanonicalSignedEventBytesOnly,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ReticulumRoutingMetadataV1 {
+ pub scope: RadrootsTransportMeshScopeId,
+ pub gateway: ReticulumGatewaySemanticsV1,
+ pub privacy: ReticulumPrivacySemanticsV1,
+}
+
+impl ReticulumRoutingMetadataV1 {
+ pub fn local() -> Self {
+ Self {
+ scope: RadrootsTransportMeshScopeId::local_reticulum(),
+ gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding,
+ privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ReticulumDestinationV1 {
+ pub uri: RadrootsTransportTargetUri,
+ pub routing: ReticulumRoutingMetadataV1,
+ pub label: Option<RadrootsTransportTargetLabel>,
+ pub fingerprint: RadrootsTransportTargetFingerprint,
+}
+
+impl ReticulumDestinationV1 {
+ pub fn local() -> Self {
+ Self::new(
+ RADROOTS_RETICULUM_ENDPOINT_URI,
+ ReticulumRoutingMetadataV1::local().scope,
+ None,
+ )
+ .expect("local Reticulum destination")
+ }
+
+ pub fn new(
+ uri: impl AsRef<str>,
+ scope: RadrootsTransportMeshScopeId,
+ label: Option<RadrootsTransportTargetLabel>,
+ ) -> Result<Self, RadrootsTransportError> {
+ let target = RadrootsTransportTarget::reticulum_with_metadata(
+ uri.as_ref(),
+ Some(scope),
+ label.clone(),
+ )?;
+ Ok(Self {
+ uri: target.uri,
+ routing: ReticulumRoutingMetadataV1 {
+ scope: target.scope.expect("Reticulum destination scope"),
+ gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding,
+ privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly,
+ },
+ label,
+ fingerprint: target.fingerprint,
+ })
+ }
+
+ pub fn from_target(target: &RadrootsTransportTarget) -> Result<Self, RadrootsTransportError> {
+ if target.kind != RadrootsTransportKind::Reticulum
+ || target.uri.as_str() != RADROOTS_RETICULUM_ENDPOINT_URI
+ {
+ return Err(RadrootsTransportError::InvalidTargetUri);
+ }
+ let Some(scope) = target.scope.clone() else {
+ return Err(RadrootsTransportError::EmptyTargetScope);
+ };
+ Ok(Self {
+ uri: target.uri.clone(),
+ routing: ReticulumRoutingMetadataV1 {
+ scope,
+ gateway: ReticulumGatewaySemanticsV1::NoGatewayForwarding,
+ privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly,
+ },
+ label: target.label.clone(),
+ fingerprint: target.fingerprint.clone(),
+ })
+ }
+
+ pub fn transport_target(&self) -> Result<RadrootsTransportTarget, RadrootsTransportError> {
+ RadrootsTransportTarget::reticulum_with_metadata(
+ self.uri.as_str(),
+ Some(self.routing.scope.clone()),
+ self.label.clone(),
+ )
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ReticulumCapabilityReportV1 {
+ pub delivery_required: bool,
+ pub fetch_required: bool,
+ pub can_deliver: bool,
+ pub can_fetch: bool,
+ pub can_discover: bool,
+ pub can_forward_gateway: bool,
+ pub can_observe_receipts: bool,
+ pub destination: ReticulumDestinationV1,
+ pub payload_policy: ReticulumPayloadPolicyV1,
+}
+
+impl ReticulumCapabilityReportV1 {
+ pub fn unavailable_local() -> Self {
+ Self {
+ delivery_required: true,
+ fetch_required: false,
+ can_deliver: false,
+ can_fetch: false,
+ can_discover: false,
+ can_forward_gateway: false,
+ can_observe_receipts: false,
+ destination: ReticulumDestinationV1::local(),
+ payload_policy: ReticulumPayloadPolicyV1::v1(),
+ }
+ }
+}
diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs
@@ -180,6 +180,9 @@ impl RadrootsTransportOutcome {
pub struct RadrootsTransportCapabilities {
pub deliver: bool,
pub fetch: bool,
+ pub discovery: bool,
+ pub gateway_forwarding: bool,
+ pub receipt_observation: bool,
}
impl RadrootsTransportCapabilities {
@@ -187,6 +190,9 @@ impl RadrootsTransportCapabilities {
Self {
deliver: false,
fetch: false,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
}
}
@@ -194,6 +200,9 @@ impl RadrootsTransportCapabilities {
Self {
deliver: true,
fetch: false,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
}
}
@@ -201,6 +210,9 @@ impl RadrootsTransportCapabilities {
Self {
deliver: false,
fetch: true,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
}
}
@@ -208,6 +220,34 @@ impl RadrootsTransportCapabilities {
Self {
deliver: true,
fetch: true,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
+ }
+ }
+
+ pub const fn with_discovery(mut self, discovery: bool) -> Self {
+ self.discovery = discovery;
+ self
+ }
+
+ pub const fn with_gateway_forwarding(mut self, gateway_forwarding: bool) -> Self {
+ self.gateway_forwarding = gateway_forwarding;
+ self
+ }
+
+ pub const fn with_receipt_observation(mut self, receipt_observation: bool) -> Self {
+ self.receipt_observation = receipt_observation;
+ self
+ }
+
+ pub const fn reticulum_unavailable() -> Self {
+ Self {
+ deliver: false,
+ fetch: false,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
}
}
}
diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs
@@ -551,6 +551,9 @@ fn transport_publish_capabilities_keep_canonical_status_fields() {
"pub struct TransportPublishOperationCapabilities",
"pub deliver: bool,",
"pub fetch: bool,",
+ "pub discovery: bool,",
+ "pub gateway_forwarding: bool,",
+ "pub receipt_observation: bool,",
"TransportPublishImplementation::Real",
"TransportPublishCapabilityMaturity::Preview",
"TransportPublishCapabilityAvailability::Unavailable",
@@ -560,6 +563,9 @@ fn transport_publish_capabilities_keep_canonical_status_fields() {
"capabilities: TransportPublishOperationCapabilities",
"deliver: true",
"fetch: false",
+ "discovery: false",
+ "gateway_forwarding: false",
+ "receipt_observation: false",
] {
assert!(
source.contains(required),
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -9,6 +9,9 @@ use radroots_transport::{
RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget,
RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel,
RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
+ ReticulumCapabilityReportV1, ReticulumDuplicateFragmentBehaviorV1,
+ ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1,
+ ReticulumPrivacySemanticsV1,
};
use serde_json::Value;
@@ -40,6 +43,82 @@ fn target_fingerprints_are_stable_and_transport_scoped() {
}
#[test]
+fn reticulum_destination_v1_is_canonical_and_stable() {
+ let target = RadrootsTransportTarget::reticulum().expect("reticulum target");
+ let destination = radroots_transport::ReticulumDestinationV1::from_target(&target)
+ .expect("destination from target");
+ let local = radroots_transport::ReticulumDestinationV1::local();
+
+ assert_eq!(destination, local);
+ assert_eq!(destination.uri.as_str(), RADROOTS_RETICULUM_ENDPOINT_URI);
+ assert_eq!(
+ destination.routing.scope.as_str(),
+ RADROOTS_RETICULUM_SCOPE_ID
+ );
+ assert_eq!(
+ destination.routing.gateway,
+ ReticulumGatewaySemanticsV1::NoGatewayForwarding
+ );
+ assert_eq!(
+ destination.routing.privacy,
+ ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly
+ );
+ assert_eq!(destination.fingerprint, target.fingerprint);
+ assert_eq!(
+ destination
+ .transport_target()
+ .expect("transport target")
+ .fingerprint,
+ target.fingerprint
+ );
+ assert_eq!(
+ destination.fingerprint.as_str(),
+ "39142c9a79d6912655e0ad00fb5dbfbe9d2d91b4999e5d68d04a81d89a77f831"
+ );
+ assert!(
+ radroots_transport::ReticulumDestinationV1::new(
+ "reticulum:other",
+ RadrootsTransportMeshScopeId::local_reticulum(),
+ None,
+ )
+ .is_err()
+ );
+}
+
+#[test]
+fn reticulum_capability_report_v1_is_explicitly_unavailable_without_fragmentation() {
+ let report = ReticulumCapabilityReportV1::unavailable_local();
+
+ assert!(report.delivery_required);
+ assert!(!report.fetch_required);
+ assert!(!report.can_deliver);
+ assert!(!report.can_fetch);
+ assert!(!report.can_discover);
+ assert!(!report.can_forward_gateway);
+ assert!(!report.can_observe_receipts);
+ assert_eq!(
+ report.payload_policy.fragment_policy.mode,
+ ReticulumFragmentationModeV1::Unsupported
+ );
+ assert_eq!(report.payload_policy.fragment_policy.max_fragment_count, 1);
+ assert_eq!(
+ report.payload_policy.fragment_policy.max_reassembled_bytes,
+ report.payload_policy.max_payload_bytes
+ );
+ assert_eq!(
+ report
+ .payload_policy
+ .fragment_policy
+ .duplicate_fragment_behavior,
+ ReticulumDuplicateFragmentBehaviorV1::Reject
+ );
+ assert_eq!(
+ report.payload_policy.fragment_policy.integrity_verification,
+ ReticulumFragmentIntegrityV1::PayloadDigest
+ );
+}
+
+#[test]
fn transport_kind_parser_round_trips_first_wave_canonical_labels() {
assert_eq!(
RadrootsTransportKind::parse(" NOSTR ").expect("nostr kind"),
@@ -298,6 +377,9 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() {
assert_eq!(json["usable_for_delivery"], true);
assert_eq!(json["capabilities"]["deliver"], true);
assert_eq!(json["capabilities"]["fetch"], false);
+ assert_eq!(json["capabilities"]["discovery"], false);
+ assert_eq!(json["capabilities"]["gateway_forwarding"], false);
+ assert_eq!(json["capabilities"]["receipt_observation"], false);
assert_eq!(json["message"], "ready");
for retired in [
"kind",
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -949,6 +949,9 @@ impl TransportPublishCapabilities {
capabilities: TransportPublishOperationCapabilities {
deliver: true,
fetch: false,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
},
reticulum_behavior: None,
message: "Nostr relay publish is available".to_owned(),
@@ -963,6 +966,9 @@ impl TransportPublishCapabilities {
capabilities: TransportPublishOperationCapabilities {
deliver: false,
fetch: false,
+ discovery: false,
+ gateway_forwarding: false,
+ receipt_observation: false,
},
reticulum_behavior: Some(
TransportPublishReticulumBehavior::RejectDeliveryAttempts,
@@ -1045,6 +1051,9 @@ pub struct TransportPublishTransportCapability {
pub struct TransportPublishOperationCapabilities {
pub deliver: bool,
pub fetch: bool,
+ pub discovery: bool,
+ pub gateway_forwarding: bool,
+ pub receipt_observation: bool,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -1543,6 +1552,9 @@ mod tests {
assert!(nostr.usable_for_delivery);
assert!(nostr.capabilities.deliver);
assert!(!nostr.capabilities.fetch);
+ assert!(!nostr.capabilities.discovery);
+ assert!(!nostr.capabilities.gateway_forwarding);
+ assert!(!nostr.capabilities.receipt_observation);
let reticulum = capabilities
.publish
.transports
@@ -1565,6 +1577,9 @@ mod tests {
assert!(!reticulum.usable_for_delivery);
assert!(!reticulum.capabilities.deliver);
assert!(!reticulum.capabilities.fetch);
+ assert!(!reticulum.capabilities.discovery);
+ assert!(!reticulum.capabilities.gateway_forwarding);
+ assert!(!reticulum.capabilities.receipt_observation);
assert_eq!(
reticulum.reticulum_behavior,
Some(TransportPublishReticulumBehavior::RejectDeliveryAttempts)
diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs
@@ -10,13 +10,14 @@ use alloc::vec::Vec;
use core::fmt;
use radroots_transport::{
RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransport,
- RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity,
- RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
- RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt,
- RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
- RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome,
- RadrootsTransportOutcomeKind, RadrootsTransportStatus, RadrootsTransportTarget,
- RadrootsTransportTargetReceipt,
+ RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability,
+ RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt,
+ RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
+ RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
+ RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind,
+ RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind,
+ RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetReceipt,
+ ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumPayloadPolicyV1,
};
const DEFAULT_PROFILE_ID: &str = "transport.reticulum.default";
@@ -118,6 +119,8 @@ pub struct RadrootsReticulumProfile {
scope: RadrootsTransportMeshScopeId,
agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
behavior: RadrootsReticulumBehavior,
+ destination: ReticulumDestinationV1,
+ capability_report: ReticulumCapabilityReportV1,
}
impl RadrootsReticulumProfile {
@@ -132,22 +135,34 @@ impl RadrootsReticulumProfile {
if profile_id.trim().is_empty() || profile_id.chars().any(char::is_whitespace) {
return Err(RadrootsReticulumError::InvalidProfileId);
}
+ let destination = ReticulumDestinationV1::new(endpoint.as_str(), scope.clone(), None)
+ .map_err(|_| RadrootsReticulumError::InvalidEndpoint)?;
+ let capability_report = ReticulumCapabilityReportV1 {
+ destination: destination.clone(),
+ payload_policy: ReticulumPayloadPolicyV1::v1(),
+ ..ReticulumCapabilityReportV1::unavailable_local()
+ };
Ok(Self {
profile_id,
endpoint,
scope,
agent_endpoint,
behavior,
+ destination,
+ capability_report,
})
}
pub fn deferred_until_implemented() -> Self {
+ let capability_report = ReticulumCapabilityReportV1::unavailable_local();
Self {
profile_id: DEFAULT_PROFILE_ID.to_owned(),
endpoint: RadrootsReticulumEndpoint::default(),
scope: RadrootsTransportMeshScopeId::local_reticulum(),
agent_endpoint: None,
behavior: RadrootsReticulumBehavior::RejectDeliveryAttempts,
+ destination: capability_report.destination.clone(),
+ capability_report,
}
}
@@ -181,11 +196,21 @@ impl RadrootsReticulumProfile {
self.behavior
}
+ pub fn destination(&self) -> &ReticulumDestinationV1 {
+ &self.destination
+ }
+
+ pub fn capability_report(&self) -> &ReticulumCapabilityReportV1 {
+ &self.capability_report
+ }
+
pub fn status(&self) -> RadrootsReticulumStatus {
RadrootsReticulumStatus {
behavior: self.behavior,
scope: self.scope.clone(),
agent_endpoint: self.agent_endpoint.clone(),
+ destination: self.destination.clone(),
+ capability_report: self.capability_report.clone(),
transport_status: RadrootsTransportStatus::new(
RadrootsTransportKind::Reticulum,
true,
@@ -195,6 +220,7 @@ impl RadrootsReticulumProfile {
)
.with_maturity(RadrootsTransportCapabilityMaturity::Preview)
.with_availability(RadrootsTransportCapabilityAvailability::Unavailable)
+ .with_capabilities(RadrootsTransportCapabilities::reticulum_unavailable())
.with_profile_id(self.profile_id.clone())
.with_endpoint_uri(self.endpoint.as_str()),
}
@@ -213,6 +239,8 @@ pub struct RadrootsReticulumStatus {
pub behavior: RadrootsReticulumBehavior,
pub scope: RadrootsTransportMeshScopeId,
pub agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ pub destination: ReticulumDestinationV1,
+ pub capability_report: ReticulumCapabilityReportV1,
pub transport_status: RadrootsTransportStatus,
}
diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs
@@ -6,6 +6,8 @@ use radroots_transport::{
RadrootsTransportFetchRequest, RadrootsTransportImplementationState, RadrootsTransportKind,
RadrootsTransportMeshScopeId, RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet,
+ ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1,
+ ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1,
};
use radroots_transport_reticulum::{
RadrootsReticulumAgentEndpoint, RadrootsReticulumBehavior, RadrootsReticulumEndpoint,
@@ -79,6 +81,61 @@ fn default_profile_is_configured_deferred_until_implemented_and_rejecting() {
Some(RADROOTS_RETICULUM_ENDPOINT_URI)
);
assert_eq!(
+ profile.destination().uri.as_str(),
+ RADROOTS_RETICULUM_ENDPOINT_URI
+ );
+ assert_eq!(
+ profile.destination().routing.scope.as_str(),
+ RADROOTS_RETICULUM_SCOPE_ID
+ );
+ assert_eq!(
+ status.destination.routing.gateway,
+ ReticulumGatewaySemanticsV1::NoGatewayForwarding
+ );
+ assert_eq!(
+ status.destination.routing.privacy,
+ ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly
+ );
+ assert!(status.capability_report.delivery_required);
+ assert!(!status.capability_report.fetch_required);
+ assert!(!status.capability_report.can_deliver);
+ assert!(!status.capability_report.can_fetch);
+ assert!(!status.capability_report.can_discover);
+ assert!(!status.capability_report.can_forward_gateway);
+ assert!(!status.capability_report.can_observe_receipts);
+ assert_eq!(
+ status.capability_report.destination.fingerprint,
+ status.destination.fingerprint
+ );
+ assert_eq!(
+ status.capability_report.payload_policy.fragment_policy.mode,
+ ReticulumFragmentationModeV1::Unsupported
+ );
+ assert_eq!(
+ status
+ .capability_report
+ .payload_policy
+ .fragment_policy
+ .max_fragment_count,
+ 1
+ );
+ assert_eq!(
+ status
+ .capability_report
+ .payload_policy
+ .fragment_policy
+ .duplicate_fragment_behavior,
+ ReticulumDuplicateFragmentBehaviorV1::Reject
+ );
+ assert_eq!(
+ status
+ .capability_report
+ .payload_policy
+ .fragment_policy
+ .integrity_verification,
+ ReticulumFragmentIntegrityV1::PayloadDigest
+ );
+ assert_eq!(
status.transport_status.message,
RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE
);
@@ -215,6 +272,15 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
profile.agent_endpoint().map(|endpoint| endpoint.as_str()),
Some("reticulum-agent://localhost:19999")
);
+ assert_eq!(
+ profile
+ .capability_report()
+ .destination
+ .routing
+ .scope
+ .as_str(),
+ RADROOTS_RETICULUM_SCOPE_ID
+ );
}
#[test]
@@ -497,6 +563,9 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() {
assert!(!status.transport_status.usable_for_delivery);
assert!(!status.transport_status.capabilities.deliver);
assert!(!status.transport_status.capabilities.fetch);
+ assert!(!status.transport_status.capabilities.discovery);
+ assert!(!status.transport_status.capabilities.gateway_forwarding);
+ assert!(!status.transport_status.capabilities.receipt_observation);
let receipt = transport
.deliver(delivery_request(vec![reticulum_target(