use std::collections::BTreeMap;
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::book::VettingBook;
use super::hidden::{HiddenParams, Mode};
use super::queries::QueryKind;
pub const MODE_TTL: Duration = Duration::minutes(10);
pub const MODE_REASK_AFTER: Duration = Duration::minutes(2);
pub const MANIFEST_MIN_GAP: Duration = Duration::seconds(30);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VetterMode {
Named,
PcsZkp,
}
impl VetterMode {
#[must_use]
pub fn words(self) -> &'static str {
match self {
VetterMode::Named => "named vetting",
VetterMode::PcsZkp => "PCS ZKP",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModeRead {
pub mode: VetterMode,
pub read_at: DateTime<Utc>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ModeFailure {
Unsent(String),
Unanswered,
Refused(String),
Unreadable(String),
}
impl ModeFailure {
#[must_use]
pub fn words(&self) -> String {
match self {
ModeFailure::Unsent(e) => format!(
"the question could not be sent ({e}) — your mediator or connection may be down"
),
ModeFailure::Unanswered => format!(
"no answer within {} seconds — its service may be offline or slow",
super::queries::QUERY_TIMEOUT.num_seconds()
),
ModeFailure::Refused(code) => format!("it refused to say ({code})"),
ModeFailure::Unreadable(detail) => format!(
"it answered in a form this client cannot read — the two disagree about the \
manifest; it is not a refusal ({detail})"
),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModeCheck {
pub asked_at: Option<DateTime<Utc>>,
pub failed: Option<(ModeFailure, DateTime<Utc>)>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModeReading {
pub read: Option<ModeRead>,
pub failed: Option<(ModeFailure, DateTime<Utc>)>,
pub inferred: bool,
}
impl ModeReading {
#[must_use]
pub fn mode(&self) -> Option<VetterMode> {
self.read.map(|r| r.mode)
}
#[must_use]
pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
self.read.is_none_or(|r| now - r.read_at > MODE_TTL)
}
#[must_use]
pub fn read_since(&self, since: DateTime<Utc>) -> Option<VetterMode> {
self.read
.filter(|r| !self.inferred && r.read_at >= since)
.map(|r| r.mode)
}
#[must_use]
pub fn failed_since(&self, since: DateTime<Utc>) -> Option<&ModeFailure> {
self.failed
.as_ref()
.filter(|(_, at)| *at >= since)
.map(|(f, _)| f)
}
#[must_use]
pub fn last_known_words(&self, now: DateTime<Utc>) -> String {
match self.read {
Some(r) => format!("{}, read {}", r.mode.words(), age_words(now - r.read_at)),
None => "never read".to_string(),
}
}
}
#[must_use]
pub fn age_words(age: Duration) -> String {
if age < Duration::minutes(1) {
"just now".to_string()
} else if age < Duration::hours(1) {
format!("{} min ago", age.num_minutes())
} else if age < Duration::days(2) {
format!("{} h ago", age.num_hours())
} else {
format!("{} days ago", age.num_days())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModeSwitch {
pub community: String,
pub from: VetterMode,
pub to: VetterMode,
pub at: DateTime<Utc>,
}
impl ModeSwitch {
#[must_use]
pub fn words(&self, name: &str) -> String {
format!(
"{name} switched from {} to {}.",
self.from.words(),
self.to.words()
)
}
}
const MAX_MODE_SWITCHES: usize = 16;
#[must_use]
pub fn next_monthly_label(period: &str) -> Option<(String, NaiveDate)> {
let (y, m) = period.split_once('-')?;
let (y, m): (i32, u32) = (y.parse().ok()?, m.parse().ok()?);
let first = NaiveDate::from_ymd_opt(y, m, 1)?;
let next = first.checked_add_months(chrono::Months::new(1))?;
Some((format!("{:04}-{:02}", next.year(), next.month()), next))
}
pub(crate) fn read_payload(raw: &Value) -> Result<(VetterMode, Option<HiddenParams>), String> {
let mut unreadable = None;
for criterion in raw
.get("criteria")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
match super::hidden::read_mode(criterion) {
Ok(Mode::Hidden(p)) => return Ok((VetterMode::PcsZkp, Some(*p))),
Ok(Mode::Named) => {}
Err(e) => unreadable = unreadable.or(Some(e.to_string())),
}
}
match unreadable {
Some(e) => Err(e),
None => Ok((VetterMode::Named, None)),
}
}
impl VettingBook {
#[must_use]
pub fn vetter_mode(&self, community: &str) -> ModeReading {
let known = self.communities.iter().find(|c| c.community == community);
let recorded = known.and_then(|c| c.vetter_mode);
let read = recorded.or_else(|| {
let pcs = self.hidden_published.contains_key(community)
|| self
.criteria
.iter()
.any(|k| k.community == community && k.hidden_vetting());
known.map(|c| ModeRead {
mode: if pcs {
VetterMode::PcsZkp
} else {
VetterMode::Named
},
read_at: c.fetched_at,
})
});
ModeReading {
read,
failed: self
.mode_checks
.get(community)
.and_then(|c| c.failed.clone()),
inferred: recorded.is_none() && read.is_some(),
}
}
#[must_use]
pub fn pcs_zkp(&self, community: &str) -> bool {
self.vetter_mode(community).mode() == Some(VetterMode::PcsZkp)
}
pub fn learn_mode(
&mut self,
community: &str,
raw: &Value,
before: Option<VetterMode>,
now: DateTime<Utc>,
) -> bool {
let (mode, params) = match read_payload(raw) {
Ok(read) => read,
Err(detail) => {
self.mode_failed(community, ModeFailure::Unreadable(detail), now);
return false;
}
};
let mut changed = false;
match ¶ms {
Some(params) => {
if self.hidden_published.get(community) != Some(params) {
self.hidden_published
.insert(community.to_string(), params.clone());
changed = true;
self.vetter_refresh_due = true;
}
}
None => changed |= self.hidden_published.remove(community).is_some(),
}
if let Some(live) = params.as_ref() {
let mut resume = false;
for held in self
.hidden_vetter
.iter_mut()
.filter(|h| h.community == community)
{
held.take_reading(live, now);
changed = true;
resume |= std::mem::take(&mut held.awaiting_read);
}
if resume {
self.vetter_refresh_due = true;
}
}
if let Some(known) = self
.communities
.iter_mut()
.find(|c| c.community == community)
{
changed |= known.vetter_mode.is_none_or(|r| r.mode != mode);
known.vetter_mode = Some(ModeRead { mode, read_at: now });
}
self.mode_checks
.entry(community.to_string())
.or_default()
.failed = None;
if let Some(from) = before.filter(|from| *from != mode) {
self.mode_switches.push(ModeSwitch {
community: community.to_string(),
from,
to: mode,
at: now,
});
let excess = self.mode_switches.len().saturating_sub(MAX_MODE_SWITCHES);
self.mode_switches.drain(..excess);
}
changed
}
pub fn mode_asked(&mut self, community: &str, now: DateTime<Utc>) {
self.mode_checks
.entry(community.to_string())
.or_default()
.asked_at = Some(now);
}
pub fn mode_failed(&mut self, community: &str, failure: ModeFailure, now: DateTime<Utc>) {
let check = self.mode_checks.entry(community.to_string()).or_default();
if matches!(failure, ModeFailure::Unsent(_)) {
check.asked_at = None;
}
check.failed = Some((failure, now));
}
pub fn listener_connected(&mut self) {
let mut any = false;
for check in self.mode_checks.values_mut() {
if matches!(check.failed, Some((ModeFailure::Unsent(_), _))) {
check.failed = None;
check.asked_at = None;
any = true;
}
}
if any || !self.hidden_vetter.is_empty() {
self.vetter_refresh_failures = 0;
self.vetter_refresh_due = true;
}
}
#[must_use]
pub fn mode_refresh_due(&self, community: &str, now: DateTime<Utc>) -> bool {
self.vetter_mode(community).is_stale(now)
&& self.waiting_on(community, QueryKind::Manifest).is_none()
&& self
.mode_checks
.get(community)
.and_then(|c| c.asked_at)
.is_none_or(|at| now - at >= MODE_REASK_AFTER)
}
#[must_use]
pub fn manifest_recently_asked(&self, community: &str, now: DateTime<Utc>) -> bool {
let recent = |at: DateTime<Utc>| now - at < MANIFEST_MIN_GAP;
self.waiting_on(community, QueryKind::Manifest).is_some()
|| self
.mode_checks
.get(community)
.and_then(|c| c.asked_at)
.is_some_and(recent)
|| self
.communities
.iter()
.find(|c| c.community == community)
.and_then(|c| c.vetter_mode)
.is_some_and(|r| recent(r.read_at))
}
#[must_use]
pub fn params_reread_owed(&self, community: &str) -> bool {
let asked_at = self.mode_checks.get(community).and_then(|c| c.asked_at);
self.waiting_on(community, QueryKind::Manifest).is_none()
&& self.hidden_vetter.iter().any(|h| {
h.community == community
&& h.reread_owed()
&& h.over_quota
.as_ref()
.is_some_and(|q| asked_at.is_none_or(|a| a <= q.at))
})
}
pub fn take_mode_switches(&mut self) -> Vec<ModeSwitch> {
std::mem::take(&mut self.mode_switches)
}
}
pub type ModeChecks = BTreeMap<String, ModeCheck>;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn manifest(pcs: bool) -> Value {
let mut vetting = json!({ "minStatements": 1 });
if pcs {
vetting["ext"] = json!({
super::super::hidden::HIDDEN_VETTING_NS: {
"suite": super::super::hidden::SUITE,
"helperKey": "zHelper",
"tokenKey": "zToken",
"vetterLabels": ["vetter/2026-10"],
"tokenLabels": ["token/2026-10"],
}
});
}
json!({ "criteria": [ { "id": "c1", "vetting": vetting } ] })
}
fn book_knowing(community: &str, now: DateTime<Utc>) -> VettingBook {
let mut book = VettingBook::default();
book.communities.push(super::super::book::KnownCommunity {
community: community.into(),
branding: Default::default(),
requested: Vec::new(),
fetched_at: now,
protocol: None,
routes: Vec::new(),
post_quantum_key: None,
vetter_mode: None,
});
book
}
const VTC: &str = "did:web:vtc.example";
#[test]
fn a_fresh_reading_replaces_a_stale_one() {
let then = Utc::now() - Duration::hours(5);
let now = Utc::now();
let mut book = book_knowing(VTC, then);
book.learn_mode(VTC, &manifest(true), None, then);
assert_eq!(book.vetter_mode(VTC).mode(), Some(VetterMode::PcsZkp));
assert!(book.vetter_mode(VTC).is_stale(now), "five hours old");
assert!(book.mode_refresh_due(VTC, now));
let before = book.vetter_mode(VTC).mode();
book.learn_mode(VTC, &manifest(false), before, now);
let reading = book.vetter_mode(VTC);
assert_eq!(reading.mode(), Some(VetterMode::Named));
assert!(!reading.is_stale(now));
assert_eq!(reading.read_since(now), Some(VetterMode::Named));
assert!(
!book.hidden_published.contains_key(VTC),
"a community that stopped publishing parameters is not believed to run PCS ZKP"
);
let switches = book.take_mode_switches();
assert_eq!(switches.len(), 1);
assert_eq!(
switches[0].words("first-vtc"),
"first-vtc switched from PCS ZKP to named vetting."
);
assert!(book.take_mode_switches().is_empty(), "said once");
}
#[test]
fn an_engine_alone_is_not_pcs_mode() {
let now = Utc::now();
let mut book = book_knowing(VTC, now);
book.learn_mode(VTC, &manifest(false), None, now);
assert!(!book.pcs_zkp(VTC));
}
#[test]
fn a_failure_is_kept_beside_the_last_reading_not_instead_of_it() {
let then = Utc::now() - Duration::hours(1);
let now = Utc::now();
let mut book = book_knowing(VTC, then);
book.learn_mode(VTC, &manifest(true), None, then);
book.mode_failed(VTC, ModeFailure::Unanswered, now);
let reading = book.vetter_mode(VTC);
assert_eq!(reading.mode(), Some(VetterMode::PcsZkp), "last known stays");
assert_eq!(
reading.read_since(now),
None,
"but nothing may decide on it"
);
assert_eq!(reading.failed_since(now), Some(&ModeFailure::Unanswered));
assert!(
reading
.last_known_words(now)
.contains("PCS ZKP, read 1 h ago")
);
book.learn_mode(VTC, &manifest(true), Some(VetterMode::PcsZkp), now);
assert!(book.vetter_mode(VTC).failed.is_none());
}
#[test]
fn an_unsent_question_is_not_an_ask_and_a_connect_asks_again() {
let now = Utc::now();
let mut book = book_knowing(VTC, now - chrono::Duration::hours(1));
book.mode_asked(VTC, now);
book.mode_failed(
VTC,
ModeFailure::Unsent("no listener installed".into()),
now,
);
assert!(
book.mode_checks.get(VTC).and_then(|c| c.asked_at).is_none(),
"an unsent question is not an ask"
);
assert!(
book.mode_refresh_due(VTC, now),
"so the next one is not held back"
);
book.vetter_refresh_due = false;
book.listener_connected();
assert!(
book.vetter_mode(VTC).failed.is_none(),
"the failure described a connection that is back"
);
assert!(
book.vetter_refresh_due,
"and the mode is asked for again now"
);
}
#[test]
fn an_unreadable_manifest_is_a_failure_not_a_mode() {
let now = Utc::now();
let mut book = book_knowing(VTC, now);
let raw = json!({ "criteria": [ { "id": "c1", "vetting": {
"extCritical": ["https://example.org/unknown"], "ext": {}
} } ] });
assert!(!book.learn_mode(VTC, &raw, None, now));
let reading = book.vetter_mode(VTC);
assert!(matches!(
reading.failed_since(now),
Some(ModeFailure::Unreadable(_))
));
assert!(reading.read_since(now).is_none());
}
#[test]
fn failures_say_which_kind_they_are() {
let words: Vec<String> = [
ModeFailure::Unsent("no mediator".into()),
ModeFailure::Unanswered,
ModeFailure::Refused("permissionDenied".into()),
ModeFailure::Unreadable("missing field".into()),
]
.iter()
.map(ModeFailure::words)
.collect();
assert!(words[0].contains("could not be sent"));
assert!(words[1].contains("no answer"));
assert!(words[2].contains("refused"));
assert!(words[3].contains("not a refusal"));
}
#[test]
fn the_next_monthly_label_rolls_over_the_year() {
assert_eq!(
next_monthly_label("2026-10"),
Some((
"2026-11".into(),
NaiveDate::from_ymd_opt(2026, 11, 1).unwrap()
))
);
assert_eq!(
next_monthly_label("2026-12").map(|(l, _)| l),
Some("2027-01".into())
);
assert_eq!(next_monthly_label("spring"), None);
}
#[test]
fn a_shown_mode_is_not_asked_for_again_too_soon() {
let now = Utc::now();
let mut book = book_knowing(VTC, now - Duration::hours(2));
assert!(book.mode_refresh_due(VTC, now));
book.mode_asked(VTC, now);
assert!(!book.mode_refresh_due(VTC, now + Duration::seconds(30)));
assert!(book.mode_refresh_due(VTC, now + MODE_REASK_AFTER));
}
}