commit 6435ca9c20de00ce8cf0c832068d90f3607412cf
parent 915bc14f3951f99ba853667690776068f991a6af
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 11:09:50 +0000
sdk: migrate transport profiles and policies
- compose profiles from canonical target and policy types
- validate quorum and required targets before operations
- model preview transports with canonical unavailable statuses
- remove duplicate wrappers tests and fallback vocabulary
Diffstat:
3 files changed, 258 insertions(+), 363 deletions(-)
diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs
@@ -1 +1,237 @@
-//! Transport capability composition.
+//! Explicit user-facing transport profile composition.
+//!
+//! Profiles retain canonical `radroots_transport` identities, targets,
+//! policies, and statuses. They select no adapter implicitly and never replace
+//! an unavailable selection with another transport.
+
+use radroots_transport::{
+ Error, SinkStatus, SourceStatus, TargetSet, TransportId,
+ capability::{Availability, Maturity, SinkCapabilities, SourceCapabilities},
+ policy::SatisfactionPolicy,
+};
+
+const PREVIEW_UNAVAILABLE_MESSAGE: &str = "preview transport is unavailable in this SDK release";
+
+/// A side-effect-free transport selection for a client operation.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Profile {
+ selection: Selection,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+enum Selection {
+ LocalOnly,
+ Delivery {
+ targets: TargetSet,
+ satisfaction: SatisfactionPolicy,
+ },
+ UnavailablePreview {
+ source: SourceStatus,
+ sink: SinkStatus,
+ },
+}
+
+impl Profile {
+ /// Selects local persistence only, with no transport target or fallback.
+ #[must_use]
+ pub const fn local_only() -> Self {
+ Self {
+ selection: Selection::LocalOnly,
+ }
+ }
+
+ /// Selects an exact bounded target set and canonical satisfaction policy.
+ ///
+ /// Impossible quorum and required-target policies are rejected here by the
+ /// owning transport contract. Construction performs no network operation.
+ pub fn delivery(targets: TargetSet, satisfaction: SatisfactionPolicy) -> Result<Self, Error> {
+ satisfaction.validate_for(&targets)?;
+ Ok(Self {
+ selection: Selection::Delivery {
+ targets,
+ satisfaction,
+ },
+ })
+ }
+
+ /// Describes a preview transport that is intentionally not selectable.
+ ///
+ /// Both canonical capability directions remain explicitly unconfigured
+ /// and unavailable. The profile has no targets and therefore cannot fall
+ /// back to local, Nostr, daemon, or another transport.
+ #[must_use]
+ pub fn unavailable_preview(transport_id: TransportId) -> Self {
+ Self {
+ selection: Selection::UnavailablePreview {
+ source: SourceStatus::new(
+ transport_id,
+ false,
+ Maturity::Preview,
+ Availability::Unavailable,
+ SourceCapabilities::NONE,
+ PREVIEW_UNAVAILABLE_MESSAGE,
+ ),
+ sink: SinkStatus::new(
+ transport_id,
+ false,
+ Maturity::Preview,
+ Availability::Unavailable,
+ SinkCapabilities::NONE,
+ PREVIEW_UNAVAILABLE_MESSAGE,
+ ),
+ },
+ }
+ }
+
+ /// Returns whether this profile authorizes no transport operation.
+ #[must_use]
+ pub const fn is_local_only(&self) -> bool {
+ matches!(self.selection, Selection::LocalOnly)
+ }
+
+ /// Returns the exact selected targets, if delivery is authorized.
+ #[must_use]
+ pub const fn targets(&self) -> Option<&TargetSet> {
+ match &self.selection {
+ Selection::Delivery { targets, .. } => Some(targets),
+ Selection::LocalOnly | Selection::UnavailablePreview { .. } => None,
+ }
+ }
+
+ /// Returns the exact selected satisfaction policy, if delivery is authorized.
+ #[must_use]
+ pub const fn satisfaction(&self) -> Option<&SatisfactionPolicy> {
+ match &self.selection {
+ Selection::Delivery { satisfaction, .. } => Some(satisfaction),
+ Selection::LocalOnly | Selection::UnavailablePreview { .. } => None,
+ }
+ }
+
+ /// Returns canonical source status for an unavailable preview.
+ #[must_use]
+ pub const fn source_status(&self) -> Option<&SourceStatus> {
+ match &self.selection {
+ Selection::UnavailablePreview { source, .. } => Some(source),
+ Selection::LocalOnly | Selection::Delivery { .. } => None,
+ }
+ }
+
+ /// Returns canonical sink status for an unavailable preview.
+ #[must_use]
+ pub const fn sink_status(&self) -> Option<&SinkStatus> {
+ match &self.selection {
+ Selection::UnavailablePreview { sink, .. } => Some(sink),
+ Selection::LocalOnly | Selection::Delivery { .. } => None,
+ }
+ }
+}
+
+impl Default for Profile {
+ fn default() -> Self {
+ Self::local_only()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_transport::{
+ Error, TARGET_SET_MAX_ITEMS, Target,
+ capability::{Availability, Maturity},
+ policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy},
+ target::TargetFingerprint,
+ };
+
+ use super::*;
+
+ fn target(index: usize) -> Target {
+ Target::nostr_relay(format!("wss://relay-{index}.example")).expect("target")
+ }
+
+ #[test]
+ fn delivery_profile_preserves_canonical_targets_and_policy() {
+ let targets = TargetSet::new(vec![target(1), target(2)]).expect("target set");
+ let policy = SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all());
+ let profile = Profile::delivery(targets.clone(), policy.clone()).expect("profile");
+
+ assert_eq!(profile.targets(), Some(&targets));
+ assert_eq!(profile.satisfaction(), Some(&policy));
+ assert!(!profile.is_local_only());
+ assert!(profile.source_status().is_none());
+ assert!(profile.sink_status().is_none());
+ }
+
+ #[test]
+ fn canonical_target_and_policy_bounds_fail_during_profile_construction() {
+ assert_eq!(TargetSet::new(Vec::new()), Err(Error::EmptyTargetSet));
+ assert_eq!(
+ TargetSet::new((0..=TARGET_SET_MAX_ITEMS).map(target).collect()),
+ Err(Error::TargetSetTooLarge)
+ );
+
+ let targets = TargetSet::new(vec![target(1)]).expect("target set");
+ let quorum = SatisfactionPolicy::new(
+ SatisfactionClass::Delivered,
+ TargetPolicy::quorum(2).expect("non-zero quorum"),
+ );
+ assert_eq!(
+ Profile::delivery(targets.clone(), quorum),
+ Err(Error::InvalidSatisfactionPolicy)
+ );
+
+ let missing =
+ TargetFingerprint::from_target(target(2).kind(), target(2).uri(), target(2).scope());
+ let required = SatisfactionPolicy::new(
+ SatisfactionClass::Accepted,
+ TargetPolicy::required(vec![missing]).expect("required policy"),
+ );
+ assert_eq!(
+ Profile::delivery(targets, required),
+ Err(Error::RequiredTargetNotRequested)
+ );
+ }
+
+ #[test]
+ fn preview_transport_is_explicitly_unavailable_and_unselectable() {
+ let profile = Profile::unavailable_preview(TransportId::RETICULUM);
+ let source = profile.source_status().expect("source status");
+ let sink = profile.sink_status().expect("sink status");
+
+ assert_eq!(source.transport_id(), TransportId::RETICULUM);
+ assert_eq!(sink.transport_id(), TransportId::RETICULUM);
+ assert!(!source.is_configured());
+ assert!(!sink.is_configured());
+ assert_eq!(source.maturity(), Maturity::Preview);
+ assert_eq!(sink.maturity(), Maturity::Preview);
+ assert_eq!(source.availability(), Availability::Unavailable);
+ assert_eq!(sink.availability(), Availability::Unavailable);
+ assert!(!source.capabilities().can_fetch());
+ assert!(!sink.capabilities().can_deliver());
+ assert!(profile.targets().is_none());
+ assert!(profile.satisfaction().is_none());
+ }
+
+ #[test]
+ fn local_and_preview_profiles_never_substitute_fallback_targets() {
+ let local = Profile::local_only();
+ let preview = Profile::unavailable_preview(TransportId::RETICULUM);
+ assert!(local.is_local_only());
+ assert!(local.targets().is_none());
+ assert!(preview.targets().is_none());
+
+ let selected = TargetSet::new(vec![target(7)]).expect("selected targets");
+ let profile = Profile::delivery(
+ selected.clone(),
+ SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()),
+ )
+ .expect("profile");
+ assert_eq!(profile.targets(), Some(&selected));
+ assert!(
+ profile
+ .targets()
+ .expect("targets")
+ .targets()
+ .iter()
+ .all(|target| *target.kind() == TransportId::NOSTR)
+ );
+ }
+}
diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs
@@ -3,6 +3,7 @@ use std::collections::BTreeSet;
const MANIFEST: &str = include_str!("../Cargo.toml");
const ROOT: &str = include_str!("../src/lib.rs");
const CLIENT: &str = include_str!("../src/client.rs");
+const TRANSPORT: &str = include_str!("../src/transport.rs");
#[test]
fn manifest_has_final_identity_and_dependency_boundary() {
@@ -137,6 +138,26 @@ fn sdk_source_contains_no_studio_storage_surface() {
}
}
+#[test]
+fn transport_profiles_reuse_canonical_types_and_forbid_fallback() {
+ assert!(TRANSPORT.contains("use radroots_transport::{"));
+ assert!(TRANSPORT.contains("satisfaction.validate_for(&targets)?"));
+ assert!(TRANSPORT.contains("Selection::UnavailablePreview"));
+ for duplicate in [
+ "pub struct TargetSet",
+ "pub enum TargetPolicy",
+ "pub enum SatisfactionPolicy",
+ "pub struct SourceStatus",
+ "pub struct SinkStatus",
+ "DefaultProfile",
+ ] {
+ assert!(
+ !TRANSPORT.contains(duplicate),
+ "SDK transport source contains forbidden duplicate or fallback `{duplicate}`"
+ );
+ }
+}
+
fn dependency_names(manifest: &str) -> BTreeSet<&str> {
let dependencies = manifest
.split_once("[dependencies]")
diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs
@@ -1,362 +0,0 @@
-use super::{
- MeshScopeId, NostrProfile, NostrRelayUrlPolicy, PublishMode, ReticulumAgentEndpoint,
- ReticulumBehavior, ReticulumProfile, SatisfactionPolicy, TargetPolicy, TargetSet,
- TransportProfile,
-};
-use crate::{RadrootsSdkError, SDK_TRANSPORT_TARGET_MAX_COUNT};
-use radroots_transport::{RadrootsTransportError, Target, TransportId};
-use radroots_transport_reticulum::RADROOTS_RETICULUM_ENDPOINT_URI;
-
-use crate::serializer_failure::assert_struct_serialize_error_paths;
-
-#[test]
-fn publish_mode_and_ack_policy_serialize_explicit_product_contracts() {
- assert_eq!(
- serde_json::to_value(PublishMode::DryRun).expect("json"),
- serde_json::json!("dry_run")
- );
- assert_eq!(
- serde_json::to_value(PublishMode::EnqueueOnly).expect("json"),
- serde_json::json!("enqueue_only")
- );
- assert_eq!(
- serde_json::to_value(PublishMode::EnqueueAndPublish).expect("json"),
- serde_json::json!("enqueue_and_publish")
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::NoWait).expect("json"),
- serde_json::json!("no_wait")
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::AnyAccepted).expect("json"),
- serde_json::json!("any_accepted")
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::AllAccepted).expect("json"),
- serde_json::json!("all_accepted")
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::quorum_accepted(2).expect("satisfaction policy"))
- .expect("json"),
- serde_json::json!({ "quorum_accepted": { "threshold": 2 } })
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::AnyDelivered).expect("json"),
- serde_json::json!("any_delivered")
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::AllDelivered).expect("json"),
- serde_json::json!("all_delivered")
- );
- assert_eq!(
- serde_json::to_value(SatisfactionPolicy::quorum_delivered(3).expect("satisfaction policy"))
- .expect("json"),
- serde_json::json!({ "quorum_delivered": { "threshold": 3 } })
- );
- assert_eq!(
- serde_json::to_value(
- SatisfactionPolicy::required_accepted_targets(["a".repeat(64)])
- .expect("satisfaction policy")
- )
- .expect("json"),
- serde_json::json!({
- "required_accepted_targets": {
- "target_fingerprints": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
- }
- })
- );
- assert!(matches!(
- SatisfactionPolicy::quorum_accepted(0),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message == "satisfaction policy threshold must require at least one target"
- ));
-}
-
-fn is_local_ws_relay(value: &str) -> bool {
- let Some(rest) = value.strip_prefix("ws://") else {
- return false;
- };
- let authority = rest
- .split_once('/')
- .map(|(authority, _)| authority)
- .unwrap_or(rest);
- let host = relay_authority_host(authority);
- matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]"))
-}
-
-fn relay_authority_host(authority: &str) -> Option<String> {
- if let Some(after_open) = authority.strip_prefix('[') {
- let close_index = after_open.find(']')?;
- return Some(format!("[{}]", &after_open[..close_index]));
- }
- Some(
- authority
- .split_once(':')
- .map(|(host, _)| host)
- .unwrap_or(authority)
- .to_owned(),
- )
-}
-
-#[test]
-fn transport_profile_policy_serializes_as_kind_only() {
- let transport_profile_policy = TargetPolicy::default_profile();
- assert_eq!(
- serde_json::to_value(&transport_profile_policy).expect("json"),
- serde_json::json!({ "kind": "default_profile" })
- );
- assert_struct_serialize_error_paths(&transport_profile_policy, 1);
-}
-
-#[test]
-fn target_set_accessors_and_configured_relays_cover_empty_paths() {
- assert!(
- TransportProfile::local_only()
- .configured_nostr_relay_urls()
- .is_empty()
- );
-
- let targets = TargetSet::nostr_relays(
- ["wss://relay-a.example.com", "wss://relay-b.example.com"],
- NostrRelayUrlPolicy::Public,
- )
- .expect("targets");
-
- assert_eq!(targets.len(), 2);
- assert!(!targets.is_empty());
- assert_struct_serialize_error_paths(&targets, 2);
- assert_struct_serialize_error_paths(&TargetPolicy::explicit(targets.clone()), 3);
- let targets_json = serde_json::to_value(&targets).expect("targets json");
- assert_eq!(
- targets_json["targets"].as_array().expect("targets").len(),
- 2
- );
- assert_eq!(
- targets_json["canonical_targets"]
- .as_array()
- .expect("canonical targets")
- .len(),
- 2
- );
- assert_eq!(
- targets.nostr_relay_urls(),
- vec![
- "wss://relay-a.example.com".to_owned(),
- "wss://relay-b.example.com".to_owned()
- ]
- );
-
- assert_eq!(
- TargetPolicy::try_nostr_relays(
- vec!["wss://relay-c.example.com".to_owned()],
- NostrRelayUrlPolicy::Public,
- )
- .expect("explicit policy"),
- TargetPolicy::Explicit(
- TargetSet::nostr_relays(["wss://relay-c.example.com"], NostrRelayUrlPolicy::Public)
- .expect("target set"),
- )
- );
- assert_eq!(
- serde_json::to_value(
- TargetPolicy::try_nostr_relays(
- vec!["wss://relay-c.example.com".to_owned()],
- NostrRelayUrlPolicy::Public,
- )
- .expect("trade explicit policy")
- )
- .expect("trade policy json"),
- serde_json::json!({
- "kind": "explicit",
- "targets": [{
- "kind": "nostr",
- "uri": "wss://relay-c.example.com",
- "scope": null,
- "label": null,
- "fingerprint": "ec4b5005dd1fcf0d949045e3d5524f9a6a95209ecc888f582ae2e9bf69e5b8e6"
- }],
- "canonical_targets": ["ec4b5005dd1fcf0d949045e3d5524f9a6a95209ecc888f582ae2e9bf69e5b8e6"]
- })
- );
-
- let nostr_profile = TransportProfile::nostr(
- NostrProfile::new(["wss://relay-d.example.com"], NostrRelayUrlPolicy::Public)
- .expect("Nostr profile"),
- );
- assert_eq!(
- nostr_profile.configured_nostr_relay_urls(),
- vec!["wss://relay-d.example.com".to_owned()]
- );
-}
-
-#[test]
-fn target_sets_reject_duplicate_transport_fingerprints() {
- let duplicate_relays = TargetSet::nostr_relays(
- [
- "wss://relay-a.example.com/path",
- "WSS://RELAY-A.EXAMPLE.COM/path",
- ],
- NostrRelayUrlPolicy::Public,
- )
- .expect_err("duplicate relays");
-
- assert!(matches!(
- duplicate_relays,
- RadrootsSdkError::Transport { ref message }
- if message == "transport target set contains duplicate fingerprints"
- ));
-
- let first =
- Target::new(TransportId::NOSTR, "wss://relay-a.example.com/path").expect("first target");
- let second =
- Target::new(TransportId::NOSTR, "WSS://RELAY-A.EXAMPLE.COM/path").expect("second target");
- let duplicate_targets =
- TargetSet::transport_targets(vec![first, second]).expect_err("duplicate targets");
-
- assert!(matches!(
- duplicate_targets,
- RadrootsSdkError::Transport { ref message }
- if message == "transport target set contains duplicate fingerprints"
- ));
-}
-
-#[test]
-fn reticulum_profile_uses_canonical_endpoint_and_behavior_names() {
- let profile = ReticulumProfile::deferred_until_implemented();
-
- assert_eq!(profile.endpoint_uri(), RADROOTS_RETICULUM_ENDPOINT_URI);
- assert_eq!(
- profile.behavior(),
- ReticulumBehavior::RejectDeliveryAttempts
- );
- assert_eq!(
- ReticulumBehavior::RejectDeliveryAttempts.as_str(),
- "reject_delivery_attempts"
- );
- assert_eq!(
- ReticulumBehavior::DeferDeliveryPlans.as_str(),
- "defer_delivery_plans"
- );
- assert_eq!(
- serde_json::to_value(profile).expect("profile json"),
- serde_json::json!({
- "endpoint_uri": "reticulum:local",
- "scope": "local",
- "agent_endpoint": null,
- "behavior": "reject_delivery_attempts"
- })
- );
-}
-
-#[test]
-fn reticulum_profile_preserves_explicit_scope_and_agent_endpoint() {
- let profile = ReticulumProfile::deferred_until_implemented()
- .with_scope(MeshScopeId::parse("farmers_market").expect("scope"))
- .with_agent_endpoint(
- ReticulumAgentEndpoint::parse("reticulum-agent:local").expect("agent endpoint"),
- );
-
- assert_eq!(profile.scope().as_str(), "farmers_market");
- assert_eq!(
- profile.agent_endpoint().expect("agent endpoint").as_str(),
- "reticulum-agent:local"
- );
- assert_eq!(
- serde_json::to_value(profile).expect("profile json"),
- serde_json::json!({
- "endpoint_uri": "reticulum:local",
- "scope": "farmers_market",
- "agent_endpoint": "reticulum-agent:local",
- "behavior": "reject_delivery_attempts"
- })
- );
-}
-
-#[test]
-fn reticulum_agent_endpoint_rejects_non_agent_endpoint_families() {
- for invalid in [
- "",
- "reticulum-agent:",
- " reticulum-agent:local",
- "reticulum-agent:local ",
- "RETICULUM-AGENT:local",
- "reticulum:local",
- "https://reticulum.example.com",
- "ws://127.0.0.1:9735",
- ] {
- assert!(matches!(
- ReticulumAgentEndpoint::parse(invalid),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message == "Reticulum agent endpoint is invalid"
- ));
- }
-}
-
-#[test]
-fn explicit_target_sets_reject_noncanonical_reticulum_endpoints() {
- for invalid in [" reticulum:local", "reticulum:local "] {
- assert_eq!(
- Target::new(TransportId::RETICULUM, invalid)
- .expect_err("syntactically invalid transport target"),
- RadrootsTransportError::InvalidTargetUri
- );
- }
-
- for noncanonical in [
- "RETICULUM:deferred-until-implemented".to_owned(),
- "reticulum:Preview-Unavailable".to_owned(),
- ["reticulum:", "pre", "view"].concat(),
- "reticulum:local-alt".to_owned(),
- "reticulum:custom".to_owned(),
- ] {
- let target = Target::new(TransportId::RETICULUM, noncanonical.as_str())
- .expect("transport-neutral target");
- assert!(matches!(
- TargetSet::transport_targets(vec![target]),
- Err(RadrootsSdkError::InvalidRequest { ref message })
- if message == "Reticulum endpoint must be reticulum:local"
- ));
- }
-}
-
-#[test]
-fn normalized_relays_reject_empty_and_over_limit_sets() {
- assert!(matches!(
- TargetSet::nostr_relays(Vec::<String>::new(), NostrRelayUrlPolicy::Public),
- Err(RadrootsSdkError::EmptyTransportTargets { .. })
- ));
-
- let too_many = (0..=SDK_TRANSPORT_TARGET_MAX_COUNT)
- .map(|index| format!("wss://relay-{index}.example.com"))
- .collect::<Vec<_>>();
- assert!(matches!(
- TargetSet::nostr_relays(too_many, NostrRelayUrlPolicy::Public),
- Err(RadrootsSdkError::TransportTargetLimitExceeded { actual, .. })
- if actual == SDK_TRANSPORT_TARGET_MAX_COUNT + 1
- ));
-}
-
-#[test]
-fn local_ws_authority_parser_handles_ipv6_ports_and_non_ws_values() {
- assert!(is_local_ws_relay("ws://localhost:8080/path"));
- assert!(is_local_ws_relay("ws://127.0.0.1:8080"));
- assert!(is_local_ws_relay("ws://[::1]:8080"));
- assert!(!is_local_ws_relay("wss://relay.example.com"));
- assert!(!is_local_ws_relay("ws://relay.example.com"));
- assert!(matches!(
- TargetSet::nostr_relays(["ws://relay.example.com"], NostrRelayUrlPolicy::Localhost),
- Err(RadrootsSdkError::InvalidRelayUrl { reason, .. })
- if reason.contains("localhost")
- ));
- assert!(matches!(
- TargetSet::nostr_relays(["ws://relay.example.com"], NostrRelayUrlPolicy::Public),
- Err(RadrootsSdkError::InvalidRelayUrl { reason, .. })
- if reason.contains("localhost")
- ));
- assert_eq!(relay_authority_host("[::1]:8080"), Some("[::1]".to_owned()));
- assert_eq!(
- relay_authority_host("relay.example.com:443"),
- Some("relay.example.com".to_owned())
- );
- assert_eq!(relay_authority_host("[::1"), None);
-}