openvtc-core 0.4.0

OpenVTC Core Library
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! Task queue for tracking in-progress OpenVTC workflows.
//!
//! Tasks represent pending actions such as relationship handshakes, trust pings,
//! and VRC exchanges. Each task has a unique ID, a [`TaskType`], and a creation
//! timestamp.

use std::{collections::HashMap, fmt::Display, sync::Arc};

use chrono::{DateTime, Utc};
use dtg_credentials::DTGCredential;
use serde::{Deserialize, Serialize};

use tracing::debug;

use crate::{config::account::PersonaId, relationships::RelationshipRequestBody, vrc::VrcRequest};

/// Defined Task Types for OpenVTC.
///
/// Each variant represents a discrete workflow step that the user may need to
/// act on or that is awaiting a remote response.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TaskType {
    /// We sent a relationship request to a remote party.
    RelationshipRequestOutbound { to: Arc<String> },
    /// A remote party sent us a relationship request awaiting our response.
    RelationshipRequestInbound {
        from: Arc<String>,
        to: Arc<String>,
        request: RelationshipRequestBody,
    },
    /// Our relationship request was rejected by the remote party.
    RelationshipRequestRejected,
    /// Our relationship request was accepted by the remote party.
    RelationshipRequestAccepted,
    /// The relationship handshake has been finalized (fully established).
    RelationshipRequestFinalized,
    /// A trust-ping was sent to verify connectivity with the remote party.
    ///
    /// `remote_p_did` is the relationship's remote persona DID — the key into
    /// `Relationships`. Look the relationship up there at the use site rather
    /// than holding an embedded snapshot.
    ///
    /// On-disk compatibility (R20): pre-R20 configs serialized an embedded
    /// `relationship` object here instead of `remote_p_did`. Such configs still
    /// load — `remote_p_did` is `#[serde(default)]` (empty) and the now-extra
    /// `relationship` field is ignored. The acceptable degradation: a pre-R20
    /// *in-flight* TrustPing task (these live for seconds) loads with an empty
    /// `remote_p_did`, so its remote display falls back to blank until the task
    /// is replaced; the relationship itself still exists in `Relationships`.
    TrustPing {
        from: Arc<String>,
        to: Arc<String>,
        #[serde(default)]
        remote_p_did: Arc<String>,
    },
    /// A trust-pong response was received from the remote party.
    TrustPong,
    /// We sent a VRC request to a remote party.
    ///
    /// `remote_p_did` is the relationship key into `Relationships`. See
    /// [`TaskType::TrustPing`] for the pre-R20 on-disk compatibility note.
    VRCRequestOutbound {
        #[serde(default)]
        remote_p_did: Arc<String>,
    },
    /// A remote party sent us a VRC request awaiting our response.
    ///
    /// `remote_p_did` is the relationship key into `Relationships`. See
    /// [`TaskType::TrustPing`] for the pre-R20 on-disk compatibility note.
    VRCRequestInbound {
        request: VrcRequest,
        #[serde(default)]
        remote_p_did: Arc<String>,
    },
    /// Our VRC request was rejected by the remote party.
    VRCRequestRejected,
    /// A VRC has been issued (either by us or received from a remote party).
    VRCIssued { vrc: Box<DTGCredential> },
    /// Vetter: an applicant redeemed one of our tickets. Open a session when
    /// the two of you are together.
    VettingRequestInbound {
        /// Our desk handle (`crate::vetting::VettingBook::desk_entry`).
        request_id: String,
        /// The applicant's join DID.
        applicant: Arc<String>,
        /// The community they are applying to.
        community: String,
    },
    /// Applicant: a vetter opened a session. Read the match code to each
    /// other, then send the card.
    VettingSessionInbound {
        /// The application.
        application_id: String,
        /// The session document id.
        session_id: String,
        /// The vetter.
        vetter: Arc<String>,
    },
    /// Vetter: the applicant's card arrived and verified. Check the person,
    /// then attest or decline.
    VettingCardReceived {
        /// Our desk handle.
        request_id: String,
        /// The applicant's join DID.
        applicant: Arc<String>,
    },
    /// Vetter: our `vetter` role credential from a community has lapsed, or is
    /// about to.
    ///
    /// Raised by the hourly sweep rather than by anything arriving, because
    /// nothing does arrive: a grant lapses by the passage of time, and the
    /// first sign is an applicant's request refused at *their* end as
    /// `notEligible`, for a reason they cannot act on and we never see.
    VetterGrantExpiring {
        /// The community that issued it.
        community: Arc<String>,
        /// Whether it has lapsed already, as opposed to being about to.
        expired: bool,
        /// When it lapses or lapsed, as the page shows it.
        valid_until: String,
    },
}

