use exo_core::{Did, Timestamp};
use serde::{Deserialize, Serialize};
use crate::errors::GovernanceError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RoleName(pub String);
impl RoleName {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuccessionPlan {
pub role: RoleName,
pub current_holder: Did,
pub successors: Vec<Did>,
pub updated_at: Timestamp,
}
impl SuccessionPlan {
#[must_use]
pub fn next_successor(&self) -> Option<&Did> {
self.successors.first()
}
#[must_use]
pub fn has_successors(&self) -> bool {
!self.successors.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SuccessionTrigger {
Declaration,
Unresponsiveness {
duration_ms: u64,
last_active: Timestamp,
},
DesignatedActivator { activator: Did },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuccessionResult {
pub role: RoleName,
pub previous_holder: Did,
pub new_holder: Did,
pub trigger: SuccessionTrigger,
pub activated_at: Timestamp,
}
pub fn activate_succession(
plan: &SuccessionPlan,
trigger: SuccessionTrigger,
now: &Timestamp,
) -> Result<SuccessionResult, GovernanceError> {
let new_holder = plan.next_successor().ok_or_else(|| {
GovernanceError::ActionNotFound(format!("no successors defined for role {}", plan.role.0))
})?;
match &trigger {
SuccessionTrigger::Declaration => {
}
SuccessionTrigger::Unresponsiveness {
duration_ms,
last_active,
} => {
let elapsed = now.physical_ms.saturating_sub(last_active.physical_ms);
if elapsed < *duration_ms {
return Err(GovernanceError::InvalidTransition {
from: "active".into(),
to: format!(
"succession (need {}ms unresponsive, only {}ms elapsed)",
duration_ms, elapsed
),
});
}
}
SuccessionTrigger::DesignatedActivator { activator } => {
if *activator == plan.current_holder {
return Err(GovernanceError::InvalidTransition {
from: "self-activation".into(),
to: "use Declaration trigger for voluntary step-down".into(),
});
}
}
}
Ok(SuccessionResult {
role: plan.role.clone(),
previous_holder: plan.current_holder.clone(),
new_holder: new_holder.clone(),
trigger,
activated_at: *now,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn did(n: &str) -> Did {
Did::new(&format!("did:exo:{n}")).unwrap()
}
fn ts(ms: u64) -> Timestamp {
Timestamp::new(ms, 0)
}
fn sample_plan() -> SuccessionPlan {
SuccessionPlan {
role: RoleName::new("ceo"),
current_holder: did("alice"),
successors: vec![did("bob"), did("charlie")],
updated_at: ts(1000),
}
}
#[test]
fn declaration_succeeds() {
let plan = sample_plan();
let result = activate_succession(&plan, SuccessionTrigger::Declaration, &ts(5000)).unwrap();
assert_eq!(result.previous_holder, did("alice"));
assert_eq!(result.new_holder, did("bob"));
assert_eq!(result.role, RoleName::new("ceo"));
assert_eq!(result.activated_at, ts(5000));
}
#[test]
fn unresponsiveness_triggers_after_timeout() {
let plan = sample_plan();
let trigger = SuccessionTrigger::Unresponsiveness {
duration_ms: 3_600_000, last_active: ts(1000),
};
let result = activate_succession(&plan, trigger, &ts(7_201_000)).unwrap();
assert_eq!(result.new_holder, did("bob"));
}
#[test]
fn unresponsiveness_rejects_too_early() {
let plan = sample_plan();
let trigger = SuccessionTrigger::Unresponsiveness {
duration_ms: 3_600_000,
last_active: ts(1000),
};
let err = activate_succession(&plan, trigger, &ts(1_801_000));
assert!(matches!(
err,
Err(GovernanceError::InvalidTransition { .. })
));
}
#[test]
fn designated_activator_succeeds() {
let plan = sample_plan();
let trigger = SuccessionTrigger::DesignatedActivator {
activator: did("board-chair"),
};
let result = activate_succession(&plan, trigger, &ts(5000)).unwrap();
assert_eq!(result.new_holder, did("bob"));
}
#[test]
fn designated_activator_rejects_self_activation() {
let plan = sample_plan();
let trigger = SuccessionTrigger::DesignatedActivator {
activator: did("alice"),
};
let err = activate_succession(&plan, trigger, &ts(5000));
assert!(matches!(
err,
Err(GovernanceError::InvalidTransition { .. })
));
}
#[test]
fn no_successors_fails() {
let plan = SuccessionPlan {
role: RoleName::new("treasurer"),
current_holder: did("alice"),
successors: vec![],
updated_at: ts(1000),
};
let err = activate_succession(&plan, SuccessionTrigger::Declaration, &ts(5000));
assert!(matches!(err, Err(GovernanceError::ActionNotFound(_))));
}
#[test]
fn plan_next_successor() {
let plan = sample_plan();
assert_eq!(plan.next_successor(), Some(&did("bob")));
}
#[test]
fn plan_has_successors() {
assert!(sample_plan().has_successors());
let empty = SuccessionPlan {
role: RoleName::new("r"),
current_holder: did("a"),
successors: vec![],
updated_at: ts(0),
};
assert!(!empty.has_successors());
}
#[test]
fn role_name_eq() {
assert_eq!(RoleName::new("ceo"), RoleName::new("ceo"));
assert_ne!(RoleName::new("ceo"), RoleName::new("cto"));
}
#[test]
fn succession_trigger_serde() {
let triggers = vec![
SuccessionTrigger::Declaration,
SuccessionTrigger::Unresponsiveness {
duration_ms: 3_600_000,
last_active: ts(1000),
},
SuccessionTrigger::DesignatedActivator {
activator: did("board"),
},
];
for t in &triggers {
let json = serde_json::to_string(t).unwrap();
let t2: SuccessionTrigger = serde_json::from_str(&json).unwrap();
assert_eq!(&t2, t);
}
}
}