Skip to main content

agent_id_core/
receipt.rs

1//! Interaction receipts for recording agent-to-agent interactions.
2
3use crate::{signing, Did, Error, Result, RootKey};
4use chrono::{DateTime, Utc};
5use ed25519_dalek::{Signature, Verifier};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use uuid::Uuid;
10
11/// Type of interaction between agents.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum InteractionType {
15    /// Direct communication.
16    Message,
17    /// Response to content.
18    Reply,
19    /// Joint work on something.
20    Collaboration,
21    /// Exchange of value/service.
22    Transaction,
23    /// Public vouch.
24    Endorsement,
25    /// Disagreement or conflict.
26    Dispute,
27}
28
29/// Outcome of an interaction.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum InteractionOutcome {
33    /// Interaction completed successfully.
34    Completed,
35    /// Interaction is still in progress.
36    InProgress,
37    /// Interaction was cancelled.
38    Cancelled,
39    /// Interaction failed.
40    Failed,
41}
42
43/// Context about where/how the interaction occurred.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct InteractionContext {
47    /// Platform where interaction occurred (e.g., "moltbook", "discord").
48    pub platform: String,
49
50    /// Channel within platform (e.g., "public_post", "dm").
51    pub channel: String,
52
53    /// Type of interaction.
54    pub interaction_type: InteractionType,
55
56    /// Hash of content (if applicable).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub content_hash: Option<String>,
59
60    /// Parent interaction ID (for replies).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub parent_id: Option<String>,
63
64    /// Additional metadata.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub metadata: Option<serde_json::Value>,
67}
68
69impl InteractionContext {
70    /// Create a new interaction context.
71    pub fn new(
72        platform: impl Into<String>,
73        channel: impl Into<String>,
74        interaction_type: InteractionType,
75    ) -> Self {
76        Self {
77            platform: platform.into(),
78            channel: channel.into(),
79            interaction_type,
80            content_hash: None,
81            parent_id: None,
82            metadata: None,
83        }
84    }
85
86    /// Add content hash.
87    pub fn with_content(mut self, content: &[u8]) -> Self {
88        let hash = Sha256::digest(content);
89        self.content_hash = Some(format!("sha256:{}", hex::encode(hash)));
90        self
91    }
92
93    /// Add parent ID (for replies).
94    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
95        self.parent_id = Some(parent_id.into());
96        self
97    }
98
99    /// Add metadata.
100    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
101        self.metadata = Some(metadata);
102        self
103    }
104}
105
106/// A signature on the receipt from a participant.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ParticipantSignature {
110    /// Key used to sign (e.g., "did:key:...#session-1").
111    pub key: String,
112
113    /// Base64-encoded signature.
114    pub sig: String,
115
116    /// When the signature was made (unix ms).
117    pub signed_at: i64,
118}
119
120/// A signed record of an interaction between two or more agents.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct InteractionReceipt {
124    /// Type identifier.
125    #[serde(rename = "type")]
126    pub type_: String,
127
128    /// Protocol version.
129    pub version: String,
130
131    /// Unique receipt ID.
132    pub id: String,
133
134    /// DIDs of all participants.
135    pub participants: Vec<String>,
136
137    /// DID of the initiator.
138    pub initiator: String,
139
140    /// When the interaction occurred (unix ms).
141    pub timestamp: i64,
142
143    /// Context about the interaction.
144    pub context: InteractionContext,
145
146    /// Outcome of the interaction.
147    pub outcome: InteractionOutcome,
148
149    /// Signatures from participants (DID -> signature).
150    #[serde(default)]
151    pub signatures: HashMap<String, ParticipantSignature>,
152}
153
154impl InteractionReceipt {
155    /// Create a new unsigned interaction receipt.
156    pub fn new(initiator: Did, participants: Vec<Did>, context: InteractionContext) -> Self {
157        Self {
158            type_: "InteractionReceipt".to_string(),
159            version: "1.0".to_string(),
160            id: Uuid::now_v7().to_string(),
161            participants: participants.iter().map(|d| d.to_string()).collect(),
162            initiator: initiator.to_string(),
163            timestamp: Utc::now().timestamp_millis(),
164            context,
165            outcome: InteractionOutcome::InProgress,
166            signatures: HashMap::new(),
167        }
168    }
169
170    /// Set the outcome.
171    pub fn with_outcome(mut self, outcome: InteractionOutcome) -> Self {
172        self.outcome = outcome;
173        self
174    }
175
176    /// Set custom timestamp.
177    pub fn at(mut self, time: DateTime<Utc>) -> Self {
178        self.timestamp = time.timestamp_millis();
179        self
180    }
181
182    /// Get the data to be signed (excludes signatures).
183    fn signing_data(&self) -> Result<Vec<u8>> {
184        let data = serde_json::json!({
185            "type": self.type_,
186            "version": self.version,
187            "id": self.id,
188            "participants": self.participants,
189            "initiator": self.initiator,
190            "timestamp": self.timestamp,
191            "context": self.context,
192            "outcome": self.outcome,
193        });
194        signing::canonicalize(&data)
195    }
196
197    /// Add a signature from a participant.
198    pub fn sign(&mut self, signer: &RootKey, key_id: impl Into<String>) -> Result<()> {
199        let did = signer.did().to_string();
200
201        // Verify signer is a participant
202        if !self.participants.contains(&did) {
203            return Err(Error::Validation("Signer is not a participant".into()));
204        }
205
206        let canonical = self.signing_data()?;
207        let sig = signer.sign(&canonical);
208
209        self.signatures.insert(
210            did,
211            ParticipantSignature {
212                key: key_id.into(),
213                sig: base64::Engine::encode(
214                    &base64::engine::general_purpose::STANDARD,
215                    sig.to_bytes(),
216                ),
217                signed_at: Utc::now().timestamp_millis(),
218            },
219        );
220
221        Ok(())
222    }
223
224    /// Verify a specific participant's signature.
225    pub fn verify_participant(&self, participant_did: &str) -> Result<()> {
226        let sig_data = self
227            .signatures
228            .get(participant_did)
229            .ok_or_else(|| Error::Validation("No signature from participant".into()))?;
230
231        let sig_bytes =
232            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &sig_data.sig)
233                .map_err(|_| Error::InvalidSignature)?;
234
235        let signature =
236            Signature::from_bytes(&sig_bytes.try_into().map_err(|_| Error::InvalidSignature)?);
237
238        let did: Did = participant_did.parse()?;
239        let public_key = did.public_key()?;
240
241        let canonical = self.signing_data()?;
242
243        public_key
244            .verify(&canonical, &signature)
245            .map_err(|_| Error::InvalidSignature)
246    }
247
248    /// Verify all signatures.
249    pub fn verify_all(&self) -> Result<()> {
250        for did in self.signatures.keys() {
251            self.verify_participant(did)?;
252        }
253        Ok(())
254    }
255
256    /// Check if all participants have signed.
257    pub fn is_fully_signed(&self) -> bool {
258        self.participants
259            .iter()
260            .all(|p| self.signatures.contains_key(p))
261    }
262
263    /// Get list of participants who haven't signed yet.
264    pub fn pending_signatures(&self) -> Vec<&str> {
265        self.participants
266            .iter()
267            .filter(|p| !self.signatures.contains_key(*p))
268            .map(|s| s.as_str())
269            .collect()
270    }
271
272    /// Get the number of signatures.
273    pub fn signature_count(&self) -> usize {
274        self.signatures.len()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_create_receipt() {
284        let agent_a = RootKey::generate();
285        let agent_b = RootKey::generate();
286
287        let context = InteractionContext::new("moltbook", "public_post", InteractionType::Reply)
288            .with_content(b"Hello, world!");
289
290        let receipt =
291            InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
292
293        assert_eq!(receipt.participants.len(), 2);
294        assert_eq!(receipt.initiator, agent_a.did().to_string());
295        assert!(receipt.context.content_hash.is_some());
296    }
297
298    #[test]
299    fn test_sign_receipt() {
300        let agent_a = RootKey::generate();
301        let agent_b = RootKey::generate();
302
303        let context = InteractionContext::new("discord", "dm", InteractionType::Message);
304
305        let mut receipt =
306            InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context)
307                .with_outcome(InteractionOutcome::Completed);
308
309        // Sign from both parties
310        receipt
311            .sign(&agent_a, format!("{}#session-1", agent_a.did()))
312            .unwrap();
313        receipt
314            .sign(&agent_b, format!("{}#session-1", agent_b.did()))
315            .unwrap();
316
317        assert!(receipt.is_fully_signed());
318        receipt.verify_all().unwrap();
319    }
320
321    #[test]
322    fn test_partial_signatures() {
323        let agent_a = RootKey::generate();
324        let agent_b = RootKey::generate();
325
326        let context = InteractionContext::new("moltbook", "post", InteractionType::Endorsement);
327
328        let mut receipt =
329            InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
330
331        receipt
332            .sign(&agent_a, format!("{}#root", agent_a.did()))
333            .unwrap();
334
335        assert!(!receipt.is_fully_signed());
336        assert_eq!(receipt.pending_signatures().len(), 1);
337        assert_eq!(receipt.signature_count(), 1);
338    }
339
340    #[test]
341    fn test_non_participant_cannot_sign() {
342        let agent_a = RootKey::generate();
343        let agent_b = RootKey::generate();
344        let outsider = RootKey::generate();
345
346        let context = InteractionContext::new("platform", "channel", InteractionType::Message);
347
348        let mut receipt =
349            InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
350
351        let result = receipt.sign(&outsider, format!("{}#root", outsider.did()));
352        assert!(result.is_err());
353    }
354
355    #[test]
356    fn test_reply_context() {
357        let _agent = RootKey::generate();
358
359        let context = InteractionContext::new("twitter", "reply", InteractionType::Reply)
360            .with_parent("parent-tweet-id-123")
361            .with_content(b"Great point!")
362            .with_metadata(serde_json::json!({"likes": 42}));
363
364        assert_eq!(context.parent_id, Some("parent-tweet-id-123".to_string()));
365        assert!(context.content_hash.is_some());
366        assert!(context.metadata.is_some());
367    }
368}