impl Display for TaskType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let friendly_name = match self {
            TaskType::RelationshipRequestOutbound { .. } => "Relationship Request (Outbound)",
            TaskType::RelationshipRequestInbound { .. } => "Relationship Request (Inbound)",
            TaskType::RelationshipRequestRejected => "Relationship Request Rejected",
            TaskType::RelationshipRequestAccepted => "Relationship Request Accepted",
            TaskType::RelationshipRequestFinalized => "Relationship Request Finalized",
            TaskType::TrustPing { .. } => "Trust Ping Sent",
            TaskType::TrustPong => "Trust Pong Received",
            TaskType::VRCRequestOutbound { .. } => "VRC Request Sent",
            TaskType::VRCRequestInbound { .. } => "VRC Request Received",
            TaskType::VRCRequestRejected => "VRC Request Rejected",
            TaskType::VRCIssued { .. } => "VRC Issued",
            TaskType::VettingRequestInbound { .. } => "Vetting Request (Inbound)",
            TaskType::VettingSessionInbound { .. } => "Vetting Session (Send Card)",
            TaskType::VettingCardReceived { .. } => "Vetting Card Received",
            TaskType::VetterGrantExpiring { expired: true, .. } => "Vetter Credential Expired",
            TaskType::VetterGrantExpiring { .. } => "Vetter Credential Expiring",
        };
        write!(f, "{}", friendly_name)
    }
}

/// Collection of in-progress tasks, indexed by task ID.
///
/// # A task carrying a pre-v1 VRC is dropped on load
///
/// [`TaskType::VRCIssued`] holds the VRC itself, and a VRC stored before DTG
/// Credentials v1 no longer parses. Rather than fail the whole protected config
/// for one inbox item, such a task is dropped with a logged reason (the peer
/// can issue a fresh VRC). Any other task that fails to parse still fails the
/// load, as it always has — only the case this migration creates is tolerated.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Tasks {
    /// key: Task ID
    ///
    /// Plain values (no `Arc<Mutex>`): there is exactly one mutating task (the
    /// `StateHandler` loop), so mutation goes through `&mut` and is infallible.
    pub tasks: HashMap<Arc<String>, Task>,
}

impl<'de> Deserialize<'de> for Tasks {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        #[derive(Deserialize)]
        struct Raw {
            #[serde(default)]
            tasks: HashMap<Arc<String>, serde_json::Value>,
        }
        let raw = Raw::deserialize(deserializer)?;
        let mut tasks = HashMap::with_capacity(raw.tasks.len());
        for (id, value) in raw.tasks {
            let carries_vrc = value.pointer("/type_/VRCIssued/vrc").is_some();
            match serde_json::from_value::<Task>(value) {
                Ok(task) => {
                    tasks.insert(id, task);
                }
                Err(e) if carries_vrc => tracing::warn!(
                    task = %id,
                    reason = %e,
                    "dropping a stored VRC task whose credential does not conform to DTG Credentials v1"
                ),
                Err(e) => return Err(serde::de::Error::custom(e)),
            }
        }
        Ok(Tasks { tasks })
    }
}

impl Tasks {
    /// Removes a task by ID. Returns `true` if the task was found and removed.
    pub fn remove(&mut self, id: &Arc<String>) -> bool {
        let removed = self.tasks.remove(id).is_some();
        if removed {
            debug!("task removed: id={}", id);
        }
        removed
    }

    /// Creates a new untagged task (no owning persona) with the given ID and
    /// type, inserts it, and returns a reference to it. Use [`Tasks::new_task_for`]
    /// to attribute the task to a specific persona for community-scoping (D10).
    pub fn new_task(&mut self, id: &Arc<String>, type_: TaskType) -> &Task {
        self.new_task_for(id, type_, None)
    }

