Skip to main content

qualia_client_core/wellfair/api/
agency.rs

1//! Agency layer + wellbeing assessment
2
3use super::super::journal::JournalEntry;
4use qualia_cooperative_core::agency_delegation::{
5    agency_delegation_full_json, build_agency_delegation_envelope, delegation_permits,
6    parse_agency_delegation, AccessDecision, AccessRequest, AgencyDelegation, ConsentState,
7    Precedence,
8};
9use qualia_cooperative_core::agency_domain::agency_domain_taxonomy;
10use qualia_cooperative_core::taxonomy::Sphere;
11use qualia_cooperative_core::trigger::TriggerContext;
12use wellfare_core::assessment::{
13    assessment_summary, build_assessment_envelope, instrument, instrument_dto, instruments,
14    parse_assessment, score, AssessmentResult, InstrumentDto,
15};
16
17use super::*;
18
19impl WebizenHostApi {
20    // --- Agency layer: supported-agency delegations (ADR §7–§10; cooperative-core agency_*) -------
21    //
22    // A delegation binds a principal to their agent(s) for a *domain of agency* under an authority
23    // profile + values anchor, gated by an optional trigger and fail-closed ABAC. Persisted through
24    // the same signed journal path as other Restricted records (self-authored → commits; a proxy
25    // write would suspend into guardianship, T1.5). The **lossless** delegation JSON is stored as the
26    // record summary so the full object reconstructs on read; updates append a superseding version of
27    // the same delegation id (latest-wins projection in `list_agency_delegations`).
28
29    /// Persist a delegation (create or supersede). Returns the committed journal entry.
30    pub fn add_agency_delegation(
31        &mut self,
32        delegation: &AgencyDelegation,
33    ) -> Result<JournalEntry, String> {
34        let asserted = Self::now_unix() as u32;
35        let envelope = build_agency_delegation_envelope(
36            delegation,
37            &self.owner_did,
38            &self.author_did,
39            asserted,
40        );
41        let summary = agency_delegation_full_json(delegation);
42        self.submit_record_with_summary(
43            QAPP_COOPERATIVE,
44            envelope,
45            SOURCE_COOPERATIVE,
46            Some(summary),
47        )?;
48        self.finalize_batch().ok();
49        self.list_journal_by_kind("agency_delegation", 1)?
50            .into_iter()
51            .next()
52            .ok_or_else(|| "agency delegation committed but journal empty".into())
53    }
54
55    /// Build and persist a new delegation from primitive fields (so the Tauri layer needs no
56    /// cooperative-core types). Validates the domain against the seeded taxonomy; an empty
57    /// `values_anchor` defaults to the UN-HR anchor (`urn:un:hr:udhr`). Returns the created record.
58    #[allow(clippy::too_many_arguments)]
59    pub fn create_agency_delegation(
60        &mut self,
61        principal_did: &str,
62        domain: &str,
63        values_anchor: &str,
64        agent_dids: Vec<String>,
65        precedence: &str,
66        consent: &str,
67    ) -> Result<AgencyDelegation, String> {
68        if agency_domain_taxonomy().get(domain).is_none() {
69            return Err(format!("unknown domain of agency: {domain}"));
70        }
71        let anchor = if values_anchor.trim().is_empty() {
72            "urn:un:hr:udhr"
73        } else {
74            values_anchor
75        };
76        let mut d = AgencyDelegation::new(principal_did, domain, anchor, Self::now_unix() as u32);
77        d.agent_dids = agent_dids
78            .into_iter()
79            .map(|s| s.trim().to_string())
80            .filter(|s| !s.is_empty())
81            .collect();
82        d.precedence = match precedence {
83            "secondary" => Precedence::Secondary,
84            "local_temporary" => Precedence::LocalTemporary,
85            _ => Precedence::Primary,
86        };
87        d.consent = agency_consent_from_str(consent).unwrap_or(ConsentState::Pending);
88        self.add_agency_delegation(&d)?;
89        Ok(d)
90    }
91
92    /// List the current delegations — latest version per delegation id (updates supersede).
93    ///
94    /// The journal is append-only and lists **newest-first**, so the first record seen for a given
95    /// logical delegation id is its latest version (append order == version order). This is robust
96    /// even when several versions share the same `asserted_time_unix` second.
97    pub fn list_agency_delegations(&self, limit: usize) -> Result<Vec<AgencyDelegation>, String> {
98        use std::collections::HashSet;
99        let entries = self.list_journal_by_kind("agency_delegation", limit)?;
100        let mut seen: HashSet<String> = HashSet::new();
101        let mut out: Vec<AgencyDelegation> = Vec::new();
102        for e in entries {
103            let Some(summary) = e.summary.as_deref() else {
104                continue;
105            };
106            let Some(d) = parse_agency_delegation(summary) else {
107                continue;
108            };
109            if seen.insert(d.id.clone()) {
110                out.push(d); // first-seen (newest-first order) == the latest version
111            }
112        }
113        out.sort_by(|a, b| {
114            a.valid_from_unix
115                .cmp(&b.valid_from_unix)
116                .then_with(|| a.id.cmp(&b.id))
117        });
118        Ok(out)
119    }
120
121    /// Fetch a single current delegation by its logical id.
122    pub fn get_agency_delegation(&self, delegation_id: &str) -> Result<AgencyDelegation, String> {
123        self.list_agency_delegations(512)?
124            .into_iter()
125            .find(|d| d.id == delegation_id)
126            .ok_or_else(|| format!("agency delegation '{delegation_id}' not found"))
127    }
128
129    /// Update the principal's consent state (grant / withdraw) — appends a superseding version.
130    pub fn set_agency_delegation_consent(
131        &mut self,
132        delegation_id: &str,
133        consent: ConsentState,
134    ) -> Result<JournalEntry, String> {
135        let mut d = self.get_agency_delegation(delegation_id)?;
136        d.consent = consent;
137        self.add_agency_delegation(&d)
138    }
139
140    /// Revoke a delegation — appends a superseding, revoked version (revocation is monotonic).
141    pub fn revoke_agency_delegation(
142        &mut self,
143        delegation_id: &str,
144    ) -> Result<JournalEntry, String> {
145        let mut d = self.get_agency_delegation(delegation_id)?;
146        d.revoked = true;
147        self.add_agency_delegation(&d)
148    }
149
150    /// The seeded domains of agency (id + label + description + consequential/selfhood flags), for a
151    /// delegation-creation picker. Category terms are excluded — only the 17 leaf domains.
152    pub fn list_agency_domains(&self) -> Vec<AgencyDomainInfo> {
153        let tax = agency_domain_taxonomy();
154        tax.all()
155            .iter()
156            .filter(|t| t.category.is_some())
157            .map(|t| AgencyDomainInfo {
158                id: t.id.clone(),
159                label: t.label.clone(),
160                category: t.category.clone(),
161                description: t.description.clone(),
162                consequential: t.attr("consequential") == Some("true"),
163                selfhood: t.sphere() == Sphere::Selfhood,
164            })
165            .collect()
166    }
167
168    /// Evaluate the fail-closed ABAC for a delegation against an access request built from the
169    /// delegation's own domain. `action` is `"read" | "write" | "decide"`. Uses a bare trigger
170    /// context (now only) — trigger-gated delegations therefore read as inactive here; supplying a
171    /// richer context (events/attestations) is a follow-up. Demonstrates the safety invariants:
172    /// selfhood default-deny, and consequential judgements requiring declared provenance + horizon.
173    pub fn evaluate_agency_access(
174        &self,
175        delegation_id: &str,
176        action: &str,
177        data_class: &str,
178    ) -> Result<AccessDecision, String> {
179        let d = self.get_agency_delegation(delegation_id)?;
180        let tax = agency_domain_taxonomy();
181        let sphere = match tax.get(&d.domain).map(|t| t.sphere()) {
182            Some(Sphere::Selfhood) => Sphere::Selfhood,
183            _ => Sphere::Personhood,
184        };
185        let request = AccessRequest {
186            domain: d.domain.clone(),
187            data_class: data_class.to_string(),
188            action: action.to_string(),
189            sphere,
190            jurisdiction: None,
191            provenance: None,
192        };
193        let ctx = TriggerContext::at(Self::now_unix() as u32);
194        Ok(delegation_permits(&d, &tax, &request, &ctx))
195    }
196
197    // --- Wellbeing self-assessment instruments (T2.2; PHQ-9 / GAD-7) ---------------------------
198    //
199    // A self-monitoring aid, not a diagnosis. Scoring is fail-closed in the domain layer; results
200    // persist as Restricted records through the signed journal (lossless summary → reconstructs).
201
202    /// The instruments this build ships (definitions: items, options, bands, disclaimer).
203    pub fn list_assessment_instruments(&self) -> Vec<InstrumentDto> {
204        instruments().into_iter().map(instrument_dto).collect()
205    }
206
207    /// Score `responses` against the given instrument and persist the result. Returns the scored
208    /// outcome (total, band, interpretation, any safety flags). Errors if the instrument is unknown
209    /// or the responses are the wrong count / out of range (fail-closed in `score`).
210    pub fn record_assessment(
211        &mut self,
212        instrument_id: &str,
213        responses: Vec<u8>,
214    ) -> Result<AssessmentResult, String> {
215        let inst = instrument(instrument_id)
216            .ok_or_else(|| format!("unknown assessment instrument: {instrument_id}"))?;
217        let now = Self::now_unix() as u32;
218        let result = score(inst, &responses, now)?;
219        let envelope = build_assessment_envelope(&result, &self.owner_did, &self.author_did, now);
220        let summary = assessment_summary(&result);
221        self.submit_record_with_summary(QAPP_WELLBEING, envelope, SOURCE_WELLBEING, Some(summary))?;
222        self.finalize_batch().ok();
223        Ok(result)
224    }
225
226    /// Past assessment results, newest-first, reconstructed from the journal.
227    pub fn list_assessments(&self, limit: usize) -> Result<Vec<AssessmentResult>, String> {
228        let entries = self.list_journal_by_kind("wellbeing_assessment", limit)?;
229        Ok(entries
230            .iter()
231            .filter_map(|e| e.summary.as_deref().and_then(parse_assessment))
232            .collect())
233    }
234}