qualia_client_core/wellfair/
receipt.rs1use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use super::policy::DecisionResult;
10
11pub const RECEIPTS_FILE: &str = "wellfair/receipts.jsonl";
12pub const MAX_LIST: usize = 128;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct ReceiptRecord {
16 pub id: String,
17 pub timestamp_unix: u32,
18 pub qapp_id: String,
19 pub record_id: String,
20 pub decision: String,
21 pub obligations: Vec<String>,
22 pub checkpoint_hash: Option<String>,
23}
24
25pub struct ReceiptLog {
26 path: PathBuf,
27}
28
29impl ReceiptLog {
30 pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
31 let path = storage_root.as_ref().join(RECEIPTS_FILE);
32 if let Some(parent) = path.parent() {
33 fs::create_dir_all(parent)?;
34 }
35 if !path.exists() {
36 OpenOptions::new().create(true).write(true).open(&path)?;
37 }
38 Ok(Self { path })
39 }
40
41 pub fn append(&self, record: &ReceiptRecord) -> std::io::Result<()> {
42 let line =
43 serde_json::to_string(record).map_err(|e| std::io::Error::other(e.to_string()))?;
44 let mut file = OpenOptions::new().append(true).open(&self.path)?;
45 writeln!(file, "{line}")?;
46 file.sync_all()?;
47 Ok(())
48 }
49
50 pub fn list_recent(&self, limit: usize) -> std::io::Result<Vec<ReceiptRecord>> {
51 let file = fs::File::open(&self.path)?;
52 let reader = BufReader::new(file);
53 let mut records = Vec::new();
54 for line in reader.lines() {
55 let line = line?;
56 if line.trim().is_empty() {
57 continue;
58 }
59 if let Ok(record) = serde_json::from_str::<ReceiptRecord>(&line) {
60 records.push(record);
61 }
62 }
63 let keep = limit.min(MAX_LIST);
64 if records.len() > keep {
65 records.drain(0..records.len() - keep);
66 }
67 records.reverse();
68 Ok(records)
69 }
70}
71
72pub fn receipt_from_decision(
73 qapp_id: &str,
74 record_id: &str,
75 timestamp_unix: u32,
76 decision: &DecisionResult,
77 checkpoint_hash: Option<[u8; 32]>,
78) -> ReceiptRecord {
79 let (decision_label, obligations) = match decision {
80 DecisionResult::Permit { obligations } => ("permit".to_string(), obligations.clone()),
81 DecisionResult::Deny { reasons } => ("deny".to_string(), reasons.clone()),
82 DecisionResult::Prompt { .. } => ("prompt".to_string(), vec![]),
83 DecisionResult::Suspend { required_approvals } => (
84 "suspend".to_string(),
85 vec![format!("approvals:{required_approvals}")],
86 ),
87 };
88 ReceiptRecord {
89 id: format!(
90 "rcpt-{timestamp_unix}-{}",
91 &record_id[record_id.len().saturating_sub(8)..]
92 ),
93 timestamp_unix,
94 qapp_id: qapp_id.to_string(),
95 record_id: record_id.to_string(),
96 decision: decision_label,
97 obligations,
98 checkpoint_hash: checkpoint_hash.map(hex::encode),
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn receipt_log_appends() {
108 let dir = tempfile::tempdir().unwrap();
109 let log = ReceiptLog::open(dir.path()).unwrap();
110 let record = receipt_from_decision(
111 "wellfair-health",
112 "urn:wellfair:weight:x",
113 100,
114 &DecisionResult::Permit {
115 obligations: vec!["emit_wal_receipt".into()],
116 },
117 None,
118 );
119 log.append(&record).unwrap();
120 assert_eq!(log.list_recent(5).unwrap().len(), 1);
121 }
122}