use auths_infra_http::HttpAsyncWitnessClient;
use auths_keri::Prefix;
use auths_keri::Said;
use auths_keri::witness::{DuplicityEvidence, WitnessError, WitnessReport};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperatorObservation {
Said(Said),
Gap,
Unreachable,
}
#[derive(Debug, Clone)]
pub struct OperatorReading {
pub aid: Prefix,
pub observation: OperatorObservation,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperatorLiveness {
pub aid: Prefix,
pub reachable: bool,
pub last_success: Option<DateTime<Utc>>,
pub latency_ms: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct CrossReadVerdict {
pub sequence: u128,
pub conflicts: Vec<DuplicityEvidence>,
pub incomplete: Vec<Prefix>,
pub down: Vec<Prefix>,
pub agreed_said: Option<Said>,
}
impl CrossReadVerdict {
pub const DETECTION_LABEL: &'static str = "sampled, not yet non-repudiable";
pub fn has_conflict(&self) -> bool {
!self.conflicts.is_empty()
}
}
pub fn cross_read_verdict(
prefix: &Prefix,
sequence: u128,
readings: &[OperatorReading],
now: DateTime<Utc>,
) -> CrossReadVerdict {
let mut verdict = CrossReadVerdict {
sequence,
..Default::default()
};
let mut reporters: Vec<(Prefix, Said)> = Vec::new();
for reading in readings {
match &reading.observation {
OperatorObservation::Said(said) => reporters.push((reading.aid.clone(), said.clone())),
OperatorObservation::Gap => verdict.incomplete.push(reading.aid.clone()),
OperatorObservation::Unreachable => verdict.down.push(reading.aid.clone()),
}
}
let Some((canonical_aid, canonical_said)) = reporters.first().cloned() else {
return verdict;
};
verdict.agreed_said = Some(canonical_said.clone());
let stamp = now.to_rfc3339();
for (aid, said) in reporters.iter().skip(1) {
if *said != canonical_said {
verdict.agreed_said = None;
verdict.conflicts.push(DuplicityEvidence {
prefix: prefix.clone(),
sequence,
event_a_said: canonical_said.clone(),
event_b_said: said.clone(),
witness_reports: vec![
WitnessReport {
witness_id: canonical_aid.as_str().to_string(),
observed_said: canonical_said.clone(),
observed_at: Some(stamp.clone()),
},
WitnessReport {
witness_id: aid.as_str().to_string(),
observed_said: said.clone(),
observed_at: Some(stamp.clone()),
},
],
});
}
}
verdict
}
pub fn quorum_at_risk(total: usize, down: usize, threshold: usize) -> bool {
total.saturating_sub(down) < threshold
}
fn classify_observation(result: &Result<Option<Said>, WitnessError>) -> OperatorObservation {
match result {
Ok(Some(said)) => OperatorObservation::Said(said.clone()),
Ok(None) => OperatorObservation::Gap,
Err(_) => OperatorObservation::Unreachable,
}
}
pub async fn monitor_round(
prefix: &Prefix,
sequence: u64,
operators: &[(Prefix, HttpAsyncWitnessClient)],
now: DateTime<Utc>,
) -> (CrossReadVerdict, Vec<OperatorLiveness>) {
let mut readings = Vec::with_capacity(operators.len());
let mut liveness = Vec::with_capacity(operators.len());
for (aid, client) in operators {
let started = std::time::Instant::now();
let result = client.said_at_seq(prefix, sequence).await;
let latency_ms = started.elapsed().as_millis() as u64;
let observation = classify_observation(&result);
let reachable = !matches!(observation, OperatorObservation::Unreachable);
liveness.push(OperatorLiveness {
aid: aid.clone(),
reachable,
last_success: reachable.then_some(now),
latency_ms: reachable.then_some(latency_ms),
});
readings.push(OperatorReading {
aid: aid.clone(),
observation,
});
}
(
cross_read_verdict(prefix, sequence as u128, &readings, now),
liveness,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn prefix() -> Prefix {
Prefix::new_unchecked("EIdentity000000000000000000000000000000000000".to_string())
}
fn aid(n: u8) -> Prefix {
Prefix::new_unchecked(format!("BWitness{n:0>36}"))
}
fn said(s: &str) -> Said {
Said::new_unchecked(s.to_string())
}
fn now() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
fn reading(n: u8, obs: OperatorObservation) -> OperatorReading {
OperatorReading {
aid: aid(n),
observation: obs,
}
}
#[test]
fn same_seq_same_said_no_flag() {
let readings = vec![
reading(1, OperatorObservation::Said(said("EEventA"))),
reading(2, OperatorObservation::Said(said("EEventA"))),
];
let verdict = cross_read_verdict(&prefix(), 5, &readings, now());
assert!(!verdict.has_conflict());
assert_eq!(verdict.agreed_said, Some(said("EEventA")));
}
#[test]
fn same_seq_different_said_is_flagged_naming_witnesses() {
let readings = vec![
reading(1, OperatorObservation::Said(said("EEventA"))),
reading(2, OperatorObservation::Said(said("EEventB"))),
];
let verdict = cross_read_verdict(&prefix(), 5, &readings, now());
assert!(verdict.has_conflict());
let evidence = &verdict.conflicts[0];
assert_eq!(evidence.sequence, 5);
let named: Vec<&str> = evidence
.witness_reports
.iter()
.map(|r| r.witness_id.as_str())
.collect();
assert!(named.contains(&aid(1).as_str()));
assert!(named.contains(&aid(2).as_str()));
assert_eq!(verdict.agreed_said, None);
}
#[test]
fn head_numbers_equal_but_saids_differ_still_flagged() {
let readings = vec![
reading(1, OperatorObservation::Said(said("EHonest"))),
reading(2, OperatorObservation::Said(said("EForked"))),
];
let verdict = cross_read_verdict(&prefix(), 9, &readings, now());
assert!(verdict.has_conflict());
}
#[test]
fn one_has_seq_other_gap_is_incomplete_not_duplicity() {
let readings = vec![
reading(1, OperatorObservation::Said(said("EEventA"))),
reading(2, OperatorObservation::Gap),
];
let verdict = cross_read_verdict(&prefix(), 5, &readings, now());
assert!(!verdict.has_conflict());
assert_eq!(verdict.incomplete, vec![aid(2)]);
}
#[test]
fn unreachable_is_down_distinct_from_equivocation() {
let readings = vec![
reading(1, OperatorObservation::Said(said("EEventA"))),
reading(2, OperatorObservation::Unreachable),
];
let verdict = cross_read_verdict(&prefix(), 5, &readings, now());
assert!(!verdict.has_conflict());
assert_eq!(verdict.down, vec![aid(2)]);
assert!(quorum_at_risk(3, 1, 3));
assert!(!quorum_at_risk(3, 1, 2));
}
#[test]
fn detection_label_is_sampled() {
assert!(CrossReadVerdict::DETECTION_LABEL.contains("sampled"));
assert!(CrossReadVerdict::DETECTION_LABEL.contains("not yet non-repudiable"));
}
#[test]
fn classify_maps_client_results() {
assert_eq!(
classify_observation(&Ok(Some(said("E1")))),
OperatorObservation::Said(said("E1"))
);
assert_eq!(classify_observation(&Ok(None)), OperatorObservation::Gap);
assert_eq!(
classify_observation(&Err(WitnessError::Timeout(5000))),
OperatorObservation::Unreachable
);
}
}