Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D

Les deux côtés gardés partout. Aux coutures :
- une objection (redit / contredit) porte sur deux articles, jamais sur un module ;
  un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du
  titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre
  de départ, ou 3 jours), et documents.title suit à l'application ;
- votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran
  et dans document.json ;
- « Modules à voter » ne compte plus une version retenue par une objection (test
  vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ;
- le panier : une fonction pour les articles et les modules, avec les versions
  retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut
  répondre à une objection ;
- migrations 0007 (modules) puis 0008 (objections) ;
- C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres).

Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55,
build, e2e 35 / 35 (C1–C33 et M1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 09:41:30 +01:00
co-authored by Claude Opus 5.5
82 changed files with 8285 additions and 677 deletions
+25
View File
@@ -7,6 +7,12 @@
/// The SS58 prefix of the Ğ1 chain.
pub const G1_PREFIX: u16 = 4450;
/// The longest text read as an address. An SS58 address of a 32-byte key is
/// about 48 characters; base58 decoding costs the square of the length, so a
/// longer text is refused before it is decoded (review of 74f96be: 250 000
/// characters took 32 s on a thread of the server).
pub const MAX_LEN: usize = 64;
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
fn base58_decode(s: &str) -> Option<Vec<u8>> {
@@ -72,6 +78,9 @@ fn prefix_bytes(prefix: u16) -> Vec<u8> {
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
if addr.len() > MAX_LEN {
return None;
}
let data = base58_decode(addr)?;
let (prefix, at) = match *data.first()? {
b @ 0..=63 => (u16::from(b), 1),
@@ -146,6 +155,22 @@ mod tests {
}
}
#[test]
fn a_long_text_is_refused_before_it_is_decoded() {
// Base58 decoding costs the square of the length, on a thread of the server:
// 250 000 characters took 32 s (review of 74f96be). An SS58 address is about 48.
for (g1, _, generic) in VECTORS {
assert!(g1.len() <= 64 && generic.len() <= 64);
}
let long = "z".repeat(40_000);
let t = std::time::Instant::now();
assert_eq!(decode(&long), None);
assert_eq!(to_g1(&long), None);
assert!(t.elapsed() < std::time::Duration::from_millis(20), "{:?}", t.elapsed());
// One character too many, even of leading ones (zero bytes), is refused.
assert_eq!(decode(&format!("{}{}", "1".repeat(65 - VECTORS[0].0.len()), VECTORS[0].0)), None);
}
#[test]
fn what_is_not_an_address() {
let (g1, _, _) = VECTORS[0];
+65 -10
View File
@@ -17,6 +17,11 @@
//! per identity, in the order of the chain — block, then rank of the event in
//! the block. A ballot confirmed late never overrides a more recent one.
//!
//! Anyone who sees a remark may copy it. A ballot bound to an account is settled
//! by that account's comment only; a ballot bound to none (a Ğecko code) by a
//! **paid** comment only — a bare remark costs nothing to send, a transfer of
//! the amount does.
//!
//! Pure: the salt comes from the caller, the facts of the chain too.
use crate::address::{self, G1_PREFIX};
@@ -217,6 +222,10 @@ pub enum Verdict {
Paid { identity: ChainIdentity },
/// Signed by another account than the one bound: a copy of the remark, ignored.
OtherAuthor,
/// A ballot bound to no account (a Ğecko code), and a comment without the
/// transfer: a copy of the remark anyone may send for free, ignored. Only a
/// paid comment settles such a ballot.
Unpaid,
/// The account is not that of an identity.
NoIdentity,
/// No transfer of the amount to the vote account in the same transaction.
@@ -227,30 +236,46 @@ impl Verdict {
/// Why a ballot is refused, as stored and translated.
pub fn reason(self) -> Option<&'static str> {
match self {
Self::Paid { .. } | Self::OtherAuthor => None,
Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None,
Self::NoIdentity => Some("no_identity"),
Self::NoTransfer => Some("transfer"),
}
}
/// Whether this comment settles the ballot (counted or refused), or is a copy to pass over.
pub fn settles(self) -> bool {
!matches!(self, Self::OtherAuthor | Self::Unpaid)
}
}
/// Whether the comment's transaction carries the transfer the ballot asks:
/// from its signer, to the vote account, at least the amount.
pub fn paid(c: &Comment, e: &Expected) -> bool {
c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount)
}
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
if e.author.is_some_and(|a| a != c.author) {
return Verdict::OtherAuthor;
}
let paid = paid(c, e);
if e.author.is_none() && !paid {
return Verdict::Unpaid;
}
let Some(identity) = c.identity else { return Verdict::NoIdentity };
if !c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) {
if !paid {
return Verdict::NoTransfer;
}
Verdict::Paid { identity }
}
/// The comment that settles a ballot: the first in the order of the chain that is
/// not a copy by another account. `None`: nothing yet, keep waiting.
/// not a copy — by another account than the one bound, or, unbound, without the
/// transfer. `None`: nothing yet, keep waiting.
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
let mut sorted: Vec<&Comment> = comments.iter().collect();
sorted.sort_by_key(|c| c.position);
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| *v != Verdict::OtherAuthor)
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| v.settles())
}
#[cfg(test)]
@@ -400,15 +425,18 @@ mod tests {
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former);
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
// Too little, elsewhere, or nothing.
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer);
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer);
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer);
// Paid from another account than the signer's.
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer);
// Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound.
let bound = Expected { author: Some(key(SIMONE)), ..e };
for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] {
let c = comment(1, SIMONE, Some((1304, true)), unpaid);
assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}");
assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}");
}
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
assert_eq!(ok.reason(), None);
assert_eq!(Verdict::Unpaid.reason(), None);
assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles());
}
#[test]
@@ -429,4 +457,31 @@ mod tests {
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
assert_eq!(judge(&[], &free), None);
}
#[test]
fn an_unbound_ballot_is_settled_by_a_paid_comment_only() {
// A Ğecko code binds no account. A remark alone costs nothing to send:
// a bare copy must not settle the ballot (review of 74f96be, P3).
let free = Expected { account: key(ALICE), amount: 10, author: None };
let bare = comment(8, SIMONE, None, &[]);
let short = comment(9, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
let elsewhere = comment(10, ALICE, Some((1, true)), &[(ALICE, SIMONE, 10)]);
assert_eq!(judge(&[bare.clone(), short.clone(), elsewhere.clone()], &free), None, "nothing paid: keep waiting");
let paid = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]);
assert_eq!(
judge(&[paid.clone(), elsewhere, short, bare], &free),
Some((paid.position, Verdict::Paid { identity: SIMONE_MEMBER }))
);
// Paid by an account without identity: paid, so it settles — refused.
let nobody = comment(7, ALICE, None, &[(ALICE, ALICE, 10)]);
assert_eq!(judge(&[paid.clone(), nobody.clone()], &free), Some((nobody.position, Verdict::NoIdentity)));
// The limit that stays (ADR 0008): a paid copy, earlier on the chain, takes the ballot.
let copy = comment(6, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]);
let theirs = ChainIdentity { index: 1, member: true };
assert_eq!(judge(&[paid, copy.clone()], &free), Some((copy.position, Verdict::Paid { identity: theirs })));
// Bound to an account: its own comment without the transfer is refused, and told.
let bound = Expected { author: Some(key(SIMONE)), ..free };
let own = comment(12, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
assert_eq!(judge(std::slice::from_ref(&own), &bound), Some((own.position, Verdict::NoTransfer)));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Who applies a typo fix, and when (Yvv, 2026-10-30, L13). What a typo is
//! does not change: `edit` (letters and spaces, judged against the voted text).
//!
//! « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais
//! auteur a toujours la main tant que c'est pas le texte en vote. Sinon seule
//! une coquille validée corrige le texte en vote. »
//!
//! - The person who **answers for the version** — its author; for a text
//! without author (a starting text), an administrator of trustinWallet
//! (`trustin_access::answers_typo`) — applies a fix at once (`route`).
//! - Anyone else's fix **waits**: the one who answers is told by a message,
//! validates or refuses, and **without an answer within 3 days it applies by
//! itself** (`tick`). On a text in force, only a validated fix (or one passed
//! at the deadline) corrects it.
//! - **The author keeps the hand while the version is not in force**: a fix
//! applied (validated or passed at the deadline) may still be refused — the
//! text it was written over comes back —, and a refused one validated after
//! all. Once in force, an answer is final.
//! - A fix is written only over the text it was written over (`fits`): two
//! fixes waiting on one text are each judged against the voted text; once one
//! applies, the other no longer fits and is **stale** (« recharge, puis
//! recommence »). Slips never add up.
//! - Translations stay out of this: editorial, without validation.
use serde::{Deserialize, Serialize};
/// Days the one who answers for the version has, before a fix applies by itself.
pub const ANSWER_DAYS: i64 = 3;
/// Where a fix goes when it is written.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
/// Applied at once: the writer answers for the version.
Apply,
/// Waits for the one who answers for the version, or for the deadline.
Await,
}
pub fn route(writer_answers: bool) -> Route {
if writer_answers { Route::Apply } else { Route::Await }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TypoStatus {
/// Written, waiting for an answer or the deadline.
Pending,
/// In the text.
Applied,
/// Not in the text: refused (or taken back by the author before the text was in force).
Refused,
/// The text changed before it could apply: written over a text that is no longer there.
Stale,
}
impl TypoStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Applied => "applied",
Self::Refused => "refused",
Self::Stale => "stale",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Pending, Self::Applied, Self::Refused, Self::Stale].into_iter().find(|s| s.as_str() == raw)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Validate,
Refuse,
}
/// What a step does to the text of the version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextChange {
/// Write the fix (only where `fits`).
Write,
/// Put back the text the fix was written over (only where the fix is still the text).
Restore,
Keep,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Settle {
pub status: TypoStatus,
pub text: TextChange,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TypoRefusal {
/// Already answered (or stale): nothing left to decide.
Settled,
}
/// An answer of the one who answers for the version. `in_force`: the version is in force now.
pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result<Settle, TypoRefusal> {
let to = |status, text| Ok(Settle { status, text });
match (status, answer) {
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write),
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep),
// The author keeps the hand while the version is not in force.
(TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore),
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write),
_ => Err(TypoRefusal::Settled),
}
}
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
(status == TypoStatus::Pending && due <= now)
.then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
}
/// A text as a fix sees it: the revision it belongs to, its title and body.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Text<'a> {
pub revision: i64,
pub title: &'a str,
pub body: &'a str,
}
/// A fix is written only over the very text it was written over: same revision
/// (no change of substance since), same title and body (no other fix since).
pub fn fits(base: Text<'_>, current: Text<'_>) -> bool {
base == current
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edit::{EditKind, classify};
#[test]
fn the_one_who_answers_applies_at_once_anyone_else_waits() {
assert_eq!(route(true), Route::Apply, "the author, or an administrator for a starting text");
assert_eq!(route(false), Route::Await);
}
#[test]
fn a_pending_fix_is_validated_or_refused() {
for in_force in [false, true] {
assert_eq!(
answer(TypoStatus::Pending, Answer::Validate, in_force),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
assert_eq!(
answer(TypoStatus::Pending, Answer::Refuse, in_force),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep })
);
}
}
#[test]
fn the_author_keeps_the_hand_until_the_text_is_in_force() {
// A proposal: an applied fix may be taken back, a refused one validated after all.
assert_eq!(
answer(TypoStatus::Applied, Answer::Refuse, false),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore })
);
assert_eq!(
answer(TypoStatus::Refused, Answer::Validate, false),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
// In force: an answer is final.
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Validate, true), Err(TypoRefusal::Settled));
// Nothing to answer twice the same way, and never a stale fix.
assert_eq!(answer(TypoStatus::Applied, Answer::Validate, false), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Refuse, false), Err(TypoRefusal::Settled));
for a in [Answer::Validate, Answer::Refuse] {
assert_eq!(answer(TypoStatus::Stale, a, false), Err(TypoRefusal::Settled));
}
}
#[test]
fn without_an_answer_it_applies_at_the_deadline() {
let due = "2026-11-03T09:00:00Z";
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
assert_eq!(
tick(TypoStatus::Pending, due, due),
Some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
// A deadline passed while the server was stopped is taken at the next tick.
assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some());
for s in [TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(tick(s, due, "2027-02-01T00:00:00Z"), None, "{s:?}");
}
}
#[test]
fn two_fixes_waiting_on_one_text_the_second_goes_stale() {
let voted = Text { revision: 1, title: "Réciprocité", body: "des persones qui respectent" };
let first = Text { revision: 1, title: "Réciprocité", body: "des personnes qui respectent" };
let second = Text { revision: 1, title: "Réciprocité", body: "des persones qui repectent" };
// Each is judged against the voted text, as before: each is a typo there.
assert_eq!(classify(voted.body, first.body), EditKind::Typographic);
assert_eq!(classify(voted.body, second.body), EditKind::Typographic);
// Both were written over the voted text; the first applies.
assert!(fits(voted, voted));
// Then the text is the first fix: the second no longer fits — stale, never merged.
assert!(!fits(voted, first));
// A change of substance in between (a new revision) does the same, even back to the same words.
assert!(!fits(voted, Text { revision: 2, ..voted }));
}
#[test]
fn words_round_trip() {
for s in [TypoStatus::Pending, TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(TypoStatus::parse(s.as_str()), Some(s));
}
assert_eq!(TypoStatus::parse("typo"), None);
}
}
+2
View File
@@ -10,9 +10,11 @@ pub mod address;
pub mod assist;
pub mod ballot;
pub mod compose;
pub mod correction;
pub mod edit;
pub mod inertia;
pub mod names;
pub mod objection;
pub mod order;
pub mod pending;
pub mod threshold;
+460
View File
@@ -0,0 +1,460 @@
//! 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);
//! - 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 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> {
[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> {
[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<u32> {
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<Effect>,
}
#[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<Option<Step>, 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)
}
#[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 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);
}
}