Skip to main content

qualia_client_core/wellfair/api/
host_core.rs

1//! Core methods: accessibility, snapshot, policy, consent, records, conditions, companion import, medications, diet, journal
2
3use std::path::Path;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use super::super::accessibility_prefs;
7use super::super::consent_store::ConsentGrantRecord;
8use super::super::export_package::{
9    build_export_package, export_policy_receipt, ExportReceipt, HealthExportPackage,
10};
11use super::super::graph_query::GraphCoverageRow;
12use super::super::host_state::WellfairHostSnapshot;
13use super::super::host_state::{
14    AccessibilityPreferences, ConsentGrantDraft, PolicyDecisionDto, SubmitOutcome,
15};
16use super::super::import_samsung::{
17    import_samsung_folder, ingest_companion_health_bundle, SamsungImportReport,
18};
19use super::super::journal::JournalEntry;
20use super::super::policy::{DecisionResult, PolicyDecisionService};
21use super::super::receipt::{receipt_from_decision, ReceiptRecord};
22use super::super::sanctuary::{apply_sanctuary_projection, load_prefs as load_sanctuary_prefs};
23use super::super::snapshot::build_host_snapshot;
24use super::super::sync_outbox::SyncOutboxEntry;
25use super::super::vault::VaultService;
26use ed25519_dalek::SigningKey;
27use qualia_core_db::key_vault::KeyVault;
28use sha2::{Digest, Sha256};
29use wellfare_core::companion_sync::CompanionHealthBundle;
30use wellfare_core::conditions::{
31    allergy_summary, build_allergy_envelope, build_condition_envelope, condition_summary,
32    AllergyReport, ConditionReport,
33};
34use wellfare_core::medication::{
35    self, AdministrationStatus, DietEntry, MedicationAdministration, MedicationCatalogEntry,
36};
37use wellfare_core::personal_records::{
38    build_disputed_diagnosis_envelope, build_housing_safety_envelope, disputed_diagnosis_summary,
39    housing_safety_summary, DisputedDiagnosisReport, HousingSafetyReport,
40};
41use wellfare_core::record::RecordEnvelope;
42
43use super::*;
44
45impl WebizenHostApi {
46    pub fn new(
47        vault: VaultService,
48        policy: PolicyDecisionService,
49        signing_key: SigningKey,
50        owner_did: String,
51        author_did: String,
52        storage_root: std::path::PathBuf,
53    ) -> Self {
54        Self {
55            vault,
56            policy,
57            signing_key,
58            owner_did,
59            author_did,
60            storage_root,
61        }
62    }
63
64    pub fn save_accessibility(&self, prefs: &AccessibilityPreferences) -> Result<(), String> {
65        accessibility_prefs::save(&self.storage_root, prefs).map_err(|e| e.to_string())
66    }
67
68    pub fn load_accessibility(&self) -> AccessibilityPreferences {
69        accessibility_prefs::load(&self.storage_root)
70    }
71
72    pub fn snapshot_from_vault(
73        key_vault: &KeyVault,
74        owner_label: &str,
75        demo_mode: bool,
76    ) -> WellfairHostSnapshot {
77        build_host_snapshot(key_vault, true, owner_label, demo_mode)
78    }
79
80    pub fn build_snapshot(
81        &mut self,
82        key_vault: &KeyVault,
83        owner_label: &str,
84    ) -> WellfairHostSnapshot {
85        let mut snap = super::super::snapshot::build_host_snapshot_with_storage(
86            key_vault,
87            true,
88            owner_label,
89            false,
90            Some(&self.storage_root),
91        );
92        if let Ok(count) = self.vault.journal_count() {
93            snap.health_record_count = count as u32;
94        }
95        snap.graph_quin_count = self.vault.graph_quin_count() as u32;
96        if let Ok(pending) = self.vault.wal_buffered_quins() {
97            snap.pending_jobs = pending as u32;
98            snap.sync_state = if pending > 0 {
99                super::super::host_state::SyncQueueState::Queued
100            } else {
101                super::super::host_state::SyncQueueState::Idle
102            };
103        }
104        if let Some(hash) = self.vault.last_checkpoint_hash() {
105            snap.last_checkpoint_prefix = Some(hex::encode(&hash[..4]));
106        }
107        if let Ok(queued) = self.vault.outbox_queued_count() {
108            snap.pending_jobs = snap.pending_jobs.saturating_add(queued as u32);
109            if queued > 0 {
110                snap.sync_state = super::super::host_state::SyncQueueState::Queued;
111            }
112        }
113        snap
114    }
115
116    /// Fetches a `.10d` asset on-demand instead of loading all assets upfront.
117    pub fn fetch_10d_asset_on_demand(&self, _asset_uri: &str) -> Result<Vec<u8>, String> {
118        // Returns the raw asset binary. For now, it returns an empty vector.
119        Ok(Vec::new())
120    }
121
122    pub(crate) fn chora_storage_root(&self) -> &std::path::Path {
123        &self.storage_root
124    }
125
126    /// The on-disk storage root for this host (where the asset cache + prefs live). Exposed so the
127    /// desktop can run blocking acquisition off the async runtime without holding the host lock.
128    pub fn storage_root(&self) -> &std::path::Path {
129        &self.storage_root
130    }
131
132    /// Stable hash used by local graph evaluators without exposing the person's DID.
133    pub fn owner_did_hash(&self) -> u64 {
134        qualia_core_db::q_hash(&self.owner_did)
135    }
136
137    pub(crate) fn chora_signing_key(&self) -> &SigningKey {
138        &self.signing_key
139    }
140
141    pub(crate) fn chora_owner_did(&self) -> &str {
142        &self.owner_did
143    }
144
145    pub(crate) fn now_unix() -> u64 {
146        SystemTime::now()
147            .duration_since(UNIX_EPOCH)
148            .map(|d| d.as_secs())
149            .unwrap_or(0)
150    }
151
152    pub fn evaluate_policy(
153        &self,
154        qapp_id: &str,
155        requested_scope: &str,
156        sensitivity: wellfare_core::record::SensitivityClass,
157        epistemic: wellfare_core::record::EpistemicStatus,
158    ) -> Result<PolicyDecisionDto, String> {
159        let now = Self::now_unix();
160        let grants = self
161            .vault
162            .list_active_consents(now)
163            .map_err(|e| e.to_string())?;
164        let decision = self.policy.evaluate_access(
165            qapp_id,
166            requested_scope,
167            sensitivity,
168            epistemic,
169            &grants,
170            now,
171            false,
172        );
173        Ok(decision.to_dto())
174    }
175
176    pub fn grant_consent(
177        &mut self,
178        draft: &ConsentGrantDraft,
179        scope: &str,
180    ) -> Result<ConsentGrantRecord, String> {
181        let grant = ConsentGrantRecord::from_draft(draft, scope);
182        self.vault
183            .append_consent(&grant)
184            .map_err(|e| e.to_string())?;
185        let ts = grant.granted_at_unix;
186        let decision = DecisionResult::Permit {
187            obligations: vec!["consent_granted".into(), "emit_wal_receipt".into()],
188        };
189        let receipt = receipt_from_decision(
190            "wellfair-shell",
191            &grant.id,
192            ts,
193            &decision,
194            self.vault.last_checkpoint_hash(),
195        );
196        self.vault
197            .append_receipt(&receipt)
198            .map_err(|e| e.to_string())?;
199        Ok(grant)
200    }
201
202    pub fn revoke_consent(&mut self, grant_id: &str) -> Result<bool, String> {
203        let revoked = self
204            .vault
205            .revoke_consent(grant_id)
206            .map_err(|e| e.to_string())?;
207        if revoked {
208            let ts = Self::now_unix() as u32;
209            let decision = DecisionResult::Deny {
210                reasons: vec!["consent_revoked".into()],
211            };
212            let receipt = receipt_from_decision(
213                "wellfair-shell",
214                grant_id,
215                ts,
216                &decision,
217                self.vault.last_checkpoint_hash(),
218            );
219            self.vault
220                .append_receipt(&receipt)
221                .map_err(|e| e.to_string())?;
222        }
223        Ok(revoked)
224    }
225
226    pub fn list_consents(&self) -> Result<Vec<ConsentGrantRecord>, String> {
227        self.vault
228            .list_active_consents(Self::now_unix())
229            .map_err(|e| e.to_string())
230    }
231
232    pub fn submit_record(
233        &mut self,
234        qapp_id: &str,
235        envelope: RecordEnvelope,
236        source: &str,
237    ) -> Result<usize, String> {
238        self.submit_record_with_summary(qapp_id, envelope, source, None)
239    }
240
241    pub fn submit_record_with_summary(
242        &mut self,
243        qapp_id: &str,
244        envelope: RecordEnvelope,
245        source: &str,
246        summary: Option<String>,
247    ) -> Result<usize, String> {
248        match self.submit_record_guarded(qapp_id, envelope, source, summary)? {
249            SubmitOutcome::Committed { quins } => Ok(quins),
250            SubmitOutcome::Suspended { .. } => {
251                Err("Policy requires guardian approval before write".into())
252            }
253        }
254    }
255
256    /// Policy-gated write that surfaces the guardian-escrow outcome instead of collapsing it to an
257    /// error. A **proxy** write of a protected (Restricted) record does not commit immediately — it
258    /// is held in a [`GuardianshipProposal`] pending M-of-N guardian co-signature (see
259    /// [`Self::vote_guardianship_proposal`]). Non-proxy writes commit exactly as before.
260    pub fn submit_record_guarded(
261        &mut self,
262        qapp_id: &str,
263        envelope: RecordEnvelope,
264        source: &str,
265        summary: Option<String>,
266    ) -> Result<SubmitOutcome, String> {
267        let now = Self::now_unix();
268        let grants = self
269            .vault
270            .list_active_consents(now)
271            .map_err(|e| e.to_string())?;
272        let is_proxy = envelope
273            .proxy_did
274            .as_deref()
275            .map(|p| p != envelope.owner_did)
276            .unwrap_or(false);
277        let decision = self.policy.evaluate_access(
278            qapp_id,
279            "write_record",
280            envelope.sensitivity,
281            envelope.epistemic_status,
282            &grants,
283            now,
284            is_proxy,
285        );
286
287        match &decision {
288            DecisionResult::Deny { reasons } => {
289                Err(format!("Policy denied: {}", reasons.join("; ")))
290            }
291            DecisionResult::Prompt { .. } => Err("Policy requires consent before write".into()),
292            DecisionResult::Suspend { required_approvals } => {
293                let proposal = self.escrow_proxy_write(&envelope, summary, *required_approvals)?;
294                let threshold = proposal.threshold;
295                Ok(SubmitOutcome::Suspended {
296                    proposal_id: proposal.id,
297                    threshold,
298                })
299            }
300            DecisionResult::Permit { .. } => {
301                let quins =
302                    self.commit_permitted(qapp_id, &envelope, source, summary, &decision)?;
303                Ok(SubmitOutcome::Committed { quins })
304            }
305        }
306    }
307
308    /// Commit a policy-permitted envelope through the signed vault path and emit its receipt.
309    pub(crate) fn commit_permitted(
310        &mut self,
311        qapp_id: &str,
312        envelope: &RecordEnvelope,
313        source: &str,
314        summary: Option<String>,
315        decision: &DecisionResult,
316    ) -> Result<usize, String> {
317        let principal_did = qualia_core_db::q_hash(&self.owner_did);
318        let committed = self
319            .vault
320            .commit_envelope(envelope, &self.signing_key, principal_did, source, summary)
321            .map_err(|e| e.to_string())?;
322        let ts = envelope.asserted_time_unix;
323        let receipt = receipt_from_decision(
324            qapp_id,
325            &envelope.id,
326            ts,
327            decision,
328            self.vault.last_checkpoint_hash(),
329        );
330        self.vault
331            .append_receipt(&receipt)
332            .map_err(|e| e.to_string())?;
333        Ok(committed)
334    }
335
336    pub fn finalize_batch(&mut self) -> Result<String, String> {
337        let hash = self.vault.checkpoint().map_err(|e| e.to_string())?;
338        Ok(hex::encode(hash))
339    }
340
341    pub fn list_health_records(&self, limit: usize) -> Result<Vec<JournalEntry>, String> {
342        let entries = self
343            .vault
344            .list_health_records(limit)
345            .map_err(|e| e.to_string())?;
346        let prefs = load_sanctuary_prefs(&self.storage_root);
347        Ok(apply_sanctuary_projection(&prefs, entries))
348    }
349
350    pub fn list_receipts(&self, limit: usize) -> Result<Vec<ReceiptRecord>, String> {
351        self.vault.list_receipts(limit).map_err(|e| e.to_string())
352    }
353
354    pub fn list_outbox(&self, limit: usize) -> Result<Vec<SyncOutboxEntry>, String> {
355        self.vault.list_outbox(limit).map_err(|e| e.to_string())
356    }
357
358    /// Standards-readable Turtle export bound to the latest checkpoint (§8.1 step 9).
359    pub fn export_health_package(
360        &mut self,
361        limit: usize,
362    ) -> Result<(HealthExportPackage, ExportReceipt), String> {
363        self.finalize_batch().ok();
364        let entries = self.list_health_records(limit)?;
365        let exported_at = Self::now_unix() as u32;
366        let pkg = build_export_package(&entries, exported_at, self.vault.last_checkpoint_hash());
367        let receipt = export_policy_receipt(&pkg, exported_at);
368        self.vault
369            .append_receipt(&receipt)
370            .map_err(|e| e.to_string())?;
371        let export_receipt = ExportReceipt::from_package(&pkg);
372        Ok((pkg, export_receipt))
373    }
374
375    /// Journal row → materialized quin coverage (bounded semantic query).
376    ///
377    /// Applies the Sanctuary projection: while Sanctuary is locked (including a
378    /// decoy session) rows for protected kinds are withheld, so the coverage/Tools
379    /// view is never an alternate read path around the boundary (master plan §5.2, §17).
380    pub fn query_graph_coverage(&self, limit: usize) -> Result<Vec<GraphCoverageRow>, String> {
381        let rows = self
382            .vault
383            .graph_coverage(limit)
384            .map_err(|e| e.to_string())?;
385        let prefs = load_sanctuary_prefs(&self.storage_root);
386        if !prefs.enabled || !prefs.locked {
387            return Ok(rows);
388        }
389        Ok(rows
390            .into_iter()
391            .filter(|row| !super::super::sanctuary::is_sanctuary_protected_kind(&row.kind))
392            .collect())
393    }
394
395    pub(crate) fn payload_hash_hex(payload: &str) -> String {
396        hex::encode(Sha256::digest(payload.as_bytes()).as_slice())
397    }
398
399    pub fn add_condition(&mut self, report: &ConditionReport) -> Result<JournalEntry, String> {
400        let payload = serde_json::to_string(report).map_err(|e| e.to_string())?;
401        let hash = Self::payload_hash_hex(&payload);
402        let asserted = Self::now_unix() as u32;
403        let envelope = build_condition_envelope(
404            report,
405            &self.owner_did,
406            &self.author_did,
407            asserted,
408            Some(hash),
409        );
410        let summary = condition_summary(report);
411        self.submit_record_with_summary(QAPP_SHELL, envelope, SOURCE_PERSONAL, Some(summary))?;
412        let entries = self.list_health_records(1)?;
413        entries
414            .into_iter()
415            .next()
416            .ok_or_else(|| "condition committed but not found in journal".to_string())
417    }
418
419    pub fn add_disputed_diagnosis(
420        &mut self,
421        report: &DisputedDiagnosisReport,
422    ) -> Result<JournalEntry, String> {
423        let payload = serde_json::to_string(report).map_err(|e| e.to_string())?;
424        let hash = Self::payload_hash_hex(&payload);
425        let asserted = Self::now_unix() as u32;
426        let envelope = build_disputed_diagnosis_envelope(
427            report,
428            &self.owner_did,
429            &self.author_did,
430            asserted,
431            Some(hash),
432        );
433        let summary = disputed_diagnosis_summary(report);
434        self.submit_record_with_summary(QAPP_SHELL, envelope, SOURCE_PERSONAL, Some(summary))?;
435        self.list_health_records(1)?
436            .into_iter()
437            .next()
438            .ok_or_else(|| "disputed diagnosis committed but not found in journal".to_string())
439    }
440
441    pub fn add_housing_safety(
442        &mut self,
443        report: &HousingSafetyReport,
444    ) -> Result<JournalEntry, String> {
445        let payload = serde_json::to_string(report).map_err(|e| e.to_string())?;
446        let hash = Self::payload_hash_hex(&payload);
447        let asserted = Self::now_unix() as u32;
448        let envelope = build_housing_safety_envelope(
449            report,
450            &self.owner_did,
451            &self.author_did,
452            asserted,
453            Some(hash),
454        );
455        let summary = housing_safety_summary(report);
456        self.submit_record_with_summary(QAPP_SHELL, envelope, SOURCE_PERSONAL, Some(summary))?;
457        self.list_health_records(1)?
458            .into_iter()
459            .next()
460            .ok_or_else(|| "housing/safety committed but not found in journal".to_string())
461    }
462
463    pub fn add_allergy(&mut self, report: &AllergyReport) -> Result<JournalEntry, String> {
464        let payload = serde_json::to_string(report).map_err(|e| e.to_string())?;
465        let hash = Self::payload_hash_hex(&payload);
466        let asserted = Self::now_unix() as u32;
467        let envelope = build_allergy_envelope(
468            report,
469            &self.owner_did,
470            &self.author_did,
471            asserted,
472            Some(hash),
473        );
474        let summary = allergy_summary(report);
475        self.submit_record_with_summary(QAPP_SHELL, envelope, SOURCE_PERSONAL, Some(summary))?;
476        let entries = self.list_health_records(1)?;
477        entries
478            .into_iter()
479            .next()
480            .ok_or_else(|| "allergy committed but not found in journal".to_string())
481    }
482
483    pub fn graph_quin_count(&self) -> usize {
484        self.vault.graph_quin_count()
485    }
486
487    pub fn import_samsung_health_folder(&mut self, folder: &Path) -> SamsungImportReport {
488        let owner = self.owner_did.clone();
489        let author = self.author_did.clone();
490        let mut report = import_samsung_folder(self, folder, &owner, &author);
491        if report.records_committed > 0 {
492            if let Ok(hash) = self.finalize_batch() {
493                report.checkpoint_hash = Some(hash);
494            }
495        }
496        report
497    }
498
499    /// Primary ingest path: companion bundle from the user's phone.
500    pub fn ingest_companion_health_bundle(
501        &mut self,
502        bundle: &CompanionHealthBundle,
503    ) -> SamsungImportReport {
504        let owner = self.owner_did.clone();
505        let author = self.author_did.clone();
506        let mut report = ingest_companion_health_bundle(self, bundle, &owner, &author);
507        if report.records_committed > 0 {
508            if let Ok(hash) = self.finalize_batch() {
509                report.checkpoint_hash = Some(hash);
510            }
511        }
512        report
513    }
514
515    const QAPP_MEDICATION: &'static str = "wellfair-medication";
516
517    pub fn add_medication(
518        &mut self,
519        name: &str,
520        dose: &str,
521        route: &str,
522        schedule_times: Vec<String>,
523    ) -> Result<JournalEntry, String> {
524        let now = Self::now_unix() as u32;
525        let entry = MedicationCatalogEntry {
526            id: medication::new_medication_id(name, now),
527            name: name.to_string(),
528            dose: dose.to_string(),
529            route: route.to_string(),
530            schedule_times,
531            prescriber: None,
532            ceased_at_unix: None,
533            created_at_unix: now,
534        };
535        let packed = medication::medication_envelope(&entry, &self.owner_did, &self.author_did);
536        self.submit_record_with_summary(
537            Self::QAPP_MEDICATION,
538            packed.envelope,
539            "wellfair-medication:ui",
540            Some(packed.summary),
541        )?;
542        self.finalize_batch().ok();
543        self.list_health_records(1)?
544            .into_iter()
545            .next()
546            .ok_or_else(|| "medication committed but journal empty".into())
547    }
548
549    pub fn record_administration(
550        &mut self,
551        medication_id: &str,
552        medication_name: &str,
553        status: AdministrationStatus,
554        notes: Option<String>,
555    ) -> Result<JournalEntry, String> {
556        let now = Self::now_unix() as u32;
557        let admin = MedicationAdministration {
558            id: medication::new_administration_id(medication_id, now),
559            medication_id: medication_id.to_string(),
560            medication_name: medication_name.to_string(),
561            status,
562            administered_at_unix: now,
563            notes,
564        };
565        let packed = medication::administration_envelope(&admin, &self.owner_did, &self.author_did);
566        self.submit_record_with_summary(
567            Self::QAPP_MEDICATION,
568            packed.envelope,
569            "wellfair-medication:ui",
570            Some(packed.summary),
571        )?;
572        self.finalize_batch().ok();
573        self.list_health_records(1)?
574            .into_iter()
575            .next()
576            .ok_or_else(|| "administration committed but journal empty".into())
577    }
578
579    pub fn add_diet_entry(
580        &mut self,
581        description: &str,
582        meal_type: &str,
583        calories_kcal: Option<u32>,
584    ) -> Result<JournalEntry, String> {
585        let now = Self::now_unix() as u32;
586        let diet = DietEntry {
587            id: medication::new_diet_id(description, now),
588            description: description.to_string(),
589            meal_type: meal_type.to_string(),
590            calories_kcal,
591            logged_at_unix: now,
592        };
593        let packed = medication::diet_envelope(&diet, &self.owner_did, &self.author_did);
594        self.submit_record_with_summary(
595            Self::QAPP_MEDICATION,
596            packed.envelope,
597            "wellfair-medication:ui",
598            Some(packed.summary),
599        )?;
600        self.finalize_batch().ok();
601        self.list_health_records(1)?
602            .into_iter()
603            .next()
604            .ok_or_else(|| "diet entry committed but journal empty".into())
605    }
606
607    pub fn list_journal_by_kind(
608        &self,
609        kind: &str,
610        limit: usize,
611    ) -> Result<Vec<JournalEntry>, String> {
612        Ok(self
613            .list_health_records(limit)?
614            .into_iter()
615            .filter(|e| e.kind == kind)
616            .collect())
617    }
618
619    /// 3D Anatomy Qapp — compute the whole-person systemic view for a lens (`"person"` /
620    /// `"clinician"`). Reads the person's condition / medication / diet records, maps them onto body
621    /// systems via the anatomy knowledge base, and returns the lens narrative + per-system burden +
622    /// an honest account of what did not map. Read-only; a computed set of **hypotheses**, never a
623    /// diagnosis. `convergence_threshold` is how many distinct adverse factors flag a system.
624    pub fn compute_anatomy_view(
625        &self,
626        lens: &str,
627        convergence_threshold: usize,
628    ) -> Result<super::super::anatomy_view::AnatomyViewReport, String> {
629        let conditions = self.list_journal_by_kind("condition", 256)?;
630        let medications = self.list_journal_by_kind("medication", 256)?;
631        let diet = self.list_journal_by_kind("diet", 256)?;
632        let state = self.get_physiological_state();
633        Ok(super::super::anatomy_view::build_report_from_journal(
634            &conditions,
635            &medications,
636            &diet,
637            super::super::anatomy_view::parse_lens(lens),
638            convergence_threshold,
639            state,
640        ))
641    }
642
643    /// 3D Anatomy Qapp — build the **whole-body render scene** (S5.7 interim visual) for the current
644    /// records + declared physiological state, viewed from `(azimuth, elevation)` in degrees. Returns a
645    /// [`webizen_render::scene_contract::RenderScene`] coloured by accumulated burden (σ → RGBA), ready
646    /// for the headless `render_scene_png` pipeline. The orbit camera lets the Studio UI spin the body.
647    /// Read-only; a computed visual of **hypotheses**, never a diagnosis.
648    pub fn compute_body_scene(
649        &self,
650        azimuth_deg: f64,
651        elevation_deg: f64,
652    ) -> Result<webizen_render::scene_contract::RenderScene, String> {
653        let report = self.compute_anatomy_view("person", 2)?;
654        let fit = self.body_fit();
655        Ok(super::super::anatomy_render::body_scene_with_fit(
656            &report,
657            azimuth_deg,
658            elevation_deg,
659            &fit,
660        ))
661    }
662}