commit 652c23d6068c67e859eed256ade32684125db0e4
parent cb5ff2e27e42c261ab3e122d8d85d75a108ce6e3
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 10:25:25 +0000
sdk: compose canonical lower interfaces in ClientBuilder
- inject storage signer source sink and sync capabilities explicitly
- provide deterministic memory composition without hidden side effects
- reject missing storage and signer-without-sink configurations
- prove memory SQLite Nostr and directional capability contracts
Diffstat:
3 files changed, 291 insertions(+), 16 deletions(-)
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -1,19 +1,273 @@
//! Client construction and lifecycle.
+use std::sync::Arc;
+
+use radroots_signing::Signer;
+use radroots_storage::Storage;
+#[cfg(feature = "memory")]
+use radroots_storage::{event::SourceGeneration, memory::MemoryStorage};
+use radroots_transport::{EventSink, EventSource};
+
+use crate::{Error, Result};
+
/// Cloneable handle to a composed Radroots client.
-///
-/// Construction is intentionally introduced by the ordered builder
-/// composition step; this type establishes the durable root identity only.
-#[derive(Clone, Debug)]
+#[derive(Clone)]
pub struct Client {
- _private: (),
+ inner: Arc<ClientInner>,
}
/// Explicit composition boundary for a [`Client`].
-///
-/// The lower capability fields and construction methods are added by the
-/// ordered composition step.
-#[derive(Debug)]
+#[derive(Default)]
pub struct ClientBuilder {
- _private: (),
+ storage: Option<Arc<dyn Storage>>,
+ signer: Option<Arc<dyn Signer>>,
+ source: Option<Arc<dyn EventSource>>,
+ sink: Option<Arc<dyn EventSink>>,
+ #[cfg(feature = "sync")]
+ sync: Option<radroots_sync::Engine>,
+}
+
+struct ClientInner {
+ storage: Arc<dyn Storage>,
+ signer: Option<Arc<dyn Signer>>,
+ source: Option<Arc<dyn EventSource>>,
+ sink: Option<Arc<dyn EventSink>>,
+ #[cfg(feature = "sync")]
+ sync: Option<radroots_sync::Engine>,
+}
+
+impl ClientBuilder {
+ /// Creates an empty builder with no hidden storage, network, signing, or
+ /// runtime side effects.
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Creates a builder backed by deterministic in-process memory storage.
+ #[cfg(feature = "memory")]
+ #[must_use]
+ pub fn memory(generation: SourceGeneration) -> Self {
+ Self::new().storage(Arc::new(MemoryStorage::new(generation)))
+ }
+
+ /// Injects the canonical storage capability.
+ #[must_use]
+ pub fn storage(mut self, storage: Arc<dyn Storage>) -> Self {
+ self.storage = Some(storage);
+ self
+ }
+
+ /// Injects an optional canonical signer capability.
+ #[must_use]
+ pub fn signer(mut self, signer: Arc<dyn Signer>) -> Self {
+ self.signer = Some(signer);
+ self
+ }
+
+ /// Injects an optional inbound event source.
+ #[must_use]
+ pub fn source(mut self, source: Arc<dyn EventSource>) -> Self {
+ self.source = Some(source);
+ self
+ }
+
+ /// Injects an optional outbound event sink.
+ #[must_use]
+ pub fn sink(mut self, sink: Arc<dyn EventSink>) -> Self {
+ self.sink = Some(sink);
+ self
+ }
+
+ /// Injects an explicitly composed synchronization engine.
+ #[cfg(feature = "sync")]
+ #[must_use]
+ pub fn sync_engine(mut self, sync: radroots_sync::Engine) -> Self {
+ self.sync = Some(sync);
+ self
+ }
+
+ /// Validates the selected capabilities and creates a client handle.
+ pub fn build(self) -> Result<Client> {
+ let storage = self.storage.ok_or(Error::MissingStorage)?;
+ if self.signer.is_some() && self.sink.is_none() {
+ return Err(Error::SignerWithoutSink);
+ }
+ Ok(Client {
+ inner: Arc::new(ClientInner {
+ storage,
+ signer: self.signer,
+ source: self.source,
+ sink: self.sink,
+ #[cfg(feature = "sync")]
+ sync: self.sync,
+ }),
+ })
+ }
+}
+
+impl Client {
+ /// Returns the injected canonical storage capability.
+ #[must_use]
+ pub fn storage(&self) -> &dyn Storage {
+ self.inner.storage.as_ref()
+ }
+
+ /// Returns the injected signer, when outbound authoring is enabled.
+ #[must_use]
+ pub fn signer(&self) -> Option<&dyn Signer> {
+ self.inner.signer.as_deref()
+ }
+
+ /// Returns the injected inbound source, when pull is enabled.
+ #[must_use]
+ pub fn source(&self) -> Option<&dyn EventSource> {
+ self.inner.source.as_deref()
+ }
+
+ /// Returns the injected outbound sink, when delivery is enabled.
+ #[must_use]
+ pub fn sink(&self) -> Option<&dyn EventSink> {
+ self.inner.sink.as_deref()
+ }
+
+ /// Returns the explicit synchronization engine, when configured.
+ #[cfg(feature = "sync")]
+ #[must_use]
+ pub fn sync_engine(&self) -> Option<&radroots_sync::Engine> {
+ self.inner.sync.as_ref()
+ }
+}
+
+impl std::fmt::Debug for Client {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("Client")
+ .field("signer", &self.inner.signer.is_some())
+ .field("source", &self.inner.source.is_some())
+ .field("sink", &self.inner.sink.is_some())
+ .finish_non_exhaustive()
+ }
+}
+
+impl std::fmt::Debug for ClientBuilder {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("ClientBuilder")
+ .field("storage", &self.storage.is_some())
+ .field("signer", &self.signer.is_some())
+ .field("source", &self.source.is_some())
+ .field("sink", &self.sink.is_some())
+ .finish_non_exhaustive()
+ }
+}
+
+#[cfg(all(test, feature = "memory"))]
+mod tests {
+ use super::*;
+ use radroots_signing::{
+ Error as SigningError, SignReceipt, SignRequest, SignerStatus, error::Kind,
+ signer::BoxFuture as SigningFuture,
+ };
+ use radroots_transport::{
+ DeliveryReceipt, DeliveryRequest, Error as TransportError, FetchPage, FetchRequest,
+ SinkStatus, SourceStatus, source::BoxFuture as TransportFuture,
+ };
+
+ struct TestSource;
+ struct TestSink;
+ struct TestSigner;
+
+ impl EventSource for TestSource {
+ fn status(&self) -> TransportFuture<'_, std::result::Result<SourceStatus, TransportError>> {
+ Box::pin(async { Err(TransportError::UnsupportedOperation) })
+ }
+
+ fn fetch(
+ &self,
+ _request: FetchRequest,
+ ) -> TransportFuture<'_, std::result::Result<FetchPage, TransportError>> {
+ Box::pin(async { Err(TransportError::UnsupportedOperation) })
+ }
+ }
+
+ impl EventSink for TestSink {
+ fn status(&self) -> TransportFuture<'_, std::result::Result<SinkStatus, TransportError>> {
+ Box::pin(async { Err(TransportError::UnsupportedOperation) })
+ }
+
+ fn deliver(
+ &self,
+ _request: DeliveryRequest,
+ ) -> TransportFuture<'_, std::result::Result<DeliveryReceipt, TransportError>> {
+ Box::pin(async { Err(TransportError::UnsupportedOperation) })
+ }
+ }
+
+ impl Signer for TestSigner {
+ fn status(&self) -> SigningFuture<'_, std::result::Result<SignerStatus, SigningError>> {
+ Box::pin(async { Err(SigningError::new(Kind::InternalError)) })
+ }
+
+ fn sign(
+ &self,
+ _request: SignRequest,
+ ) -> SigningFuture<'_, std::result::Result<SignReceipt, SigningError>> {
+ Box::pin(async { Err(SigningError::new(Kind::InternalError)) })
+ }
+ }
+
+ fn generation() -> SourceGeneration {
+ SourceGeneration::new([1; 32]).expect("non-zero generation")
+ }
+
+ #[test]
+ fn missing_storage_and_signer_without_sink_fail_closed() {
+ assert!(matches!(
+ ClientBuilder::new().build(),
+ Err(Error::MissingStorage)
+ ));
+ assert!(matches!(
+ ClientBuilder::memory(generation())
+ .signer(Arc::new(TestSigner))
+ .build(),
+ Err(Error::SignerWithoutSink)
+ ));
+ }
+
+ #[test]
+ fn memory_local_source_only_and_sink_only_compositions_are_explicit() {
+ let local = ClientBuilder::memory(generation()).build().expect("local");
+ assert!(local.source().is_none());
+ assert!(local.sink().is_none());
+ assert!(local.signer().is_none());
+
+ let source = ClientBuilder::memory(generation())
+ .source(Arc::new(TestSource))
+ .build()
+ .expect("source-only");
+ assert!(source.source().is_some());
+ assert!(source.sink().is_none());
+
+ let sink = ClientBuilder::memory(generation())
+ .sink(Arc::new(TestSink))
+ .build()
+ .expect("sink-only");
+ assert!(sink.source().is_none());
+ assert!(sink.sink().is_some());
+ }
+
+ #[test]
+ fn signer_with_sink_is_valid_and_diagnostics_are_capability_only() {
+ let client = ClientBuilder::memory(generation())
+ .sink(Arc::new(TestSink))
+ .signer(Arc::new(TestSigner))
+ .build()
+ .expect("outbound client");
+ assert!(client.signer().is_some());
+ assert_eq!(
+ format!("{client:?}"),
+ "Client { signer: true, source: false, sink: true, .. }"
+ );
+ }
}
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -2,18 +2,23 @@
use std::{error, fmt};
-/// Narrow SDK error boundary.
-///
-/// Concrete variants are introduced with the owning operation contracts.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
-pub struct Error {
- _private: (),
+pub enum Error {
+ /// No storage capability was supplied.
+ MissingStorage,
+ /// A signer cannot be selected without an outbound event sink.
+ SignerWithoutSink,
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str("Radroots SDK operation failed")
+ match self {
+ Self::MissingStorage => formatter.write_str("SDK storage capability is missing"),
+ Self::SignerWithoutSink => {
+ formatter.write_str("SDK signer requires an outbound event sink")
+ }
+ }
}
}
diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs
@@ -73,6 +73,22 @@ fn root_types_have_the_required_std_contracts() {
assert!(result.is_ok());
}
+#[cfg(feature = "sqlite")]
+#[test]
+fn sqlite_backend_implements_the_canonical_storage_capability() {
+ fn assert_storage<T: radroots_storage::Storage>() {}
+ assert_storage::<radroots_storage_sqlite::SqliteStorage>();
+}
+
+#[cfg(feature = "nostr")]
+#[test]
+fn nostr_adapter_implements_independent_source_and_sink_capabilities() {
+ fn assert_source<T: radroots_transport::EventSource>() {}
+ fn assert_sink<T: radroots_transport::EventSink>() {}
+ assert_source::<radroots_transport_nostr::NostrTransport>();
+ assert_sink::<radroots_transport_nostr::NostrTransport>();
+}
+
fn dependency_names(manifest: &str) -> BTreeSet<&str> {
let dependencies = manifest
.split_once("[dependencies]")