commit d6bcc3f917ad5aae056687f1932604f19db76a22
parent 89da5d2da4e6b1bd7b37b69ae98a7c2dc990fc83
Author: triesap <tyson@radroots.org>
Date: Sat, 11 Jul 2026 08:21:46 +0000
mesh-agent: add preview client boundary
- add radroots_mesh_agent_client as the typed mesh-agent client crate
- define schema-aligned status and publish DTOs with serde field names
- provide a mock preview-unavailable client that never reports delivery success
- guard schema hash parity, DTO naming, and preview publish behavior with focused tests
Diffstat:
6 files changed, 505 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4396,6 +4396,17 @@ dependencies = [
]
[[package]]
+name = "radroots_mesh_agent_client"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "radroots_mesh",
+ "radroots_mesh_agent_proto",
+ "radroots_transport",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
name = "radroots_mesh_agent_proto"
version = "0.1.0-alpha.2"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -46,6 +46,7 @@ members = [
"crates/transport",
"crates/types",
"crates/mesh",
+ "crates/mesh_agent_client",
"crates/mesh_agent_proto",
"crates/protected_store",
"tools/xtask",
@@ -106,6 +107,7 @@ radroots_trade = { path = "crates/trade", version = "0.1.0-alpha.2", default-fea
radroots_transport = { path = "crates/transport", version = "0.1.0-alpha.2", default-features = false }
radroots_types = { path = "crates/types", version = "0.1.0-alpha.2", default-features = false }
radroots_mesh = { path = "crates/mesh", version = "0.1.0-alpha.2", default-features = false }
+radroots_mesh_agent_client = { path = "crates/mesh_agent_client", version = "0.1.0-alpha.2", default-features = false }
radroots_mesh_agent_proto = { path = "crates/mesh_agent_proto", version = "0.1.0-alpha.2", default-features = false }
radroots_protected_store = { path = "crates/protected_store", version = "0.1.0-alpha.2", default-features = false }
radroots_secret_vault = { path = "crates/secret_vault", version = "0.1.0-alpha.2", default-features = false }
diff --git a/crates/mesh_agent_client/Cargo.toml b/crates/mesh_agent_client/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "radroots_mesh_agent_client"
+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 = "Mesh agent client boundary for Radroots"
+repository.workspace = true
+homepage.workspace = true
+readme = "README"
+
+[features]
+default = ["serde"]
+serde = ["dep:serde", "radroots_mesh/serde"]
+
+[dependencies]
+radroots_mesh = { workspace = true, default-features = false }
+radroots_mesh_agent_proto = { workspace = true, default-features = false }
+radroots_transport = { workspace = true, default-features = false }
+serde = { workspace = true, optional = true, features = ["derive", "std"] }
+
+[dev-dependencies]
+serde_json = { workspace = true, features = ["std"] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/mesh_agent_client/README b/crates/mesh_agent_client/README
@@ -0,0 +1,17 @@
+# radroots_mesh_agent_client
+
+This is the README for `radroots_mesh_agent_client`, which provides the mesh
+agent client boundary for `radroots`.
+
+## Copyright
+
+Except as otherwise noted, all files in the `radroots_mesh_agent_client`
+distribution are
+
+ Copyright (c) 2025 Tyson Lupul
+
+## License
+
+This repository is licensed under either MIT or Apache-2.0, at your option. For
+WARRANTIES, see LICENSE included in the `radroots_mesh_agent_client`
+distribution.
diff --git a/crates/mesh_agent_client/src/lib.rs b/crates/mesh_agent_client/src/lib.rs
@@ -0,0 +1,245 @@
+#![forbid(unsafe_code)]
+
+use radroots_mesh::{
+ RADROOTS_MESH_PREVIEW_DENIAL_MESSAGE, RADROOTS_MESH_PREVIEW_POLICY_ID,
+ RadrootsMeshPayloadPolicy,
+};
+use radroots_mesh_agent_proto::{
+ RADROOTS_MESH_AGENT_SCHEMA_ID, RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, schema_sha256_hex,
+};
+use radroots_transport::{
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportMeshScopeId,
+};
+
+pub const RADROOTS_MESH_AGENT_CLIENT_SCHEMA_ID: &str = RADROOTS_MESH_AGENT_SCHEMA_ID;
+pub const RADROOTS_MESH_AGENT_CLIENT_SCHEMA_NAMESPACE: &str = RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MeshAgentStatusRequest {
+ pub include_transports: bool,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MeshAgentStatusResponse {
+ pub transports: Vec<MeshAgentTransportStatus>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MeshAgentTransportStatus {
+ pub transport: MeshAgentTransportKind,
+ pub profile_id: String,
+ pub endpoint_uri: String,
+ pub configured: bool,
+ pub implementation: MeshAgentImplementation,
+ pub usable_for_delivery: bool,
+ pub message: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum MeshAgentImplementation {
+ Real,
+ Mock,
+ PreviewUnavailable,
+}
+
+impl MeshAgentImplementation {
+ pub fn schema_name(self) -> &'static str {
+ match self {
+ Self::Real => "real",
+ Self::Mock => "mock",
+ Self::PreviewUnavailable => "previewUnavailable",
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum MeshAgentTransportKind {
+ Reticulum,
+}
+
+impl MeshAgentTransportKind {
+ pub fn schema_name(self) -> &'static str {
+ match self {
+ Self::Reticulum => "reticulum",
+ }
+ }
+
+ pub fn transport_kind(self) -> RadrootsTransportKind {
+ match self {
+ Self::Reticulum => RadrootsTransportKind::Reticulum,
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MeshAgentPublishRequest {
+ pub publish_request_id: String,
+ pub payload_cbor: Vec<u8>,
+ pub event_id: String,
+ pub target_fingerprint: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MeshAgentPublishResponse {
+ pub publish_request_id: String,
+ pub status: MeshAgentResponseStatus,
+ pub transport_receipts: Vec<MeshAgentTransportReceipt>,
+ pub event_id: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum MeshAgentResponseStatus {
+ Accepted,
+ Deferred,
+ Rejected,
+}
+
+impl MeshAgentResponseStatus {
+ pub fn schema_name(self) -> &'static str {
+ match self {
+ Self::Accepted => "accepted",
+ Self::Deferred => "deferred",
+ Self::Rejected => "rejected",
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MeshAgentTransportReceipt {
+ pub transport_kind: MeshAgentTransportKind,
+ pub endpoint_uri: String,
+ pub outcome: MeshAgentTransportOutcome,
+ pub message: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum MeshAgentTransportOutcome {
+ Accepted,
+ Delivered,
+ Forwarded,
+ StoredByGateway,
+ DeferredUntilImplemented,
+ Rejected,
+ RouteUnavailable,
+ Timeout,
+ TransportUnavailable,
+}
+
+impl MeshAgentTransportOutcome {
+ pub fn schema_name(self) -> &'static str {
+ match self {
+ Self::Accepted => "accepted",
+ Self::Delivered => "delivered",
+ Self::Forwarded => "forwarded",
+ Self::StoredByGateway => "storedByGateway",
+ Self::DeferredUntilImplemented => "deferredUntilImplemented",
+ Self::Rejected => "rejected",
+ Self::RouteUnavailable => "routeUnavailable",
+ Self::Timeout => "timeout",
+ Self::TransportUnavailable => "transportUnavailable",
+ }
+ }
+
+ pub fn is_success(self) -> bool {
+ matches!(
+ self,
+ Self::Accepted | Self::Delivered | Self::Forwarded | Self::StoredByGateway
+ )
+ }
+}
+
+pub trait RadrootsMeshAgentClient {
+ fn status(&self, request: MeshAgentStatusRequest) -> MeshAgentStatusResponse;
+ fn publish(&self, request: MeshAgentPublishRequest) -> MeshAgentPublishResponse;
+ fn schema_sha256_hex(&self) -> String;
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsMockMeshAgentClient {
+ profile_id: String,
+ endpoint_uri: String,
+ scope: RadrootsTransportMeshScopeId,
+ policy: RadrootsMeshPayloadPolicy,
+}
+
+impl RadrootsMockMeshAgentClient {
+ pub fn preview_unavailable() -> Self {
+ Self {
+ profile_id: RADROOTS_MESH_PREVIEW_POLICY_ID.to_owned(),
+ endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
+ scope: RadrootsTransportMeshScopeId::local_preview(),
+ policy: RadrootsMeshPayloadPolicy::preview_unavailable(),
+ }
+ }
+
+ pub fn scope(&self) -> &RadrootsTransportMeshScopeId {
+ &self.scope
+ }
+
+ pub fn policy(&self) -> &RadrootsMeshPayloadPolicy {
+ &self.policy
+ }
+}
+
+impl Default for RadrootsMockMeshAgentClient {
+ fn default() -> Self {
+ Self::preview_unavailable()
+ }
+}
+
+impl RadrootsMeshAgentClient for RadrootsMockMeshAgentClient {
+ fn status(&self, request: MeshAgentStatusRequest) -> MeshAgentStatusResponse {
+ let transports = if request.include_transports {
+ vec![MeshAgentTransportStatus {
+ transport: MeshAgentTransportKind::Reticulum,
+ profile_id: self.profile_id.clone(),
+ endpoint_uri: self.endpoint_uri.clone(),
+ configured: true,
+ implementation: MeshAgentImplementation::PreviewUnavailable,
+ usable_for_delivery: false,
+ message: RADROOTS_MESH_PREVIEW_DENIAL_MESSAGE.to_owned(),
+ }]
+ } else {
+ Vec::new()
+ };
+ MeshAgentStatusResponse { transports }
+ }
+
+ fn publish(&self, request: MeshAgentPublishRequest) -> MeshAgentPublishResponse {
+ MeshAgentPublishResponse {
+ publish_request_id: request.publish_request_id,
+ status: MeshAgentResponseStatus::Rejected,
+ transport_receipts: vec![MeshAgentTransportReceipt {
+ transport_kind: MeshAgentTransportKind::Reticulum,
+ endpoint_uri: self.endpoint_uri.clone(),
+ outcome: MeshAgentTransportOutcome::TransportUnavailable,
+ message: RADROOTS_MESH_PREVIEW_DENIAL_MESSAGE.to_owned(),
+ }],
+ event_id: request.event_id,
+ }
+ }
+
+ fn schema_sha256_hex(&self) -> String {
+ schema_sha256_hex()
+ }
+}
diff --git a/crates/mesh_agent_client/tests/client.rs b/crates/mesh_agent_client/tests/client.rs
@@ -0,0 +1,202 @@
+use std::fs;
+use std::path::Path;
+
+use radroots_mesh::{RADROOTS_MESH_PREVIEW_DENIAL_MESSAGE, RADROOTS_MESH_PREVIEW_POLICY_ID};
+use radroots_mesh_agent_client::{
+ MeshAgentImplementation, MeshAgentPublishRequest, MeshAgentResponseStatus,
+ MeshAgentStatusRequest, MeshAgentTransportKind, MeshAgentTransportOutcome,
+ RADROOTS_MESH_AGENT_CLIENT_SCHEMA_ID, RADROOTS_MESH_AGENT_CLIENT_SCHEMA_NAMESPACE,
+ RadrootsMeshAgentClient, RadrootsMockMeshAgentClient,
+};
+use radroots_mesh_agent_proto::{
+ RADROOTS_MESH_AGENT_SCHEMA_ID, RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, schema_sha256_hex,
+};
+use radroots_transport::{
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID,
+ RadrootsTransportKind,
+};
+use serde_json::json;
+
+fn publish_request() -> MeshAgentPublishRequest {
+ MeshAgentPublishRequest {
+ publish_request_id: "publish-preview-1".to_owned(),
+ payload_cbor: vec![1, 2, 3],
+ event_id: "event-preview-1".to_owned(),
+ target_fingerprint: "target-preview-1".to_owned(),
+ }
+}
+
+#[test]
+fn mock_client_status_reports_reticulum_preview_unavailable() {
+ let client = RadrootsMockMeshAgentClient::preview_unavailable();
+ let response = client.status(MeshAgentStatusRequest {
+ include_transports: true,
+ });
+
+ assert_eq!(client.scope().as_str(), RADROOTS_RETICULUM_PREVIEW_SCOPE_ID);
+ assert_eq!(client.policy().policy_id(), RADROOTS_MESH_PREVIEW_POLICY_ID);
+ assert_eq!(response.transports.len(), 1);
+ let status = &response.transports[0];
+ assert_eq!(status.transport, MeshAgentTransportKind::Reticulum);
+ assert_eq!(
+ status.transport.transport_kind(),
+ RadrootsTransportKind::Reticulum
+ );
+ assert_eq!(status.profile_id, RADROOTS_MESH_PREVIEW_POLICY_ID);
+ assert_eq!(status.endpoint_uri, RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI);
+ assert!(status.configured);
+ assert_eq!(
+ status.implementation,
+ MeshAgentImplementation::PreviewUnavailable
+ );
+ assert!(!status.usable_for_delivery);
+ assert_eq!(status.message, RADROOTS_MESH_PREVIEW_DENIAL_MESSAGE);
+}
+
+#[test]
+fn mock_client_status_can_omit_transport_rows_without_enabling_delivery() {
+ let client = RadrootsMockMeshAgentClient::preview_unavailable();
+ let response = client.status(MeshAgentStatusRequest {
+ include_transports: false,
+ });
+
+ assert!(response.transports.is_empty());
+ assert!(!client.policy().usable_for_delivery());
+}
+
+#[test]
+fn mock_client_publish_never_reports_success_outcomes() {
+ let client = RadrootsMockMeshAgentClient::preview_unavailable();
+ let response = client.publish(publish_request());
+
+ assert_eq!(response.publish_request_id, "publish-preview-1");
+ assert_eq!(response.status, MeshAgentResponseStatus::Rejected);
+ assert_eq!(response.event_id, "event-preview-1");
+ assert_eq!(response.transport_receipts.len(), 1);
+ let receipt = &response.transport_receipts[0];
+ assert_eq!(receipt.transport_kind, MeshAgentTransportKind::Reticulum);
+ assert_eq!(
+ receipt.endpoint_uri,
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI
+ );
+ assert_eq!(
+ receipt.outcome,
+ MeshAgentTransportOutcome::TransportUnavailable
+ );
+ assert!(!receipt.outcome.is_success());
+ assert_eq!(receipt.message, RADROOTS_MESH_PREVIEW_DENIAL_MESSAGE);
+ for outcome in [
+ MeshAgentTransportOutcome::Accepted,
+ MeshAgentTransportOutcome::Delivered,
+ MeshAgentTransportOutcome::Forwarded,
+ MeshAgentTransportOutcome::StoredByGateway,
+ ] {
+ assert!(outcome.is_success());
+ assert_ne!(receipt.outcome, outcome);
+ }
+}
+
+#[test]
+fn dto_schema_names_match_capnp_surface() {
+ assert_eq!(MeshAgentTransportKind::Reticulum.schema_name(), "reticulum");
+ assert_eq!(
+ MeshAgentImplementation::PreviewUnavailable.schema_name(),
+ "previewUnavailable"
+ );
+ assert_eq!(MeshAgentResponseStatus::Accepted.schema_name(), "accepted");
+ assert_eq!(MeshAgentResponseStatus::Deferred.schema_name(), "deferred");
+ assert_eq!(MeshAgentResponseStatus::Rejected.schema_name(), "rejected");
+ assert_eq!(
+ MeshAgentTransportOutcome::TransportUnavailable.schema_name(),
+ "transportUnavailable"
+ );
+ assert_eq!(
+ MeshAgentTransportOutcome::StoredByGateway.schema_name(),
+ "storedByGateway"
+ );
+}
+
+#[test]
+fn serde_output_uses_schema_field_names() {
+ let request = publish_request();
+ let request_json = serde_json::to_value(&request).expect("serialize request");
+
+ assert_eq!(
+ request_json,
+ json!({
+ "publishRequestId": "publish-preview-1",
+ "payloadCbor": [1, 2, 3],
+ "eventId": "event-preview-1",
+ "targetFingerprint": "target-preview-1"
+ })
+ );
+
+ let client = RadrootsMockMeshAgentClient::preview_unavailable();
+ let response_json = serde_json::to_value(client.publish(request)).expect("serialize response");
+
+ assert_eq!(response_json["publishRequestId"], "publish-preview-1");
+ assert_eq!(response_json["status"], "rejected");
+ assert_eq!(
+ response_json["transportReceipts"][0]["transportKind"],
+ "reticulum"
+ );
+ assert_eq!(
+ response_json["transportReceipts"][0]["outcome"],
+ "transportUnavailable"
+ );
+}
+
+#[test]
+fn client_schema_hash_matches_proto_authority() {
+ let client = RadrootsMockMeshAgentClient::preview_unavailable();
+
+ assert_eq!(
+ RADROOTS_MESH_AGENT_CLIENT_SCHEMA_ID,
+ RADROOTS_MESH_AGENT_SCHEMA_ID
+ );
+ assert_eq!(
+ RADROOTS_MESH_AGENT_CLIENT_SCHEMA_NAMESPACE,
+ RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE
+ );
+ assert_eq!(client.schema_sha256_hex(), schema_sha256_hex());
+ assert_eq!(
+ client.schema_sha256_hex(),
+ "712aaa11dfec25abf44edb3b0be447f0596442271d46a8b1d9fedb7c3df00bb2"
+ );
+}
+
+#[test]
+fn client_source_uses_schema_field_names_without_legacy_status_terms() {
+ let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+ let source = fs::read_to_string(manifest_dir.join("src/lib.rs")).expect("read source");
+
+ for required in [
+ "MeshAgentStatusRequest",
+ "include_transports",
+ "MeshAgentStatusResponse",
+ "transports",
+ "MeshAgentTransportStatus",
+ "profile_id",
+ "endpoint_uri",
+ "usable_for_delivery",
+ "MeshAgentPublishRequest",
+ "publish_request_id",
+ "payload_cbor",
+ "event_id",
+ "target_fingerprint",
+ "MeshAgentPublishResponse",
+ "transport_receipts",
+ ] {
+ assert!(source.contains(required), "{required}");
+ }
+ for forbidden in [
+ "readiness",
+ "implementation_state",
+ "publish_usable",
+ "fetch_usable",
+ "redacted_message",
+ "preview_noop",
+ ] {
+ assert!(!source.contains(forbidden), "{forbidden}");
+ }
+}