    /// Like [`Tasks::new_task`] but tags the task with the persona that owns it
    /// (D10 attribution): the working community's persona for an outbound task, or
    /// the addressed persona for an inbound one. The community-scoped inbox filters
    /// tasks to the selected community's persona via this tag (R-C-6).
    pub fn new_task_for(
        &mut self,
        id: &Arc<String>,
        type_: TaskType,
        our_persona: Option<PersonaId>,
    ) -> &Task {
        debug!("task created: type={:?}, id={}", type_, id);
        let task = Task {
            id: id.clone(),
            type_,
            created: Utc::now(),
            our_persona,
        };
        self.tasks.entry(id.clone()).insert_entry(task).into_mut()
    }

    /// Returns the task at the given iteration position, or `None` if out of bounds.
    ///
    /// Note: HashMap iteration order is not stable across insertions and removals.
    pub fn get_by_pos(&self, pos: usize) -> Option<&Task> {
        self.tasks.iter().nth(pos).map(|(_, task)| task)
    }

    /// Retrieves a task by ID or returns None
    pub fn get_by_id(&self, id: &Arc<String>) -> Option<&Task> {
        self.tasks.get(id)
    }

    /// Clears all tasks. Returns `true` if any tasks were removed.
    pub fn clear(&mut self) -> bool {
        let flag = !self.tasks.is_empty();
        self.tasks.clear();
        flag
    }
}

/// A single in-progress OpenVTC task.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Task {
    /// Unique task identifier.
    pub id: Arc<String>,

    /// The kind of workflow this task represents.
    pub type_: TaskType,

    /// Timestamp when this task was created.
    pub created: DateTime<Utc>,

    /// Which of our account personas owns this task (D10 attribution). Set to the
    /// working community's persona (outbound) or the addressed persona (inbound);
    /// the community-scoped inbox filters tasks to the selected community's
    /// persona via this tag (R-C-6). `None` on legacy/single-persona tasks,
    /// attributed to the sole persona at view time. Skipped when `None` so older
    /// configs round-trip byte-identically.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub our_persona: Option<PersonaId>,
}

