Type System
ClawDesk uses Rust's algebraic type system to enforce correctness at compile time. The clawdesk-types crate is the zero-dependency foundation of the entire workspace, defining the vocabulary types that flow through every subsystem.
Module Structure
The clawdesk-types crate contains 11 modules:
| Module | Key Types | Purpose |
|---|---|---|
autoreply | AutoReplyRule, ReplyTemplate | Auto-response configuration |
channel | ChannelId, ChannelMeta, ChannelKind | Channel identification |
config | ClawDeskConfig, ValidatedConfig | Configuration types |
cron | CronSchedule, CronJob | Scheduled task definitions |
error | ClawDeskError, ProviderError, StorageError | Error algebra |
media | MediaAttachment, MediaType | Media payloads |
message | InboundMessage, NormalizedMessage, OutboundMessage | Message envelope |
plugin | PluginManifest, PluginId | Plugin metadata |
protocol | ProtocolVersion, WireFormat | Protocol definitions |
security | AclRule, ScopedToken | Security primitives |
session | Session, SessionKey, SessionState | Session management |
The Message Envelope: InboundMessage
The central type in ClawDesk is InboundMessage — a sum type (tagged union) that captures every possible inbound message from any channel:
/// Sum-type envelope for all inbound messages.
/// Each variant carries channel-specific metadata while ensuring
/// exhaustive handling at every processing boundary.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InboundMessage {
/// Message from Slack (includes workspace, channel, thread info)
Slack(SlackMessage),
/// Message from Discord (includes guild, channel, thread info)
Discord(DiscordMessage),
/// Message from Telegram (includes chat, reply-to, media group)
Telegram(TelegramMessage),
/// Message from WhatsApp (includes phone, media, location)
WhatsApp(WhatsAppMessage),
/// Message from Matrix (includes room, event, state)
Matrix(MatrixMessage),
/// Message from IRC (includes server, channel, nick)
Irc(IrcMessage),
/// Message from the HTTP API (direct gateway access)
Api(ApiMessage),
/// Message from the desktop app (Tauri IPC)
Desktop(DesktopMessage),
/// Message from a webhook integration
Webhook(WebhookMessage),
}
Why a Sum Type?
Using a sum type instead of a trait object provides three critical benefits:
-
Exhaustive matching — The compiler forces every
matchonInboundMessageto handle all variants. Adding a new channel is a compile-time breaking change that highlights every location needing updates. -
Zero-cost dispatch — Pattern matching compiles to a jump table, avoiding dynamic dispatch overhead.
-
Serialization — The enum is directly serializable with
serde, enabling logging, replay, and debugging.
// The compiler enforces exhaustive handling
fn extract_user_id(msg: &InboundMessage) -> UserId {
match msg {
InboundMessage::Slack(m) => UserId::from(&m.user),
InboundMessage::Discord(m) => UserId::from(&m.author),
InboundMessage::Telegram(m) => UserId::from(&m.from),
InboundMessage::WhatsApp(m) => UserId::from(&m.sender),
InboundMessage::Matrix(m) => UserId::from(&m.sender),
InboundMessage::Irc(m) => UserId::from(&m.nick),
InboundMessage::Api(m) => UserId::from(&m.api_key),
InboundMessage::Desktop(m) => UserId::from(&m.local_user),
InboundMessage::Webhook(m) => UserId::from(&m.source_id),
// Adding a new variant here causes a compile error
// until this match is updated
}
}
Each variant's inner struct carries the full channel-specific context. The enum itself acts as documentation of all supported input sources.
Normalization: NormalizedMessage
After channel-specific processing, every InboundMessage is converted into a NormalizedMessage — the canonical internal representation:
/// Channel-agnostic message representation.
/// All downstream processing operates on this type exclusively.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NormalizedMessage {
/// Unique message identifier
pub id: MessageId,
/// Session this message belongs to
pub session_key: SessionKey,
/// Source channel
pub channel_id: ChannelId,
/// User who sent the message
pub user_id: UserId,
/// Message content (text, optionally with markdown)
pub content: MessageContent,
/// Optional media attachments
pub attachments: Vec<MediaAttachment>,
/// Thread context (if replying in a thread)
pub thread: Option<ThreadContext>,
/// Original timestamp from the source channel
pub source_timestamp: DateTime<Utc>,
/// When ClawDesk received this message
pub received_at: DateTime<Utc>,
/// Additional metadata from the source channel
pub metadata: serde_json::Value,
}
Normalization Pipeline
The normalization is implemented via a From trait for each variant:
impl From<InboundMessage> for NormalizedMessage {
fn from(msg: InboundMessage) -> Self {
match msg {
InboundMessage::Slack(m) => Self {
id: MessageId::generate(),
session_key: SessionKey::from_slack(&m.team_id, &m.channel, &m.user),
channel_id: ChannelId::slack(&m.team_id, &m.channel),
user_id: UserId::from(&m.user),
content: MessageContent::from_slack_mrkdwn(&m.text),
attachments: m.files.into_iter().map(Into::into).collect(),
thread: m.thread_ts.map(|ts| ThreadContext::slack(ts)),
source_timestamp: slack_ts_to_datetime(&m.ts),
received_at: Utc::now(),
metadata: serde_json::to_value(&m.extra).unwrap_or_default(),
},
InboundMessage::Discord(m) => Self {
id: MessageId::generate(),
session_key: SessionKey::from_discord(&m.guild_id, &m.channel_id, &m.author.id),
channel_id: ChannelId::discord(&m.guild_id, &m.channel_id),
user_id: UserId::from(&m.author),
content: MessageContent::from_discord_markdown(&m.content),
attachments: m.attachments.into_iter().map(Into::into).collect(),
thread: m.thread.map(Into::into),
source_timestamp: m.timestamp,
received_at: Utc::now(),
metadata: serde_json::to_value(&m.extra).unwrap_or_default(),
},
// ... other variants follow the same pattern
}
}
}
Error Algebra
ClawDesk uses a compositional error type hierarchy built with thiserror. Each crate defines its own error enum, and they compose upward into ClawDeskError:
Per-Crate Error Types
/// Storage subsystem errors.
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("session not found: {0}")]
SessionNotFound(SessionKey),
#[error("serialization failed: {0}")]
Serialization(String),
#[error("deserialization failed: {0}")]
Deserialization(String),
#[error("transaction conflict: {0}")]
TransactionConflict(String),
#[error("WAL corruption detected at offset {offset}")]
WalCorruption { offset: u64 },
#[error("storage I/O error: {0}")]
Io(#[from] std::io::Error),
}
/// LLM provider errors.
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("provider {provider} rate limited, retry after {retry_after:?}")]
RateLimited {
provider: String,
retry_after: Option<Duration>,
},
#[error("provider {provider} returned {status}: {body}")]
ApiError {
provider: String,
status: u16,
body: String,
},
#[error("context window exceeded: {used} / {limit} tokens")]
ContextOverflow { used: usize, limit: usize },
#[error("model {model} not found in provider {provider}")]
ModelNotFound { provider: String, model: String },
#[error("provider timeout after {0:?}")]
Timeout(Duration),
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
}
Root Error Composition
/// Top-level error type that composes all subsystem errors.
#[derive(Debug, thiserror::Error)]
pub enum ClawDeskError {
#[error(transparent)]
Storage(#[from] StorageError),
#[error(transparent)]
Provider(#[from] ProviderError),
#[error(transparent)]
Channel(#[from] ChannelError),
#[error(transparent)]
Agent(#[from] AgentError),
#[error(transparent)]
Security(#[from] SecurityError),
#[error(transparent)]
Plugin(#[from] PluginError),
#[error(transparent)]
Config(#[from] ConfigError),
#[error("internal error: {0}")]
Internal(String),
}
The total error space is the sum of all variant counts:
$$ |ClawDeskError| = \sum_{i=1}^{7} |E_i| + 1 = |StorageError| + |ProviderError| + \ldots + 1 $$
With approximately 6 variants per subsystem error, the total is ~43 distinct error cases, all exhaustively matchable.
Error Conversion Flow
Errors propagate upward through the ? operator using #[from] derivations:
// In clawdesk-agents: AgentError → ClawDeskError automatically
async fn run_pipeline(
session: &SessionKey,
store: &dyn SessionStore,
provider: &dyn Provider,
) -> Result<Response, ClawDeskError> { // Returns ClawDeskError
let session = store.get_session(session) // Returns StorageError
.await?; // ? converts via #[from]
let response = provider.chat(&request) // Returns ProviderError
.await?; // ? converts via #[from]
Ok(response)
}
Configuration Types
Raw vs. Validated Configuration
ClawDesk uses the parse, don't validate pattern with two distinct config types:
/// Raw configuration — deserialized directly from TOML/YAML.
/// May contain invalid values, missing fields, or inconsistencies.
#[derive(Debug, Deserialize)]
pub struct ClawDeskConfig {
pub server: Option<ServerConfig>,
pub providers: Option<Vec<RawProviderConfig>>,
pub channels: Option<Vec<RawChannelConfig>>,
pub storage: Option<StorageConfig>,
pub security: Option<SecurityConfig>,
pub cron: Option<Vec<RawCronJob>>,
pub plugins: Option<Vec<RawPluginConfig>>,
}
/// Validated configuration — all invariants checked, defaults applied.
/// Construction is only possible through `ClawDeskConfig::validate()`.
#[derive(Debug, Clone)]
pub struct ValidatedConfig {
// Private fields — cannot be constructed directly
server: ServerConfig,
providers: Vec<ProviderConfig>,
channels: Vec<ChannelConfig>,
storage: StorageConfig,
security: SecurityConfig,
cron: Vec<CronJob>,
plugins: Vec<PluginConfig>,
}
impl ClawDeskConfig {
/// Validate raw config, applying defaults and checking invariants.
/// Returns all validation errors, not just the first one.
pub fn validate(self) -> Result<ValidatedConfig, Vec<ConfigError>> {
let mut errors = Vec::new();
// Validate server config
let server = self.server.unwrap_or_default();
if server.port == 0 {
errors.push(ConfigError::InvalidPort(0));
}
// Validate providers
let providers = match self.providers {
Some(raw) => {
raw.into_iter()
.filter_map(|p| match p.validate() {
Ok(v) => Some(v),
Err(e) => { errors.push(e); None }
})
.collect()
}
None => {
errors.push(ConfigError::NoProviders);
vec![]
}
};
// ... validate other sections
if errors.is_empty() {
Ok(ValidatedConfig {
server,
providers,
channels,
storage,
security,
cron,
plugins,
})
} else {
Err(errors)
}
}
}
ValidatedConfig has no public constructor. The only way to obtain one is through ClawDeskConfig::validate(). This means any function accepting &ValidatedConfig can trust that all invariants hold without re-checking.
Session and Identity Types
Session Key
The SessionKey uniquely identifies a conversation session across channels:
/// Composite key that uniquely identifies a conversation session.
/// Deterministically derived from channel + user + context.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionKey(String);
impl SessionKey {
/// Create a key from raw components.
pub fn new(channel_id: &ChannelId, user_id: &UserId, context: &str) -> Self {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(channel_id.as_str().as_bytes());
hasher.update(b":");
hasher.update(user_id.as_str().as_bytes());
hasher.update(b":");
hasher.update(context.as_bytes());
let hash = hasher.finalize();
Self(hex::encode(&hash[..16])) // 128-bit truncation
}
/// Generate a random session key (for API/testing).
pub fn generate() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
}
Channel Identification
/// Strongly-typed channel identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChannelId(String);
/// Channel platform type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChannelKind {
Slack,
Discord,
Telegram,
WhatsApp,
Matrix,
Irc,
Api,
Desktop,
Webhook,
}
/// Metadata about a channel instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMeta {
pub id: ChannelId,
pub kind: ChannelKind,
pub display_name: String,
pub capabilities: ChannelCapabilities,
pub created_at: DateTime<Utc>,
}
/// Bitflags for channel capability detection.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ChannelCapabilities {
pub threading: bool,
pub streaming: bool,
pub reactions: bool,
pub group_management: bool,
pub rich_text: bool,
pub file_upload: bool,
pub voice: bool,
}
Newtype Pattern
ClawDesk uses newtypes extensively to prevent type confusion:
// These are all different types — the compiler prevents mixing them
pub struct MessageId(String);
pub struct SessionKey(String);
pub struct ChannelId(String);
pub struct UserId(String);
pub struct ThreadId(String);
pub struct PluginId(String);
// This would be a compile error:
// let session = SessionKey::from(message_id); // Error: no From impl
The newtype pattern provides:
| Benefit | Description |
|---|---|
| Type safety | Cannot accidentally pass a MessageId where a SessionKey is expected |
| Self-documenting | Function signatures clearly indicate what each parameter requires |
| Validation | Constructors can enforce format invariants |
| Encapsulation | Internal representation can change without affecting callers |
Type Algebra Summary
| Category | Pattern | Types |
|---|---|---|
| Sum types | enum (tagged union) | InboundMessage, ClawDeskError, ChannelKind |
| Product types | struct (record) | NormalizedMessage, Session, ValidatedConfig |
| Newtypes | Single-field wrapper | MessageId, SessionKey, ChannelId, UserId |
| Phantom types | Zero-size markers | ValidatedConfig (enforces validation) |
| Bitflags | Capability sets | ChannelCapabilities |
$$ \text{Total type space} = \sum_{\text{enums}} \prod_{\text{fields}} |T_i| \times \prod_{\text{newtypes}} |T_j| $$
The system is designed so that the representable state space closely matches the valid state space, minimizing the gap where bugs can hide.