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}