/// Whether `task_id` is our outbound VRC request, and `from` the party it was
/// sent to — by its persona DID or the relationship DID it uses with us.
#[must_use]
pub fn is_our_vrc_request_to(
    tasks: &Tasks,
    relationships: &crate::relationships::Relationships,
    task_id: &Arc<String>,
    from: &str,
) -> bool {
    let Some(task) = tasks.get_by_id(task_id) else {
        return false;
    };
    let TaskType::VRCRequestOutbound { remote_p_did } = &task.type_ else {
        return false;
    };
    if remote_p_did.as_str() == from {
        return true;
    }
    relationships
        .get(remote_p_did)
        .is_some_and(|rel| rel.remote_did.as_str() == from)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A VRC rejection may close only our request, to the party it went to.
    #[test]
    fn only_our_vrc_request_to_that_party_is_closed() {
        let mut tasks = Tasks::default();
        let id = Arc::new("vrc-req-1".to_string());
        let bob = Arc::new("did:peer:bob".to_string());
        tasks.new_task(
            &id,
            TaskType::VRCRequestOutbound {
                remote_p_did: bob.clone(),
            },
        );
        let other = Arc::new("ping-1".to_string());
        tasks.new_task(&other, TaskType::TrustPong);
        let rels = crate::relationships::Relationships::default();
        assert!(is_our_vrc_request_to(&tasks, &rels, &id, "did:peer:bob"));
        assert!(!is_our_vrc_request_to(
            &tasks,
            &rels,
            &id,
            "did:peer:mallory"
        ));
        assert!(!is_our_vrc_request_to(
            &tasks,
            &rels,
            &other,
            "did:peer:bob"
        ));
        assert!(!is_our_vrc_request_to(
            &tasks,
            &rels,
            &Arc::new("nope".into()),
            "did:peer:bob"
        ));
    }

    #[test]
    fn test_tasks_default_empty() {
        let tasks = Tasks::default();
        assert!(tasks.tasks.is_empty(), "Default Tasks should have no tasks");
    }

    /// Forward-load compatibility (R20): a pre-R20 `Tasks` config serialized the
    /// 3 relationship-embedding variants with an embedded `relationship` object
    /// instead of the new `remote_p_did` key. Such a config must still LOAD: the
    /// now-extra `relationship` field is ignored by serde (no
    /// `deny_unknown_fields`) and the missing `remote_p_did` defaults to empty.
    /// The acceptable degradation: the in-flight task loses its embedded
    /// snapshot (the relationship still exists in `Relationships`).
    #[test]
    fn pre_r20_task_with_embedded_relationship_still_loads() {
        // A pre-R20 VRCRequestOutbound task carried an embedded `relationship`
        // object (an `Arc<Mutex<Relationship>>` serializes as a bare object).
        let old_json = r#"{
            "tasks": {
                "msg-1": {
                    "id": "msg-1",
                    "type_": {
                        "VRCRequestOutbound": {
                            "relationship": {
                                "task_id": "t1",
                                "our_did": "did:webvh:example:us",
                                "remote_did": "did:webvh:example:them",
                                "remote_p_did": "did:webvh:example:them",
                                "created": "2024-01-02T03:04:05Z",
                                "state": "Established"
                            }
                        }
                    },
                    "created": "2024-01-02T03:04:05Z"
                }
            }
        }"#;

        let tasks: Tasks = serde_json::from_str(old_json).expect("pre-R20 config still loads");
        let task = tasks
            .get_by_id(&Arc::new("msg-1".to_string()))
            .expect("task present");
        match &task.type_ {
            TaskType::VRCRequestOutbound { remote_p_did } => {
                // The embedded `relationship` was ignored; the new key defaults
                // to empty (the documented transient degradation).
                assert!(
                    remote_p_did.is_empty(),
                    "remote_p_did defaults to empty when absent from an old config"
                );
            }
            other => panic!("unexpected variant: {other}"),
        }
    }

    #[test]
    fn test_new_task_and_retrieve() {
        let mut tasks = Tasks::default();
        let id = Arc::new("task-1".to_string());
        tasks.new_task(&id, TaskType::RelationshipRequestRejected);

        assert_eq!(tasks.tasks.len(), 1);
        assert!(tasks.get_by_id(&id).is_some(), "Should find task by ID");
    }

    #[test]
    fn test_remove_task() {
        let mut tasks = Tasks::default();
        let id = Arc::new("task-1".to_string());
        tasks.new_task(&id, TaskType::RelationshipRequestAccepted);

        assert!(
            tasks.remove(&id),
            "remove should return true for existing task"
        );
        assert!(
            tasks.tasks.is_empty(),
            "Tasks should be empty after removal"
        );

        let missing = Arc::new("nonexistent".to_string());
        assert!(
            !tasks.remove(&missing),
            "remove should return false for missing task"
        );
    }

    #[test]
    fn test_get_by_position() {
        let mut tasks = Tasks::default();
        let id = Arc::new("task-pos".to_string());
        tasks.new_task(&id, TaskType::TrustPong);

        let found = tasks.get_by_pos(0);
        assert!(found.is_some(), "Should retrieve task at position 0");

        let out_of_bounds = tasks.get_by_pos(99);
        assert!(
            out_of_bounds.is_none(),
            "Should return None for out-of-bounds position"
        );
    }

    #[test]
    fn test_clear_tasks() {
        let mut tasks = Tasks::default();
        assert!(!tasks.clear(), "Clearing empty tasks should return false");

        let id = Arc::new("task-clear".to_string());
        tasks.new_task(&id, TaskType::RelationshipRequestFinalized);
        assert!(tasks.clear(), "Clearing non-empty tasks should return true");
        assert!(tasks.tasks.is_empty());
    }

    #[test]
    fn test_task_type_display() {
        let variants: Vec<(TaskType, &str)> = vec![
            (
                TaskType::RelationshipRequestOutbound {
                    to: Arc::new("did:example:1".to_string()),
                },
                "Relationship Request (Outbound)",
            ),
            (
                TaskType::RelationshipRequestRejected,
                "Relationship Request Rejected",
            ),
            (
                TaskType::RelationshipRequestAccepted,
                "Relationship Request Accepted",
            ),
            (
                TaskType::RelationshipRequestFinalized,
                "Relationship Request Finalized",
            ),
            (TaskType::TrustPong, "Trust Pong Received"),
            (TaskType::VRCRequestRejected, "VRC Request Rejected"),
        ];

        for (variant, expected) in variants {
            let display = format!("{}", variant);
            assert_eq!(
                display, expected,
                "TaskType display mismatch for {:?}",
                variant
            );
        }
    }
}