//! Objections — « redit » and « contredit » (Yvv, 2026-10-30, L12). //! //! « Faudrait un "redit" en option avec "contredit" → faut rendre plus explicite //! que l'objection doit être levée par l'auteur de la dernière arrivée pour être //! une proposition votable. Pour ce qui constitue une répétition → modifier //! l'article cible ; pour une contradiction → reformuler. → ping messagerie //! TrustinWallet → Lever l'objection ou Réitérer l'objection ; si pas de réponse //! sous 3j, alors "levée". » //! //! A member objects to a pair of versions (A, B), with a reason: B **repeats** A //! (`Redit`) or B **contradicts** A (`Contredit`). The rules, purely: //! //! - **the latest arrival** of the two (submitted last; same instant: written //! last) is no longer votable while the objection stands — unless it is in //! force: a version in force is never made non-votable, the other one is //! (`target`). Two versions both in force are not an objection: one of them //! is changed by a proposal. The target is fixed when the objection is raised; //! - the votes already cast on it stay, but it cannot enter into force //! (`votable` and the adoption: a non-votable version is blocked); //! - while it is held, one may still **vote against it or take one's vote //! back**, never add a *for* (`may_cast`, Yvv 2026-11-01: « Retirer ou //! "contre" »): it can only lose votes, and once lifted it enters with the //! votes still there; //! - its author **answers**: a substantive revision of it, or a proposal linked //! to the objection (on the article it repeats, for a « redit »; a reworded //! version, for a version without author). A substantive revision of **the //! other side** answers too: the pair changed, the objector judges again //! (`answers`). A typo fix answers nothing; //! - then the **objector** is asked, by a message: lift the objection (the //! version is votable again) or reiterate it (it stands, the author is told //! again). **Without an answer within 3 days, it is lifted** by itself; //! - **one cycle at a time**: while the objector is asked, another answer of the //! author changes nothing, and the deadline does not move; //! - the objector may lift at any time; an administrator of trustinWallet may //! lift too (never reiterate: the objection is not theirs); withdrawing either //! version ends it. use crate::ballot::Choice; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; /// Days the objector has to lift or reiterate once the author answered. pub const ANSWER_DAYS: i64 = 3; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ObjectionKind { /// The latest repeats the other: its author changes the article it repeats. Redit, /// The latest contradicts the other: its author rewords it. Contredit, } impl ObjectionKind { pub fn as_str(self) -> &'static str { match self { Self::Redit => "redit", Self::Contredit => "contredit", } } pub fn parse(raw: &str) -> Option { [Self::Redit, Self::Contredit].into_iter().find(|k| k.as_str() == raw) } } /// One version of the pair, as it stands when the objection is raised. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Side<'a> { pub id: &'a str, /// When it arrived (submitted), as a sortable stamp (RFC 3339 UTC). pub arrived_at: &'a str, /// The order it was written in, for versions of the same instant (a seed). pub seq: i64, pub in_force: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PairError { /// A version does not object to itself. Same, /// Both are in force: nothing to hold back; a proposal changes one of them. BothInForce, } fn arrival<'a>(s: &Side<'a>) -> (&'a str, i64) { (s.arrived_at, s.seq) } /// The version an objection makes non-votable: the latest arrival of the two, /// unless it is in force — then the other one. pub fn target<'a>(a: &Side<'a>, b: &Side<'a>) -> Result<&'a str, PairError> { if a.id == b.id { return Err(PairError::Same); } if a.in_force && b.in_force { return Err(PairError::BothInForce); } let latest = match arrival(a).cmp(&arrival(b)) { Ordering::Greater => a, Ordering::Less => b, // Same instant, same rank: the larger id, so that the answer never depends on the order given. Ordering::Equal => { if a.id > b.id { a } else { b } } }; let other = if latest.id == a.id { b } else { a }; Ok(if latest.in_force { other.id } else { latest.id }) } /// Why an objection was lifted. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Lift { /// By the person who raised it. Objector, /// By an administrator of trustinWallet. Admin, /// Nobody answered within the days given: lifted by itself. Deadline, /// One of the two versions was withdrawn. Withdrawn, /// A contradiction signal of before 2026-10-31 that lapsed when the text changed. Revision, } impl Lift { pub fn as_str(self) -> &'static str { match self { Self::Objector => "objector", Self::Admin => "admin", Self::Deadline => "deadline", Self::Withdrawn => "withdrawn", Self::Revision => "revision", } } pub fn parse(raw: &str) -> Option { [Self::Objector, Self::Admin, Self::Deadline, Self::Withdrawn, Self::Revision] .into_iter() .find(|l| l.as_str() == raw) } } /// Where an objection stands. Cycles count from 1: a reiteration opens the next. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Phase { /// Waiting for the author of the target to answer. No deadline: the version /// stays non-votable until the author answers or the objection is lifted. Author { cycle: u32, }, /// The author answered: waiting for the objector, until `due` (sortable stamp). Objector { cycle: u32, due: String, }, Lifted(Lift), } impl Phase { pub fn standing(&self) -> bool { !matches!(self, Self::Lifted(_)) } pub fn cycle(&self) -> Option { match self { Self::Author { cycle } | Self::Objector { cycle, .. } => Some(*cycle), Self::Lifted(_) => None, } } } /// Who acts on an objection. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Who { /// The person who raised it (an administrator who raised it acts as such). Objector, /// An administrator of trustinWallet who did not raise it. Admin, Other, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Event<'a> { /// The author answered (`answers`); `due` = now + `ANSWER_DAYS`. Answered { due: &'a str, }, Lift(Who), Reiterate(Who), /// The clock, as a sortable stamp. Tick { now: &'a str, }, /// One of the two versions was withdrawn. Withdrawn, } /// What a step asks the server to do besides storing the phase. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Effect { /// Ask the objector: lift or reiterate, by the deadline of the phase. AskObjector, /// Tell the author of the target (again): answer the objection. TellAuthor, /// The objection ended: every message about it is done. Ended, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Step { pub phase: Phase, pub effects: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Refusal { /// The objection no longer stands. Lifted, /// Only the objector (or an administrator, to lift) may. NotYours, /// Reiterating answers an answer of the author: there is none yet. NothingToReiterate, } /// One step of an objection. `Ok(None)`: nothing changes (an answer while the /// objector is already asked — one cycle at a time —, a tick before the /// deadline, an answer once lifted). pub fn step(phase: &Phase, event: &Event<'_>) -> Result, Refusal> { let ended = |lift| Some(Step { phase: Phase::Lifted(lift), effects: vec![Effect::Ended] }); match (phase, event) { (Phase::Lifted(_), Event::Answered { .. } | Event::Tick { .. } | Event::Withdrawn) => Ok(None), (Phase::Lifted(_), Event::Lift(_) | Event::Reiterate(_)) => Err(Refusal::Lifted), (Phase::Author { cycle }, Event::Answered { due }) => Ok(Some(Step { phase: Phase::Objector { cycle: *cycle, due: (*due).to_owned() }, effects: vec![Effect::AskObjector], })), (Phase::Objector { .. }, Event::Answered { .. }) => Ok(None), (_, Event::Lift(Who::Objector)) => Ok(ended(Lift::Objector)), (_, Event::Lift(Who::Admin)) => Ok(ended(Lift::Admin)), (_, Event::Lift(Who::Other) | Event::Reiterate(Who::Admin | Who::Other)) => Err(Refusal::NotYours), (Phase::Author { .. }, Event::Reiterate(Who::Objector)) => Err(Refusal::NothingToReiterate), (Phase::Objector { cycle, .. }, Event::Reiterate(Who::Objector)) => { Ok(Some(Step { phase: Phase::Author { cycle: cycle + 1 }, effects: vec![Effect::TellAuthor] })) } (Phase::Objector { due, .. }, Event::Tick { now }) if due.as_str() <= *now => Ok(ended(Lift::Deadline)), (Phase::Author { .. } | Phase::Objector { .. }, Event::Tick { .. }) => Ok(None), (Phase::Author { .. } | Phase::Objector { .. }, Event::Withdrawn) => Ok(ended(Lift::Withdrawn)), } } /// Which side of the pair a change touches. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Touched { /// The version the objection holds back. Target, /// The version it is said to repeat or contradict. Other, } /// A change made to the pair. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Change { /// A substantive revision (a new revision, votes to reconfirm). Revision(Touched), /// A typo fix: the meaning stays, nothing is answered. Typo(Touched), /// A proposal linked to the objection was submitted (a draft answers nothing). LinkedProposal, } /// Does this change answer the objection (and ask the objector)? pub fn answers(change: Change) -> bool { match change { Change::Revision(_) | Change::LinkedProposal => true, Change::Typo(_) => false, } } /// Who may answer with a linked proposal: the author of the target; anyone of /// the group when the target has no author (a starting text). pub fn may_link(target_author: Option<&str>, who: &str) -> bool { target_author.is_none_or(|a| a == who) } /// Where a linked proposal may go: on the article the target repeats or /// contradicts (« modifier l'article visé »), or on the target's own article (a /// reworded version, for a target without author). pub fn link_fits(unit: &str, target_unit: &str, other_unit: &str) -> bool { unit == other_unit || unit == target_unit } /// Is a version votable? A version in force always is; any other is not while a /// standing objection holds it back. `held` = the targets of the standing objections. pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool { in_force || !held.contains(&id) } /// May this choice be cast on a version? `held` = a standing objection holds it /// back now (never a version in force, `votable`). Yvv, 2026-11-01, to « pendant /// qu'une objection retient une version, que permet-on ? »: « Retirer ou /// "contre" ». On a held version one may take one's vote back or vote against, /// never add a *for*: it can only lose votes while it is held, and once lifted it /// enters with the votes still there. A version not held takes any choice. pub fn may_cast(held: bool, choice: Choice) -> bool { !held || choice != Choice::For } #[cfg(test)] mod tests { use super::*; fn side<'a>(id: &'a str, at: &'a str, seq: i64, in_force: bool) -> Side<'a> { Side { id, arrived_at: at, seq, in_force } } const T1: &str = "2026-10-30T10:00:00Z"; const T2: &str = "2026-10-31T09:00:00Z"; const DUE: &str = "2026-11-03T09:00:00Z"; #[test] fn the_latest_arrival_is_held_back() { let a = side("a", T1, 5, false); let b = side("b", T2, 3, false); assert_eq!(target(&a, &b), Ok("b")); assert_eq!(target(&b, &a), Ok("b"), "whatever the order given"); } #[test] fn same_instant_the_one_written_last() { // The versions of a seed share one second: the order they were written in decides. let a = side("zz", T1, 1, false); let b = side("aa", T1, 2, false); assert_eq!(target(&a, &b), Ok("aa")); assert_eq!(target(&b, &a), Ok("aa")); // Same rank too: the larger id, never the order of the arguments. let c = side("c", T1, 7, false); let d = side("d", T1, 7, false); assert_eq!((target(&c, &d), target(&d, &c)), (Ok("d"), Ok("d"))); } #[test] fn a_version_in_force_is_never_held_back() { // The latest arrival is in force (it entered after the other was submitted): the other is held. let older = side("old", T1, 1, false); let in_force = side("inf", T2, 2, true); assert_eq!(target(&older, &in_force), Ok("old")); // The usual case: a new proposal against the text in force. let seed = side("seed", T1, 1, true); let fresh = side("new", T2, 9, false); assert_eq!(target(&seed, &fresh), Ok("new")); } #[test] fn two_versions_in_force_or_one_alone_are_no_objection() { let a = side("a", T1, 1, true); let b = side("b", T2, 2, true); assert_eq!(target(&a, &b), Err(PairError::BothInForce)); assert_eq!(target(&a, &a), Err(PairError::Same)); } #[test] fn votable_unless_held_and_never_in_force() { assert!(votable("x", false, &[])); assert!(!votable("x", false, &["x"])); assert!(votable("y", false, &["x"])); assert!(votable("x", true, &["x"]), "a version in force is never made non-votable"); } #[test] fn a_held_version_takes_against_and_withdrawal_never_for() { // Yvv, 2026-11-01: « Retirer ou "contre" » — it can only lose votes while held. assert!(!may_cast(true, Choice::For), "never a new *for* while held"); assert!(may_cast(true, Choice::Against), "against, yes"); assert!(may_cast(true, Choice::None), "taking one's vote back, yes"); for c in [Choice::For, Choice::Against, Choice::None] { assert!(may_cast(false, c), "a version not held takes any choice: {c:?}"); } } #[test] fn the_full_cycle_answer_reiterate_answer_lift() { let start = Phase::Author { cycle: 1 }; assert!(start.standing()); // The objector cannot reiterate before the author answered. assert_eq!(step(&start, &Event::Reiterate(Who::Objector)), Err(Refusal::NothingToReiterate)); // No deadline while the author has not answered. assert_eq!(step(&start, &Event::Tick { now: "2027-01-01T00:00:00Z" }), Ok(None)); let asked = step(&start, &Event::Answered { due: DUE }).expect("ok").expect("changes"); assert_eq!(asked.phase, Phase::Objector { cycle: 1, due: DUE.into() }); assert_eq!(asked.effects, vec![Effect::AskObjector]); let again = step(&asked.phase, &Event::Reiterate(Who::Objector)).expect("ok").expect("changes"); assert_eq!(again.phase, Phase::Author { cycle: 2 }); assert_eq!(again.effects, vec![Effect::TellAuthor], "the author is told again"); let asked2 = step(&again.phase, &Event::Answered { due: DUE }).expect("ok").expect("changes"); assert_eq!(asked2.phase.cycle(), Some(2)); let lifted = step(&asked2.phase, &Event::Lift(Who::Objector)).expect("ok").expect("changes"); assert_eq!(lifted.phase, Phase::Lifted(Lift::Objector)); assert_eq!(lifted.effects, vec![Effect::Ended]); assert!(!lifted.phase.standing()); } #[test] fn one_cycle_at_a_time() { let asked = Phase::Objector { cycle: 1, due: DUE.into() }; // A second answer while the objector is asked: nothing changes, the deadline stays. assert_eq!(step(&asked, &Event::Answered { due: "2026-11-09T00:00:00Z" }), Ok(None)); } #[test] fn without_an_answer_of_the_objector_it_is_lifted_at_the_deadline() { let asked = Phase::Objector { cycle: 1, due: DUE.into() }; assert_eq!(step(&asked, &Event::Tick { now: "2026-11-03T08:59:59Z" }), Ok(None)); let done = step(&asked, &Event::Tick { now: DUE }).expect("ok").expect("changes"); assert_eq!(done.phase, Phase::Lifted(Lift::Deadline)); // A deadline passed while the server was stopped is taken at the next tick. let late = step(&asked, &Event::Tick { now: "2026-12-25T00:00:00Z" }).expect("ok").expect("changes"); assert_eq!(late.phase, Phase::Lifted(Lift::Deadline)); } #[test] fn who_may_lift_and_reiterate() { for phase in [Phase::Author { cycle: 1 }, Phase::Objector { cycle: 1, due: DUE.into() }] { // The objector may change their mind at any time; an administrator may lift. assert_eq!( step(&phase, &Event::Lift(Who::Objector)).expect("ok").map(|s| s.phase), Some(Phase::Lifted(Lift::Objector)) ); assert_eq!( step(&phase, &Event::Lift(Who::Admin)).expect("ok").map(|s| s.phase), Some(Phase::Lifted(Lift::Admin)) ); assert_eq!(step(&phase, &Event::Lift(Who::Other)), Err(Refusal::NotYours)); // Never reiterate someone else's objection, even as an administrator. assert_eq!(step(&phase, &Event::Reiterate(Who::Admin)), Err(Refusal::NotYours)); assert_eq!(step(&phase, &Event::Reiterate(Who::Other)), Err(Refusal::NotYours)); } } #[test] fn withdrawing_either_version_ends_it_and_a_lifted_one_stays_lifted() { let w = step(&Phase::Author { cycle: 1 }, &Event::Withdrawn).expect("ok").expect("changes"); assert_eq!(w.phase, Phase::Lifted(Lift::Withdrawn)); let lifted = Phase::Lifted(Lift::Deadline); assert_eq!(step(&lifted, &Event::Lift(Who::Objector)), Err(Refusal::Lifted)); assert_eq!(step(&lifted, &Event::Reiterate(Who::Objector)), Err(Refusal::Lifted)); assert_eq!(step(&lifted, &Event::Answered { due: DUE }), Ok(None)); assert_eq!(step(&lifted, &Event::Tick { now: DUE }), Ok(None)); assert_eq!(step(&lifted, &Event::Withdrawn), Ok(None)); } #[test] fn what_answers_an_objection() { assert!(answers(Change::Revision(Touched::Target)), "the author rewords"); assert!(answers(Change::Revision(Touched::Other)), "the other side changed: the objector judges again"); assert!(answers(Change::LinkedProposal), "the article it repeats is changed"); assert!(!answers(Change::Typo(Touched::Target)), "a typo keeps the meaning"); assert!(!answers(Change::Typo(Touched::Other))); } #[test] fn who_answers_with_a_linked_proposal_and_where() { assert!(may_link(Some("chiara"), "chiara")); assert!(!may_link(Some("chiara"), "yvv")); assert!(may_link(None, "yvv"), "a starting text has no author: anyone of the group"); assert!(link_fits("other-unit", "target-unit", "other-unit"), "the article it repeats"); assert!(link_fits("target-unit", "target-unit", "other-unit"), "a reworded version"); assert!(!link_fits("third", "target-unit", "other-unit")); } #[test] fn words_round_trip() { for k in [ObjectionKind::Redit, ObjectionKind::Contredit] { assert_eq!(ObjectionKind::parse(k.as_str()), Some(k)); } for l in [Lift::Objector, Lift::Admin, Lift::Deadline, Lift::Withdrawn, Lift::Revision] { assert_eq!(Lift::parse(l.as_str()), Some(l)); } assert_eq!(ObjectionKind::parse("signal"), None); } }