Skip to main content

qualia_client_core/wellfair/
export_package.rs

1//! Standards-readable health export package (ยง8.1 step 9).
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use super::journal::JournalEntry;
7use super::receipt::ReceiptRecord;
8
9pub const EXPORT_FORMAT_VERSION: u32 = 1;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct HealthExportManifestEntry {
13    pub id: String,
14    pub kind: String,
15    pub evidence_type: String,
16    pub sensitivity: String,
17    pub asserted_time_unix: u32,
18    pub assurance: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct HealthExportPackage {
23    pub format_version: u32,
24    pub exported_at_unix: u32,
25    pub record_count: u32,
26    pub content_sha256_hex: String,
27    pub checkpoint_hash: Option<String>,
28    pub turtle_body: String,
29    pub manifest: Vec<HealthExportManifestEntry>,
30}
31
32fn assurance_label(evidence_type: &str, kind: &str) -> &'static str {
33    if kind == "disputed_diagnosis" {
34        "disputed_self_reported_restricted"
35    } else if kind == "housing_safety" {
36        "safety_context_restricted"
37    } else if kind == "life_event" || kind == "case_task" {
38        "life_event_restricted"
39    } else if kind == "welfare_case" {
40        "welfare_case_sanctuary"
41    } else if kind == "wellbeing_observation" {
42        "wellbeing_self_report"
43    } else if kind == "therapy_note" || kind == "sanctuary_note" {
44        "sanctuary_classified"
45    } else if evidence_type.contains("SelfReported") {
46        "self_reported_restricted"
47    } else if evidence_type.contains("DeviceMeasured") {
48        "device_measured_restricted"
49    } else {
50        "asserted_restricted"
51    }
52}
53
54/// Build Turtle + manifest from committed journal rows (no heap in hot evaluators elsewhere;
55/// export is a cold path).
56pub fn build_export_package(
57    entries: &[JournalEntry],
58    exported_at_unix: u32,
59    checkpoint_hash: Option<[u8; 32]>,
60) -> HealthExportPackage {
61    let mut turtle = wellfare_core::rdf::generate_rdf_prefixes();
62    let mut manifest = Vec::with_capacity(entries.len());
63
64    for entry in entries {
65        let subj = format!("<{}>", entry.id);
66        turtle.push_str(&format!("{subj} a wf:HealthRecord , fhir:Observation ;\n"));
67        turtle.push_str(&format!("    wf:kind {:?} ;\n", entry.kind));
68        turtle.push_str(&format!(
69            "    wf:evidenceType {:?} ;\n",
70            entry.evidence_type
71        ));
72        turtle.push_str(&format!("    wf:sensitivity {:?} ;\n", entry.sensitivity));
73        turtle.push_str(&format!(
74            "    fhir:Observation.effectiveDateTime \"{}\"^^xsd:unsignedInt ;\n",
75            entry.asserted_time_unix
76        ));
77        if let Some(ref summary) = entry.summary {
78            turtle.push_str(&format!("    schema:description {:?} ;\n", summary));
79        }
80        if let Some(ref blob) = entry.blob_hash {
81            turtle.push_str(&format!("    wf:blobHash {:?} ;\n", blob));
82        }
83        turtle.push_str("    prov:wasGeneratedBy <urn:wellfair:agent:vault> .\n\n");
84
85        manifest.push(HealthExportManifestEntry {
86            id: entry.id.clone(),
87            kind: entry.kind.clone(),
88            evidence_type: entry.evidence_type.clone(),
89            sensitivity: entry.sensitivity.clone(),
90            asserted_time_unix: entry.asserted_time_unix,
91            assurance: assurance_label(&entry.evidence_type, &entry.kind).to_string(),
92        });
93    }
94
95    let content_sha256_hex = hex::encode(Sha256::digest(turtle.as_bytes()).as_slice());
96    HealthExportPackage {
97        format_version: EXPORT_FORMAT_VERSION,
98        exported_at_unix,
99        record_count: entries.len() as u32,
100        content_sha256_hex,
101        checkpoint_hash: checkpoint_hash.map(|h| hex::encode(h)),
102        turtle_body: turtle,
103        manifest,
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct ExportReceipt {
109    pub export_sha256_hex: String,
110    pub record_count: u32,
111    pub checkpoint_hash: Option<String>,
112    pub exported_at_unix: u32,
113}
114
115impl ExportReceipt {
116    pub fn from_package(pkg: &HealthExportPackage) -> Self {
117        Self {
118            export_sha256_hex: pkg.content_sha256_hex.clone(),
119            record_count: pkg.record_count,
120            checkpoint_hash: pkg.checkpoint_hash.clone(),
121            exported_at_unix: pkg.exported_at_unix,
122        }
123    }
124}
125
126pub fn export_policy_receipt(pkg: &HealthExportPackage, timestamp_unix: u32) -> ReceiptRecord {
127    ReceiptRecord {
128        id: format!(
129            "export-{}",
130            pkg.content_sha256_hex.get(..8).unwrap_or("00000000")
131        ),
132        timestamp_unix,
133        qapp_id: "wellfair-shell".into(),
134        record_id: format!("urn:wellfair:export:{}", pkg.exported_at_unix),
135        decision: "Permit".into(),
136        obligations: vec![
137            "standards_readable_export".into(),
138            "typed_assurance_manifest".into(),
139        ],
140        checkpoint_hash: pkg.checkpoint_hash.clone(),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn sample_entry(id: &str, kind: &str) -> JournalEntry {
149        JournalEntry {
150            id: id.into(),
151            kind: kind.into(),
152            asserted_time_unix: 1_700_000_000,
153            evidence_type: "DeviceMeasured".into(),
154            sensitivity: "Restricted".into(),
155            blob_hash: Some("abc".into()),
156            source: "test".into(),
157            committed_unix: 1_700_000_100,
158            summary: Some(r#"{"weight":72.0}"#.into()),
159        }
160    }
161
162    #[test]
163    fn export_package_has_turtle_and_manifest() {
164        let entries = vec![
165            sample_entry("urn:wellfair:weight:w1", "weight"),
166            sample_entry("urn:wellfair:condition:c1", "condition"),
167        ];
168        let pkg = build_export_package(&entries, 1_700_000_200, Some([7u8; 32]));
169        assert_eq!(pkg.record_count, 2);
170        assert!(pkg.turtle_body.contains("@prefix wf:"));
171        assert!(pkg.turtle_body.contains("urn:wellfair:weight:w1"));
172        assert_eq!(pkg.manifest.len(), 2);
173        assert_eq!(pkg.manifest[0].assurance, "device_measured_restricted");
174        assert!(!pkg.content_sha256_hex.is_empty());
175        assert!(pkg.checkpoint_hash.is_some());
176    }
177
178    #[test]
179    fn export_receipt_binds_checkpoint() {
180        let entries = vec![sample_entry("urn:wellfair:sleep:s1", "sleep")];
181        let pkg = build_export_package(&entries, 99, None);
182        let receipt = export_policy_receipt(&pkg, 99);
183        assert_eq!(receipt.decision, "Permit");
184        assert!(receipt
185            .obligations
186            .contains(&"standards_readable_export".into()));
187    }
188}