Skip to main content

tpcp_core/
schema.rs

1use alloc::{string::String, vec::Vec, vec};
2use alloc::collections::BTreeMap;
3use base64::{Engine as _, engine::general_purpose};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// TPCP protocol version implemented by this crate.
8pub const PROTOCOL_VERSION: &str = "0.4.0";
9
10/// Reserved UUID for broadcast/multicast messages.
11pub const BROADCAST_ID: &str = "00000000-0000-0000-0000-000000000000";
12
13/// Intent identifies the purpose of a TPCP message.
14/// Wire-format values must match the canonical Python/TS SDK exactly.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum Intent {
17    #[serde(rename = "Handshake")]
18    Handshake,
19    #[serde(rename = "Task_Request")]
20    TaskRequest,
21    #[serde(rename = "State_Sync")]
22    StateSync,
23    #[serde(rename = "State_Sync_Vector")]
24    StateSyncVector,
25    #[serde(rename = "Media_Share")]
26    MediaShare,
27    #[serde(rename = "Critique")]
28    Critique,
29    #[serde(rename = "Terminate")]
30    Terminate,
31    #[serde(rename = "ACK")]
32    Ack,
33    #[serde(rename = "NACK")]
34    Nack,
35    #[serde(rename = "Broadcast")]
36    Broadcast,
37}
38
39/// Describes a TPCP agent.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AgentIdentity {
42    pub agent_id: String,
43    pub framework: String,
44    #[serde(default)]
45    pub capabilities: Vec<String>,
46    pub public_key: String,
47    #[serde(default = "default_modality")]
48    pub modality: Vec<String>,
49}
50
51fn default_modality() -> Vec<String> {
52    vec!["text".into()]
53}
54
55/// Present on every TPCP message.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct MessageHeader {
58    pub message_id: String,
59    pub timestamp: String,
60    pub sender_id: String,
61    pub receiver_id: String,
62    pub intent: Intent,
63    #[serde(default = "default_ttl")]
64    pub ttl: u32,
65    pub protocol_version: String,
66}
67
68fn default_ttl() -> u32 {
69    30
70}
71
72/// Acknowledgement metadata referencing the message being acknowledged.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AckInfo {
75    pub acked_message_id: String,
76}
77
78/// Chunked-transfer metadata for large payloads.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ChunkInfo {
81    pub chunk_index: u32,
82    pub total_chunks: u32,
83    pub transfer_id: String,
84}
85
86/// Top-level TPCP message container.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TPCPEnvelope {
89    pub header: MessageHeader,
90    pub payload: Value,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub signature: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub ack_info: Option<AckInfo>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub chunk_info: Option<ChunkInfo>,
97}
98
99// --- Payload types ---
100
101/// Plain text content payload.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct TextPayload {
104    pub payload_type: String,
105    pub content: String,
106    #[serde(default = "default_language")]
107    pub language: String,
108}
109
110fn default_language() -> String {
111    "en".into()
112}
113
114impl TextPayload {
115    pub fn new(content: impl Into<String>) -> Self {
116        Self {
117            payload_type: "text".into(),
118            content: content.into(),
119            language: "en".into(),
120        }
121    }
122
123    pub fn validate(&self) -> Result<(), String> {
124        if self.content.is_empty() {
125            return Err("content must not be empty".into());
126        }
127        Ok(())
128    }
129}
130
131/// Semantic vector embedding payload.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct VectorEmbeddingPayload {
134    pub payload_type: String,
135    pub model_id: String,
136    pub dimensions: u32,
137    pub vector: Vec<f64>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub raw_text_fallback: Option<String>,
140}
141
142impl VectorEmbeddingPayload {
143    pub fn new(model_id: impl Into<String>, dimensions: u32, vector: Vec<f64>) -> Self {
144        Self {
145            payload_type: "vector_embedding".into(),
146            model_id: model_id.into(),
147            dimensions,
148            vector,
149            raw_text_fallback: None,
150        }
151    }
152
153    pub fn validate(&self) -> Result<(), String> {
154        if self.model_id.is_empty() {
155            return Err("model_id must not be empty".into());
156        }
157        if self.dimensions == 0 {
158            return Err("dimensions must be greater than zero".into());
159        }
160        if self.vector.len() != self.dimensions as usize {
161            return Err(alloc::format!(
162                "vector length {} does not match dimensions {}",
163                self.vector.len(),
164                self.dimensions
165            ));
166        }
167        Ok(())
168    }
169}
170
171/// CRDT state synchronization payload.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct CRDTSyncPayload {
174    pub payload_type: String,
175    pub crdt_type: String,
176    pub state: Value,
177    pub vector_clock: BTreeMap<String, i64>,
178}
179
180impl CRDTSyncPayload {
181    pub fn new(crdt_type: impl Into<String>, state: Value, vector_clock: BTreeMap<String, i64>) -> Self {
182        Self {
183            payload_type: "crdt_sync".into(),
184            crdt_type: crdt_type.into(),
185            state,
186            vector_clock,
187        }
188    }
189}
190
191/// Image data payload.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ImagePayload {
194    pub payload_type: String,
195    pub data_base64: String,
196    #[serde(default = "default_image_mime")]
197    pub mime_type: String,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub width: Option<u32>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub height: Option<u32>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub source_model: Option<String>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub caption: Option<String>,
206}
207
208fn default_image_mime() -> String { "image/png".into() }
209
210impl ImagePayload {
211    pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
212        Self {
213            payload_type: "image".into(),
214            data_base64: data_base64.into(),
215            mime_type: mime_type.into(),
216            width: None, height: None, source_model: None, caption: None,
217        }
218    }
219
220    pub fn validate(&self) -> Result<(), String> {
221        if !self.mime_type.starts_with("image/") {
222            return Err(alloc::format!(
223                "mime_type must start with 'image/', got '{}'",
224                self.mime_type
225            ));
226        }
227        if self.data_base64.is_empty() {
228            return Err("data_base64 must not be empty".into());
229        }
230        general_purpose::STANDARD
231            .decode(&self.data_base64)
232            .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
233        Ok(())
234    }
235}
236
237/// Audio data payload.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct AudioPayload {
240    pub payload_type: String,
241    pub data_base64: String,
242    #[serde(default = "default_audio_mime")]
243    pub mime_type: String,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub sample_rate: Option<u32>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub duration_seconds: Option<f64>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub source_model: Option<String>,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub transcript: Option<String>,
252}
253
254fn default_audio_mime() -> String { "audio/wav".into() }
255
256impl AudioPayload {
257    pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
258        Self {
259            payload_type: "audio".into(),
260            data_base64: data_base64.into(),
261            mime_type: mime_type.into(),
262            sample_rate: None, duration_seconds: None, source_model: None, transcript: None,
263        }
264    }
265
266    pub fn validate(&self) -> Result<(), String> {
267        if !self.mime_type.starts_with("audio/") {
268            return Err(alloc::format!(
269                "mime_type must start with 'audio/', got '{}'",
270                self.mime_type
271            ));
272        }
273        if self.data_base64.is_empty() {
274            return Err("data_base64 must not be empty".into());
275        }
276        general_purpose::STANDARD
277            .decode(&self.data_base64)
278            .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
279        Ok(())
280    }
281}
282
283/// Video data payload.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct VideoPayload {
286    pub payload_type: String,
287    pub data_base64: String,
288    #[serde(default = "default_video_mime")]
289    pub mime_type: String,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub width: Option<u32>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub height: Option<u32>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub duration_seconds: Option<f64>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub fps: Option<f64>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub source_model: Option<String>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub description: Option<String>,
302}
303
304fn default_video_mime() -> String { "video/mp4".into() }
305
306impl VideoPayload {
307    pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
308        Self {
309            payload_type: "video".into(),
310            data_base64: data_base64.into(),
311            mime_type: mime_type.into(),
312            width: None, height: None, duration_seconds: None,
313            fps: None, source_model: None, description: None,
314        }
315    }
316
317    pub fn validate(&self) -> Result<(), String> {
318        if !self.mime_type.starts_with("video/") {
319            return Err(alloc::format!(
320                "mime_type must start with 'video/', got '{}'",
321                self.mime_type
322            ));
323        }
324        if self.data_base64.is_empty() {
325            return Err("data_base64 must not be empty".into());
326        }
327        general_purpose::STANDARD
328            .decode(&self.data_base64)
329            .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
330        Ok(())
331    }
332}
333
334/// Generic binary data payload.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct BinaryPayload {
337    pub payload_type: String,
338    pub data_base64: String,
339    #[serde(default = "default_binary_mime")]
340    pub mime_type: String,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub filename: Option<String>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub description: Option<String>,
345}
346
347fn default_binary_mime() -> String { "application/octet-stream".into() }
348
349impl BinaryPayload {
350    pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
351        Self {
352            payload_type: "binary".into(),
353            data_base64: data_base64.into(),
354            mime_type: mime_type.into(),
355            filename: None, description: None,
356        }
357    }
358
359    pub fn validate(&self) -> Result<(), String> {
360        if self.mime_type.is_empty() {
361            return Err("mime_type must not be empty".into());
362        }
363        if self.data_base64.is_empty() {
364            return Err("data_base64 must not be empty".into());
365        }
366        general_purpose::STANDARD
367            .decode(&self.data_base64)
368            .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
369        Ok(())
370    }
371}
372
373/// Single sensor telemetry reading.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct TelemetryReading {
376    pub value: f64,
377    pub timestamp_ms: i64,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub quality: Option<String>,
380}
381
382/// Industrial IoT sensor data payload.
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct TelemetryPayload {
385    pub payload_type: String,
386    pub sensor_id: String,
387    pub unit: String,
388    pub readings: Vec<TelemetryReading>,
389    pub source_protocol: String,
390}
391
392impl TelemetryPayload {
393    pub fn new(sensor_id: impl Into<String>, unit: impl Into<String>,
394               source_protocol: impl Into<String>, readings: Vec<TelemetryReading>) -> Self {
395        Self {
396            payload_type: "telemetry".into(),
397            sensor_id: sensor_id.into(),
398            unit: unit.into(),
399            readings,
400            source_protocol: source_protocol.into(),
401        }
402    }
403
404    pub fn validate(&self) -> Result<(), String> {
405        if self.sensor_id.is_empty() {
406            return Err("sensor_id must not be empty".into());
407        }
408        if self.unit.is_empty() {
409            return Err("unit must not be empty".into());
410        }
411        if self.readings.is_empty() {
412            return Err("readings must not be empty".into());
413        }
414        Ok(())
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use alloc::string::ToString;
422
423    #[test]
424    fn intent_serialization_matches_wire_format() {
425        let cases = vec![
426            (Intent::Handshake, "\"Handshake\""),
427            (Intent::TaskRequest, "\"Task_Request\""),
428            (Intent::StateSync, "\"State_Sync\""),
429            (Intent::StateSyncVector, "\"State_Sync_Vector\""),
430            (Intent::MediaShare, "\"Media_Share\""),
431            (Intent::Critique, "\"Critique\""),
432            (Intent::Terminate, "\"Terminate\""),
433            (Intent::Ack, "\"ACK\""),
434            (Intent::Nack, "\"NACK\""),
435            (Intent::Broadcast, "\"Broadcast\""),
436        ];
437        for (intent, expected) in cases {
438            let json = serde_json::to_string(&intent).unwrap();
439            assert_eq!(json, expected, "Intent wire format mismatch");
440        }
441    }
442
443    #[test]
444    fn text_payload_round_trip() {
445        let payload = TextPayload::new("hello world");
446        let json = serde_json::to_string(&payload).unwrap();
447        let parsed: TextPayload = serde_json::from_str(&json).unwrap();
448        assert_eq!(parsed.payload_type, "text");
449        assert_eq!(parsed.content, "hello world");
450        assert_eq!(parsed.language, "en");
451    }
452
453    #[test]
454    fn envelope_with_ack_and_chunk_info() {
455        let envelope = TPCPEnvelope {
456            header: MessageHeader {
457                message_id: "msg-1".to_string(),
458                timestamp: "2026-03-15T00:00:00Z".to_string(),
459                sender_id: "sender-1".to_string(),
460                receiver_id: "receiver-1".to_string(),
461                intent: Intent::Ack,
462                ttl: 30,
463                protocol_version: PROTOCOL_VERSION.to_string(),
464            },
465            payload: serde_json::json!({"payload_type": "text", "content": "ack"}),
466            signature: Some("sig123".to_string()),
467            ack_info: Some(AckInfo {
468                acked_message_id: "orig-msg-1".to_string(),
469            }),
470            chunk_info: Some(ChunkInfo {
471                chunk_index: 0,
472                total_chunks: 3,
473                transfer_id: "transfer-1".to_string(),
474            }),
475        };
476
477        let json = serde_json::to_string(&envelope).unwrap();
478        let parsed: TPCPEnvelope = serde_json::from_str(&json).unwrap();
479
480        assert_eq!(parsed.ack_info.as_ref().unwrap().acked_message_id, "orig-msg-1");
481        assert_eq!(parsed.chunk_info.as_ref().unwrap().chunk_index, 0);
482        assert_eq!(parsed.chunk_info.as_ref().unwrap().total_chunks, 3);
483        assert_eq!(parsed.chunk_info.as_ref().unwrap().transfer_id, "transfer-1");
484    }
485
486    #[test]
487    fn telemetry_payload_round_trip() {
488        let payload = TelemetryPayload::new(
489            "sensor_1", "celsius", "opcua",
490            vec![TelemetryReading { value: 42.5, timestamp_ms: 1000, quality: Some("Good".to_string()) }],
491        );
492        let json = serde_json::to_string(&payload).unwrap();
493        let parsed: TelemetryPayload = serde_json::from_str(&json).unwrap();
494        assert_eq!(parsed.sensor_id, "sensor_1");
495        assert_eq!(parsed.readings.len(), 1);
496        assert_eq!(parsed.readings[0].value, 42.5);
497    }
498}
499
500#[cfg(test)]
501mod validation_tests {
502    use super::*;
503
504    #[test]
505    fn text_payload_rejects_empty() {
506        let p = TextPayload { payload_type: "text".into(), content: "".into(), language: "en".into() };
507        assert!(p.validate().is_err());
508    }
509
510    #[test]
511    fn text_payload_accepts_nonempty() {
512        let p = TextPayload { payload_type: "text".into(), content: "hello".into(), language: "en".into() };
513        assert!(p.validate().is_ok());
514    }
515
516    #[test]
517    fn vector_rejects_dimension_mismatch() {
518        let p = VectorEmbeddingPayload {
519            payload_type: "vector_embedding".into(),
520            model_id: "test".into(),
521            dimensions: 3,
522            vector: vec![1.0, 2.0],
523            raw_text_fallback: None,
524        };
525        assert!(p.validate().is_err());
526    }
527
528    #[test]
529    fn vector_accepts_matching_dimensions() {
530        let p = VectorEmbeddingPayload {
531            payload_type: "vector_embedding".into(),
532            model_id: "test".into(),
533            dimensions: 3,
534            vector: vec![1.0, 2.0, 3.0],
535            raw_text_fallback: None,
536        };
537        assert!(p.validate().is_ok());
538    }
539
540    #[test]
541    fn image_payload_rejects_bad_mime() {
542        let p = ImagePayload::new("aGVsbG8=", "text/plain");
543        assert!(p.validate().is_err());
544    }
545
546    #[test]
547    fn image_payload_rejects_invalid_base64() {
548        let p = ImagePayload::new("not!!valid@@base64", "image/png");
549        assert!(p.validate().is_err());
550    }
551
552    #[test]
553    fn image_payload_accepts_valid() {
554        // "hello" base64-encoded
555        let p = ImagePayload::new("aGVsbG8=", "image/png");
556        assert!(p.validate().is_ok());
557    }
558
559    #[test]
560    fn audio_payload_rejects_bad_mime() {
561        let p = AudioPayload::new("aGVsbG8=", "image/png");
562        assert!(p.validate().is_err());
563    }
564
565    #[test]
566    fn audio_payload_accepts_valid() {
567        let p = AudioPayload::new("aGVsbG8=", "audio/wav");
568        assert!(p.validate().is_ok());
569    }
570
571    #[test]
572    fn video_payload_rejects_bad_mime() {
573        let p = VideoPayload::new("aGVsbG8=", "audio/wav");
574        assert!(p.validate().is_err());
575    }
576
577    #[test]
578    fn video_payload_accepts_valid() {
579        let p = VideoPayload::new("aGVsbG8=", "video/mp4");
580        assert!(p.validate().is_ok());
581    }
582
583    #[test]
584    fn binary_payload_rejects_empty_data() {
585        let p = BinaryPayload::new("", "application/octet-stream");
586        assert!(p.validate().is_err());
587    }
588
589    #[test]
590    fn binary_payload_accepts_valid() {
591        let p = BinaryPayload::new("aGVsbG8=", "application/octet-stream");
592        assert!(p.validate().is_ok());
593    }
594
595    #[test]
596    fn telemetry_rejects_empty_sensor_id() {
597        let p = TelemetryPayload {
598            payload_type: "telemetry".into(),
599            sensor_id: "".into(),
600            unit: "celsius".into(),
601            readings: vec![TelemetryReading { value: 1.0, timestamp_ms: 0, quality: None }],
602            source_protocol: "opcua".into(),
603        };
604        assert!(p.validate().is_err());
605    }
606
607    #[test]
608    fn telemetry_rejects_empty_unit() {
609        let p = TelemetryPayload {
610            payload_type: "telemetry".into(),
611            sensor_id: "s1".into(),
612            unit: "".into(),
613            readings: vec![TelemetryReading { value: 1.0, timestamp_ms: 0, quality: None }],
614            source_protocol: "opcua".into(),
615        };
616        assert!(p.validate().is_err());
617    }
618
619    #[test]
620    fn telemetry_rejects_empty_readings() {
621        let p = TelemetryPayload {
622            payload_type: "telemetry".into(),
623            sensor_id: "s1".into(),
624            unit: "celsius".into(),
625            readings: vec![],
626            source_protocol: "opcua".into(),
627        };
628        assert!(p.validate().is_err());
629    }
630
631    #[test]
632    fn telemetry_accepts_valid() {
633        let p = TelemetryPayload::new(
634            "sensor_1", "celsius", "opcua",
635            vec![TelemetryReading { value: 42.5, timestamp_ms: 1000, quality: None }],
636        );
637        assert!(p.validate().is_ok());
638    }
639}