use std::collections::BTreeSet;
use exo_core::{Did, Timestamp};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ConflictError {
#[error("recusal required: actor {actor} has {severity} conflict — vote blocked")]
RecusalRequired {
actor: String,
severity: ConflictSeverity,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictDeclaration {
pub declarant_did: Did,
pub nature: String,
pub related_dids: Vec<Did>,
pub timestamp: Timestamp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionRequest {
pub action_id: String,
pub actor_did: Did,
pub affected_dids: Vec<Did>,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conflict {
pub declaration: ConflictDeclaration,
pub affected_did: Did,
pub severity: ConflictSeverity,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConflictSeverity {
Advisory,
Material,
Disqualifying,
}
impl std::fmt::Display for ConflictSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConflictSeverity::Advisory => f.write_str("Advisory"),
ConflictSeverity::Material => f.write_str("Material"),
ConflictSeverity::Disqualifying => f.write_str("Disqualifying"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardAcknowledgment {
pub declarant_did: Did,
pub acknowledger_did: Did,
pub declaration_timestamp: Timestamp,
pub acknowledged_at: Timestamp,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StandingConflictRegister {
entries: Vec<ConflictDeclaration>,
acknowledgments: Vec<BoardAcknowledgment>,
}
impl StandingConflictRegister {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, decl: ConflictDeclaration) {
let already_exists = self.entries.iter().any(|e| {
e.declarant_did == decl.declarant_did
&& e.nature == decl.nature
&& e.related_dids == decl.related_dids
});
if !already_exists {
self.entries.push(decl);
}
}
pub fn record_acknowledgment(&mut self, ack: BoardAcknowledgment) {
self.acknowledgments.push(ack);
}
#[must_use]
pub fn declarations_for_action(&self, action: &ActionRequest) -> Vec<&ConflictDeclaration> {
self.entries
.iter()
.filter(|d| {
d.declarant_did == action.actor_did
&& d.related_dids
.iter()
.any(|r| action.affected_dids.contains(r))
})
.collect()
}
#[must_use]
pub fn acknowledgment_count(&self, declarant: &Did) -> usize {
let mut acknowledgers = BTreeSet::new();
for acknowledgment in &self.acknowledgments {
if &acknowledgment.declarant_did != declarant
|| &acknowledgment.acknowledger_did == declarant
{
continue;
}
let registered_disclosure = self.entries.iter().any(|entry| {
&entry.declarant_did == declarant
&& entry.timestamp == acknowledgment.declaration_timestamp
});
if registered_disclosure {
acknowledgers.insert(acknowledgment.acknowledger_did.clone());
}
}
acknowledgers.len()
}
#[must_use]
pub fn all_declarations(&self) -> &[ConflictDeclaration] {
&self.entries
}
}
#[must_use]
pub fn check_conflicts(
actor: &Did,
action: &ActionRequest,
declarations: &[ConflictDeclaration],
) -> Vec<Conflict> {
let mut conflicts = Vec::new();
for decl in declarations {
if &decl.declarant_did != actor {
continue;
}
for related in &decl.related_dids {
if action.affected_dids.contains(related) {
let severity =
if decl.nature.contains("financial") || decl.nature.contains("ownership") {
ConflictSeverity::Disqualifying
} else if decl.nature.contains("personal") || decl.nature.contains("family") {
ConflictSeverity::Material
} else {
ConflictSeverity::Advisory
};
conflicts.push(Conflict {
declaration: decl.clone(),
affected_did: related.clone(),
severity,
});
}
}
}
conflicts
}
#[must_use]
pub fn must_recuse(conflicts: &[Conflict]) -> bool {
conflicts.iter().any(|c| {
c.severity == ConflictSeverity::Disqualifying || c.severity == ConflictSeverity::Material
})
}
#[must_use = "conflict enforcement result must be handled — do not silently discard"]
pub fn check_and_block(actor: &Did, conflicts: &[Conflict]) -> Result<(), ConflictError> {
for c in conflicts {
if c.severity == ConflictSeverity::Disqualifying || c.severity == ConflictSeverity::Material
{
return Err(ConflictError::RecusalRequired {
actor: actor.to_string(),
severity: c.severity.clone(),
});
}
}
Ok(())
}
#[must_use]
pub fn adjusted_quorum_denominator(total_members: usize, recused_count: usize) -> usize {
total_members.saturating_sub(recused_count).max(1)
}
#[cfg(test)]
mod tests {
use super::*;
fn did(name: &str) -> Did {
Did::new(&format!("did:exo:{name}")).expect("ok")
}
fn decl(nature: &str, related: &str) -> ConflictDeclaration {
ConflictDeclaration {
declarant_did: did("alice"),
nature: nature.into(),
related_dids: vec![did(related)],
timestamp: Timestamp::new(1000, 0),
}
}
fn action(affected: &str) -> ActionRequest {
ActionRequest {
action_id: "a1".into(),
actor_did: did("alice"),
affected_dids: vec![did(affected)],
description: "test".into(),
}
}
#[test]
fn no_conflicts_when_none() {
assert!(check_conflicts(&did("alice"), &action("bob"), &[]).is_empty());
}
#[test]
fn financial_disqualifying() {
let c = check_conflicts(
&did("alice"),
&action("bob"),
&[decl("financial interest", "bob")],
);
assert_eq!(c.len(), 1);
assert_eq!(c[0].severity, ConflictSeverity::Disqualifying);
assert!(must_recuse(&c));
}
#[test]
fn personal_material() {
let c = check_conflicts(
&did("alice"),
&action("carol"),
&[decl("personal relationship", "carol")],
);
assert_eq!(c[0].severity, ConflictSeverity::Material);
assert!(must_recuse(&c));
}
#[test]
fn advisory_no_recuse() {
let c = check_conflicts(
&did("alice"),
&action("dave"),
&[decl("acquaintance", "dave")],
);
assert_eq!(c[0].severity, ConflictSeverity::Advisory);
assert!(!must_recuse(&c));
}
#[test]
fn no_overlap() {
assert!(
check_conflicts(&did("alice"), &action("carol"), &[decl("financial", "bob")])
.is_empty()
);
}
#[test]
fn different_actor_ignored() {
let d = ConflictDeclaration {
declarant_did: did("bob"),
nature: "financial".into(),
related_dids: vec![did("carol")],
timestamp: Timestamp::new(1000, 0),
};
assert!(check_conflicts(&did("alice"), &action("carol"), &[d]).is_empty());
}
#[test]
fn must_recuse_empty() {
assert!(!must_recuse(&[]));
}
#[test]
fn ownership_disqualifying() {
let c = check_conflicts(
&did("alice"),
&action("bob"),
&[decl("ownership stake", "bob")],
);
assert_eq!(c[0].severity, ConflictSeverity::Disqualifying);
}
#[test]
fn family_material() {
let c = check_conflicts(
&did("alice"),
&action("bob"),
&[decl("family member", "bob")],
);
assert_eq!(c[0].severity, ConflictSeverity::Material);
}
#[test]
fn check_and_block_disqualifying_blocks_vote() {
let conflicts = check_conflicts(
&did("alice"),
&action("bob"),
&[decl("financial interest", "bob")],
);
let result = check_and_block(&did("alice"), &conflicts);
assert!(result.is_err(), "Disqualifying conflict must block vote");
let err = result.unwrap_err();
assert!(matches!(err, ConflictError::RecusalRequired { .. }));
assert!(err.to_string().contains("vote blocked"));
}
#[test]
fn check_and_block_material_blocks_vote() {
let conflicts = check_conflicts(
&did("alice"),
&action("carol"),
&[decl("personal relationship", "carol")],
);
let result = check_and_block(&did("alice"), &conflicts);
assert!(result.is_err(), "Material conflict must block vote");
}
#[test]
fn check_and_block_advisory_permits_vote() {
let conflicts = check_conflicts(
&did("alice"),
&action("dave"),
&[decl("acquaintance", "dave")],
);
assert!(
check_and_block(&did("alice"), &conflicts).is_ok(),
"Advisory conflict must not block vote"
);
}
#[test]
fn check_and_block_no_conflicts_permits_vote() {
assert!(check_and_block(&did("alice"), &[]).is_ok());
}
#[test]
fn board_acknowledgment_records_receipt() {
let mut register = StandingConflictRegister::new();
let declarant = did("alice");
register.register(decl("financial interest", "bob"));
let ack = BoardAcknowledgment {
declarant_did: declarant.clone(),
acknowledger_did: did("carol"),
declaration_timestamp: Timestamp::new(1000, 0),
acknowledged_at: Timestamp::new(2000, 0),
};
register.record_acknowledgment(ack);
assert_eq!(register.acknowledgment_count(&declarant), 1);
}
#[test]
fn board_acknowledgment_excludes_declarant() {
let mut register = StandingConflictRegister::new();
let declarant = did("alice");
let ack = BoardAcknowledgment {
declarant_did: declarant.clone(),
acknowledger_did: declarant.clone(), declaration_timestamp: Timestamp::new(1000, 0),
acknowledged_at: Timestamp::new(2000, 0),
};
register.record_acknowledgment(ack);
assert_eq!(
register.acknowledgment_count(&declarant),
0,
"Declarant self-acknowledgment must not count toward DGCL §144(a)(1)"
);
}
#[test]
fn board_acknowledgment_count_excludes_unrelated_declarants() {
let mut register = StandingConflictRegister::new();
let alice = did("alice");
let dave = did("dave");
register.register(ConflictDeclaration {
declarant_did: alice.clone(),
nature: "financial interest".into(),
related_dids: vec![did("bob")],
timestamp: Timestamp::new(1000, 0),
});
register.register(ConflictDeclaration {
declarant_did: dave.clone(),
nature: "personal relationship".into(),
related_dids: vec![did("eve")],
timestamp: Timestamp::new(1000, 0),
});
register.record_acknowledgment(BoardAcknowledgment {
declarant_did: dave.clone(),
acknowledger_did: did("carol"),
declaration_timestamp: Timestamp::new(1000, 0),
acknowledged_at: Timestamp::new(3000, 0),
});
assert_eq!(
register.acknowledgment_count(&alice),
0,
"acknowledgments for Dave's disclosure must not count for Alice"
);
assert_eq!(register.acknowledgment_count(&dave), 1);
}
#[test]
fn board_acknowledgment_count_deduplicates_acknowledgers() {
let mut register = StandingConflictRegister::new();
let alice = did("alice");
register.register(ConflictDeclaration {
declarant_did: alice.clone(),
nature: "financial interest".into(),
related_dids: vec![did("bob")],
timestamp: Timestamp::new(1000, 0),
});
for acknowledged_at in [Timestamp::new(2000, 0), Timestamp::new(2001, 0)] {
register.record_acknowledgment(BoardAcknowledgment {
declarant_did: alice.clone(),
acknowledger_did: did("carol"),
declaration_timestamp: Timestamp::new(1000, 0),
acknowledged_at,
});
}
assert_eq!(
register.acknowledgment_count(&alice),
1,
"the same board member must not inflate acknowledgment quorum"
);
}
#[test]
fn board_acknowledgment_count_excludes_unregistered_declarations() {
let mut register = StandingConflictRegister::new();
let alice = did("alice");
register.record_acknowledgment(BoardAcknowledgment {
declarant_did: alice.clone(),
acknowledger_did: did("carol"),
declaration_timestamp: Timestamp::new(9999, 0),
acknowledged_at: Timestamp::new(10_000, 0),
});
assert_eq!(
register.acknowledgment_count(&alice),
0,
"acknowledgments must not count without a registered disclosure"
);
}
#[test]
fn standing_register_cross_decision_detection() {
let mut register = StandingConflictRegister::new();
register.register(ConflictDeclaration {
declarant_did: did("alice"),
nature: "financial interest".into(),
related_dids: vec![did("acme-corp")],
timestamp: Timestamp::new(1000, 0),
});
let action_b = ActionRequest {
action_id: "decision-b".into(),
actor_did: did("alice"),
affected_dids: vec![did("acme-corp")],
description: "approve contract with acme".into(),
};
let relevant = register.declarations_for_action(&action_b);
assert_eq!(
relevant.len(),
1,
"Cross-decision conflict must be detected"
);
}
#[test]
fn standing_register_deduplicates() {
let mut register = StandingConflictRegister::new();
let d = decl("financial interest", "bob");
register.register(d.clone());
register.register(d);
assert_eq!(register.all_declarations().len(), 1);
}
#[test]
fn standing_register_unrelated_action_not_flagged() {
let mut register = StandingConflictRegister::new();
register.register(decl("financial interest", "bob"));
let unrelated = ActionRequest {
action_id: "a2".into(),
actor_did: did("alice"),
affected_dids: vec![did("carol")], description: "vote on carol proposal".into(),
};
assert!(register.declarations_for_action(&unrelated).is_empty());
}
#[test]
fn quorum_adjustment_reduces_denominator() {
assert_eq!(adjusted_quorum_denominator(5, 2), 3);
}
#[test]
fn quorum_adjustment_all_recuse_returns_one() {
assert_eq!(adjusted_quorum_denominator(3, 3), 1);
}
#[test]
fn quorum_adjustment_no_recusals_unchanged() {
assert_eq!(adjusted_quorum_denominator(7, 0), 7);
}
#[test]
fn conflict_errors_do_not_depend_on_debug_formatting() {
let source = include_str!("conflict.rs")
.split("// ---------------------------------------------------------------------------\n// Core types")
.next()
.expect("error section");
assert!(
!source.contains("{severity:?}"),
"conflict errors must use explicit stable severity labels"
);
}
}