Skip to main content

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:

ModuleKey TypesPurpose
autoreplyAutoReplyRule, ReplyTemplateAuto-response configuration
channelChannelId, ChannelMeta, ChannelKindChannel identification
configClawDeskConfig, ValidatedConfigConfiguration types
cronCronSchedule, CronJobScheduled task definitions
errorClawDeskError, ProviderError, StorageErrorError algebra
mediaMediaAttachment, MediaTypeMedia payloads
messageInboundMessage, NormalizedMessage, OutboundMessageMessage envelope
pluginPluginManifest, PluginIdPlugin metadata
protocolProtocolVersion, WireFormatProtocol definitions
securityAclRule, ScopedTokenSecurity primitives
sessionSession, SessionKey, SessionStateSession 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:

  1. Exhaustive matching — The compiler forces every match on InboundMessage to handle all variants. Adding a new channel is a compile-time breaking change that highlights every location needing updates.

  2. Zero-cost dispatch — Pattern matching compiles to a jump table, avoiding dynamic dispatch overhead.

  3. 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
}
}
Type-Level Documentation

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),
}
Error Cardinality

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)
}
}
}
Type Safety Guarantee

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:

BenefitDescription
Type safetyCannot accidentally pass a MessageId where a SessionKey is expected
Self-documentingFunction signatures clearly indicate what each parameter requires
ValidationConstructors can enforce format invariants
EncapsulationInternal representation can change without affecting callers

Type Algebra Summary

CategoryPatternTypes
Sum typesenum (tagged union)InboundMessage, ClawDeskError, ChannelKind
Product typesstruct (record)NormalizedMessage, Session, ValidatedConfig
NewtypesSingle-field wrapperMessageId, SessionKey, ChannelId, UserId
Phantom typesZero-size markersValidatedConfig (enforces validation)
BitflagsCapability setsChannelCapabilities

$$ \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.