Skip to main content

x0x/
dm_capability.rs

1//! Mesh-wide DM-capability advertisement — so senders can discover which
2//! peers support the gossip DM inbox path without needing an explicit
3//! `AgentCard` exchange.
4//!
5//! This is complementary to the `AgentCard.dm_capabilities` field:
6//! - AgentCards are the authoritative record (signed+authenticated when
7//!   exchanged via invite links / card imports).
8//! - The capability advert is the mesh-wide "I'm here and I support v1"
9//!   broadcast that VPS bootstrap nodes and other mesh members use to
10//!   discover each other's DM support without ever exchanging cards.
11//!
12//! Design trade-offs:
13//! - Advert is signed by the sender's ML-DSA-65 agent key so receivers
14//!   verify authenticity before caching.
15//! - Cached entries have a TTL (15 minutes) so stale adverts don't
16//!   persist forever; senders republish every 5 minutes during normal
17//!   operation.
18//! - This is NOT a presence system — it's strictly capability discovery.
19//!   Presence + liveness continue to be handled by
20//!   `saorsa-gossip-presence`.
21
22use crate::dm::DmCapabilities;
23use crate::identity::{AgentId, MachineId};
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::sync::Mutex;
27use std::time::{Duration, Instant};
28
29/// Well-known gossip topic for capability adverts. Every x0x 0.18+ agent
30/// subscribes on mesh join.
31pub const DM_CAPABILITY_TOPIC: &str = "x0x/caps/v1";
32
33/// Domain-separation prefix for the advert signature bytes.
34const ADVERT_SIGN_DOMAIN: &[u8] = b"x0x-caps-v1";
35
36/// Cadence at which agents republish their advert.
37pub const ADVERT_PUBLISH_INTERVAL_SECS: u64 = 300;
38
39/// How long a cached advert remains usable before it's considered stale.
40/// Must be > `ADVERT_PUBLISH_INTERVAL_SECS` so that a single missed
41/// publish window doesn't evict the cache entry.
42pub const ADVERT_CACHE_TTL_SECS: u64 = 900;
43
44/// Signed capability advertisement broadcast on the mesh-wide capability
45/// topic.
46///
47/// Domain-separated signed bytes:
48/// `ADVERT_SIGN_DOMAIN || agent_id || machine_id || created_at_unix_ms
49///  || postcard(capabilities)`.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CapabilityAdvert {
52    /// Wire version. Bumped on breaking changes.
53    pub protocol_version: u16,
54
55    /// Advertising agent's id.
56    pub agent_id: [u8; 32],
57
58    /// Machine binding the ML-DSA-65 signature to a specific daemon
59    /// process (so an agent_id can't advertise from two machines
60    /// simultaneously — receivers can detect churn).
61    pub machine_id: [u8; 32],
62
63    /// Sender-local unix-ms at advert generation.
64    pub created_at_unix_ms: u64,
65
66    /// The advertised capabilities.
67    pub capabilities: DmCapabilities,
68
69    /// ML-DSA-65 signature over the domain-separated advert bytes.
70    pub signature: Vec<u8>,
71}
72
73impl CapabilityAdvert {
74    /// Build the canonical signed-bytes representation (what ML-DSA-65
75    /// signs/verifies over).
76    pub fn signed_bytes(&self) -> Result<Vec<u8>, postcard::Error> {
77        let caps_bytes = postcard::to_stdvec(&self.capabilities)?;
78        let mut out =
79            Vec::with_capacity(ADVERT_SIGN_DOMAIN.len() + 2 + 32 + 32 + 8 + caps_bytes.len());
80        out.extend_from_slice(ADVERT_SIGN_DOMAIN);
81        out.extend_from_slice(&self.protocol_version.to_be_bytes());
82        out.extend_from_slice(&self.agent_id);
83        out.extend_from_slice(&self.machine_id);
84        out.extend_from_slice(&self.created_at_unix_ms.to_be_bytes());
85        out.extend_from_slice(&caps_bytes);
86        Ok(out)
87    }
88}
89
90/// In-memory cache of `AgentId → latest CapabilityAdvert`, with TTL
91/// eviction.
92///
93/// Senders consult this cache before each `send_direct` call to determine
94/// whether the recipient supports the gossip DM inbox path.
95pub struct CapabilityStore {
96    inner: Mutex<HashMap<[u8; 32], CachedAdvert>>,
97    ttl: Duration,
98}
99
100struct CachedAdvert {
101    capabilities: DmCapabilities,
102    _machine_id: [u8; 32],
103    seen_at: Instant,
104}
105
106impl Default for CapabilityStore {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl CapabilityStore {
113    /// Construct an empty store with the default TTL.
114    #[must_use]
115    pub fn new() -> Self {
116        Self {
117            inner: Mutex::new(HashMap::new()),
118            ttl: Duration::from_secs(ADVERT_CACHE_TTL_SECS),
119        }
120    }
121
122    /// Custom-TTL store (primarily for tests).
123    #[must_use]
124    pub fn with_ttl(ttl: Duration) -> Self {
125        Self {
126            inner: Mutex::new(HashMap::new()),
127            ttl,
128        }
129    }
130
131    /// Look up a peer's capability. Returns `None` if unknown or expired.
132    pub fn lookup(&self, agent_id: &AgentId) -> Option<DmCapabilities> {
133        let Ok(mut inner) = self.inner.lock() else {
134            return None;
135        };
136        let now = Instant::now();
137        let entry = inner.get(agent_id.as_bytes())?;
138        if now.duration_since(entry.seen_at) > self.ttl {
139            inner.remove(agent_id.as_bytes());
140            return None;
141        }
142        Some(entry.capabilities.clone())
143    }
144
145    /// Insert / refresh a cache entry.
146    pub fn insert(&self, agent_id: AgentId, machine_id: MachineId, capabilities: DmCapabilities) {
147        let Ok(mut inner) = self.inner.lock() else {
148            return;
149        };
150        inner.insert(
151            *agent_id.as_bytes(),
152            CachedAdvert {
153                capabilities,
154                _machine_id: *machine_id.as_bytes(),
155                seen_at: Instant::now(),
156            },
157        );
158    }
159
160    /// Current cache size (diagnostic).
161    pub fn len(&self) -> usize {
162        self.inner.lock().map(|g| g.len()).unwrap_or_default()
163    }
164
165    /// True if empty.
166    pub fn is_empty(&self) -> bool {
167        self.len() == 0
168    }
169}
170
171/// Current unix-ms (convenience mirror of `dm::now_unix_ms` to keep this
172/// module's dependencies narrow).
173#[must_use]
174pub fn now_unix_ms() -> u64 {
175    std::time::SystemTime::now()
176        .duration_since(std::time::UNIX_EPOCH)
177        .map(|d| d.as_millis() as u64)
178        .unwrap_or_default()
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn capability_store_insert_and_lookup() {
187        let store = CapabilityStore::new();
188        let agent_id = AgentId([1u8; 32]);
189        let machine_id = MachineId([2u8; 32]);
190        let caps = DmCapabilities::v1_gossip_ready(vec![0u8; 1184]);
191        assert!(store.lookup(&agent_id).is_none());
192        store.insert(agent_id, machine_id, caps.clone());
193        let got = store.lookup(&agent_id).expect("hit");
194        assert_eq!(got.max_protocol_version, caps.max_protocol_version);
195        assert_eq!(got.gossip_inbox, caps.gossip_inbox);
196    }
197
198    #[test]
199    fn capability_store_expires_on_ttl() {
200        let store = CapabilityStore::with_ttl(Duration::from_millis(50));
201        let agent_id = AgentId([3u8; 32]);
202        let machine_id = MachineId([4u8; 32]);
203        store.insert(
204            agent_id,
205            machine_id,
206            DmCapabilities::v1_gossip_ready(vec![0u8; 1184]),
207        );
208        assert!(store.lookup(&agent_id).is_some());
209        std::thread::sleep(Duration::from_millis(100));
210        assert!(store.lookup(&agent_id).is_none());
211    }
212
213    #[test]
214    fn advert_signed_bytes_deterministic() {
215        let advert = CapabilityAdvert {
216            protocol_version: 1,
217            agent_id: [7u8; 32],
218            machine_id: [8u8; 32],
219            created_at_unix_ms: 1_234_567_890_000,
220            capabilities: DmCapabilities::v1_gossip_ready(vec![0u8; 1184]),
221            signature: vec![0u8; 64],
222        };
223        let a = advert.signed_bytes().expect("signed bytes");
224        let b = advert.signed_bytes().expect("signed bytes 2");
225        assert_eq!(a, b);
226        assert!(a.starts_with(ADVERT_SIGN_DOMAIN));
227    }
228}