commit 1b54111a98cbd402243420846f77bc29b50bbbb1
parent 66edce8df3a716a27fe2eaba1af8da3901767c6f
Author: triesap <tyson@radroots.org>
Date: Mon, 13 Jul 2026 06:10:03 +0000
transport: add operation capabilities
- add UnsupportedOperation to the public transport error contract
- expose deliver and fetch capabilities on transport status models
- report Nostr fetch as unsupported and Reticulum preview as inert
- cover protocol capability rows and focused transport tests
Diffstat:
11 files changed, 161 insertions(+), 36 deletions(-)
diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs
@@ -632,9 +632,9 @@ mod tests {
#[cfg(feature = "transport-workers")]
use radroots_event::draft::{RadrootsSignedEvent, RadrootsSignedEventParts};
use radroots_transport::{
- RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
- RadrootsTransportDeliveryTargetStatus, RadrootsTransportFetchReceipt,
- RadrootsTransportFetchRequest, RadrootsTransportFuture,
+ RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt,
+ RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
+ RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture,
RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportOutcome,
RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass,
RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget,
@@ -690,7 +690,8 @@ mod tests {
RadrootsTransportImplementationState::Real,
true,
"ready",
- ))
+ )
+ .with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch()))
})
}
@@ -838,6 +839,10 @@ mod tests {
.expect("receipt");
let status = transport.status().await.expect("status");
assert_eq!(status.kind, RadrootsTransportKind::Nostr);
+ assert_eq!(
+ status.capabilities,
+ RadrootsTransportCapabilities::deliver_and_fetch()
+ );
let fetch = transport
.fetch(RadrootsTransportFetchRequest::new(
"nostr-fetch",
@@ -913,6 +918,8 @@ mod tests {
status.implementation,
RadrootsTransportImplementationState::PreviewUnavailable
);
+ assert!(!status.capabilities.deliver);
+ assert!(!status.capabilities.fetch);
let fetch = transport
.fetch(RadrootsTransportFetchRequest::new(
"reticulum-fetch",
diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs
@@ -2,6 +2,7 @@ use core::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsTransportError {
+ UnsupportedOperation,
EmptyTransportKind,
InvalidTransportKind,
EmptyTargetUri,
@@ -29,6 +30,7 @@ pub enum RadrootsTransportError {
impl fmt::Display for RadrootsTransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
+ Self::UnsupportedOperation => f.write_str("transport operation is unsupported"),
Self::EmptyTransportKind => f.write_str("transport kind is empty"),
Self::InvalidTransportKind => f.write_str("transport kind is invalid"),
Self::EmptyTargetUri => f.write_str("transport target URI is empty"),
diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs
@@ -25,8 +25,8 @@ pub use message::{
};
pub use payload::RadrootsTransportPayload;
pub use status::{
- RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, RadrootsTransportOutcomeKind,
- RadrootsTransportStatus,
+ RadrootsTransportCapabilities, RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome,
+ RadrootsTransportOutcomeKind, RadrootsTransportStatus,
};
pub use target::{
RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint,
diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs
@@ -173,6 +173,43 @@ impl RadrootsTransportOutcome {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTransportCapabilities {
+ pub deliver: bool,
+ pub fetch: bool,
+}
+
+impl RadrootsTransportCapabilities {
+ pub const fn none() -> Self {
+ Self {
+ deliver: false,
+ fetch: false,
+ }
+ }
+
+ pub const fn deliver_only() -> Self {
+ Self {
+ deliver: true,
+ fetch: false,
+ }
+ }
+
+ pub const fn fetch_only() -> Self {
+ Self {
+ deliver: false,
+ fetch: true,
+ }
+ }
+
+ pub const fn deliver_and_fetch() -> Self {
+ Self {
+ deliver: true,
+ fetch: true,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsTransportStatus {
#[cfg_attr(feature = "serde", serde(rename = "transport"))]
pub kind: RadrootsTransportKind,
@@ -181,6 +218,7 @@ pub struct RadrootsTransportStatus {
pub configured: bool,
pub implementation: RadrootsTransportImplementationState,
pub usable_for_delivery: bool,
+ pub capabilities: RadrootsTransportCapabilities,
pub message: String,
}
@@ -199,10 +237,20 @@ impl RadrootsTransportStatus {
configured,
implementation,
usable_for_delivery,
+ capabilities: if usable_for_delivery {
+ RadrootsTransportCapabilities::deliver_only()
+ } else {
+ RadrootsTransportCapabilities::none()
+ },
message: message.into(),
}
}
+ pub fn with_capabilities(mut self, capabilities: RadrootsTransportCapabilities) -> Self {
+ self.capabilities = capabilities;
+ self
+ }
+
pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self {
self.profile_id = Some(profile_id.into());
self
diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs
@@ -407,11 +407,18 @@ fn transport_publish_capabilities_keep_canonical_status_fields() {
"pub configured: bool,",
"pub implementation: TransportPublishImplementation,",
"pub usable_for_delivery: bool,",
+ "pub capabilities: TransportPublishOperationCapabilities,",
+ "pub struct TransportPublishOperationCapabilities",
+ "pub deliver: bool,",
+ "pub fetch: bool,",
"TransportPublishImplementation::Real",
"TransportPublishImplementation::PreviewUnavailable",
"configured: true",
"usable_for_delivery: true",
"usable_for_delivery: false",
+ "capabilities: TransportPublishOperationCapabilities",
+ "deliver: true",
+ "fetch: false",
] {
assert!(
source.contains(required),
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -1,10 +1,11 @@
use radroots_transport::{
RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID,
- RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
- RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt,
- RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
- RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome,
- RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
+ RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt,
+ RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
+ RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
+ RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind,
+ RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind,
+ RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget,
RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel,
RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
@@ -204,6 +205,10 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() {
RadrootsTransportImplementationState::Real
);
assert!(status.usable_for_delivery);
+ assert_eq!(
+ status.capabilities,
+ RadrootsTransportCapabilities::deliver_only()
+ );
assert_eq!(status.message, "ready");
let json = serde_json::to_value(&status).expect("status json");
@@ -211,6 +216,8 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() {
assert_eq!(json["implementation"], "real");
assert_eq!(json["configured"], true);
assert_eq!(json["usable_for_delivery"], true);
+ assert_eq!(json["capabilities"]["deliver"], true);
+ assert_eq!(json["capabilities"]["fetch"], false);
assert_eq!(json["message"], "ready");
for retired in [
"kind",
@@ -382,6 +389,10 @@ fn fingerprint_parser_rejects_non_sha256_hex() {
fn transport_errors_have_stable_display_strings() {
let cases = [
(
+ RadrootsTransportError::UnsupportedOperation,
+ "transport operation is unsupported",
+ ),
+ (
RadrootsTransportError::EmptyTransportKind,
"transport kind is empty",
),
@@ -920,7 +931,8 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() {
RadrootsTransportImplementationState::Real,
true,
"ready",
- ))
+ )
+ .with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch()))
})
}
@@ -963,6 +975,10 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() {
assert_eq!(transport.transport_kind(), RadrootsTransportKind::Local);
let status = futures::executor::block_on(transport.status()).expect("status");
assert_eq!(status.kind, RadrootsTransportKind::Local);
+ assert_eq!(
+ status.capabilities,
+ RadrootsTransportCapabilities::deliver_and_fetch()
+ );
let delivery =
futures::executor::block_on(transport.deliver(RadrootsTransportDeliveryRequest::new(
"deliver-1",
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -684,6 +684,18 @@ fn transport_satisfaction_policy_for_publishable(
fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRelayTransportError {
match error {
+ RadrootsTransportError::UnsupportedOperation
+ | RadrootsTransportError::EmptyTransportKind
+ | RadrootsTransportError::InvalidTransportKind
+ | RadrootsTransportError::EmptyTargetScope
+ | RadrootsTransportError::InvalidTargetScope
+ | RadrootsTransportError::EmptyTargetLabel
+ | RadrootsTransportError::InvalidTargetLabel
+ | RadrootsTransportError::InvalidSatisfactionPolicy
+ | RadrootsTransportError::EmptyRequiredTargetSet
+ | RadrootsTransportError::DuplicateRequiredTargetFingerprint => {
+ RadrootsRelayTransportError::Transport(error.to_string())
+ }
RadrootsTransportError::EmptyTargetUri
| RadrootsTransportError::InvalidTargetUri
| RadrootsTransportError::EmptyTargetSet
@@ -701,17 +713,6 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela
| RadrootsTransportError::PayloadDigestMismatch => {
RadrootsRelayTransportError::NostrEventJson(error.to_string())
}
- RadrootsTransportError::EmptyTransportKind
- | RadrootsTransportError::InvalidTransportKind
- | RadrootsTransportError::EmptyTargetScope
- | RadrootsTransportError::InvalidTargetScope
- | RadrootsTransportError::EmptyTargetLabel
- | RadrootsTransportError::InvalidTargetLabel
- | RadrootsTransportError::InvalidSatisfactionPolicy
- | RadrootsTransportError::EmptyRequiredTargetSet
- | RadrootsTransportError::DuplicateRequiredTargetFingerprint => {
- RadrootsRelayTransportError::Transport(error.to_string())
- }
}
}
diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs
@@ -6,12 +6,12 @@ use core::time::Duration;
use futures::future::BoxFuture;
use radroots_event::draft::{RadrootsSignedEvent, RadrootsSignedEventParts};
use radroots_transport::{
- RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
- RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
- RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind,
- RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload,
- RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget,
- RadrootsTransportTargetReceipt,
+ RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt,
+ RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportFetchReceipt,
+ RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
+ RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportOutcomeKind,
+ RadrootsTransportPayload, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus,
+ RadrootsTransportTarget, RadrootsTransportTargetReceipt,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -138,7 +138,8 @@ impl<A> RadrootsNostrTransport<A> {
RadrootsTransportImplementationState::Real,
true,
"ready",
- ),
+ )
+ .with_capabilities(RadrootsTransportCapabilities::deliver_only()),
}
}
@@ -206,9 +207,7 @@ where
&'a self,
_request: RadrootsTransportFetchRequest,
) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> {
- Box::pin(
- async move { Err(radroots_transport::RadrootsTransportError::InvalidTransportKind) },
- )
+ Box::pin(async move { Err(RadrootsTransportError::UnsupportedOperation) })
}
}
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -18,9 +18,10 @@ use radroots_outbox::{
};
use radroots_transport::{
RadrootsTransport, RadrootsTransportDeliveryRequest, RadrootsTransportError,
- RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportPayload,
- RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy,
- RadrootsTransportTarget, RadrootsTransportTargetLabel, RadrootsTransportTargetSet,
+ RadrootsTransportFetchRequest, RadrootsTransportKind, RadrootsTransportMeshScopeId,
+ RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel,
+ RadrootsTransportTargetSet,
};
use radroots_transport_nostr::{
RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsNostrTransport,
@@ -718,8 +719,11 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() {
);
let receipt = transport.deliver(request).await.expect("delivery");
+ let status = transport.status().await.expect("status");
assert_eq!(adapter.captured_raw_events(), vec![signed.raw_json]);
+ assert!(status.capabilities.deliver);
+ assert!(!status.capabilities.fetch);
assert_eq!(receipt.request_id, "facade-request-1");
assert_eq!(receipt.target_receipts.len(), 1);
assert_eq!(receipt.target_receipts[0].target, target);
@@ -735,6 +739,22 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() {
}
#[tokio::test]
+async fn nostr_transport_facade_reports_fetch_as_unsupported_operation() {
+ let transport = RadrootsNostrTransport::new(RadrootsMockRelayPublishAdapter::new());
+ let target_set =
+ RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)]).expect("targets");
+ let error = transport
+ .fetch(RadrootsTransportFetchRequest::new(
+ "facade-fetch-unsupported",
+ target_set,
+ ))
+ .await
+ .expect_err("fetch unsupported");
+
+ assert_eq!(error, RadrootsTransportError::UnsupportedOperation);
+}
+
+#[tokio::test]
async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() {
let signed = signed_post("facade rejected");
let transport = RadrootsNostrTransport::new(RadrootsMockRelayPublishAdapter::new());
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -992,6 +992,10 @@ impl TransportPublishCapabilities {
configured: true,
implementation: TransportPublishImplementation::Real,
usable_for_delivery: true,
+ capabilities: TransportPublishOperationCapabilities {
+ deliver: true,
+ fetch: false,
+ },
preview_behavior: None,
message: "Nostr relay publish is available".to_owned(),
},
@@ -1000,6 +1004,10 @@ impl TransportPublishCapabilities {
configured: true,
implementation: TransportPublishImplementation::PreviewUnavailable,
usable_for_delivery: false,
+ capabilities: TransportPublishOperationCapabilities {
+ deliver: false,
+ fetch: false,
+ },
preview_behavior: Some(
TransportPublishPreviewBehavior::RejectDeliveryAttempts,
),
@@ -1048,6 +1056,7 @@ pub struct TransportPublishTransportCapability {
pub configured: bool,
pub implementation: TransportPublishImplementation,
pub usable_for_delivery: bool,
+ pub capabilities: TransportPublishOperationCapabilities,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
@@ -1057,6 +1066,14 @@ pub struct TransportPublishTransportCapability {
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct TransportPublishOperationCapabilities {
+ pub deliver: bool,
+ pub fetch: bool,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransportPublishDeliveryPolicyName {
@@ -1519,6 +1536,8 @@ mod tests {
assert!(nostr.configured);
assert_eq!(nostr.implementation, TransportPublishImplementation::Real);
assert!(nostr.usable_for_delivery);
+ assert!(nostr.capabilities.deliver);
+ assert!(!nostr.capabilities.fetch);
let reticulum = capabilities
.publish
.transports
@@ -1531,6 +1550,8 @@ mod tests {
TransportPublishImplementation::PreviewUnavailable
);
assert!(!reticulum.usable_for_delivery);
+ assert!(!reticulum.capabilities.deliver);
+ assert!(!reticulum.capabilities.fetch);
assert_eq!(
reticulum.preview_behavior,
Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts)
diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs
@@ -292,6 +292,8 @@ fn core_transport_trait_reports_preview_status_delivery_and_fetch() {
RadrootsTransportImplementationState::PreviewUnavailable
);
assert!(!status.usable_for_delivery);
+ assert!(!status.capabilities.deliver);
+ assert!(!status.capabilities.fetch);
let delivery = futures::executor::block_on(RadrootsTransport::deliver(
&transport,
@@ -503,6 +505,8 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() {
RadrootsTransportImplementationState::PreviewUnavailable
);
assert!(!status.transport_status.usable_for_delivery);
+ assert!(!status.transport_status.capabilities.deliver);
+ assert!(!status.transport_status.capabilities.fetch);
let receipt = transport
.deliver(delivery_request(vec![reticulum_target(