Skip to main content

qualia_client_core/wellfair/
receipt.rs

1//! Typed policy receipts appended after durable WAL commits.
2
3use 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}