sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 9e511cc931c7b6c51cf168ec37d1b62a43944a12
parent 1ce1da677c6992ce87129a59cd6db2a9a720e401
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 10:37:23 +0000

sdk: implement the unified capability model

- define stable presentation-independent capability identities
- separate compilation configuration availability and maturity
- preserve preview transports as explicit unsupported reports
- verify feature profiles degradation and lifecycle precedence

Diffstat:
Mcrates/sdk/src/capability.rs | 324++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/client.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 414 insertions(+), 5 deletions(-)

diff --git a/crates/sdk/src/capability.rs b/crates/sdk/src/capability.rs @@ -1 +1,323 @@ -//! Client capability reporting. +//! Side-effect-free client capability reporting. + +use std::collections::{BTreeMap, BTreeSet}; + +/// Stable runtime identity for an SDK capability. +/// +/// These values intentionally describe behavior rather than Cargo features. +/// Construction is private so every reported ID comes from the governed +/// catalog below. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CapabilityId(&'static str); + +impl CapabilityId { + /// Canonical storage operations. + pub const CANONICAL_STORAGE: Self = Self("storage.canonical"); + /// Persistent storage operations. + pub const PERSISTENT_STORAGE: Self = Self("storage.persistent"); + /// Storage backup and restore operations. + pub const BACKUP_RESTORE: Self = Self("storage.backup-restore"); + /// Local signing. + pub const LOCAL_SIGNING: Self = Self("signing.local"); + /// NIP-46 remote signing. + pub const NIP46_SIGNING: Self = Self("signing.nip46"); + /// Nostr event fetch. + pub const NOSTR_FETCH: Self = Self("transport.nostr.fetch"); + /// Nostr event delivery. + pub const NOSTR_DELIVERY: Self = Self("transport.nostr.delivery"); + /// Reticulum event fetch preview. + pub const RETICULUM_FETCH: Self = Self("transport.reticulum.fetch"); + /// Reticulum event delivery preview. + pub const RETICULUM_DELIVERY: Self = Self("transport.reticulum.delivery"); + /// Mesh transport preview. + pub const MESH_TRANSPORT: Self = Self("transport.mesh"); + /// SimpleX transport experiment. + pub const SIMPLEX_TRANSPORT: Self = Self("transport.simplex"); + /// Daemon-mediated event delivery. + pub const DAEMON_DELIVERY: Self = Self("transport.daemon.delivery"); + /// Inbound synchronization. + pub const SYNC_PULL: Self = Self("sync.pull"); + /// Outbound synchronization. + pub const SYNC_PUSH: Self = Self("sync.push"); + /// Farm event publication. + pub const FARM_PUBLICATION: Self = Self("product.farm.publish"); + /// Listing event publication. + pub const LISTING_PUBLICATION: Self = Self("product.listing.publish"); + /// Trade command execution. + pub const TRADE_COMMANDS: Self = Self("product.trade.command"); + /// Trade queries. + pub const TRADE_QUERIES: Self = Self("product.trade.query"); + /// Knowledge event support. + pub const KNOWLEDGE_EVENTS: Self = Self("event.knowledge"); + + /// Returns the stable presentation-independent identity. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +impl std::fmt::Display for CapabilityId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.0) + } +} + +/// Product maturity independent of runtime availability. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Maturity { + /// Supported Release V1 behavior. + Stable, + /// Preserved pre-stable behavior with an explicit compatibility warning. + Preview, + /// Exploratory behavior without a compatibility commitment. + Experimental, +} + +/// Current runtime availability independent of compilation and configuration. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Availability { + /// The configured capability is ready. + Available, + /// The configured capability is usable with reduced functionality. + Degraded, + /// The capability is compiled but not currently usable. + Unavailable, + /// The capability is not supported by this build. + Unsupported, +} + +/// One immutable capability observation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CapabilityStatus { + id: CapabilityId, + compiled: bool, + configured: bool, + availability: Availability, + maturity: Maturity, +} + +impl CapabilityStatus { + /// Returns the stable runtime identity. + #[must_use] + pub const fn id(self) -> CapabilityId { + self.id + } + + /// Returns whether support was compiled into this package. + #[must_use] + pub const fn is_compiled(self) -> bool { + self.compiled + } + + /// Returns whether the host configured the capability for this client. + #[must_use] + pub const fn is_configured(self) -> bool { + self.configured + } + + /// Returns the current side-effect-free availability observation. + #[must_use] + pub const fn availability(self) -> Availability { + self.availability + } + + /// Returns the independent product maturity classification. + #[must_use] + pub const fn maturity(self) -> Maturity { + self.maturity + } +} + +/// Complete deterministic capability report for one client observation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityReport { + statuses: Vec<CapabilityStatus>, +} + +impl CapabilityReport { + /// Returns all known capabilities in stable catalog order. + pub fn iter(&self) -> impl ExactSizeIterator<Item = &CapabilityStatus> { + self.statuses.iter() + } + + /// Finds one capability by stable runtime identity. + #[must_use] + pub fn get(&self, id: CapabilityId) -> Option<CapabilityStatus> { + self.statuses.iter().copied().find(|status| status.id == id) + } +} + +#[derive(Clone, Copy)] +struct Definition { + id: CapabilityId, + maturity: Maturity, + compiled: bool, +} + +const CATALOG: &[Definition] = &[ + Definition::stable(CapabilityId::CANONICAL_STORAGE, true), + Definition::stable(CapabilityId::PERSISTENT_STORAGE, cfg!(feature = "sqlite")), + Definition::stable(CapabilityId::BACKUP_RESTORE, true), + Definition::stable(CapabilityId::LOCAL_SIGNING, cfg!(feature = "local-signing")), + Definition::stable(CapabilityId::NIP46_SIGNING, cfg!(feature = "nip46")), + Definition::stable(CapabilityId::NOSTR_FETCH, cfg!(feature = "nostr")), + Definition::stable(CapabilityId::NOSTR_DELIVERY, cfg!(feature = "nostr")), + Definition::preview(CapabilityId::RETICULUM_FETCH), + Definition::preview(CapabilityId::RETICULUM_DELIVERY), + Definition::experimental(CapabilityId::MESH_TRANSPORT), + Definition::experimental(CapabilityId::SIMPLEX_TRANSPORT), + Definition::stable(CapabilityId::DAEMON_DELIVERY, cfg!(feature = "radrootsd")), + Definition::stable(CapabilityId::SYNC_PULL, cfg!(feature = "sync")), + Definition::stable(CapabilityId::SYNC_PUSH, cfg!(feature = "sync")), + Definition::stable(CapabilityId::FARM_PUBLICATION, true), + Definition::stable(CapabilityId::LISTING_PUBLICATION, true), + Definition::stable(CapabilityId::TRADE_COMMANDS, true), + Definition::stable(CapabilityId::TRADE_QUERIES, true), + Definition::stable(CapabilityId::KNOWLEDGE_EVENTS, cfg!(feature = "knowledge")), +]; + +impl Definition { + const fn stable(id: CapabilityId, compiled: bool) -> Self { + Self { + id, + maturity: Maturity::Stable, + compiled, + } + } + + const fn preview(id: CapabilityId) -> Self { + Self { + id, + maturity: Maturity::Preview, + compiled: false, + } + } + + const fn experimental(id: CapabilityId) -> Self { + Self { + id, + maturity: Maturity::Experimental, + compiled: false, + } + } +} + +pub(crate) struct Context<'a> { + pub(crate) storage: bool, + pub(crate) signer: bool, + pub(crate) source: bool, + pub(crate) sink: bool, + pub(crate) sync: bool, + pub(crate) lifecycle_availability: Availability, + pub(crate) explicitly_configured: &'a BTreeSet<CapabilityId>, + pub(crate) overrides: &'a BTreeMap<CapabilityId, Availability>, +} + +pub(crate) fn report(context: Context<'_>) -> CapabilityReport { + let statuses = CATALOG + .iter() + .map(|definition| { + let configured = configured(definition.id, &context); + let availability = if !definition.compiled { + Availability::Unsupported + } else if !configured { + Availability::Unavailable + } else if context.lifecycle_availability != Availability::Available { + context.lifecycle_availability + } else { + context + .overrides + .get(&definition.id) + .copied() + .unwrap_or(context.lifecycle_availability) + }; + CapabilityStatus { + id: definition.id, + compiled: definition.compiled, + configured, + availability, + maturity: definition.maturity, + } + }) + .collect(); + CapabilityReport { statuses } +} + +fn configured(id: CapabilityId, context: &Context<'_>) -> bool { + match id { + CapabilityId::CANONICAL_STORAGE + | CapabilityId::BACKUP_RESTORE + | CapabilityId::TRADE_QUERIES => context.storage, + CapabilityId::SYNC_PULL => context.sync && context.source, + CapabilityId::SYNC_PUSH => context.sync && context.sink, + CapabilityId::FARM_PUBLICATION + | CapabilityId::LISTING_PUBLICATION + | CapabilityId::TRADE_COMMANDS => context.signer && context.sink, + CapabilityId::KNOWLEDGE_EVENTS => cfg!(feature = "knowledge"), + CapabilityId::RETICULUM_FETCH + | CapabilityId::RETICULUM_DELIVERY + | CapabilityId::MESH_TRANSPORT + | CapabilityId::SIMPLEX_TRANSPORT => false, + _ => context.explicitly_configured.contains(&id), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_ids_are_unique_and_not_feature_names() { + for (index, definition) in CATALOG.iter().enumerate() { + assert!( + !CATALOG[..index] + .iter() + .any(|candidate| candidate.id == definition.id), + "duplicate capability {}", + definition.id + ); + assert!(definition.id.as_str().contains('.')); + } + } + + #[test] + fn maturity_and_unsupported_preview_states_are_independent() { + let overrides = BTreeMap::new(); + let explicitly_configured = BTreeSet::new(); + let report = report(Context { + storage: true, + signer: false, + source: false, + sink: false, + sync: false, + lifecycle_availability: Availability::Available, + explicitly_configured: &explicitly_configured, + overrides: &overrides, + }); + let storage = report + .get(CapabilityId::CANONICAL_STORAGE) + .expect("storage"); + assert!(storage.is_compiled()); + assert!(storage.is_configured()); + assert_eq!(storage.availability(), Availability::Available); + assert_eq!(storage.maturity(), Maturity::Stable); + + let knowledge = report + .get(CapabilityId::KNOWLEDGE_EVENTS) + .expect("knowledge"); + assert_eq!(knowledge.is_compiled(), cfg!(feature = "knowledge")); + assert_eq!(knowledge.is_configured(), cfg!(feature = "knowledge")); + + let reticulum = report + .get(CapabilityId::RETICULUM_FETCH) + .expect("reticulum"); + assert!(!reticulum.is_compiled()); + assert!(!reticulum.is_configured()); + assert_eq!(reticulum.availability(), Availability::Unsupported); + assert_eq!(reticulum.maturity(), Maturity::Preview); + + let mesh = report.get(CapabilityId::MESH_TRANSPORT).expect("mesh"); + assert_eq!(mesh.maturity(), Maturity::Experimental); + } +} diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -1,8 +1,11 @@ //! Client construction and lifecycle. -use std::sync::{ - Arc, - atomic::{AtomicU8, Ordering}, +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }, }; use radroots_signing::Signer; @@ -11,7 +14,10 @@ use radroots_storage::Storage; use radroots_storage::{event::SourceGeneration, memory::MemoryStorage}; use radroots_transport::{EventSink, EventSource}; -use crate::{Error, Result}; +use crate::{ + Error, Result, + capability::{Availability, CapabilityId, CapabilityReport}, +}; /// Cloneable handle to a composed Radroots client. #[derive(Clone)] @@ -28,6 +34,8 @@ pub struct ClientBuilder { sink: Option<Arc<dyn EventSink>>, #[cfg(feature = "sync")] sync: Option<radroots_sync::Engine>, + capability_availability: BTreeMap<CapabilityId, Availability>, + explicitly_configured_capabilities: BTreeSet<CapabilityId>, } struct ClientInner { @@ -37,6 +45,8 @@ struct ClientInner { sink: Option<Arc<dyn EventSink>>, #[cfg(feature = "sync")] sync: Option<radroots_sync::Engine>, + capability_availability: BTreeMap<CapabilityId, Availability>, + explicitly_configured_capabilities: BTreeSet<CapabilityId>, lifecycle: AtomicU8, } @@ -96,6 +106,18 @@ impl ClientBuilder { self } + /// Marks a specialized capability as configured and records its + /// host-observed initial availability without probing resources. + /// + /// Reports ignore this observation for capabilities that are not compiled + /// or configured. Runtime IDs are independent from Cargo feature names. + #[must_use] + pub fn capability_availability(mut self, id: CapabilityId, availability: Availability) -> Self { + self.explicitly_configured_capabilities.insert(id); + self.capability_availability.insert(id, availability); + self + } + /// Validates the selected capabilities and creates a client handle. pub fn build(self) -> Result<Client> { let storage = self.storage.ok_or(Error::MissingStorage)?; @@ -110,6 +132,8 @@ impl ClientBuilder { sink: self.sink, #[cfg(feature = "sync")] sync: self.sync, + capability_availability: self.capability_availability, + explicitly_configured_capabilities: self.explicitly_configured_capabilities, lifecycle: AtomicU8::new(OPEN), }), }) @@ -117,6 +141,27 @@ impl ClientBuilder { } impl Client { + /// Returns a deterministic capability report without probing resources or + /// performing filesystem, network, signing, or storage operations. + #[must_use] + pub fn capabilities(&self) -> CapabilityReport { + let lifecycle_availability = match self.inner.lifecycle.load(Ordering::Acquire) { + OPEN => Availability::Available, + CLOSING | CLOSE_RETRY_REQUIRED => Availability::Degraded, + _ => Availability::Unavailable, + }; + crate::capability::report(crate::capability::Context { + storage: true, + signer: self.inner.signer.is_some(), + source: self.inner.source.is_some(), + sink: self.inner.sink.is_some(), + sync: self.sync_is_configured(), + lifecycle_availability, + explicitly_configured: &self.inner.explicitly_configured_capabilities, + overrides: &self.inner.capability_availability, + }) + } + /// Returns the injected canonical storage capability. pub fn storage(&self) -> Result<&dyn Storage> { self.require_open()?; @@ -195,6 +240,16 @@ impl Client { _ => Err(Error::ClientClosed), } } + + #[cfg(feature = "sync")] + fn sync_is_configured(&self) -> bool { + self.inner.sync.is_some() + } + + #[cfg(not(feature = "sync"))] + fn sync_is_configured(&self) -> bool { + false + } } struct CloseAttempt { @@ -415,6 +470,38 @@ mod tests { assert!(client.is_closed()); } + #[test] + fn capability_reports_separate_configuration_degradation_and_lifecycle() { + let local = ClientBuilder::memory(generation()) + .capability_availability(CapabilityId::CANONICAL_STORAGE, Availability::Degraded) + .build() + .expect("client"); + let report = local.capabilities(); + let storage = report + .get(CapabilityId::CANONICAL_STORAGE) + .expect("storage"); + assert!(storage.is_compiled()); + assert!(storage.is_configured()); + assert_eq!(storage.availability(), Availability::Degraded); + + let signing = report.get(CapabilityId::LOCAL_SIGNING).expect("signing"); + assert!(!signing.is_configured()); + assert!(matches!( + signing.availability(), + Availability::Unavailable | Availability::Unsupported + )); + + block_on(local.close()).expect("close"); + assert_eq!( + local + .capabilities() + .get(CapabilityId::BACKUP_RESTORE) + .expect("backup") + .availability(), + Availability::Unavailable + ); + } + struct ThreadWaker; impl Wake for ThreadWaker {