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:
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 {