Skip to main content

qualia_client_core/
agreements.rs

1//! First-class peer-agreement store — the terms of a relationship between parties.
2//!
3//! This is **P1** of `docs/plans/rights-aware-peer-agreement-addressbook.md`, the counterpart to the
4//! `directory.rs` addressbook (**P0**). Where the directory hosts the *Parties* (the addressbook, joined by
5//! pairwise DID) this module hosts the *Agreements* that govern each relationship.
6//!
7//! An [`Agreement`] is the recorded terms of a relationship between parties, **grounded in
8//! values-credentials** ([`Agreement::values_anchors`]) — e.g. UDHR articles — rather than asserted from
9//! nowhere. Each term is an [`Undertaking`] (a right, obligation, prohibition, or permission) that may cite
10//! the values-credential it derives from ([`Undertaking::source`]). Formation is staged
11//! ([`FormationStage`]) and each party records its own [`ConsentState`] — the agreement is only fully
12//! consented once every party has [`ConsentState::Granted`] (see [`all_granted`]).
13//!
14//! Following the crate's store conventions (`directory.rs`, `social_peers.rs`), the pure list/consent
15//! helpers ([`set_consent`], [`all_granted`]) carry the logic and are unit-tested in isolation, while the
16//! `*_agreement(s)` functions layer a thin pretty-JSON persistence step on top (a `Vec<Agreement>` at
17//! `app_meta_dir()/agreements.json`).
18
19use std::fs;
20use std::path::PathBuf;
21
22use serde::{Deserialize, Serialize};
23
24use crate::state::app_meta_dir;
25
26/// Where an agreement is in its formation lifecycle.
27///
28/// `Draft` → `Offered` (put to the other parties) → `Agreed` (all parties consented) → `Ratified` (finalised
29/// / signed). The stage is descriptive record-keeping; [`all_granted`] is the authoritative consent check.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub enum FormationStage {
32    Draft,
33    Offered,
34    Agreed,
35    Ratified,
36}
37
38/// The deontic character of a single term within an agreement.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub enum UndertakingKind {
41    /// A right held by a party.
42    Right,
43    /// A duty a party owes.
44    Obligation,
45    /// Something a party must not do.
46    Prohibition,
47    /// Something a party is permitted to do.
48    Permission,
49}
50
51/// One term of an agreement: a right, obligation, prohibition, or permission, optionally grounded in a
52/// values-credential.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct Undertaking {
55    /// The deontic character of this term.
56    pub kind: UndertakingKind,
57    /// Human-readable statement of the term.
58    pub text: String,
59    /// The values-credential this term derives from (e.g. a UDHR article id), if any. `None` = ungrounded /
60    /// self-asserted.
61    pub source: Option<String>,
62}
63
64/// A party's consent to an agreement.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub enum ConsentState {
67    /// Consent has not yet been given (nor refused).
68    Pending,
69    /// The party consents.
70    Granted,
71    /// The party has withdrawn consent it previously gave.
72    Withdrawn,
73}
74
75/// One party's consent record within an agreement.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct PartyConsent {
78    /// The consenting party's DID.
79    pub did: String,
80    /// This party's current consent state.
81    pub consent: ConsentState,
82    /// Detached signature over the agreed terms, hex-encoded, once the party has signed. `None` until signed.
83    pub signature_hex: Option<String>,
84}
85
86/// The recorded terms of a relationship between parties, grounded in values-credentials.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct Agreement {
89    /// Stable identifier for this agreement (the store key).
90    pub id: String,
91    /// Human-readable title.
92    pub title: String,
93    /// The DID of the relationship this agreement governs (joins to the directory).
94    pub relationship_did: String,
95    /// The parties to this agreement, by DID.
96    pub parties: Vec<String>,
97    /// The values-credentials this agreement is anchored in (e.g. UDHR article ids).
98    pub values_anchors: Vec<String>,
99    /// The choice of law / jurisdiction governing this agreement (e.g. urn:jurisdiction:AU).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub jurisdiction: Option<String>,
102    /// The intended purposes of the agreement (e.g. urn:intent:public-good).
103    #[serde(default)]
104    pub intents: Vec<String>,
105    /// The contextual nature of the artifact being produced or licensed (e.g. urn:context:humanitarian-ict).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub artifact_context: Option<String>,
108    /// The terms of the agreement.
109    pub undertakings: Vec<Undertaking>,
110    /// Per-party consent records.
111    pub consents: Vec<PartyConsent>,
112    /// Where the agreement is in its formation lifecycle.
113    pub stage: FormationStage,
114    /// Unix seconds at which the agreement was created.
115    pub created_at: u64,
116    /// Unix seconds at which the agreement was last updated.
117    pub updated_at: u64,
118}
119
120// ---------------------------------------------------------------------------
121// Pure helpers (unit-tested; no filesystem)
122// ---------------------------------------------------------------------------
123
124/// Set party `did`'s consent to `state`, in place.
125///
126/// If a [`PartyConsent`] for `did` already exists its `consent` is replaced (its `signature_hex` is kept);
127/// otherwise a new `PartyConsent { did, consent: state, signature_hex: None }` is appended.
128pub fn set_consent(a: &mut Agreement, did: &str, state: ConsentState) {
129    if let Some(slot) = a.consents.iter_mut().find(|c| c.did == did) {
130        slot.consent = state;
131    } else {
132        a.consents.push(PartyConsent {
133            did: did.to_string(),
134            consent: state,
135            signature_hex: None,
136        });
137    }
138}
139
140/// Is every party consented?
141///
142/// True **iff** `a.parties` is non-empty AND every party DID has a [`PartyConsent`] whose `consent` is
143/// [`ConsentState::Granted`]. An agreement with no parties is never "all granted".
144pub fn all_granted(a: &Agreement) -> bool {
145    !a.parties.is_empty()
146        && a.parties.iter().all(|did| {
147            a.consents
148                .iter()
149                .any(|c| c.did == *did && c.consent == ConsentState::Granted)
150        })
151}
152
153// ---------------------------------------------------------------------------
154// Persistence (filesystem)
155// ---------------------------------------------------------------------------
156
157fn agreements_path() -> PathBuf {
158    app_meta_dir().join("agreements.json")
159}
160
161fn save_agreements(agreements: &[Agreement]) -> Result<(), String> {
162    let path = agreements_path();
163    if let Some(parent) = path.parent() {
164        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
165    }
166    let text = serde_json::to_string_pretty(agreements).map_err(|e| e.to_string())?;
167    fs::write(path, text).map_err(|e| e.to_string())
168}
169
170/// Load every stored agreement. Returns `vec![]` if the store file is absent or unreadable.
171pub fn list_agreements() -> Vec<Agreement> {
172    fs::read_to_string(agreements_path())
173        .ok()
174        .and_then(|t| serde_json::from_str(&t).ok())
175        .unwrap_or_default()
176}
177
178/// Insert-or-update `a` (keyed by [`Agreement::id`]), then persist the store.
179///
180/// If an agreement with the same `id` already exists it is replaced in place (preserving its position);
181/// otherwise `a` is appended.
182pub fn upsert_agreement(a: Agreement) -> Result<(), String> {
183    let mut agreements = list_agreements();
184    if let Some(slot) = agreements.iter_mut().find(|x| x.id == a.id) {
185        *slot = a;
186    } else {
187        agreements.push(a);
188    }
189    save_agreements(&agreements)
190}
191
192/// Every agreement involving `did` — as a party ([`Agreement::parties`]) OR as the relationship the
193/// agreement governs ([`Agreement::relationship_did`]).
194pub fn agreements_for(did: &str) -> Vec<Agreement> {
195    list_agreements()
196        .into_iter()
197        .filter(|a| a.relationship_did == did || a.parties.iter().any(|p| p == did))
198        .collect()
199}
200
201// ---------------------------------------------------------------------------
202// Tests — PURE ONLY. These build `Agreement` values in memory and exercise the
203// pure helpers; they never touch the real filesystem / app dir.
204// ---------------------------------------------------------------------------
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// A two-party agreement with no consent records yet.
211    fn agreement(parties: &[&str]) -> Agreement {
212        Agreement {
213            id: "ag-1".to_string(),
214            title: "Care relationship".to_string(),
215            relationship_did: "did:wf:rel".to_string(),
216            parties: parties.iter().map(|s| s.to_string()).collect(),
217            values_anchors: vec!["udhr:art-12".to_string()],
218            undertakings: vec![Undertaking {
219                kind: UndertakingKind::Right,
220                text: "The person controls their own health data.".to_string(),
221                source: Some("udhr:art-12".to_string()),
222            }],
223            consents: vec![],
224            stage: FormationStage::Draft,
225            jurisdiction: None,
226            intents: Vec::new(),
227            artifact_context: None,
228            created_at: 1_700_000_000,
229            updated_at: 1_700_000_000,
230        }
231    }
232
233    #[test]
234    fn set_consent_on_new_did_appends_granted() {
235        let mut a = agreement(&["did:wf:alice", "did:wf:bob"]);
236        assert!(a.consents.is_empty());
237
238        set_consent(&mut a, "did:wf:alice", ConsentState::Granted);
239
240        assert_eq!(a.consents.len(), 1);
241        assert_eq!(a.consents[0].did, "did:wf:alice");
242        assert_eq!(a.consents[0].consent, ConsentState::Granted);
243        assert_eq!(a.consents[0].signature_hex, None);
244    }
245
246    #[test]
247    fn set_consent_on_existing_did_updates_in_place() {
248        let mut a = agreement(&["did:wf:alice", "did:wf:bob"]);
249        set_consent(&mut a, "did:wf:alice", ConsentState::Granted);
250        // Give the existing record a signature to prove it survives the update.
251        a.consents[0].signature_hex = Some("deadbeef".to_string());
252
253        set_consent(&mut a, "did:wf:alice", ConsentState::Withdrawn);
254
255        // Still one record (updated, not appended); signature preserved; consent changed.
256        assert_eq!(a.consents.len(), 1);
257        assert_eq!(a.consents[0].did, "did:wf:alice");
258        assert_eq!(a.consents[0].consent, ConsentState::Withdrawn);
259        assert_eq!(a.consents[0].signature_hex, Some("deadbeef".to_string()));
260    }
261
262    #[test]
263    fn all_granted_false_while_any_pending_true_when_all_granted() {
264        let mut a = agreement(&["did:wf:alice", "did:wf:bob"]);
265
266        // No consents at all → not all granted.
267        assert!(!all_granted(&a));
268
269        // One granted, the other still (implicitly) pending → not all granted.
270        set_consent(&mut a, "did:wf:alice", ConsentState::Granted);
271        assert!(!all_granted(&a));
272
273        // Bob explicitly Pending → still not all granted.
274        set_consent(&mut a, "did:wf:bob", ConsentState::Pending);
275        assert!(!all_granted(&a));
276
277        // Bob grants → now every party is granted.
278        set_consent(&mut a, "did:wf:bob", ConsentState::Granted);
279        assert!(all_granted(&a));
280    }
281
282    #[test]
283    fn all_granted_false_for_empty_parties() {
284        let mut a = agreement(&[]);
285        // Even a stray granted consent doesn't make a party-less agreement "all granted".
286        set_consent(&mut a, "did:wf:ghost", ConsentState::Granted);
287        assert!(!all_granted(&a));
288    }
289}