Skip to main content

qualia_client_core/wellfair/api/
guardianship.rs

1//! Guardianship + transparency + disclosure
2
3use super::super::host_state::{GuardianshipProposalView, SubmitOutcome};
4use super::super::journal::JournalEntry;
5use super::super::live_share::{
6    append_live_share_journal, live_share_decision_journal_entry, live_share_request_journal_entry,
7    sanctuary_allows_classified_projection, validate_live_share_decision, LiveShareStore,
8};
9use super::super::policy::DecisionResult;
10use super::super::sanctuary::load_prefs as load_sanctuary_prefs;
11use wellfare_core::conditions::{build_condition_envelope, condition_summary};
12use wellfare_core::guardianship::{
13    build_proposal_envelope, build_vote_envelope, derive_status, parse_proposal_summary,
14    parse_vote_summary, proposal_summary, vote_summary, GuardianshipProposal, GuardianshipVote,
15    ProposalState,
16};
17use wellfare_core::live_share::{LiveSectionRequest, UsageAgreement};
18use wellfare_core::record::RecordEnvelope;
19
20use super::*;
21
22impl WebizenHostApi {
23    // --- Guardianship approval escrow (M-of-N co-signature for proxy actions; T1.5) -------------
24    //
25    // Supported agency, not warden control: a proxy writing a protected record on the principal's
26    // behalf suspends into a `GuardianshipProposal`; guardians co-sign with immutable votes; the
27    // escrowed record commits on ratification. See `wellfare_core::guardianship`.
28
29    /// Submit a record that may be a proxy action, surfacing the guardian-escrow outcome. Callers
30    /// that set `envelope.proxy_did` use this instead of `submit_record` so a suspended write is a
31    /// first-class result (a pending proposal), not an error.
32    pub fn submit_proxy_record(
33        &mut self,
34        qapp_id: &str,
35        envelope: RecordEnvelope,
36        source: &str,
37        summary: Option<String>,
38    ) -> Result<SubmitOutcome, String> {
39        let outcome = self.submit_record_guarded(qapp_id, envelope, source, summary)?;
40        self.finalize_batch().ok();
41        Ok(outcome)
42    }
43
44    /// A supporter records a condition **on the principal's behalf** (a proxy action). The write is
45    /// escrowed for M-of-N guardian co-signature; the returned outcome carries the pending proposal
46    /// id. This is the supported-agency entry point the desktop exposes for the approval tray.
47    pub fn propose_proxy_condition(
48        &mut self,
49        proxy_did: &str,
50        report: &wellfare_core::conditions::ConditionReport,
51    ) -> Result<SubmitOutcome, String> {
52        let asserted = Self::now_unix() as u32;
53        let mut envelope =
54            build_condition_envelope(report, &self.owner_did, proxy_did, asserted, None);
55        envelope.proxy_did = Some(proxy_did.to_string());
56        let summary = condition_summary(report);
57        self.submit_proxy_record(QAPP_CLINICAL, envelope, SOURCE_CLINICAL, Some(summary))
58    }
59
60    /// Escrow a proxy write as a guardianship proposal pending M-of-N co-signature.
61    pub(crate) fn escrow_proxy_write(
62        &mut self,
63        envelope: &RecordEnvelope,
64        summary: Option<String>,
65        threshold: u8,
66    ) -> Result<GuardianshipProposal, String> {
67        let proxy = envelope
68            .proxy_did
69            .clone()
70            .unwrap_or_else(|| self.author_did.clone());
71        let kind = wellfare_core::conditions::journal_kind_for_record_id(&envelope.id);
72        let reason = format!(
73            "Proxy write of a protected '{kind}' record on the principal's behalf requires guardian co-signature"
74        );
75        let proposal = GuardianshipProposal::new(
76            &envelope.owner_did,
77            proxy,
78            threshold,
79            envelope,
80            summary,
81            reason,
82            Self::now_unix() as u32,
83        );
84        let asserted = Self::now_unix() as u32;
85        let prop_env =
86            build_proposal_envelope(&proposal, &self.owner_did, &self.author_did, asserted);
87        // The proposal record is a non-proxy governance write → commits normally (no recursion).
88        self.submit_record_with_summary(
89            QAPP_GUARDIANSHIP,
90            prop_env,
91            SOURCE_GUARDIANSHIP,
92            Some(proposal_summary(&proposal)),
93        )?;
94        self.finalize_batch().ok();
95        Ok(proposal)
96    }
97
98    /// Pending and recently-resolved guardianship proposals for the approval tray.
99    pub fn list_guardianship_proposals(
100        &self,
101        limit: usize,
102    ) -> Result<Vec<GuardianshipProposalView>, String> {
103        let rows = self.list_health_records(limit)?;
104        let mut proposals = Vec::new();
105        let mut votes = Vec::new();
106        for row in &rows {
107            let Some(ref summary) = row.summary else {
108                continue;
109            };
110            match row.kind.as_str() {
111                "guardianship_proposal" => {
112                    if let Some(p) = parse_proposal_summary(summary) {
113                        proposals.push(p);
114                    }
115                }
116                "guardianship_vote" => {
117                    if let Some(v) = parse_vote_summary(summary) {
118                        votes.push(v);
119                    }
120                }
121                _ => {}
122            }
123        }
124        let committed_ids: std::collections::HashSet<&str> =
125            rows.iter().map(|r| r.id.as_str()).collect();
126        let mut views: Vec<GuardianshipProposalView> = proposals
127            .iter()
128            .map(|p| {
129                let status = derive_status(p, &votes);
130                let committed = p
131                    .escrowed_record_id()
132                    .map(|id| committed_ids.contains(id.as_str()))
133                    .unwrap_or(false);
134                GuardianshipProposalView::from_status(p, &status, committed)
135            })
136            .collect();
137        views.sort_by(|a, b| b.created_unix.cmp(&a.created_unix));
138        Ok(views)
139    }
140
141    /// Record a guardian's co-signature (or objection). On ratification the escrowed record commits
142    /// through the normal signed vault path; the commit is idempotent (a replayed final vote will
143    /// not double-write the record).
144    pub fn vote_guardianship_proposal(
145        &mut self,
146        proposal_id: &str,
147        guardian_did: &str,
148        approve: bool,
149        reason: Option<String>,
150    ) -> Result<GuardianshipProposalView, String> {
151        let proposal = self
152            .find_proposal(proposal_id)?
153            .ok_or_else(|| format!("Unknown guardianship proposal: {proposal_id}"))?;
154
155        let vote = GuardianshipVote::new(
156            proposal_id,
157            guardian_did,
158            approve,
159            reason,
160            Self::now_unix() as u32,
161        );
162        let asserted = Self::now_unix() as u32;
163        let vote_env = build_vote_envelope(&vote, &self.owner_did, &self.author_did, asserted);
164        self.submit_record_with_summary(
165            QAPP_GUARDIANSHIP,
166            vote_env,
167            SOURCE_GUARDIANSHIP,
168            Some(vote_summary(&vote)),
169        )?;
170        self.finalize_batch().ok();
171
172        let votes = self.list_guardianship_votes(proposal_id)?;
173        let status = derive_status(&proposal, &votes);
174
175        let mut committed = self.escrowed_already_committed(&proposal)?;
176        if status.state == ProposalState::Ratified && !committed {
177            if let Some(escrowed) = proposal.escrowed_envelope() {
178                let decision = DecisionResult::Permit {
179                    obligations: vec!["guardianship_ratified".into(), "emit_wal_receipt".into()],
180                };
181                // Already M-of-N approved: commit through the signed path, bypassing re-escrow.
182                self.commit_permitted(
183                    QAPP_GUARDIANSHIP,
184                    &escrowed,
185                    SOURCE_GUARDIANSHIP,
186                    proposal.escrowed_summary.clone(),
187                    &decision,
188                )?;
189                self.finalize_batch().ok();
190                committed = true;
191            }
192        }
193
194        Ok(GuardianshipProposalView::from_status(
195            &proposal, &status, committed,
196        ))
197    }
198
199    pub(crate) fn find_proposal(
200        &self,
201        proposal_id: &str,
202    ) -> Result<Option<GuardianshipProposal>, String> {
203        let rows =
204            self.list_journal_by_kind("guardianship_proposal", super::super::journal::MAX_LIST)?;
205        Ok(rows
206            .into_iter()
207            .filter_map(|r| r.summary.as_deref().and_then(parse_proposal_summary))
208            .find(|p| p.id == proposal_id))
209    }
210
211    fn list_guardianship_votes(&self, proposal_id: &str) -> Result<Vec<GuardianshipVote>, String> {
212        let rows =
213            self.list_journal_by_kind("guardianship_vote", super::super::journal::MAX_LIST)?;
214        Ok(rows
215            .into_iter()
216            .filter_map(|r| r.summary.as_deref().and_then(parse_vote_summary))
217            .filter(|v| v.proposal_id == proposal_id)
218            .collect())
219    }
220
221    fn escrowed_already_committed(&self, proposal: &GuardianshipProposal) -> Result<bool, String> {
222        let Some(escrowed_id) = proposal.escrowed_record_id() else {
223            return Ok(false);
224        };
225        let kind = wellfare_core::conditions::journal_kind_for_record_id(&escrowed_id);
226        let rows = self.list_journal_by_kind(kind, super::super::journal::MAX_LIST)?;
227        Ok(rows.iter().any(|r| r.id == escrowed_id))
228    }
229
230    /// Companion requests a live section projection; owner must approve minimum kinds before data flows.
231    pub fn submit_live_share_request(
232        &self,
233        request: &LiveSectionRequest,
234    ) -> Result<JournalEntry, String> {
235        let now = Self::now_unix();
236        let store = LiveShareStore::open(&self.storage_root).map_err(|e| e.to_string())?;
237        let record = store
238            .enqueue_request(request.clone(), now)
239            .map_err(|e| e.to_string())?;
240        let committed_unix = now as u32;
241        let entry = live_share_request_journal_entry(&record, committed_unix);
242        append_live_share_journal(&self.storage_root, &entry)?;
243        Ok(entry)
244    }
245
246    /// Owner approves or denies a pending live share; sanctuary-classified kinds fail closed unless unlocked.
247    pub fn decide_live_share_request(
248        &self,
249        request_id: &str,
250        approved: bool,
251        projection_kinds: &[String],
252        deny_reason: Option<&str>,
253    ) -> Result<JournalEntry, String> {
254        let now = Self::now_unix();
255        let store = LiveShareStore::open(&self.storage_root).map_err(|e| e.to_string())?;
256        let pending = store
257            .get_request(request_id)
258            .map_err(|e| e.to_string())?
259            .ok_or_else(|| format!("live share request '{request_id}' not found"))?;
260        if pending.status != super::super::live_share::LiveShareRequestStatus::Pending {
261            return Err(format!("live share request '{request_id}' already decided"));
262        }
263        let sanctuary_prefs = load_sanctuary_prefs(&self.storage_root);
264        let sanctuary_unlocked = sanctuary_allows_classified_projection(&sanctuary_prefs);
265        validate_live_share_decision(&pending, approved, projection_kinds, sanctuary_unlocked)?;
266        let deny = if approved {
267            None
268        } else {
269            Some(
270                deny_reason
271                    .filter(|s| !s.is_empty())
272                    .unwrap_or("owner denied live share request"),
273            )
274        };
275        let updated = store
276            .decide(request_id, approved, projection_kinds, now, deny.as_deref())
277            .map_err(|e| e.to_string())?;
278        let committed_unix = now as u32;
279        let entry = live_share_decision_journal_entry(&updated, committed_unix);
280        append_live_share_journal(&self.storage_root, &entry)?;
281        Ok(entry)
282    }
283
284    pub fn get_live_share_record(
285        &self,
286        request_id: &str,
287    ) -> Result<Option<super::super::live_share::LiveShareRequestRecord>, String> {
288        LiveShareStore::open(&self.storage_root)
289            .map_err(|e| e.to_string())?
290            .get_request(request_id)
291            .map_err(|e| e.to_string())
292    }
293
294    pub fn list_pending_live_shares(
295        &self,
296        limit: usize,
297    ) -> Result<Vec<LiveSectionRequest>, String> {
298        LiveShareStore::open(&self.storage_root)
299            .map_err(|e| e.to_string())?
300            .list_pending(limit)
301            .map_err(|e| e.to_string())
302    }
303
304    pub fn register_usage_agreement(&self, agreement: &UsageAgreement) -> Result<(), String> {
305        LiveShareStore::open(&self.storage_root)
306            .map_err(|e| e.to_string())?
307            .save_usage_agreement(agreement)
308            .map_err(|e| e.to_string())
309    }
310}