use chrono::{DateTime, Utc};
use super::applicant::{Application, NextStep, RequestState};
use super::vetter::{DeskEntry, DeskState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StepState {
Done,
Current,
Waiting,
Todo,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApplicantStep {
Requirements,
Face,
Vetters,
Sessions,
Statements,
Join,
}
impl ApplicantStep {
pub const ALL: [ApplicantStep; 6] = [
ApplicantStep::Requirements,
ApplicantStep::Face,
ApplicantStep::Vetters,
ApplicantStep::Sessions,
ApplicantStep::Statements,
ApplicantStep::Join,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
ApplicantStep::Requirements => "Requirements",
ApplicantStep::Face => "Face",
ApplicantStep::Vetters => "Vetters",
ApplicantStep::Sessions => "Sessions",
ApplicantStep::Statements => "Statements",
ApplicantStep::Join => "Join",
}
}
#[must_use]
pub fn explain(self, hidden: bool) -> &'static [&'static str] {
match (self, hidden) {
(ApplicantStep::Requirements, _) => &[
"A community that vets its members publishes what it needs before it will \
decide on you: how many vetters, how they must check you, and what they check.",
"Nothing about you has been sent yet. Reading the requirements first is how you \
decide whether to apply at all.",
],
(ApplicantStep::Face, _) => &[
"Vetters never see your whole identity. They see a face — the attributes you \
choose to show — and check it against your documents.",
"The face is worn in this community's context, so the community sees the same \
face when you join. Its values must match your documents exactly.",
],
(ApplicantStep::Vetters, _) => &[
"A vetter is a member the community has named to vouch for people. You reach \
one with their ticket — a link or QR code they give you, or one found in the \
community's directory.",
"Each request goes to one vetter. You need statements from different vetters, \
so ask as many as the requirements call for.",
],
(ApplicantStep::Sessions, _) => &[
"When you and a vetter are together — in person or on a call — they open a \
session, and both your screens show the same match code.",
"Read it aloud and hear it read back. Matching codes prove the card you send \
reaches the person in front of you, not someone who intercepted the request.",
],
(ApplicantStep::Statements, false) => &[
"After checking you, each vetter signs a statement that they did, and sends it \
to you. You hold them; the community has none of them yet.",
"These statements are named: when you join, the community sees which vetters \
vouched for you.",
],
(ApplicantStep::Statements, true) => &[
"After checking you, each vetter sends you an attestation. You hold them; the \
community has none of them yet.",
"This community uses a PCS zero-knowledge proof: when you join, it learns that \
enough vetters vouched for you, but never which ones.",
],
(ApplicantStep::Join, false) => &[
"Joining sends the community your persona's DID, the vetting statements you \
hold, and what your face shows. Review it before it goes.",
"Each statement names its vetter, so the community will see who vouched for \
you.",
],
(ApplicantStep::Join, true) => &[
"Joining sends the community your persona's DID, what your face shows, and a \
zero-knowledge proof built from the attestations you hold.",
"The proof shows enough vetters vouched for you without revealing who — the \
community never sees a vetter's identity.",
],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JourneyStep<S> {
pub step: S,
pub state: StepState,
pub detail: Option<String>,
}
#[must_use]
pub fn applicant_journey(app: &Application, now: DateTime<Utc>) -> Vec<JourneyStep<ApplicantStep>> {
let next = app.next_step(now);
let evaluation = app.checklist(now);
let satisfied = evaluation.as_ref().is_some_and(|e| e.satisfied());
let needed = app
.requirements
.as_ref()
.map(|r| r.min_statements.get() as usize);
let held = app
.statements
.iter()
.filter(|s| s.valid_until > now)
.count();
let asked = app
.requests
.iter()
.filter(|r| {
!matches!(
r.state,
RequestState::Declined { .. } | RequestState::Refused { .. }
)
})
.count();
let open_sessions = app
.requests
.iter()
.filter(|r| matches!(r.state, RequestState::Session { ref card, .. } if card.is_none()))
.count();
let waiting_on_vetters = app.requests.iter().any(|r| {
matches!(
r.state,
RequestState::Sent | RequestState::Accepted { .. } | RequestState::Session { .. }
)
});
let cards_sent = app
.requests
.iter()
.filter(|r| {
matches!(r.state, RequestState::Session { ref card, .. } if card.is_some())
|| matches!(r.state, RequestState::Attested { .. })
})
.count();
let known = app.requirements.is_some();
let state_of = |step: ApplicantStep| -> StepState {
match step {
ApplicantStep::Requirements if known => StepState::Done,
ApplicantStep::Requirements => StepState::Current,
_ if !known => StepState::Todo,
ApplicantStep::Face if app.face.is_some() => StepState::Done,
ApplicantStep::Face if cards_sent > 0 || held > 0 => StepState::Done,
ApplicantStep::Face => StepState::Current,
_ if satisfied => match step {
ApplicantStep::Join => StepState::Current,
_ => StepState::Done,
},
ApplicantStep::Vetters if asked >= needed.unwrap_or(1) => StepState::Done,
ApplicantStep::Vetters if matches!(next, NextStep::AskVetter) => StepState::Current,
ApplicantStep::Vetters if asked > 0 => StepState::Done,
ApplicantStep::Vetters => StepState::Current,
ApplicantStep::Sessions if open_sessions > 0 => StepState::Current,
ApplicantStep::Sessions if asked == 0 => StepState::Todo,
ApplicantStep::Sessions if waiting_on_vetters => StepState::Waiting,
ApplicantStep::Sessions => StepState::Done,
ApplicantStep::Statements if waiting_on_vetters || held > 0 => StepState::Waiting,
ApplicantStep::Statements => StepState::Todo,
ApplicantStep::Join => StepState::Todo,
}
};
let detail_of = |step: ApplicantStep| -> Option<String> {
match step {
ApplicantStep::Face => app.face.as_ref().map(|f| f.name.clone()),
ApplicantStep::Vetters if asked > 0 => Some(format!("{asked} asked")),
ApplicantStep::Sessions if open_sessions > 0 => Some(format!(
"{open_sessions} code{} to read",
if open_sessions == 1 { "" } else { "s" }
)),
ApplicantStep::Sessions if cards_sent > 0 => Some(format!("{cards_sent} card(s) sent")),
ApplicantStep::Statements => needed.map(|n| format!("{} of {n}", held.min(n))),
_ => None,
}
};
let mut steps: Vec<JourneyStep<ApplicantStep>> = ApplicantStep::ALL
.iter()
.map(|&step| JourneyStep {
step,
state: state_of(step),
detail: detail_of(step),
})
.collect();
let mut seen_current = false;
for s in &mut steps {
if s.state == StepState::Current {
if seen_current {
s.state = StepState::Todo;
}
seen_current = true;
}
}
steps
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VetterStep {
Request,
Session,
Code,
Card,
Check,
Sign,
}
impl VetterStep {
pub const ALL: [VetterStep; 6] = [
VetterStep::Request,
VetterStep::Session,
VetterStep::Code,
VetterStep::Card,
VetterStep::Check,
VetterStep::Sign,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
VetterStep::Request => "Request",
VetterStep::Session => "Session",
VetterStep::Code => "Match code",
VetterStep::Card => "Card",
VetterStep::Check => "Check",
VetterStep::Sign => "Sign",
}
}
#[must_use]
pub fn explain(self, hidden: bool) -> &'static [&'static str] {
match (self, hidden) {
(VetterStep::Request, _) => &[
"Someone used one of your tickets to ask you to vet them for this community. \
Nothing is decided yet, and you never have to take a request on.",
"Arrange to meet — in person or on a call. Decline at any point; the community \
is not told why.",
],
(VetterStep::Session, _) => &[
"Open a session when the two of you are together. It sends the applicant a \
challenge, and both screens show the same match code.",
"Choose how you are meeting. The statement records it, and the community counts \
statements by method.",
],
(VetterStep::Code, _) => &[
"Read the match code aloud and wait for them to read it back. Do not continue \
if it differs.",
"Matching codes prove the card you are about to receive comes from the person \
you are talking to — not from someone who intercepted the request.",
],
(VetterStep::Card, _) => &[
"Their card is the face they chose to show you, signed by the DID they will \
join with. It is verified before you see it.",
"Its values are what you check their documents against. You keep it only \
briefly, and only to make this decision.",
],
(VetterStep::Check, _) => &[
"Look at their identity document. Check it appears genuine, that the photo is \
the person you are talking to, and that each value on the card matches it.",
"Do not keep a copy of the document. Your statement records which kind of \
document you saw, never its contents.",
],
(VetterStep::Sign, false) => &[
"Signing says, as you, that you checked this person this way. It goes to the \
applicant, who presents it when they join.",
"This statement is named: the community will see your DID against this \
applicant. You can withdraw it later if you learn it was wrong.",
],
(VetterStep::Sign, true) => &[
"Signing sends the applicant an attestation that you checked them this way.",
"This community uses a PCS zero-knowledge proof: it counts your attestation \
toward the applicant's admission without ever learning it came from you.",
],
}
}
}
#[must_use]
pub fn vetter_journey(entry: &DeskEntry) -> (Vec<JourneyStep<VetterStep>>, Option<VetterEnding>) {
use VetterStep as V;
let (reached, ending): (usize, Option<VetterEnding>) = match &entry.state {
DeskState::Accepted => (1, None),
DeskState::Session { .. } => (2, None),
DeskState::CardReceived { .. } => (4, None),
DeskState::Attested { .. } => (V::ALL.len(), Some(VetterEnding::Signed)),
DeskState::Declined { .. } => (0, Some(VetterEnding::Declined)),
};
let steps = V::ALL
.iter()
.enumerate()
.map(|(i, &step)| {
let state = match ending {
Some(VetterEnding::Declined) => StepState::Todo,
_ if i < reached => StepState::Done,
_ if matches!(entry.state, DeskState::Session { .. }) && step == V::Card => {
StepState::Waiting
}
_ if i == reached => StepState::Current,
_ => StepState::Todo,
};
JourneyStep {
step,
state,
detail: match (&entry.state, step) {
(
DeskState::Session { session } | DeskState::CardReceived { session, .. },
V::Code,
) => Some(session.match_code.clone()),
_ => None,
},
}
})
.collect();
(steps, ending)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VetterEnding {
Signed,
Declined,
}
#[must_use]
pub fn current<S: Copy>(steps: &[JourneyStep<S>]) -> Option<S> {
steps
.iter()
.find(|s| s.state == StepState::Current)
.map(|s| s.step)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::account::PersonaId;
use crate::vetting::applicant::ChosenFace;
fn app() -> Application {
Application::new(
"did:web:vtc.example",
PersonaId::new(),
"did:key:zApplicant",
Utc::now(),
)
.unwrap()
}
fn requirements() -> vta_sdk::protocols::vetting::VettingRequirements {
serde_json::from_value(serde_json::json!({
"version": "0.1",
"statementType": vta_sdk::protocols::vetting::VETTED_PREDICATE,
"minStatements": 2,
"acceptedMethods": ["inPerson", "video"],
"eligibleVetters": { "role": "vetter" }
}))
.unwrap()
}
fn states(app: &Application) -> Vec<StepState> {
applicant_journey(app, Utc::now())
.into_iter()
.map(|s| s.state)
.collect()
}
use StepState::{Current, Done, Todo};
#[test]
fn a_new_application_starts_by_learning_the_requirements() {
let a = app();
assert_eq!(states(&a), [Current, Todo, Todo, Todo, Todo, Todo]);
assert_eq!(
current(&applicant_journey(&a, Utc::now())),
Some(ApplicantStep::Requirements)
);
}
#[test]
fn with_requirements_known_the_face_comes_next_then_vetters() {
let mut a = app();
a.requirements = Some(requirements());
assert_eq!(states(&a), [Done, Current, Todo, Todo, Todo, Todo]);
a.face = Some(ChosenFace {
profile_id: "p".into(),
name: "Work".into(),
});
let journey = applicant_journey(&a, Utc::now());
assert_eq!(current(&journey), Some(ApplicantStep::Vetters));
assert_eq!(journey[1].detail.as_deref(), Some("Work"));
assert_eq!(journey[4].detail.as_deref(), Some("0 of 2"));
}
#[test]
fn every_step_explains_itself_and_hidden_mode_says_vetters_stay_hidden() {
for step in ApplicantStep::ALL {
assert!(!step.explain(false).is_empty(), "{step:?}");
assert!(!step.explain(true).is_empty(), "{step:?}");
}
let named = ApplicantStep::Join.explain(false).join(" ");
let hidden = ApplicantStep::Join.explain(true).join(" ");
assert!(named.contains("see who vouched"), "{named}");
assert!(hidden.contains("without revealing who"), "{hidden}");
}
}