Skip to main content

agentic_contracts/
receipts.rs

1//! Receipt integration with Identity sister.
2//!
3//! Identity is the receipt system. All sisters that create auditable
4//! actions use Identity for receipts. Hydra queries Identity for receipts.
5
6use crate::context::ContextId;
7use crate::errors::SisterResult;
8use crate::types::{Metadata, SisterType, UniqueId};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Unique receipt identifier.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct ReceiptId(pub UniqueId);
15
16impl ReceiptId {
17    pub fn new() -> Self {
18        Self(UniqueId::new())
19    }
20}
21
22impl Default for ReceiptId {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl std::fmt::Display for ReceiptId {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "rcpt_{}", self.0)
31    }
32}
33
34/// Action outcome.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "status", rename_all = "snake_case")]
37pub enum ActionOutcome {
38    /// Action succeeded.
39    Success {
40        #[serde(skip_serializing_if = "Option::is_none")]
41        result: Option<serde_json::Value>,
42    },
43
44    /// Action failed.
45    Failure {
46        error_code: String,
47        error_message: String,
48    },
49
50    /// Action partially succeeded.
51    Partial {
52        #[serde(skip_serializing_if = "Option::is_none")]
53        result: Option<serde_json::Value>,
54        warnings: Vec<String>,
55    },
56}
57
58impl ActionOutcome {
59    pub fn success() -> Self {
60        Self::Success { result: None }
61    }
62
63    pub fn success_with(result: impl Serialize) -> Self {
64        Self::Success {
65            result: serde_json::to_value(result).ok(),
66        }
67    }
68
69    pub fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
70        Self::Failure {
71            error_code: code.into(),
72            error_message: message.into(),
73        }
74    }
75
76    pub fn partial(warnings: Vec<String>) -> Self {
77        Self::Partial {
78            result: None,
79            warnings,
80        }
81    }
82
83    pub fn is_success(&self) -> bool {
84        matches!(self, Self::Success { .. })
85    }
86
87    pub fn is_failure(&self) -> bool {
88        matches!(self, Self::Failure { .. })
89    }
90}
91
92/// Action record to be receipted.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ActionRecord {
95    /// What sister performed this.
96    pub sister_type: SisterType,
97
98    /// What action was performed.
99    pub action_type: String,
100
101    /// Action parameters (sanitized - no secrets).
102    #[serde(default)]
103    pub parameters: Metadata,
104
105    /// Outcome.
106    pub outcome: ActionOutcome,
107
108    /// Evidence pointers.
109    #[serde(default)]
110    pub evidence_ids: Vec<String>,
111
112    /// Context ID where this happened.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub context_id: Option<ContextId>,
115
116    /// Timestamp.
117    pub timestamp: DateTime<Utc>,
118}
119
120impl ActionRecord {
121    /// Create a new action record.
122    pub fn new(
123        sister_type: SisterType,
124        action_type: impl Into<String>,
125        outcome: ActionOutcome,
126    ) -> Self {
127        Self {
128            sister_type,
129            action_type: action_type.into(),
130            parameters: Metadata::new(),
131            outcome,
132            evidence_ids: vec![],
133            context_id: None,
134            timestamp: Utc::now(),
135        }
136    }
137
138    /// Add a parameter.
139    pub fn param(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
140        if let Ok(v) = serde_json::to_value(value) {
141            self.parameters.insert(key.into(), v);
142        }
143        self
144    }
145
146    /// Add evidence.
147    pub fn evidence(mut self, evidence_id: impl Into<String>) -> Self {
148        self.evidence_ids.push(evidence_id.into());
149        self
150    }
151
152    /// Set context.
153    pub fn in_context(mut self, context_id: ContextId) -> Self {
154        self.context_id = Some(context_id);
155        self
156    }
157}
158
159/// A receipt (signed action record).
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Receipt {
162    /// Receipt ID.
163    pub id: ReceiptId,
164
165    /// The action that was recorded.
166    pub action: ActionRecord,
167
168    /// Signature (from Identity).
169    pub signature: String,
170
171    /// Position in the hash chain.
172    pub chain_position: u64,
173
174    /// Hash of previous receipt (for chain integrity).
175    pub previous_hash: String,
176
177    /// This receipt's hash.
178    pub hash: String,
179
180    /// When the receipt was created.
181    pub created_at: DateTime<Utc>,
182}
183
184impl Receipt {
185    /// Verify the receipt signature (requires Identity).
186    /// This is a placeholder - actual verification happens via Identity sister.
187    pub fn verify_signature(&self, _public_key: &[u8]) -> bool {
188        // In practice, this would use ed25519 verification
189        // For now, return true as placeholder
190        !self.signature.is_empty()
191    }
192
193    /// Get the action type.
194    pub fn action_type(&self) -> &str {
195        &self.action.action_type
196    }
197
198    /// Check if action was successful.
199    pub fn was_successful(&self) -> bool {
200        self.action.outcome.is_success()
201    }
202}
203
204/// Filter for querying receipts.
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ReceiptFilter {
207    /// Filter by sister type.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub sister_type: Option<SisterType>,
210
211    /// Filter by action type.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub action_type: Option<String>,
214
215    /// Filter by context.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub context_id: Option<ContextId>,
218
219    /// Filter by time (after).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub after: Option<DateTime<Utc>>,
222
223    /// Filter by time (before).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub before: Option<DateTime<Utc>>,
226
227    /// Filter by outcome.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub outcome: Option<String>, // "success", "failure", "partial"
230
231    /// Limit.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub limit: Option<usize>,
234
235    /// Offset.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub offset: Option<usize>,
238}
239
240impl ReceiptFilter {
241    pub fn new() -> Self {
242        Self::default()
243    }
244
245    pub fn for_sister(mut self, sister_type: SisterType) -> Self {
246        self.sister_type = Some(sister_type);
247        self
248    }
249
250    pub fn action(mut self, action_type: impl Into<String>) -> Self {
251        self.action_type = Some(action_type.into());
252        self
253    }
254
255    pub fn in_context(mut self, context_id: ContextId) -> Self {
256        self.context_id = Some(context_id);
257        self
258    }
259
260    pub fn after(mut self, time: DateTime<Utc>) -> Self {
261        self.after = Some(time);
262        self
263    }
264
265    pub fn before(mut self, time: DateTime<Utc>) -> Self {
266        self.before = Some(time);
267        self
268    }
269
270    pub fn successful_only(mut self) -> Self {
271        self.outcome = Some("success".to_string());
272        self
273    }
274
275    pub fn limit(mut self, limit: usize) -> Self {
276        self.limit = Some(limit);
277        self
278    }
279}
280
281/// Receipt integration trait.
282///
283/// Sisters that create auditable actions implement this trait to
284/// integrate with Identity for receipt creation.
285pub trait ReceiptIntegration {
286    /// Create a receipt for an action (via Identity).
287    fn create_receipt(&self, action: ActionRecord) -> SisterResult<ReceiptId>;
288
289    /// Get receipt by ID (from Identity).
290    fn get_receipt(&self, id: ReceiptId) -> SisterResult<Receipt>;
291
292    /// List receipts for this sister.
293    fn list_receipts(&self, filter: ReceiptFilter) -> SisterResult<Vec<Receipt>>;
294
295    /// Get receipt count.
296    fn receipt_count(&self) -> SisterResult<u64> {
297        self.list_receipts(ReceiptFilter::new())
298            .map(|r| r.len() as u64)
299    }
300
301    /// Get receipts for a specific action type.
302    fn receipts_for_action(&self, action_type: &str) -> SisterResult<Vec<Receipt>> {
303        self.list_receipts(ReceiptFilter::new().action(action_type))
304    }
305}
306
307/// Helper for creating action records easily.
308pub struct ActionBuilder {
309    sister_type: SisterType,
310    action_type: String,
311}
312
313impl ActionBuilder {
314    pub fn new(sister_type: SisterType, action_type: impl Into<String>) -> Self {
315        Self {
316            sister_type,
317            action_type: action_type.into(),
318        }
319    }
320
321    pub fn success(self) -> ActionRecord {
322        ActionRecord::new(self.sister_type, self.action_type, ActionOutcome::success())
323    }
324
325    pub fn success_with(self, result: impl Serialize) -> ActionRecord {
326        ActionRecord::new(
327            self.sister_type,
328            self.action_type,
329            ActionOutcome::success_with(result),
330        )
331    }
332
333    pub fn failure(self, code: impl Into<String>, message: impl Into<String>) -> ActionRecord {
334        ActionRecord::new(
335            self.sister_type,
336            self.action_type,
337            ActionOutcome::failure(code, message),
338        )
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_action_record() {
348        let record = ActionRecord::new(SisterType::Memory, "memory_add", ActionOutcome::success())
349            .param("content", "test memory")
350            .evidence("ev_123");
351
352        assert_eq!(record.sister_type, SisterType::Memory);
353        assert_eq!(record.action_type, "memory_add");
354        assert!(record.outcome.is_success());
355        assert_eq!(record.evidence_ids, vec!["ev_123"]);
356    }
357
358    #[test]
359    fn test_action_builder() {
360        let record = ActionBuilder::new(SisterType::Vision, "vision_capture")
361            .success_with(serde_json::json!({"capture_id": "cap_123"}));
362
363        assert_eq!(record.action_type, "vision_capture");
364        assert!(record.outcome.is_success());
365    }
366
367    #[test]
368    fn test_receipt_filter() {
369        let filter = ReceiptFilter::new()
370            .for_sister(SisterType::Memory)
371            .action("memory_add")
372            .successful_only()
373            .limit(10);
374
375        assert_eq!(filter.sister_type, Some(SisterType::Memory));
376        assert_eq!(filter.action_type, Some("memory_add".to_string()));
377        assert_eq!(filter.outcome, Some("success".to_string()));
378        assert_eq!(filter.limit, Some(10));
379    }
380}