Fusion du lot F : objections redit / contredit, coquilles validées par leur auteur, messagerie, délai de 3 jours

Conflits avec B2 : docs.rs garde shown_tally / shown_evaluation et votable ;
proposals.rs sert les totaux montrés et « votable » ; CONTROLES réunit C24
(« faire une objection », le panier par son adresse), C28–C33 et les deux passages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 05:57:21 +01:00
co-authored by Claude Opus 5.5
55 changed files with 5435 additions and 456 deletions
+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 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);
}
}
@@ -0,0 +1,93 @@
-- Objections « redit » / « contredit », typo fixes validated, and the messages
-- of trustinWallet (2026-10-31, lot F, ADR 0010). Additive only: nothing is
-- dropped, nothing is rewritten that an older image needs.
--
-- The contradiction signals of before become objections « contredit », in the
-- same table: an older image (rollback) still reads them as signals, and every
-- lifting still writes `lifted_at` / `lifted_by` as before.
ALTER TABLE signals ADD COLUMN kind TEXT NOT NULL DEFAULT 'contredit'; -- redit | contredit
ALTER TABLE signals ADD COLUMN held_id TEXT; -- the version held back (not votable): the latest arrival, never one in force
ALTER TABLE signals ADD COLUMN phase TEXT NOT NULL DEFAULT 'author'; -- author | objector | lifted
ALTER TABLE signals ADD COLUMN cycle INTEGER NOT NULL DEFAULT 1; -- a reiteration opens the next
ALTER TABLE signals ADD COLUMN due_at TEXT; -- phase objector: lifted by itself at this time
ALTER TABLE signals ADD COLUMN answered_at TEXT; -- the last answer of the author
ALTER TABLE signals ADD COLUMN answer TEXT; -- revision | other | proposal
ALTER TABLE signals ADD COLUMN answer_id TEXT; -- the version revised, or the proposal linked
ALTER TABLE signals ADD COLUMN lift TEXT; -- objector | admin | deadline | withdrawn | revision
-- The signals of before that were lifted stay lifted: by their author, by an
-- administrator, or because the text changed (the rule of before).
UPDATE signals SET phase = 'lifted', lift = CASE
WHEN lifted_by = 'revision' THEN 'revision'
WHEN lifted_by = author_id THEN 'objector'
ELSE 'admin' END
WHERE lifted_at IS NOT NULL;
-- The standing ones wait for the author of the version they now hold back: the
-- latest arrival of the two (submitted last; the same instant: written last),
-- unless it is in force — then the other. Both in force: none is held.
UPDATE signals SET held_id = (
SELECT CASE
WHEN a.status = 'in_force' AND b.status = 'in_force' THEN NULL
WHEN (IFNULL(a.submitted_at, a.created_at), a.rowid, a.id) > (IFNULL(b.submitted_at, b.created_at), b.rowid, b.id)
THEN CASE WHEN a.status = 'in_force' THEN b.id ELSE a.id END
ELSE CASE WHEN b.status = 'in_force' THEN a.id ELSE b.id END
END
FROM proposals a, proposals b
WHERE a.id = signals.proposal_id AND b.id = signals.target_proposal_id)
WHERE lifted_at IS NULL;
CREATE INDEX signals_proposal ON signals(proposal_id);
CREATE INDEX signals_target ON signals(target_proposal_id);
CREATE INDEX signals_due ON signals(phase, due_at);
-- A proposal written to answer an objection (« modifier l'article visé »).
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
-- A typo fix written by someone other than the one who answers for the version:
-- it waits for them (or for the deadline), then applies — or not.
CREATE TABLE typos (
id TEXT PRIMARY KEY,
proposal_id TEXT NOT NULL REFERENCES proposals(id),
doc_id TEXT NOT NULL REFERENCES documents(id),
writer_id TEXT NOT NULL REFERENCES identities(id), -- who wrote the fix
answerer_id TEXT, -- the author of the version, who answers; NULL = administrators (no author)
revision INTEGER NOT NULL, -- the revision it was written over
base_title TEXT NOT NULL, -- the text it was written over
base_body TEXT NOT NULL,
title TEXT NOT NULL, -- the fix
body TEXT NOT NULL,
status TEXT NOT NULL, -- pending | applied | refused | stale
created_at TEXT NOT NULL,
due_at TEXT NOT NULL, -- applied by itself then, if nobody answered
settled_at TEXT,
settled_by TEXT -- identity id, 'deadline', or 'changed' (stale)
);
CREATE INDEX typos_proposal ON typos(proposal_id, status);
CREATE INDEX typos_due ON typos(status, due_at);
-- The revision a validated fix wrote, or the one that took it back (kind 'revert'),
-- and who let it in: the identity that validated it, or 'deadline'.
ALTER TABLE revisions ADD COLUMN typo_id TEXT;
ALTER TABLE revisions ADD COLUMN answered_by TEXT;
-- The messages of trustinWallet: one person, one box. The text is told by the
-- interface from the subject (in the language of the reader); the gestures
-- follow the subject's state, so a message dealt with offers none.
CREATE TABLE messages (
id TEXT PRIMARY KEY,
recipient_id TEXT NOT NULL REFERENCES identities(id),
doc_id TEXT NOT NULL REFERENCES documents(id),
topic TEXT NOT NULL, -- objection | typo
subject_id TEXT NOT NULL, -- signals.id | typos.id
kind TEXT NOT NULL, -- objection_raised | objection_reiterated | objection_answered | typo_pending
cycle INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
due_at TEXT,
read_at TEXT,
done_at TEXT,
outcome TEXT -- answered | lifted | reiterated | deadline | admin | withdrawn | applied | refused | stale
);
CREATE INDEX messages_box ON messages(recipient_id, created_at);
CREATE INDEX messages_subject ON messages(topic, subject_id);
+6 -1
View File
@@ -203,7 +203,8 @@ fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> se
/// The electorate changes (the web of trust grows or shrinks): every few
/// minutes, every document is passed through the engine again — after the
/// usual participation of each group is measured again, once a day.
/// usual participation of each group is measured again, once a day, and after
/// the deadlines of three days are passed (`deadlines`).
pub async fn sweeper(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(300));
loop {
@@ -211,6 +212,10 @@ pub async fn sweeper(state: AppState) {
if let Err(e) = crate::participation::refresh(&state, false).await {
tracing::warn!(error = %e, "usual participation");
}
// Objections lifted and typo fixes applied at their deadline (three days).
if let Err(e) = crate::deadlines::sweep(&state).await {
tracing::warn!(error = %e, "deadlines");
}
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
Ok(ids) => ids,
Err(e) => {
+4
View File
@@ -120,6 +120,10 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010).
if !st.votable(p) {
return Err(AppError::Conflict("objection"));
}
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
}
Ok(out)
+57
View File
@@ -0,0 +1,57 @@
//! The clock of the deadlines (objections, typo fixes): the time of the
//! machine, plus an offset that only tests move — the API tests directly, the
//! end-to-end tests through `POST /api/dev/clock`, open with the dev login only.
//! A deadline is never waited for with a sleep.
use crate::util;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use time::{Duration, OffsetDateTime};
#[derive(Clone, Debug, Default)]
pub struct Clock {
offset_secs: Arc<AtomicI64>,
}
impl Clock {
pub fn at(&self) -> OffsetDateTime {
OffsetDateTime::now_utc() + Duration::seconds(self.offset_secs.load(Ordering::Relaxed))
}
/// Now, as a sortable stamp (RFC 3339 UTC, whole seconds).
pub fn now(&self) -> String {
util::stamp(self.at())
}
pub fn in_days(&self, days: i64) -> String {
util::stamp(self.at() + Duration::days(days))
}
/// Moves the clock forward (tests only).
pub fn advance(&self, secs: i64) {
self.offset_secs.fetch_add(secs, Ordering::Relaxed);
}
pub fn offset(&self) -> i64 {
self.offset_secs.load(Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advancing_moves_now_and_the_deadlines() {
let c = Clock::default();
let before = c.now();
c.advance(3 * 86_400);
assert!(c.now() > before);
assert!(c.in_days(0) >= c.now());
assert_eq!(c.offset(), 3 * 86_400);
// Clones share the offset: the server and its tasks read one clock.
let other = c.clone();
other.advance(1);
assert_eq!(c.offset(), 3 * 86_400 + 1);
}
}
+57
View File
@@ -0,0 +1,57 @@
//! The deadlines of three days (2026-10-31, lot F): an objection whose author
//! answered and whose objector said nothing is lifted; a typo fix nobody
//! answered applies. Run by the adoption sweeper (every 5 minutes) and once at
//! start, so a deadline passed while the server was stopped is dealt with as
//! soon as it starts again. Tests move the clock (`AppState::clock`), never sleep.
use crate::AppState;
use crate::adoption;
use crate::error::{AppError, AppResult};
use crate::{objections, typos};
use axum::Json;
use axum::extract::State;
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Swept {
/// Objections lifted at their deadline.
pub lifted: usize,
/// Typo fixes applied at their deadline.
pub applied: usize,
/// Typo fixes that could not apply: their text was gone.
pub stale: usize,
}
/// One pass: the objections, then the typo fixes, then the adoption of the
/// documents where an objection was lifted (a version may now enter into force).
pub async fn sweep(state: &AppState) -> AppResult<Swept> {
let (lifted, docs) = objections::due(state).await?;
let fixes = typos::due(state).await?;
for doc in &docs {
adoption::run(state, doc).await?;
}
Ok(Swept { lifted, applied: fixes.applied, stale: fixes.stale })
}
#[derive(Deserialize)]
pub struct ClockBody {
seconds: i64,
}
/// `POST /api/dev/clock` — the end-to-end tests move the server's clock forward
/// and pass the deadlines at once. Open with the dev login only, like it.
pub async fn dev_clock(State(state): State<AppState>, Json(b): Json<ClockBody>) -> AppResult<Json<Value>> {
if !state.cfg.dev_login {
return Err(AppError::NotFound);
}
if b.seconds <= 0 || b.seconds > 400 * 86_400 {
return Err(AppError::Invalid("seconds"));
}
state.clock.advance(b.seconds);
let swept = sweep(&state).await?;
Ok(Json(json!({
"now": state.clock.now(), "offset": state.clock.offset(),
"lifted": swept.lifted, "applied": swept.applied, "stale": swept.stale,
})))
}
+18 -27
View File
@@ -72,6 +72,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
Ok(())
}
/// Does this identity open the group now (see `trustin_access::opens`)?
pub async fn opens(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
let has = granted(state, &group.slug, &user.id).await?;
Ok(trustin_access::opens(&user.to_identity(), &group.to_group(), has))
}
/// May this identity vote in the group (see `trustin_access::votes_in`)?
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
let has = granted(state, &group.slug, &user.id).await?;
@@ -253,7 +259,9 @@ pub struct DocState {
pub units: Vec<UnitRow>,
pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>,
/// Versions held back by a standing objection: not votable, never entering into force.
pub blocked: HashSet<String>,
/// Standing objections touching each version, either side.
pub signal_counts: HashMap<String, i64>,
pub electorate: u32,
pub electorate_source: &'static str,
@@ -331,32 +339,9 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
}
}
#[derive(sqlx::FromRow)]
struct SignalRow {
proposal_id: String,
target_proposal_id: String,
}
let signals = sqlx::query_as::<_, SignalRow>(
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
let mut blocked = HashSet::new();
let mut signal_counts: HashMap<String, i64> = HashMap::new();
for s in &signals {
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
// Two texts that contradict each other never stand in force together.
if in_force.contains(s.target_proposal_id.as_str()) {
blocked.insert(s.proposal_id.clone());
}
if in_force.contains(s.proposal_id.as_str()) {
blocked.insert(s.target_proposal_id.clone());
}
}
// A standing objection holds back the latest arrival of its pair (never a version
// in force): not votable, and it cannot enter into force (ADR 0010).
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
let (electorate, electorate_source) = electorate(state, &group).await?;
let measure = participation::stored(state, &group.slug).await?;
let participation = Participation::of(
@@ -410,6 +395,11 @@ impl DocState {
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
}
/// Open to a vote: open or in force, and not held back by an objection.
pub fn votable(&self, p: &ProposalRow) -> bool {
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
}
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
self.units
@@ -560,7 +550,8 @@ fn proposal_json(
"evaluation": eval,
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"votable": st.votable(p),
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
+35 -6
View File
@@ -2,19 +2,24 @@
//!
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
//! and runs four background workers: the electorate refresher (size of the Ğ1
//! web of trust), the adoption sweeper, the ballot watcher (votes signed on the
//! Ğ1 chain, confirmed on the indexer) and the publisher (adopted texts pushed to the git repository
//! that client applications read).
//! web of trust), the adoption sweeper (with the deadlines of three days of
//! objections and typo fixes), the ballot watcher (votes signed on the Ğ1 chain,
//! confirmed on the indexer) and the publisher (adopted texts pushed to the git
//! repository that client applications read).
pub mod access;
pub mod adoption;
pub mod auth;
pub mod ballots;
pub mod clock;
pub mod config;
pub mod deadlines;
pub mod docs;
pub mod error;
pub mod indexer;
pub mod inertia_amend;
pub mod messages;
pub mod objections;
pub mod overview;
pub mod participation;
pub mod proposals;
@@ -22,6 +27,7 @@ pub mod publish;
pub mod seed;
pub mod seed_amend;
pub mod team;
pub mod typos;
pub mod util;
pub mod web;
pub mod wot;
@@ -50,6 +56,8 @@ pub struct AppState {
pub publish_wake: Arc<Notify>,
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
pub upcoming: Arc<overview::Upcoming>,
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
pub clock: clock::Clock,
}
impl AppState {
@@ -59,7 +67,14 @@ impl AppState {
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
.build()?;
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
Ok(Self {
db,
cfg: Arc::new(cfg),
http,
publish_wake: Arc::new(Notify::new()),
upcoming,
clock: clock::Clock::default(),
})
}
}
@@ -129,9 +144,17 @@ pub fn api_router() -> Router<AppState> {
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
.route("/proposals/{id}/split", post(proposals::split))
.route("/proposals/{id}/vote", post(proposals::vote))
.route("/proposals/{id}/signals", post(proposals::signal))
.route("/proposals/{id}/objections", post(objections::raise))
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
.route("/signals/{id}/lift", post(proposals::lift_signal))
.route("/objections/{id}/lift", post(objections::lift))
.route("/objections/{id}/reiterate", post(objections::reiterate))
.route("/typos/{id}/validate", post(typos::validate))
.route("/typos/{id}/refuse", post(typos::refuse))
.route("/messages", get(messages::list))
.route("/messages/unread", get(messages::unread))
.route("/messages/read", post(messages::read_all))
.route("/messages/{id}/read", post(messages::read))
.route("/dev/clock", post(deadlines::dev_clock))
.route("/units/{id}/inertia", post(proposals::propose_inertia))
.route("/feedback", post(access::send_feedback))
.route("/access", get(access::overview))
@@ -212,6 +235,12 @@ pub async fn run() -> anyhow::Result<()> {
tracing::warn!(error = %e, "usual participation");
}
auth::bootstrap(&state).await?;
// A deadline passed while the server was stopped is dealt with now.
match deadlines::sweep(&state).await {
Ok(s) if s != deadlines::Swept::default() => tracing::info!(?s, "deadlines passed while stopped"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "deadlines"),
}
if let Err(e) = publish::ensure_key(&state).await {
tracing::warn!(error = %e, "publication key");
}
+238
View File
@@ -0,0 +1,238 @@
//! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the
//! application, in the menu, with the number of unread messages — no mail.
//!
//! A message is about a subject (an objection, a typo fix) and carries the
//! gestures that subject asks of its recipient now — lift or reiterate,
//! validate or refuse — and its deadline. A message dealt with offers no
//! gesture any more. Its text is told by the interface, in the language of the
//! reader, from the subject. Each person reads their own messages only
//! (`trustin_access::reads_message`); acting on a subject marks one's messages
//! about it as read.
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::error::{AppError, AppResult};
use crate::{docs, objections, typos, util};
use axum::Json;
use axum::extract::{Path, State};
use serde_json::{Value, json};
/// The most recent messages shown in the box.
const SHOWN: i64 = 100;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Topic {
Objection,
Typo,
}
impl Topic {
pub fn as_str(self) -> &'static str {
match self {
Self::Objection => "objection",
Self::Typo => "typo",
}
}
}
/// Puts a message in someone's box.
#[allow(clippy::too_many_arguments)]
pub async fn notify(
state: &AppState,
recipient: &str,
doc_id: &str,
topic: Topic,
subject_id: &str,
kind: &str,
cycle: i64,
due: Option<&str>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO messages (id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(util::id())
.bind(recipient)
.bind(doc_id)
.bind(topic.as_str())
.bind(subject_id)
.bind(kind)
.bind(cycle)
.bind(state.clock.now())
.bind(due)
.execute(&state.db)
.await?;
Ok(())
}
/// The messages about a subject (of some kinds, or all) are dealt with: what became of it.
pub async fn close(
state: &AppState,
topic: Topic,
subject_id: &str,
kinds: Option<&[&str]>,
outcome: &str,
) -> AppResult<()> {
let now = state.clock.now();
match kinds {
None => {
sqlx::query(
"UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND done_at IS NULL",
)
.bind(&now)
.bind(outcome)
.bind(topic.as_str())
.bind(subject_id)
.execute(&state.db)
.await?;
}
Some(kinds) => {
for kind in kinds {
sqlx::query(
"UPDATE messages SET done_at = ?, outcome = ?
WHERE topic = ? AND subject_id = ? AND kind = ? AND done_at IS NULL",
)
.bind(&now)
.bind(outcome)
.bind(topic.as_str())
.bind(subject_id)
.bind(kind)
.execute(&state.db)
.await?;
}
}
}
Ok(())
}
/// Someone acted on a subject: their messages about it are read.
pub async fn read_subject(state: &AppState, recipient: &str, topic: Topic, subject_id: &str) -> AppResult<()> {
sqlx::query(
"UPDATE messages SET read_at = ? WHERE recipient_id = ? AND topic = ? AND subject_id = ? AND read_at IS NULL",
)
.bind(state.clock.now())
.bind(recipient)
.bind(topic.as_str())
.bind(subject_id)
.execute(&state.db)
.await?;
Ok(())
}
async fn unread_of(state: &AppState, recipient: &str) -> AppResult<i64> {
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE recipient_id = ? AND read_at IS NULL")
.bind(recipient)
.fetch_one(&state.db)
.await?)
}
#[derive(sqlx::FromRow)]
struct MessageRow {
id: String,
recipient_id: String,
doc_id: String,
topic: String,
subject_id: String,
kind: String,
cycle: i64,
created_at: String,
due_at: Option<String>,
read_at: Option<String>,
done_at: Option<String>,
outcome: Option<String>,
}
#[derive(sqlx::FromRow)]
struct DocLine {
group_slug: String,
slug: String,
title: String,
}
async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult<Option<Value>> {
let doc = sqlx::query_as::<_, DocLine>("SELECT group_slug, slug, title FROM documents WHERE id = ?")
.bind(&m.doc_id)
.fetch_optional(&state.db)
.await?;
let Some(doc) = doc else { return Ok(None) };
// A message tells what is in a group: shown only while its recipient opens it.
if !docs::opens(state, me, &docs::load_group(state, &doc.group_slug).await?).await? {
return Ok(None);
}
let (subject, gestures) = match m.topic.as_str() {
"objection" => match objections::for_message(state, me, &m.subject_id, &m.kind, m.cycle).await? {
Some((v, g)) => (json!({ "objection": v }), g),
None => return Ok(None),
},
"typo" => match typos::for_message(state, me, &m.subject_id).await? {
Some((v, g)) => (json!({ "typo": v }), g),
None => return Ok(None),
},
_ => return Ok(None),
};
let mut out = json!({
"id": m.id, "topic": m.topic, "kind": m.kind, "cycle": m.cycle, "createdAt": m.created_at,
"dueAt": m.due_at, "read": m.read_at.is_some(), "done": m.done_at.is_some() || gestures.is_empty(),
"outcome": m.outcome, "gestures": gestures,
"doc": { "id": m.doc_id, "groupSlug": doc.group_slug, "slug": doc.slug, "title": util::parse_lang(&doc.title) },
});
if let (Some(o), Value::Object(s)) = (out.as_object_mut(), subject) {
o.extend(s);
}
Ok(Some(out))
}
/// `GET /api/messages` — one's own box, newest first.
pub async fn list(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
let rows = sqlx::query_as::<_, MessageRow>(
"SELECT id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at, read_at, done_at, outcome
FROM messages WHERE recipient_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?",
)
.bind(&me.id)
.bind(SHOWN)
.fetch_all(&state.db)
.await?;
let mut out = Vec::with_capacity(rows.len());
for m in &rows {
if !trustin_access::reads_message(&me.to_identity(), &m.recipient_id) {
continue;
}
if let Some(v) = view(&state, &me, m).await? {
out.push(v);
}
}
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await?, "messages": out })))
}
/// `GET /api/messages/unread` — the number next to « Messages » in the menu.
pub async fn unread(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
}
/// `POST /api/messages/{id}/read` — one's own message only (404 otherwise).
pub async fn read(State(state): State<AppState>, User(me): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
let recipient: String = sqlx::query_scalar("SELECT recipient_id FROM messages WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
if !trustin_access::reads_message(&me.to_identity(), &recipient) {
return Err(AppError::NotFound);
}
sqlx::query("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL")
.bind(state.clock.now())
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
}
/// `POST /api/messages/read` — every message of one's box.
pub async fn read_all(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
sqlx::query("UPDATE messages SET read_at = ? WHERE recipient_id = ? AND read_at IS NULL")
.bind(state.clock.now())
.bind(&me.id)
.execute(&state.db)
.await?;
Ok(Json(json!({ "unread": 0 })))
}
+587
View File
@@ -0,0 +1,587 @@
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
//!
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of
//! the pair, never a version in force, is no longer votable while the objection
//! stands; its author answers (a substantive revision, or a proposal linked to
//! the objection — a revision of the other side answers too); the objector is
//! then asked, by a message, to lift or reiterate; without an answer within 3
//! days the objection is lifted (`due`, run by the sweeper and at start).
//!
//! Objections live in the table of the contradiction signals of before
//! (`signals`, migration 0008): an older image still reads them as signals, and
//! every lifting writes `lifted_at` / `lifted_by` as it did.
use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::docs::{self, DocState, UnitRow};
use crate::error::{AppError, AppResult};
use crate::messages::{self, Topic};
use crate::proposals;
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::edit;
use ld_engine::objection::{
self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who,
};
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
pub const MIN_REASON: usize = 10;
pub const MAX_REASON: usize = 600;
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct ObjectionRow {
pub id: String,
/// The version the objection was raised from.
pub proposal_id: String,
/// The version it was raised against.
pub target_proposal_id: String,
pub reason: String,
/// The objector.
pub author_id: String,
pub created_at: String,
pub lifted_at: Option<String>,
pub lifted_by: Option<String>,
pub kind: String,
/// The version held back (not votable).
pub held_id: Option<String>,
pub phase: String,
pub cycle: i64,
pub due_at: Option<String>,
pub answered_at: Option<String>,
pub answer: Option<String>,
pub answer_id: Option<String>,
pub lift: Option<String>,
}
const SELECT: &str = "SELECT id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by,
kind, held_id, phase, cycle, due_at, answered_at, answer, answer_id, lift FROM signals";
impl ObjectionRow {
pub fn phase(&self) -> Phase {
let cycle = u32::try_from(self.cycle).unwrap_or(1);
match self.phase.as_str() {
"objector" => Phase::Objector { cycle, due: self.due_at.clone().unwrap_or_default() },
"lifted" => Phase::Lifted(self.lift.as_deref().and_then(Lift::parse).unwrap_or(Lift::Objector)),
_ => Phase::Author { cycle },
}
}
pub fn kind(&self) -> ObjectionKind {
ObjectionKind::parse(&self.kind).unwrap_or(ObjectionKind::Contredit)
}
/// The version the held one is said to repeat or contradict.
pub fn other_id(&self) -> &str {
if self.held_id.as_deref() == Some(self.proposal_id.as_str()) {
&self.target_proposal_id
} else {
&self.proposal_id
}
}
fn touches(&self, id: &str) -> Option<Touched> {
if self.held_id.as_deref() == Some(id) {
Some(Touched::Target)
} else if self.proposal_id == id || self.target_proposal_id == id {
Some(Touched::Other)
} else {
None
}
}
}
async fn load(state: &AppState, id: &str) -> AppResult<ObjectionRow> {
sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Standing objections touching one version, either side.
async fn standing_around(state: &AppState, proposal_id: &str) -> AppResult<Vec<ObjectionRow>> {
Ok(sqlx::query_as::<_, ObjectionRow>(&format!(
"{SELECT} WHERE phase <> 'lifted' AND (proposal_id = ?1 OR target_proposal_id = ?1) ORDER BY created_at, rowid"
))
.bind(proposal_id)
.fetch_all(&state.db)
.await?)
}
/// For a document: the versions held back by a standing objection (never one in
/// force), and how many standing objections touch each version.
pub async fn held(
state: &AppState,
doc_id: &str,
units: &[UnitRow],
) -> AppResult<(HashSet<String>, HashMap<String, i64>)> {
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
"{SELECT} WHERE phase <> 'lifted' AND proposal_id IN
(SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ?)"
))
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
let mut held = HashSet::new();
let mut counts: HashMap<String, i64> = HashMap::new();
for o in &rows {
*counts.entry(o.proposal_id.clone()).or_default() += 1;
*counts.entry(o.target_proposal_id.clone()).or_default() += 1;
if let Some(h) = &o.held_id
&& !objection::votable(h, in_force.contains(h.as_str()), &[h.as_str()])
{
held.insert(h.clone());
}
}
Ok((held, counts))
}
/// The order a version arrived in: its submission (or creation), then the order it was written in.
async fn arrival(state: &AppState, p: &docs::ProposalRow) -> AppResult<(String, i64)> {
let seq: i64 =
sqlx::query_scalar("SELECT rowid FROM proposals WHERE id = ?").bind(&p.id).fetch_one(&state.db).await?;
Ok((p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), seq))
}
fn is_in_force(st: &DocState, id: &str) -> bool {
st.units.iter().any(|u| u.in_force_id.as_deref() == Some(id))
}
fn who(user: &IdentityRow, objector: &str) -> Who {
let me = user.to_identity();
if trustin_access::reiterates_objection(&me, objector) {
Who::Objector
} else if trustin_access::lifts_objection(&me, objector) {
Who::Admin
} else {
Who::Other
}
}
fn refused(r: Refusal) -> AppError {
match r {
Refusal::Lifted => AppError::Conflict("lifted"),
Refusal::NotYours => AppError::Forbidden,
Refusal::NothingToReiterate => AppError::Conflict("nothing_to_reiterate"),
}
}
async fn author_of(state: &AppState, proposal_id: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar::<_, Option<String>>("SELECT author_id FROM proposals WHERE id = ?")
.bind(proposal_id)
.fetch_optional(&state.db)
.await?
.flatten())
}
async fn doc_of(state: &AppState, proposal_id: &str) -> AppResult<String> {
sqlx::query_scalar("SELECT u.doc_id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.id = ?")
.bind(proposal_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Stores a step, only over the phase it was computed from (two people acting at
/// once: the second gets a conflict), then does its effects. Returns false when
/// the phase had changed meanwhile.
async fn store(
state: &AppState,
o: &ObjectionRow,
step: &Step,
by: &str,
extra: Option<(&str, &str)>,
) -> AppResult<bool> {
let now = state.clock.now();
let (phase, cycle, due, lift) = match &step.phase {
Phase::Author { cycle } => ("author", i64::from(*cycle), None, None),
Phase::Objector { cycle, due } => ("objector", i64::from(*cycle), Some(due.clone()), None),
Phase::Lifted(l) => ("lifted", o.cycle, None, Some(*l)),
};
let (answer, answer_id) =
extra.map_or((o.answer.clone(), o.answer_id.clone()), |(a, id)| (Some(a.into()), Some(id.into())));
let answered_at = if extra.is_some() { Some(now.clone()) } else { o.answered_at.clone() };
let done = sqlx::query(
"UPDATE signals SET phase = ?, cycle = ?, due_at = ?, lift = ?, answer = ?, answer_id = ?, answered_at = ?,
lifted_at = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_at END,
lifted_by = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_by END
WHERE id = ? AND phase = ? AND cycle = ?",
)
.bind(phase)
.bind(cycle)
.bind(&due)
.bind(lift.map(Lift::as_str))
.bind(&answer)
.bind(&answer_id)
.bind(&answered_at)
.bind(phase)
.bind(&now)
.bind(phase)
.bind(by)
.bind(&o.id)
.bind(&o.phase)
.bind(o.cycle)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Ok(false);
}
let doc_id = doc_of(state, &o.proposal_id).await?;
let held_author = match &o.held_id {
Some(h) => author_of(state, h).await?,
None => None,
};
for effect in &step.effects {
match effect {
Effect::AskObjector => {
// The author's question of this cycle is answered.
messages::close(
state,
Topic::Objection,
&o.id,
Some(&["objection_raised", "objection_reiterated"]),
"answered",
)
.await?;
if held_author.as_deref() != Some(o.author_id.as_str()) {
messages::notify(
state,
&o.author_id,
&doc_id,
Topic::Objection,
&o.id,
"objection_answered",
cycle,
due.as_deref(),
)
.await?;
}
}
Effect::TellAuthor => {
messages::close(state, Topic::Objection, &o.id, Some(&["objection_answered"]), "reiterated").await?;
if let Some(author) = held_author.as_deref().filter(|a| *a != o.author_id) {
messages::notify(
state,
author,
&doc_id,
Topic::Objection,
&o.id,
"objection_reiterated",
cycle,
None,
)
.await?;
}
}
Effect::Ended => {
let outcome = match lift {
Some(Lift::Admin) => "admin",
Some(Lift::Deadline) => "deadline",
Some(Lift::Withdrawn) => "withdrawn",
_ => "lifted",
};
messages::close(state, Topic::Objection, &o.id, None, outcome).await?;
}
}
}
Ok(true)
}
// ---------------------------------------------------------------------------
// Raise, lift, reiterate
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RaiseBody {
other_id: String,
kind: String,
reason: String,
}
/// `POST /api/proposals/{id}/objections` — « this version repeats (or
/// contradicts) that one ». The latest arrival of the two is held back.
pub async fn raise(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<RaiseBody>,
) -> AppResult<Json<Value>> {
let (p, st) = proposals::guarded(&state, &user, &id).await?;
let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason"));
}
let kind = ObjectionKind::parse(&b.kind).ok_or(AppError::Invalid("kind"))?;
let other = st.proposals.iter().find(|x| x.id == b.other_id).ok_or(AppError::Invalid("other"))?;
let voted = |x: &docs::ProposalRow| matches!(x.status.as_str(), "open" | "in_force") && x.kind == "text";
if other.id == p.id || !voted(other) || !voted(&p) {
return Err(AppError::Invalid("other"));
}
if other.unit_id == p.unit_id {
// Rivals on one article never stand together anyway.
return Err(AppError::Invalid("same_unit"));
}
let (pa, oa) = (arrival(&state, &p).await?, arrival(&state, other).await?);
let held = objection::target(
&Side { id: &p.id, arrived_at: &pa.0, seq: pa.1, in_force: is_in_force(&st, &p.id) },
&Side { id: &other.id, arrived_at: &oa.0, seq: oa.1, in_force: is_in_force(&st, &other.id) },
)
.map_err(|e| match e {
objection::PairError::BothInForce => AppError::Invalid("both_in_force"),
objection::PairError::Same => AppError::Invalid("other"),
})?
.to_owned();
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM signals WHERE phase <> 'lifted' AND author_id = ?
AND ((proposal_id = ?2 AND target_proposal_id = ?3) OR (proposal_id = ?3 AND target_proposal_id = ?2))",
)
.bind(&user.id)
.bind(&p.id)
.bind(&other.id)
.fetch_one(&state.db)
.await?;
if exists > 0 {
return Err(AppError::Conflict("objection_exists"));
}
let oid = util::id();
sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
)
.bind(&oid)
.bind(&p.id)
.bind(&other.id)
.bind(reason)
.bind(&user.id)
.bind(state.clock.now())
.bind(kind.as_str())
.bind(&held)
.execute(&state.db)
.await?;
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
}
tracing::info!(objection = %oid, held = %held, kind = kind.as_str(), "objection raised");
Ok(Json(json!({ "id": oid, "heldId": held })))
}
async fn act(state: &AppState, user: &IdentityRow, id: &str, lift: bool) -> AppResult<ObjectionRow> {
let o = load(state, id).await?;
proposals::guarded(state, user, &o.proposal_id).await?;
let event = if lift { Event::Lift(who(user, &o.author_id)) } else { Event::Reiterate(who(user, &o.author_id)) };
let step = objection::step(&o.phase(), &event).map_err(refused)?.ok_or(AppError::Conflict("lifted"))?;
if !store(state, &o, &step, &user.id, None).await? {
return Err(AppError::Conflict("changed"));
}
messages::read_subject(state, &user.id, Topic::Objection, &o.id).await?;
tracing::info!(objection = %o.id, lift, by = %user.id, "objection lifted or reiterated");
Ok(o)
}
/// `POST /api/objections/{id}/lift` — by the objector, or an administrator.
pub async fn lift(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
let o = act(&state, &user, &id, true).await?;
let entered = adoption::run(&state, &doc_of(&state, &o.proposal_id).await?).await?;
Ok(Json(json!({ "entered": entered })))
}
/// `POST /api/objections/{id}/reiterate` — by the objector only, once the author answered.
pub async fn reiterate(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
act(&state, &user, &id, false).await?;
Ok(Json(json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// What answers an objection
// ---------------------------------------------------------------------------
/// A version changed (by `by`): the objections around it whose author it
/// answers move on; the messages of the one who answered are read.
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> {
for o in standing_around(state, proposal_id).await? {
let Some(side) = o.touches(proposal_id) else { continue };
let change = if substantive { Change::Revision(side) } else { Change::Typo(side) };
if !objection::answers(change) {
continue;
}
let due = state.clock.in_days(objection::ANSWER_DAYS);
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
let answer = if side == Touched::Target { "revision" } else { "other" };
if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? {
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
}
}
}
Ok(())
}
/// May this person write a proposal linked to this objection, on this article?
pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> {
let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?;
let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?;
let unit_of = |id: String| async move {
sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?")
.bind(id)
.fetch_one(&state.db)
.await
};
let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?);
let author = author_of(state, held).await?;
if !o.phase().standing()
|| !objection::may_link(author.as_deref(), &user.id)
|| !objection::link_fits(unit_id, &held_unit, &other_unit)
{
return Err(AppError::Invalid("link"));
}
Ok(())
}
/// A proposal linked to an objection was submitted (by `by`): it answers the objection.
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> {
let o = load(state, objection_id).await?;
if !objection::answers(Change::LinkedProposal) {
return Ok(());
}
let due = state.clock.in_days(objection::ANSWER_DAYS);
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)?
&& store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?
{
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
}
Ok(())
}
/// A version was withdrawn: the objections around it end.
pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> {
for o in standing_around(state, proposal_id).await? {
if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? {
store(state, &o, &step, by, None).await?;
}
}
Ok(())
}
/// The objections whose deadline passed are lifted by themselves; returns how
/// many, and the documents touched.
pub async fn due(state: &AppState) -> AppResult<(usize, Vec<String>)> {
let now = state.clock.now();
let rows = sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE phase = 'objector' AND due_at <= ?"))
.bind(&now)
.fetch_all(&state.db)
.await?;
let mut docs = Vec::new();
let mut lifted = 0;
for o in rows {
if let Some(step) = objection::step(&o.phase(), &Event::Tick { now: &now }).map_err(refused)?
&& store(state, &o, &step, "deadline", None).await?
{
tracing::info!(objection = %o.id, "objection lifted at its deadline");
lifted += 1;
let doc = doc_of(state, &o.proposal_id).await?;
if !docs.contains(&doc) {
docs.push(doc);
}
}
}
Ok((lifted, docs))
}
// ---------------------------------------------------------------------------
// What the screen shows
// ---------------------------------------------------------------------------
fn version_json(st: &DocState, id: &str) -> Value {
let p = st.proposals.iter().find(|p| p.id == id);
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
json!({
"id": id, "title": p.map(|p| &p.title), "status": p.map(|p| &p.status), "unitId": p.map(|p| &p.unit_id),
"code": unit.map(|u| &u.code), "author": p.and_then(|p| p.author_name.as_ref()),
"authorId": p.and_then(|p| p.author_id.as_ref()),
})
}
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
let row: Option<(String, bool)> =
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?;
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
}
/// One objection, as `viewer` sees it: who raised it, what it holds back, where
/// it stands, and what the viewer may do.
pub async fn view(state: &AppState, st: &DocState, viewer: &IdentityRow, o: &ObjectionRow) -> AppResult<Value> {
let (objector, invited) = name_of(state, &o.author_id).await?;
let phase = o.phase();
let held_author = match &o.held_id {
Some(h) => author_of(state, h).await?,
None => None,
};
let me = viewer.to_identity();
let standing = phase.standing();
let i_answer = standing && o.held_id.is_some() && objection::may_link(held_author.as_deref(), &viewer.id);
let asked = matches!(phase, Phase::Objector { .. });
Ok(json!({
"id": o.id, "kind": o.kind(), "reason": o.reason, "objector": objector, "objectorInvited": invited,
"mine": o.author_id == viewer.id, "at": o.created_at,
"phase": o.phase, "cycle": o.cycle, "dueAt": o.due_at, "answer": o.answer, "answerId": o.answer_id,
"answeredAt": o.answered_at, "lift": o.lift, "liftedAt": o.lifted_at,
"heldVersion": o.held_id.as_ref().map(|h| version_json(st, h)),
"other": version_json(st, o.other_id()),
"iAnswer": i_answer,
"canLift": standing && trustin_access::lifts_objection(&me, &o.author_id),
"canReiterate": asked && trustin_access::reiterates_objection(&me, &o.author_id),
}))
}
/// The objections around one version, newest first, each as `viewer` sees it;
/// `held` tells whether this version is the one held back.
pub async fn around(state: &AppState, st: &DocState, viewer: &IdentityRow, proposal_id: &str) -> AppResult<Vec<Value>> {
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
"{SELECT} WHERE proposal_id = ?1 OR target_proposal_id = ?1 ORDER BY created_at DESC, rowid DESC"
))
.bind(proposal_id)
.fetch_all(&state.db)
.await?;
let mut out = Vec::with_capacity(rows.len());
for o in rows {
let mut v = view(state, st, viewer, &o).await?;
v["held"] = json!(o.held_id.as_deref() == Some(proposal_id));
out.push(v);
}
Ok(out)
}
/// An objection, for a message of its box: what it is about, and the gestures
/// the message offers now (none once dealt with).
pub async fn for_message(
state: &AppState,
viewer: &IdentityRow,
subject_id: &str,
kind: &str,
cycle: i64,
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
let Ok(o) = load(state, subject_id).await else { return Ok(None) };
let st = docs::load_state(state, &doc_of(state, &o.proposal_id).await?).await?;
let v = view(state, &st, viewer, &o).await?;
let current = o.cycle == cycle;
let gestures = match (o.phase(), kind) {
(Phase::Author { .. }, "objection_raised" | "objection_reiterated") if current => vec!["respond"],
(Phase::Objector { .. }, "objection_answered") if current && o.author_id == viewer.id => {
vec!["lift", "reiterate"]
}
_ => vec![],
};
Ok(Some((v, gestures)))
}
+67 -176
View File
@@ -1,5 +1,5 @@
//! Writing: drafts, submission, corrections, votes, contradiction signals,
//! inertia changes, editorial translations.
//! Writing: drafts, submission, corrections, votes, inertia changes, editorial
//! translations. Objections live in `objections`, typo fixes of others in `typos`.
//!
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
//! système qui nécessite un vote pour corriger une coquille »):
@@ -7,9 +7,10 @@
//! - once **submitted**, a correction classified *typographic* by the engine
//! keeps every vote and is shown with its diff; a *substantive* one opens a
//! new revision and every earlier vote waits for its author to reconfirm;
//! - a text **in force** accepts typographic corrections from any member of
//! the group, recorded with their author and diff, and republished — a
//! change of meaning goes through a proposal.
//! - any member of the group may write a typo fix on a version; it applies at
//! once when its writer answers for the version (its author; an administrator
//! for a text without author), and waits for them otherwise — three days at
//! most (2026-10-31, `typos`). A change of meaning goes through a proposal.
use crate::AppState;
use crate::adoption;
@@ -17,7 +18,7 @@ use crate::auth::{IdentityRow, User};
use crate::ballots;
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::util;
use crate::{objections, typos, util};
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
@@ -29,10 +30,8 @@ use serde_json::json;
const MAX_TITLE: usize = 120;
const MAX_BODY: usize = 4000;
const MIN_REASON: usize = 10;
const MAX_REASON: usize = 600;
async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
.bind(id)
.fetch_optional(&state.db)
@@ -49,7 +48,11 @@ async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
}
/// Loads the document of a proposal and checks the person may open its group.
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
pub(crate) async fn guarded(
state: &AppState,
user: &IdentityRow,
proposal_id: &str,
) -> AppResult<(ProposalRow, DocState)> {
let p = load_proposal(state, proposal_id).await?;
let doc_id = doc_of_unit(state, &p.unit_id).await?;
let st = docs::load_state(state, &doc_id).await?;
@@ -168,6 +171,9 @@ pub struct CreateBody {
body: String,
#[serde(default)]
submit: bool,
/// Written to answer an objection (« modifier l'article visé »): on the
/// article the held version repeats or contradicts, or on its own.
objection_id: Option<String>,
}
/// Position right after `after` (or at the end), between it and the next unit.
@@ -265,9 +271,13 @@ pub async fn create(
}
unit.id.clone()
}
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
None if kind == "text" && b.objection_id.is_none() => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
None if kind == "text" => return Err(AppError::Invalid("link")),
None => return Err(AppError::Invalid("unit")),
};
if let Some(oid) = &b.objection_id {
objections::check_link(&state, &user, oid, &unit_id).await?;
}
let id = insert_proposal(
&state,
NewProposal {
@@ -281,6 +291,16 @@ pub async fn create(
},
)
.await?;
if let Some(oid) = &b.objection_id {
sqlx::query("UPDATE proposals SET objection_id = ? WHERE id = ?")
.bind(oid)
.bind(&id)
.execute(&state.db)
.await?;
if b.submit {
objections::on_linked(&state, oid, &id, &user.id).await?;
}
}
Ok(Json(json!({ "id": id, "unitId": unit_id })))
}
@@ -337,43 +357,8 @@ pub async fn edit(
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
EditKind::Typographic => {
// Anyone in the group may fix a typo of a seed text or of a text in force.
if !(is_author || p.origin == "seed" || p.status == "in_force") {
return Err(AppError::Forbidden);
}
// Written only over the text that was judged: an entry into force or another
// correction in between makes it a conflict, never a silent change.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.status)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
// A typo fix does not change the meaning: up-to-date translations stay up to date.
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
.bind(util::source_hash(title, body))
.bind(&id)
.bind(util::source_hash(&p.title, &p.body))
.execute(&state.db)
.await?;
if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
}
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
}
// Anyone in the group may fix a typo; who applies it, and when, is `typos`.
EditKind::Typographic => typos::write(&state, &user, &p, &st, title, body).await,
EditKind::Substantive => {
if p.status == "in_force" {
return Err(AppError::Conflict("substantive_in_force"));
@@ -402,14 +387,10 @@ pub async fn edit(
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
// Signals were about the former text: they lapse, their authors may signal again.
sqlx::query(
"UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL",
)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
// It answers the objections around it (the objector judges again), and
// the typo fixes waiting on the former text no longer fit.
objections::on_change(&state, &id, true, &user.id).await?;
typos::on_text_changed(&state, &id).await?;
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
}
}
@@ -446,6 +427,12 @@ pub async fn submit(
if initial.rows_affected() == 0 {
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
}
// Written to answer an objection: submitted, it answers it (a draft answers nothing).
let linked: Option<String> =
sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
if let Some(oid) = linked {
objections::on_linked(&state, &oid, &id, &user.id).await?;
}
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "open", "entered": entered })))
}
@@ -468,6 +455,9 @@ pub async fn withdraw(
.bind(&id)
.execute(&state.db)
.await?;
// Nothing left to object to, nor to correct.
objections::on_withdrawn(&state, &id, &user.id).await?;
typos::on_text_changed(&state, &id).await?;
Ok(Json(json!({ "status": "withdrawn" })))
}
@@ -557,66 +547,37 @@ pub async fn detail(
author_name: Option<String>,
author_invited: Option<bool>,
created_at: String,
typo_id: Option<String>,
answered_by: Option<String>,
answered_name: Option<String>,
}
let revs = sqlx::query_as::<_, Rev>(
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, r.created_at
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
i.kind = 'invite' AS author_invited, r.created_at, r.typo_id, r.answered_by, a.display_name AS answered_name
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id LEFT JOIN identities a ON a.id = r.answered_by
WHERE r.proposal_id = ? ORDER BY r.id",
)
.bind(&id)
.fetch_all(&state.db)
.await?;
// Each correction with the diff from the text before it.
// Each correction with the diff from the text before it; a fix someone else
// wrote says who let it in (or that it passed at its deadline).
let mut history = Vec::new();
let mut previous: Option<&Rev> = None;
for r in &revs {
let diff = previous
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
let typo = r.typo_id.as_ref().map(|t| {
json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") })
});
history.push(json!({
"revision": r.revision, "kind": r.kind, "author": r.author_name,
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
}));
previous = Some(r);
}
#[derive(sqlx::FromRow)]
struct Sig {
id: String,
proposal_id: String,
target_proposal_id: String,
reason: String,
author_id: String,
author_name: String,
author_invited: bool,
created_at: String,
lifted_at: Option<String>,
lifted_by: Option<String>,
}
let sigs = sqlx::query_as::<_, Sig>(
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
FROM signals s JOIN identities i ON i.id = s.author_id
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
)
.bind(&id)
.bind(&id)
.fetch_all(&state.db)
.await?;
let mut signals = Vec::new();
for s in sigs {
let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id };
let other = load_proposal(&state, other_id).await.ok();
let other_code = match &other {
Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()),
None => None,
};
signals.push(json!({
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
"mine": s.author_id == user.id,
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
}));
}
let objections = objections::around(&state, &st, &user, &id).await?;
let typos = typos::of_proposal(&state, &user, &p).await?;
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
@@ -637,7 +598,7 @@ pub async fn detail(
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
"myVote": my_vote.as_ref().map(|v| &v.choice),
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
@@ -647,7 +608,8 @@ pub async fn detail(
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff,
"history": history,
"signals": signals,
"objections": objections,
"typos": typos,
"electorate": st.electorate,
"electorateSource": st.electorate_source,
"voteFloor": st.doc.vote_floor,
@@ -681,6 +643,10 @@ pub async fn vote(
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
// Held back by an objection: its votes stay, no new one (ADR 0010).
if !st.votable(&p) {
return Err(AppError::Conflict("objection"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
@@ -696,81 +662,6 @@ pub async fn vote(
Ok(Json(json!({ "entered": entered })))
}
// ---------------------------------------------------------------------------
// Contradiction signals
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalBody {
target_id: String,
reason: String,
}
/// « These two texts contradict each other. » While the signal stands, the two
/// can never be in force together: the one not yet in force is blocked.
pub async fn signal(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<SignalBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason"));
}
if b.target_id == id {
return Err(AppError::Invalid("target"));
}
let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?;
if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Invalid("target"));
}
if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" {
// Rivals on one unit never stand together anyway.
return Err(AppError::Invalid("same_unit"));
}
let sid = util::id();
sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&sid)
.bind(&id)
.bind(&b.target_id)
.bind(reason)
.bind(&user.id)
.bind(util::now())
.execute(&state.db)
.await?;
Ok(Json(json!({ "id": sid })))
}
pub async fn lift_signal(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (proposal_id, author): (String, String) =
sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL")
.bind(&id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let (_, st) = guarded(&state, &user, &proposal_id).await?;
if author != user.id && !user.admin {
return Err(AppError::Forbidden);
}
sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?")
.bind(util::now())
.bind(&user.id)
.bind(&id)
.execute(&state.db)
.await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "entered": entered })))
}
// ---------------------------------------------------------------------------
// Inertia
// ---------------------------------------------------------------------------
+10 -2
View File
@@ -71,8 +71,10 @@ pub struct Facts {
pub revisions: Vec<String>,
/// Translations written or corrected by a member.
pub member_translations: i64,
/// Contradiction signals naming it, standing or lifted.
/// Contradiction signals (objections since 2026-10-31) naming it, standing or lifted.
pub signals: i64,
/// Typo fixes someone else wrote on it, whatever became of them (2026-10-31).
pub typos: i64,
pub fingerprint: String,
}
@@ -100,7 +102,7 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
"revised"
} else if f.votes > 0 {
"votes"
} else if f.revisions != ["initial"] {
} else if f.revisions != ["initial"] || f.typos > 0 {
"corrected"
} else if f.member_translations > 0 {
"translation_edited"
@@ -214,6 +216,10 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
.bind(proposal_id)
.fetch_one(&mut *conn)
.await?;
let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?")
.bind(proposal_id)
.fetch_one(&mut *conn)
.await?;
Ok(Some(Facts {
origin: p.origin,
entered: p.entered_at.is_some(),
@@ -223,6 +229,7 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
revisions,
member_translations,
signals,
typos,
fingerprint: fingerprint(&p.title, &p.body),
}))
}
@@ -383,6 +390,7 @@ mod tests {
(Some(Facts { votes: 1, ..intact() }), "votes"),
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
(Some(Facts { typos: 1, ..intact() }), "corrected"),
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
(Some(Facts { signals: 1, ..intact() }), "signalled"),
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
+477
View File
@@ -0,0 +1,477 @@
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010).
//!
//! What a typo is does not change (`ld_engine::edit`, judged against the voted
//! text). Who applies it, and when, is `ld_engine::correction`: the one who
//! answers for the version (its author; for a text without author, an
//! administrator of trustinWallet — `trustin_access::answers_typo`) applies a
//! fix at once; anyone else's waits for them, told by a message, and applies by
//! itself after 3 days (`due`, run by the sweeper and at start). While the
//! version is not in force its author keeps the hand: a fix applied may be taken
//! back, a fix refused validated after all. A fix is written only over the text
//! it was written over: once another one applies, or the substance changes, it
//! is stale. Translations stay out of this: editorial, without validation.
use crate::AppState;
use crate::adoption;
use crate::auth::{IDENTITY_COLUMNS, IdentityRow, User};
use crate::docs::{self, DocState, ProposalRow};
use crate::error::{AppError, AppResult};
use crate::messages::{self, Topic};
use crate::{proposals, util};
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::correction::{self, Answer, Route, Settle, Text, TextChange, TypoStatus};
use ld_engine::edit;
use serde_json::{Value, json};
use sqlx::SqliteConnection;
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct TypoRow {
pub id: String,
pub proposal_id: String,
pub doc_id: String,
pub writer_id: String,
pub answerer_id: Option<String>,
pub revision: i64,
pub base_title: String,
pub base_body: String,
pub title: String,
pub body: String,
pub status: String,
pub created_at: String,
pub due_at: String,
pub settled_at: Option<String>,
pub settled_by: Option<String>,
}
const SELECT: &str =
"SELECT id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body,
status, created_at, due_at, settled_at, settled_by FROM typos";
impl TypoRow {
fn status(&self) -> TypoStatus {
TypoStatus::parse(&self.status).unwrap_or(TypoStatus::Stale)
}
fn base(&self) -> Text<'_> {
Text { revision: self.revision, title: &self.base_title, body: &self.base_body }
}
fn fixed(&self) -> Text<'_> {
Text { revision: self.revision, title: &self.title, body: &self.body }
}
}
fn text_of(p: &ProposalRow) -> Text<'_> {
Text { revision: p.revision, title: &p.title, body: &p.body }
}
async fn load(state: &AppState, id: &str) -> AppResult<TypoRow> {
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Writes `to` over a version, only where its text is still `from` — and, when
/// `status` is given, only while its status is that one; records the revision and
/// keeps up-to-date translations up to date. False when the text was not there.
#[allow(clippy::too_many_arguments)]
async fn write_text(
tx: &mut SqliteConnection,
proposal_id: &str,
from: Text<'_>,
to: (&str, &str),
status: Option<&str>,
author: &str,
link: Option<(&str, &str)>,
kind: &str,
now: &str,
) -> AppResult<bool> {
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force')
AND (?9 IS NULL OR status = ?9)",
)
.bind(to.0)
.bind(to.1)
.bind(now)
.bind(proposal_id)
.bind(from.revision)
.bind(from.title)
.bind(from.body)
.bind(status)
.execute(&mut *tx)
.await?;
if done.rows_affected() != 1 {
return Ok(false);
}
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at, typo_id, answered_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(proposal_id)
.bind(from.revision)
.bind(kind)
.bind(to.0)
.bind(to.1)
.bind(author)
.bind(now)
.bind(link.map(|l| l.0))
.bind(link.map(|l| l.1))
.execute(&mut *tx)
.await?;
// A typo fix does not change the meaning: up-to-date translations stay up to date.
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
.bind(util::source_hash(to.0, to.1))
.bind(proposal_id)
.bind(util::source_hash(from.title, from.body))
.execute(&mut *tx)
.await?;
Ok(true)
}
/// After the text of a version changed, or it closed: the fixes waiting on a text
/// that is gone are stale, and their messages say so.
pub async fn on_text_changed(state: &AppState, proposal_id: &str) -> AppResult<()> {
let p = proposals::load_proposal(state, proposal_id).await?;
let open = matches!(p.status.as_str(), "open" | "in_force");
let pending = sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? AND status = 'pending'"))
.bind(proposal_id)
.fetch_all(&state.db)
.await?;
for t in pending {
if open && correction::fits(t.base(), text_of(&p)) {
continue;
}
stale(state, &t, "pending").await?;
}
Ok(())
}
async fn stale(state: &AppState, t: &TypoRow, from: &str) -> AppResult<()> {
let done = sqlx::query(
"UPDATE typos SET status = 'stale', settled_at = ?, settled_by = 'changed' WHERE id = ? AND status = ?",
)
.bind(state.clock.now())
.bind(&t.id)
.bind(from)
.execute(&state.db)
.await?;
if done.rows_affected() == 1 {
messages::close(state, Topic::Typo, &t.id, None, "stale").await?;
}
Ok(())
}
async fn republish(state: &AppState, proposal_id: &str) -> AppResult<()> {
let p = proposals::load_proposal(state, proposal_id).await?;
if p.status == "in_force" {
let doc_id: String =
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?").bind(&p.unit_id).fetch_one(&state.db).await?;
adoption::enqueue_publication(state, &docs::load_doc(state, &doc_id).await?, "correction typographique")
.await?;
}
Ok(())
}
/// A fix the engine judged typographic, written by `user` on `p`: applied at once
/// by the one who answers for the version, waiting for them otherwise.
pub async fn write(
state: &AppState,
user: &IdentityRow,
p: &ProposalRow,
st: &DocState,
title: &str,
body: &str,
) -> AppResult<Json<Value>> {
let diff = edit::diff(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
let answers = trustin_access::answers_typo(&user.to_identity(), p.author_id.as_deref());
let now = state.clock.now();
match correction::route(answers) {
Route::Apply => {
// Written only over the text that was judged: an entry into force or another
// correction in between makes it a conflict, never a silent change.
let mut tx = state.db.begin().await?;
if !write_text(&mut tx, &p.id, text_of(p), (title, body), Some(&p.status), &user.id, None, "typo", &now)
.await?
{
return Err(AppError::Conflict("changed"));
}
tx.commit().await?;
if p.status == "in_force" {
adoption::enqueue_publication(state, &st.doc, "correction typographique").await?;
}
on_text_changed(state, &p.id).await?;
Ok(Json(json!({ "edit": "typo", "diff": diff })))
}
Route::Await => {
let id = util::id();
let due = state.clock.in_days(correction::ANSWER_DAYS);
sqlx::query(
"INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body,
title, body, status, created_at, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
)
.bind(&id)
.bind(&p.id)
.bind(&st.doc.id)
.bind(&user.id)
.bind(&p.author_id)
.bind(p.revision)
.bind(&p.title)
.bind(&p.body)
.bind(title)
.bind(body)
.bind(&now)
.bind(&due)
.execute(&state.db)
.await?;
// Who answers is told: the author, or for a text without author, every
// administrator who opens the group (the others could not answer).
let recipients: Vec<String> = match &p.author_id {
Some(a) => vec![a.clone()],
None => {
let admins = sqlx::query_as::<_, IdentityRow>(&format!(
"SELECT {IDENTITY_COLUMNS} FROM identities WHERE admin = 1 AND id <> ? ORDER BY created_at"
))
.bind(&user.id)
.fetch_all(&state.db)
.await?;
let mut open = Vec::new();
for a in admins {
if docs::opens(state, &a, &st.group).await? {
open.push(a.id);
}
}
open
}
};
for r in recipients {
messages::notify(state, &r, &st.doc.id, Topic::Typo, &id, "typo_pending", 1, Some(&due)).await?;
}
let answerer = if p.author_id.is_some() { "author" } else { "admin" };
tracing::info!(typo = %id, proposal = %p.id, answerer, "typo fix waiting for validation");
Ok(Json(json!({
"edit": "typo_pending", "diff": diff, "typo": { "id": id, "dueAt": due, "answerer": answerer },
})))
}
}
}
/// Settles one fix, in one transaction: its status (only over the one read), and
/// its text. `Ok(Err("settled"))`: answered meanwhile; `Ok(Err("changed"))`: the
/// text it needs is gone — nothing is written.
async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -> AppResult<Result<(), &'static str>> {
let mut tx = state.db.begin().await?;
let claimed =
sqlx::query("UPDATE typos SET status = ?, settled_at = ?, settled_by = ? WHERE id = ? AND status = ?")
.bind(s.status.as_str())
.bind(now)
.bind(by)
.bind(&t.id)
.bind(&t.status)
.execute(&mut *tx)
.await?;
if claimed.rows_affected() != 1 {
return Ok(Err("settled"));
}
let written = match s.text {
TextChange::Write => {
write_text(
&mut tx,
&t.proposal_id,
t.base(),
(&t.title, &t.body),
None,
&t.writer_id,
Some((&t.id, by)),
"typo",
now,
)
.await?
}
TextChange::Restore => {
write_text(
&mut tx,
&t.proposal_id,
t.fixed(),
(&t.base_title, &t.base_body),
None,
by,
Some((&t.id, by)),
"revert",
now,
)
.await?
}
TextChange::Keep => true,
};
if !written {
tx.rollback().await?;
return Ok(Err("changed"));
}
tx.commit().await?;
if s.text != TextChange::Keep {
republish(state, &t.proposal_id).await?;
on_text_changed(state, &t.proposal_id).await?;
}
Ok(Ok(()))
}
async fn answer(state: &AppState, user: &IdentityRow, id: &str, a: Answer) -> AppResult<Json<Value>> {
let t = load(state, id).await?;
let (p, _) = proposals::guarded(state, user, &t.proposal_id).await?;
if !trustin_access::answers_typo(&user.to_identity(), t.answerer_id.as_deref()) {
return Err(AppError::Forbidden);
}
let status = t.status();
if status == TypoStatus::Stale {
return Err(AppError::Conflict("changed"));
}
let s = correction::answer(status, a, p.status == "in_force").map_err(|_| AppError::Conflict("settled"))?;
let now = state.clock.now();
match settle(state, &t, s, &user.id, &now).await? {
Ok(()) => {}
Err("changed") => {
// Written over a text that is gone: a pending fix is stale; one already decided stays as it is.
if status == TypoStatus::Pending {
stale(state, &t, "pending").await?;
}
return Err(AppError::Conflict("changed"));
}
Err(_) => return Err(AppError::Conflict("settled")),
}
if status == TypoStatus::Pending {
let outcome = if s.status == TypoStatus::Applied { "applied" } else { "refused" };
messages::close(state, Topic::Typo, &t.id, None, outcome).await?;
}
messages::read_subject(state, &user.id, Topic::Typo, &t.id).await?;
tracing::info!(typo = %t.id, status = s.status.as_str(), by = %user.id, "typo fix answered");
Ok(Json(json!({ "status": s.status })))
}
/// `POST /api/typos/{id}/validate`
pub async fn validate(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
answer(&state, &user, &id, Answer::Validate).await
}
/// `POST /api/typos/{id}/refuse` — refuses a pending fix, or takes back an applied
/// one while the version is not in force.
pub async fn refuse(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
answer(&state, &user, &id, Answer::Refuse).await
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Due {
pub applied: usize,
pub stale: usize,
}
/// The fixes whose deadline passed apply by themselves — where their text is still there.
pub async fn due(state: &AppState) -> AppResult<Due> {
let now = state.clock.now();
let rows = sqlx::query_as::<_, TypoRow>(&format!(
"{SELECT} WHERE status = 'pending' AND due_at <= ? ORDER BY created_at, rowid"
))
.bind(&now)
.fetch_all(&state.db)
.await?;
let mut out = Due::default();
for t in rows {
let Some(s) = correction::tick(t.status(), &t.due_at, &now) else { continue };
match settle(state, &t, s, "deadline", &now).await? {
Ok(()) => {
messages::close(state, Topic::Typo, &t.id, None, "deadline").await?;
tracing::info!(typo = %t.id, "typo fix applied at its deadline");
out.applied += 1;
}
Err("changed") => {
stale(state, &t, "pending").await?;
out.stale += 1;
}
Err(_) => {}
}
}
Ok(out)
}
// ---------------------------------------------------------------------------
// What the screen shows
// ---------------------------------------------------------------------------
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
let row: Option<(String, bool)> =
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?;
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
}
/// What `viewer` may do with a fix now.
fn gestures(viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> Vec<&'static str> {
if !trustin_access::answers_typo(&viewer.to_identity(), t.answerer_id.as_deref()) {
return vec![];
}
let in_force = p.status == "in_force";
let open = matches!(p.status.as_str(), "open" | "in_force");
match t.status() {
TypoStatus::Pending => vec!["validate", "refuse"],
TypoStatus::Applied if open && !in_force && correction::fits(t.fixed(), text_of(p)) => vec!["refuse"],
TypoStatus::Refused if open && !in_force && correction::fits(t.base(), text_of(p)) => vec!["validate"],
_ => vec![],
}
}
async fn view(state: &AppState, viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> AppResult<Value> {
let (writer, invited) = name_of(state, &t.writer_id).await?;
let settled_by = match t.settled_by.as_deref() {
Some(by @ ("deadline" | "changed")) => Some(by.to_owned()),
Some(id) => name_of(state, id).await?.0,
None => None,
};
Ok(json!({
"id": t.id, "status": t.status, "writer": writer, "writerInvited": invited, "at": t.created_at,
"dueAt": t.due_at, "settledAt": t.settled_at, "settledBy": settled_by,
"answerer": if t.answerer_id.is_some() { "author" } else { "admin" },
"diff": edit::diff(&format!("{}\n{}", t.base_title, t.base_body), &format!("{}\n{}", t.title, t.body)),
"gestures": gestures(viewer, t, p),
}))
}
/// The fixes written by others on one version, newest first, as `viewer` sees them.
pub async fn of_proposal(state: &AppState, viewer: &IdentityRow, p: &ProposalRow) -> AppResult<Vec<Value>> {
let rows =
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? ORDER BY created_at DESC, rowid DESC"))
.bind(&p.id)
.fetch_all(&state.db)
.await?;
let mut out = Vec::with_capacity(rows.len());
for t in &rows {
out.push(view(state, viewer, t, p).await?);
}
Ok(out)
}
/// A fix, for a message of its box: the gestures only while it waits.
pub async fn for_message(
state: &AppState,
viewer: &IdentityRow,
subject_id: &str,
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
let Ok(t) = load(state, subject_id).await else { return Ok(None) };
let p = proposals::load_proposal(state, &t.proposal_id).await?;
let mut v = view(state, viewer, &t, &p).await?;
let code: Option<String> =
sqlx::query_scalar("SELECT code FROM units WHERE id = ?").bind(&p.unit_id).fetch_optional(&state.db).await?;
v["version"] = json!({ "id": p.id, "title": p.title, "status": p.status, "unitId": p.unit_id, "code": code });
let gestures = if t.status() == TypoStatus::Pending { gestures(viewer, &t, &p) } else { vec![] };
v["gestures"] = json!(gestures);
Ok(Some((v, gestures)))
}
+32 -21
View File
@@ -186,20 +186,22 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
#[tokio::test]
async fn editing_is_not_voting() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
// An administrator of trustinWallet answers for the starting texts (no author).
a.login("Yvv", true).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e1 = unit(&doc, "E1")["inForce"].clone();
let pid = e1["id"].as_str().expect("id").to_owned();
// Anyone may give the text in force its typographic apostrophes, without a vote.
// Anyone may give the text in force its typographic apostrophes, without a vote:
// since 2026-10-31 it waits for validation (tests/typos.rs).
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
assert!(fixed.contains("m\u{2019}engage"));
let (s, v) =
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "typo");
assert_eq!(v["edit"], "typo_pending");
// A change of meaning in force is refused: it goes through a proposal.
let (s, v) = b
.call(
@@ -259,13 +261,14 @@ async fn editing_is_not_voting() {
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
// Typo fixes are measured against the voted text: slips never add up.
// Typo fixes are measured against the voted text: slips never add up (applied at
// once here: the administrator answers for a starting text).
let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b
let (_, v) = a
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
.await;
assert_eq!(v["edit"], "typo", "one slip is a typo");
let (s, v) = b
let (s, v) = a
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
.await;
assert_eq!(
@@ -273,7 +276,7 @@ async fn editing_is_not_voting() {
(StatusCode::CONFLICT, Some("substantive_in_force")),
"a second slip on top is not"
);
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
let (_, v) = a.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
assert_eq!(v["edit"], "typo", "back to the voted text");
// An address is compared exactly.
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
@@ -335,8 +338,11 @@ async fn editing_is_not_voting() {
assert_eq!(s, StatusCode::CONFLICT);
}
/// Replaces « a contradiction signal blocks entering into force » (2026-10-21):
/// since 2026-10-31 an objection makes the latest arrival non-votable (ADR 0010;
/// the full cycle: tests/objections.rs).
#[tokio::test]
async fn a_contradiction_signal_blocks_entering_into_force() {
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
@@ -345,22 +351,25 @@ async fn a_contradiction_signal_blocks_entering_into_force() {
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
let (s, v) = b
.call(
"POST",
&format!("/api/proposals/{k1}/signals"),
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
&format!("/api/proposals/{k1}/objections"),
Some(json!({ "otherId": e2, "kind": "contredit", "reason": "Ces règles contredisent la réciprocité en vigueur." })),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let sid = v["id"].as_str().expect("sid").to_owned();
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
// Only its author lifts it; then the pass lets the text in.
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
assert_eq!(v["heldId"], k1.as_str());
let oid = v["id"].as_str().expect("id").to_owned();
let (s, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")), "not votable while it stands");
// Only its author lifts it (or an administrator); then the second voice lets the text in.
let (s, _) = a.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
let (_, v) = b.call("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two");
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(v["entered"][0]["code"], "K1");
}
@@ -1063,7 +1072,8 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
.expect("count");
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
// From now on a typo is measured against the new text (written by a member, it
// waits for an administrator), and the Préambule is a change of meaning.
let body = i1["body"].as_str().expect("body");
let (s, v) = c
.call(
@@ -1072,7 +1082,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
)
.await;
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo_pending")), "{v}");
let (s, v) = c
.call(
"PATCH",
@@ -1113,6 +1123,7 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
Touch::Vote => {
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
}
// Since 2026-10-31 it waits for an administrator: a fix waiting is a trace too.
Touch::Typo => {
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
@@ -1133,8 +1144,8 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
c.call(
"POST",
&format!("/api/proposals/{k1}/signals"),
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
&format!("/api/proposals/{k1}/objections"),
Some(json!({ "otherId": pid, "kind": "contredit", "reason": "Ces règles contredisent le préambule." })),
)
.await
}
+26 -4
View File
@@ -711,17 +711,15 @@ async fn a_session_sees_its_own_ballots_only() {
}
#[tokio::test]
async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let e4 = version(&doc, "E4", "0");
let e5 = version(&doc, "E5", "0");
// A contradiction signalled on E5 against the text in force E1: E5 cannot enter.
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
let e1 = version(&doc, "E1", "inForce");
c.ok("POST", &format!("/api/proposals/{e5}/signals"), Some(json!({ "targetId": e1, "reason": reason }))).await;
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
for (n, account) in keys.iter().enumerate() {
@@ -732,6 +730,27 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
if n == 49 {
// The last ballot is signed; before the chain confirms it, an objection holds
// E5 back against the text in force E1 (ADR 0010): no new ballot may hold it.
let o = c
.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })),
)
.await;
assert_eq!(o["heldId"], e5.as_str());
let id = doc["doc"]["id"].as_str().expect("id");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{id}/ballots"),
Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection")));
}
last = b;
if n % 10 == 9 {
// A session holds at most 20 pending ballots: the background task confirms on the way.
@@ -750,8 +769,11 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
// A ballot sealed before the objection counts, as a vote already cast: kept, but
// the version held back cannot enter into force.
assert_eq!(tally(&d, &e5), (50, 0));
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
assert_eq!(find(&d, &e5)["votable"], false);
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
}
// ---------------------------------------------------------------------------
+662
View File
@@ -0,0 +1,662 @@
//! Objections « redit » / « contredit » and the messages of trustinWallet
//! (2026-10-31, lot F, ADR 0010), end to end on an in-memory database. The
//! three days of a deadline pass on the server's clock, never with a sleep.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, app, deadlines, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
let (s, v) = self.call(method, uri, body).await;
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
v
}
/// The status and the error code of a call expected to fail.
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
let (s, v) = self.call(method, uri, body).await;
(s, v["error"].as_str().unwrap_or_default().to_owned())
}
async fn login(&mut self, name: &str, admin: bool) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.ok("GET", &format!("/api/docs/{id}"), None).await
}
async fn propose(&mut self, doc: &Value, body: Value) -> String {
let id = doc["doc"]["id"].as_str().expect("id");
self.ok("POST", &format!("/api/docs/{id}/proposals"), Some(body)).await["id"].as_str().expect("id").to_owned()
}
async fn object(&mut self, from: &str, other: &str, kind: &str, reason: &str) -> Value {
self.ok(
"POST",
&format!("/api/proposals/{from}/objections"),
Some(json!({ "otherId": other, "kind": kind, "reason": reason })),
)
.await
}
async fn messages(&mut self) -> Value {
self.ok("GET", "/api/messages", None).await
}
async fn detail(&mut self, id: &str) -> Value {
self.ok("GET", &format!("/api/proposals/{id}"), None).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
doc["units"]
.as_array()
.expect("units")
.iter()
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
.find(|p| p["id"] == id)
.expect("version")
}
fn gestures(m: &Value) -> Vec<&str> {
m["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
}
const REPEATS: &str = "Elle répète l'unicité du compte, déjà en vigueur.";
const CONTRADICTS: &str = "Elle dit le contraire de la réciprocité en vigueur.";
/// C28, by the API: a « redit », the proposal no longer votable, its author changes
/// the article it repeats, the objector lifts.
#[tokio::test]
async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector_lifts() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1");
let (e1_unit, e1_id) =
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
let b = chiara
.propose(
&doc,
json!({ "title": "Un seul compte", "body": "Je n'ai qu'un seul compte membre, et je m'y tiens.", "submit": true }),
)
.await;
let o = yvv.object(&b, &e1_id, "redit", REPEATS).await;
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, never the text in force");
let oid = o["id"].as_str().expect("id").to_owned();
let d = chiara.doc("essai").await;
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
assert_eq!(find(&d, &e1_id)["objections"], 1);
// Nobody votes on it any more: neither one vote nor a grouped one.
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
let many = yvv
.err(
"POST",
&format!("/api/docs/{doc_id}/votes"),
Some(json!({ "lines": [{ "proposalId": b, "choice": "for" }] })),
)
.await;
assert_eq!(many, (StatusCode::CONFLICT, "objection".into()));
// Its author is told, in her messages, what to do; the objector has nothing yet.
let m = chiara.messages().await;
assert_eq!(m["unread"], 1);
let raised = &m["messages"][0];
assert_eq!((raised["kind"].as_str(), raised["topic"].as_str()), (Some("objection_raised"), Some("objection")));
assert_eq!(gestures(raised), ["respond"]);
assert_eq!(raised["objection"]["kind"], "redit");
assert_eq!(raised["objection"]["heldVersion"]["id"], b.as_str());
assert_eq!(raised["objection"]["other"]["id"], e1_id.as_str());
assert_eq!(raised["objection"]["objector"], "Yvv");
assert_eq!(raised["doc"]["groupSlug"], "essai");
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
let det = chiara.detail(&b).await;
let ob = &det["objections"][0];
assert_eq!(
(ob["held"].as_bool(), ob["iAnswer"].as_bool(), ob["phase"].as_str()),
(Some(true), Some(true), Some("author"))
);
// Before an answer there is nothing to reiterate; only the objector (or an administrator) lifts.
let r = yvv.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
assert_eq!(r, (StatusCode::CONFLICT, "nothing_to_reiterate".into()));
assert_eq!(chiara.err("POST", &format!("/api/objections/{oid}/lift"), None).await.0, StatusCode::FORBIDDEN);
// A linked proposal must go on the article repeated (or her own): never elsewhere, never by someone else.
let e2_unit = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
let elsewhere =
json!({ "unitId": e2_unit, "objectionId": oid, "title": "Réciprocité", "body": "Autre.", "submit": true });
let (s, _) = chiara.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(elsewhere)).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let by_yvv = json!({ "unitId": e1_unit, "objectionId": oid, "title": "Unicité", "body": "Autre.", "submit": true });
assert_eq!(
yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(by_yvv)).await.0,
StatusCode::BAD_REQUEST
);
// Chiara changes the article it repeats: a proposal on E1, linked to the objection.
let linked = chiara
.propose(
&doc,
json!({
"unitId": e1_unit, "objectionId": oid, "title": "Unicité du compte", "submit": true,
"body": "Je m'engage sur l'honneur à n'avoir qu'un seul compte membre, et à m'y tenir.",
}),
)
.await;
let det = yvv.detail(&b).await;
let ob = &det["objections"][0];
assert_eq!((ob["phase"].as_str(), ob["answer"].as_str()), (Some("objector"), Some("proposal")));
assert_eq!(ob["answerId"], linked.as_str());
assert_eq!((ob["canLift"].as_bool(), ob["canReiterate"].as_bool()), (Some(true), Some(true)));
// The objector is asked: lift or reiterate, by three days; her message is dealt with.
let m = yvv.messages().await;
assert_eq!(m["unread"], 1);
let asked = &m["messages"][0];
assert_eq!(asked["kind"], "objection_answered");
assert_eq!(gestures(asked), ["lift", "reiterate"]);
assert!(asked["dueAt"].as_str().is_some_and(|d| d > asked["createdAt"].as_str().expect("at")), "{asked}");
let m = chiara.messages().await;
assert_eq!(gestures(&m["messages"][0]), Vec::<&str>::new());
assert_eq!(m["messages"][0]["outcome"], "answered");
assert_eq!(m["unread"], 0, "answering reads one's message");
// Yvv lifts from his message: the proposal is votable again, the message offers nothing more.
yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
let d = yvv.doc("essai").await;
assert_eq!(find(&d, &b)["votable"], true);
let m = yvv.messages().await;
assert_eq!((m["unread"].as_i64(), gestures(&m["messages"][0]).len()), (Some(0), 0), "acting reads it");
assert_eq!(m["messages"][0]["outcome"], "lifted");
yvv.ok("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
// Lifted is lifted.
let again = yvv.err("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(again, (StatusCode::CONFLICT, "lifted".into()));
}
/// C29, by the API: a « contredit » answered by a rewording; one cycle at a time;
/// a reiteration tells the author again; without an answer, lifted at the deadline.
#[tokio::test]
async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let title = "Certifier largement";
let body = "Je m'engage à certifier aussi des personnes qui ne respectent pas ces engagements.";
let b = chiara.propose(&doc, json!({ "title": title, "body": body, "submit": true })).await;
let oid = yvv.object(&b, &e2, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
let phase =
|d: &Value| (d["objections"][0]["phase"].as_str().map(str::to_owned), d["objections"][0]["cycle"].as_i64());
// A typo fix answers nothing.
let typo = chiara
.ok(
"PATCH",
&format!("/api/proposals/{b}"),
Some(json!({ "title": title, "body": body.replace('\'', "\u{2019}") })),
)
.await;
assert_eq!(typo["edit"], "typo");
assert_eq!(phase(&chiara.detail(&b).await), (Some("author".into()), Some(1)));
// Rewording answers: the objector is asked.
let reworded =
"Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées.";
let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await;
assert_eq!(r["edit"], "substantive");
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message");
let det = yvv.detail(&b).await;
assert_eq!(phase(&det), (Some("objector".into()), Some(1)));
assert_eq!(det["objections"][0]["answer"], "revision");
let due = det["objections"][0]["dueAt"].as_str().expect("due").to_owned();
// One cycle at a time: another rewording changes neither the messages nor the deadline.
state.clock.advance(3600);
let again = format!("{reworded} Et à les revoir.");
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": again }))).await;
assert_eq!(yvv.detail(&b).await["objections"][0]["dueAt"], due.as_str());
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
// Reiterated: the objection stands, the author is told again, a second cycle opens.
yvv.ok("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
assert_eq!(phase(&yvv.detail(&b).await), (Some("author".into()), Some(2)));
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], false);
let m = chiara.messages().await;
let kinds: Vec<&str> = m["messages"].as_array().expect("m").iter().filter_map(|x| x["kind"].as_str()).collect();
assert_eq!(kinds, ["objection_reiterated", "objection_raised"], "newest first");
assert_eq!(gestures(&m["messages"][0]), ["respond"]);
let m = yvv.messages().await;
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("reiterated"), 0));
// A new answer, then nobody: three days later it is lifted by itself.
let third = "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.";
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": third }))).await;
assert_eq!(phase(&yvv.detail(&b).await), (Some("objector".into()), Some(2)));
state.clock.advance(3 * 86_400 - 60);
deadlines::sweep(&state).await.expect("sweep");
assert_eq!(yvv.detail(&b).await["objections"][0]["phase"], "objector", "not yet");
state.clock.advance(120);
let swept = deadlines::sweep(&state).await.expect("sweep");
assert_eq!(swept.lifted, 1);
let det = yvv.detail(&b).await;
assert_eq!(
(det["objections"][0]["phase"].as_str(), det["objections"][0]["lift"].as_str()),
(Some("lifted"), Some("deadline"))
);
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], true);
let m = yvv.messages().await;
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
}
#[tokio::test]
async fn a_rewording_of_the_other_side_asks_the_objector_too() {
let (mut luna, _) = Client::new().await;
luna.login("Luna", false).await;
let mut chiara = luna.other();
chiara.login("Chiara", false).await;
let mut yvv = luna.other();
yvv.login("Yvv", false).await;
let doc = luna.doc("essai").await;
let a = luna
.propose(
&doc,
json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs.", "submit": true }),
)
.await;
let b = chiara
.propose(
&doc,
json!({ "title": "Injoignable", "body": "Je ne suis pas tenu de rester joignable.", "submit": true }),
)
.await;
let o = yvv.object(&a, &b, "contredit", "Ces deux articles disent le contraire.").await;
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, whichever side the objection was raised from");
// Luna rewords the earlier one: the pair changed, the objector judges again.
luna.ok(
"PATCH",
&format!("/api/proposals/{a}"),
Some(json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs, sauf absence annoncée." })),
)
.await;
let det = yvv.detail(&b).await;
assert_eq!(
(det["objections"][0]["phase"].as_str(), det["objections"][0]["answer"].as_str()),
(Some("objector"), Some("other"))
);
assert_eq!(yvv.messages().await["messages"][0]["kind"], "objection_answered");
}
#[tokio::test]
async fn an_administrator_lifts_but_never_reiterates_and_withdrawing_ends_it() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut admin = chiara.other();
admin.login("Admin", true).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
chiara
.ok(
"PATCH",
&format!("/api/proposals/{b}"),
Some(json!({ "title": "Un compte", "body": "Un seul compte membre par être humain." })),
)
.await;
// An administrator never speaks for the objector...
assert_eq!(admin.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await.0, StatusCode::FORBIDDEN);
// ...but may lift.
admin.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
let det = admin.detail(&b).await;
assert_eq!(det["objections"][0]["lift"], "admin");
assert_eq!(yvv.messages().await["messages"][0]["outcome"], "admin");
// Withdrawing either version ends a standing objection.
let c = chiara
.propose(&doc, json!({ "title": "Deux comptes", "body": "Deux comptes membres par personne.", "submit": true }))
.await;
let oid2 = yvv.object(&c, &e1, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
chiara.ok("POST", &format!("/api/proposals/{c}/withdraw"), None).await;
let lifted: (String, Option<String>) = {
let det = yvv.detail(&c).await;
let ob = det["objections"].as_array().expect("o").iter().find(|o| o["id"] == oid2.as_str()).expect("o").clone();
(ob["phase"].as_str().expect("p").to_owned(), ob["lift"].as_str().map(str::to_owned))
};
assert_eq!(lifted, ("lifted".into(), Some("withdrawn".into())));
}
#[tokio::test]
async fn what_an_objection_may_be_about() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let e3 = unit(&doc, "E3");
let (v1, v2) = (e3["proposals"][0]["id"].as_str().expect("v1"), e3["proposals"][1]["id"].as_str().expect("v2"));
let post = |from: &str, other: &str, kind: &str, reason: &str| {
(format!("/api/proposals/{from}/objections"), json!({ "otherId": other, "kind": kind, "reason": reason }))
};
let cases = [
(post(&e1, &e2, "contredit", CONTRADICTS), "both_in_force"),
(post(v1, v2, "redit", REPEATS), "same_unit"),
(post(&e1, &e1, "redit", REPEATS), "other"),
(post(v1, &e1, "signal", REPEATS), "kind"),
(post(v1, &e1, "redit", "court"), "reason"),
];
for ((uri, body), code) in cases {
let (s, e) = yvv.err("POST", &uri, Some(body)).await;
assert_eq!((s, e.as_str()), (StatusCode::BAD_REQUEST, code));
}
// A draft is nobody's business yet.
let draft = chiara
.propose(&doc, json!({ "title": "Brouillon", "body": "Pas encore soumis au vote.", "submit": false }))
.await;
let (s, _) = yvv
.call(
"POST",
&format!("/api/proposals/{v1}/objections"),
Some(json!({ "otherId": draft, "kind": "redit", "reason": REPEATS })),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// One standing objection per person and pair.
yvv.object(v1, &e1, "redit", REPEATS).await;
let twice = yvv
.err(
"POST",
&format!("/api/proposals/{e1}/objections"),
Some(json!({ "otherId": v1, "kind": "contredit", "reason": CONTRADICTS })),
)
.await;
assert_eq!(twice, (StatusCode::CONFLICT, "objection_exists".into()));
// Someone else may object to the same pair.
chiara.object(v1, &e1, "contredit", CONTRADICTS).await;
}
/// Votes cast before the objection stay counted; the version cannot enter into
/// force while it is held, and enters once the objection is lifted.
#[tokio::test]
async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
// Every member of the group for it (written as they would have been before the objection).
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
.fetch_all(&state.db)
.await
.expect("members");
for m in &members {
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
.bind(&b)
.bind(m)
.execute(&state.db)
.await
.expect("vote");
}
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
let d = chiara.doc("essai").await;
assert_eq!(find(&d, &b)["tally"]["for"].as_u64(), Some(members.len() as u64), "the votes stay");
assert!(find(&d, &b)["evaluation"]["reached"].as_bool().expect("reached"));
let lifted = yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
}
#[tokio::test]
async fn a_message_is_read_by_its_recipient_only() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", true).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
yvv.object(&b, &e1, "redit", REPEATS).await;
let m = chiara.messages().await;
let mid = m["messages"][0]["id"].as_str().expect("id").to_owned();
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
// Not even an administrator reads or marks someone else's message.
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
assert_eq!(yvv.call("POST", &format!("/api/messages/{mid}/read"), None).await.0, StatusCode::NOT_FOUND);
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
let mut nobody = chiara.other();
assert_eq!(nobody.call("GET", "/api/messages", None).await.0, StatusCode::UNAUTHORIZED);
// Marked as read, it stays in the box.
assert_eq!(chiara.ok("POST", &format!("/api/messages/{mid}/read"), None).await["unread"], 0);
let m = chiara.messages().await;
assert_eq!((m["messages"][0]["read"].as_bool(), gestures(&m["messages"][0])), (Some(true), vec!["respond"]));
// All at once.
let c = chiara
.propose(&doc, json!({ "title": "Deux", "body": "Deux comptes membres par personne.", "submit": true }))
.await;
yvv.object(&c, &e1, "contredit", CONTRADICTS).await;
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
assert_eq!(chiara.ok("POST", "/api/messages/read", None).await["unread"], 0);
}
/// A deadline passed while the server was stopped is dealt with at the next start.
#[tokio::test]
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
let path = std::env::temp_dir().join(format!("ld-lot-f-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let url = format!("sqlite://{}", path.display());
let (b, oid) = {
let db = open_db(&url).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let mut chiara = Client { router: app(state.clone()), cookie: None };
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(
&doc,
json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }),
)
.await;
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
let reworded = "Un seul compte membre par être humain pour la certifcation.";
chiara
.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": reworded })))
.await;
// And a typo fix of Yvv's on it, waiting for Chiara.
let fixed = reworded.replace("certifcation", "certification");
let fix =
yvv.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": fixed }))).await;
assert_eq!(fix["edit"], "typo_pending");
state.db.close().await;
(b, oid)
};
// Four days later, the server starts again.
let db = open_db(&url).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
state.clock.advance(4 * 86_400);
let swept = deadlines::sweep(&state).await.expect("start");
assert_eq!((swept.lifted, swept.applied), (1, 1));
let (phase, lift): (String, Option<String>) = sqlx::query_as("SELECT phase, lift FROM signals WHERE id = ?")
.bind(&oid)
.fetch_one(&state.db)
.await
.expect("o");
assert_eq!((phase.as_str(), lift.as_deref()), ("lifted", Some("deadline")));
let body: String =
sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
assert_eq!(body, "Un seul compte membre par être humain pour la certification.");
state.db.close().await;
let _ = std::fs::remove_file(&path);
}
/// The contradiction signals of before become objections « contredit », without
/// loss, on a base migrated up to the version before (0001 to 0007).
#[tokio::test]
async fn contradiction_signals_of_before_become_contredits() {
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
let mut before = sqlx::migrate!("./migrations");
before.migrations = before.migrations.iter().filter(|m| m.version < 8).cloned().collect::<Vec<_>>().into();
before.run(&pool).await.expect("up to 0007");
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
// The documents as the image of before wrote them (its amendments read tables of now).
seed::documents(&state).await.expect("seed");
let id = |code: &'static str, which: &'static str| {
let pool = pool.clone();
async move {
let sql = if which == "in_force" {
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'in_force'"
} else {
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'open' ORDER BY p.rowid LIMIT 1"
};
sqlx::query_scalar::<_, String>(sql).bind(code).fetch_one(&pool).await.expect("id")
}
};
let (e2, k1, e4, e5) =
(id("E2", "in_force").await, id("K1", "open").await, id("E4", "open").await, id("E5", "open").await);
sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-30T00:00:00Z')")
.execute(&pool)
.await
.expect("identity");
let signal = |sid: &'static str, from: String, to: String, lifted: Option<&'static str>| {
let pool = pool.clone();
async move {
sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by)
VALUES (?, ?, ?, 'Ces deux textes se contredisent.', 'dev:chiara', '2026-10-30T00:00:00Z', ?, ?)",
)
.bind(sid)
.bind(from)
.bind(to)
.bind(lifted.map(|_| "2026-10-30T01:00:00Z"))
.bind(lifted)
.execute(&pool)
.await
.expect("signal");
}
};
signal("s1", k1.clone(), e2.clone(), None).await;
signal("s2", e4.clone(), e5.clone(), None).await;
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
signal("s4", e5.clone(), e2.clone(), Some("revision")).await;
// The image of now starts on it.
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("0008");
type Row = (String, String, String, Option<String>, Option<String>);
let rows: Vec<Row> = sqlx::query_as("SELECT id, kind, phase, held_id, lift FROM signals ORDER BY id")
.fetch_all(&pool)
.await
.expect("rows");
assert_eq!(
rows,
[
("s1".into(), "contredit".into(), "author".into(), Some(k1.clone()), None),
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".into())),
],
"K1 against the text in force; E5 written after E4 in the same second"
);
// Nothing lost: reasons, authors and dates as they were.
let kept: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM signals WHERE reason = 'Ces deux textes se contredisent.' AND author_id = 'dev:chiara'
AND created_at = '2026-10-30T00:00:00Z'",
)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(kept, 4);
// And the server reads them as objections: K1 and E5 are no longer votable.
let st = ld_server::docs::load_state(
&state,
&sqlx::query_scalar::<_, String>("SELECT id FROM documents WHERE group_slug = 'essai'")
.fetch_one(&pool)
.await
.expect("doc"),
)
.await
.expect("state");
assert!(
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
);
}
+384
View File
@@ -0,0 +1,384 @@
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010), end to end on an
//! in-memory database. What a typo is does not change (`ld_engine::edit`); who
//! applies it, and when, does.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, deadlines, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
let (s, v) = self.call(method, uri, body).await;
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
v
}
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
let (s, v) = self.call(method, uri, body).await;
(s, v["error"].as_str().unwrap_or_default().to_owned())
}
async fn login(&mut self, name: &str, admin: bool) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.ok("GET", &format!("/api/docs/{id}"), None).await
}
async fn propose(&mut self, doc: &Value, title: &str, body: &str) -> String {
let id = doc["doc"]["id"].as_str().expect("id");
let v = self
.ok(
"POST",
&format!("/api/docs/{id}/proposals"),
Some(json!({ "title": title, "body": body, "submit": true })),
)
.await;
v["id"].as_str().expect("id").to_owned()
}
async fn fix(&mut self, id: &str, title: &str, body: &str) -> Value {
self.ok("PATCH", &format!("/api/proposals/{id}"), Some(json!({ "title": title, "body": body }))).await
}
async fn detail(&mut self, id: &str) -> Value {
self.ok("GET", &format!("/api/proposals/{id}"), None).await
}
async fn messages(&mut self) -> Value {
self.ok("GET", "/api/messages", None).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn gestures(v: &Value) -> Vec<&str> {
v["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
}
fn last(history: &Value) -> &Value {
history.as_array().expect("history").last().expect("entry")
}
const TITLE: &str = "Réciprocité";
const VOTED: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagemnts.";
const FIX_1: &str = "Je m'engage à ne certifier que des personnes qui respectent ces engagemnts.";
const FIX_2: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagements.";
/// C30, by the API: someone else's typo on a proposal waits for its author, who
/// validates it from her messages — and keeps the hand while it is not in force.
#[tokio::test]
async fn someone_elses_typo_on_a_proposal_waits_for_its_author() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
let w = yvv.fix(&p, TITLE, FIX_1).await;
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
assert!(w["typo"]["dueAt"].is_string());
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
// Nothing written yet: the text stays, and the pending fix shows with its diff.
let d = yvv.detail(&p).await;
assert_eq!(d["proposal"]["body"], VOTED);
let t = &d["typos"][0];
assert_eq!((t["status"].as_str(), t["writer"].as_str()), (Some("pending"), Some("Yvv")));
assert!(t["diff"].as_array().is_some_and(|x| !x.is_empty()));
assert_eq!(gestures(t), Vec::<&str>::new(), "the writer does not answer");
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), ["validate", "refuse"]);
assert_eq!(yvv.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
// Its author is told, by a message with the gestures.
let m = chiara.messages().await;
let msg = &m["messages"][0];
assert_eq!((msg["kind"].as_str(), msg["topic"].as_str()), (Some("typo_pending"), Some("typo")));
assert_eq!(gestures(msg), ["validate", "refuse"]);
assert_eq!(msg["typo"]["writer"], "Yvv");
assert_eq!(msg["typo"]["version"]["id"], p.as_str());
// Validated: the fix is in the text, the vote stays.
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
let d = chiara.detail(&p).await;
assert_eq!(d["proposal"]["body"], FIX_1);
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
let h = last(&d["history"]);
assert_eq!(
(h["kind"].as_str(), h["author"].as_str(), h["typo"]["answeredBy"].as_str()),
(Some("typo"), Some("Yvv"), Some("Chiara"))
);
let m = chiara.messages().await;
assert_eq!(
(gestures(&m["messages"][0]).len(), m["messages"][0]["outcome"].as_str(), m["unread"].as_i64()),
(0, Some("applied"), Some(0))
);
// The author keeps the hand while it is not in force: she takes it back, then validates it again.
assert_eq!(gestures(&d["typos"][0]), ["refuse"]);
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
let d = chiara.detail(&p).await;
assert_eq!(d["proposal"]["body"], VOTED);
assert_eq!(last(&d["history"])["kind"], "revert");
assert_eq!(gestures(&d["typos"][0]), ["validate"]);
chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
// Her own fixes apply at once, as before.
let own = chiara.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await;
assert_eq!(own["edit"], "typo");
// A change of meaning stays refused to anyone else.
let s = yvv
.err("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": TITLE, "body": "Tout autre chose." })))
.await;
assert_eq!(s, (StatusCode::CONFLICT, "substantive_not_author".into()));
}
/// C31, by the API: on a text in force, only a validated fix corrects it — by its
/// author, or an administrator for a starting text; else by itself after three days.
#[tokio::test]
async fn on_a_text_in_force_only_a_validated_typo_corrects_it() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", true).await;
let doc = chiara.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let corrections = || {
let db = state.db.clone();
let doc_id = doc_id.clone();
async move {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM outbox WHERE doc_id = ? AND reason = 'correction typographique'",
)
.bind(doc_id)
.fetch_one(&db)
.await
.expect("outbox")
}
};
let e1 = unit(&doc, "E1")["inForce"].clone();
let e1_id = e1["id"].as_str().expect("id").to_owned();
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
let w = chiara.fix(&e1_id, e1["title"].as_str().expect("t"), &fixed).await;
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("admin")));
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
assert_ne!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str(), "not in force before it is validated");
assert_eq!(corrections().await, 0, "nothing republished");
// A starting text has no author: an administrator answers, from his messages.
assert_eq!(gestures(&yvv.messages().await["messages"][0]), ["validate", "refuse"]);
assert_eq!(chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
assert_eq!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str());
assert_eq!(corrections().await, 1, "republished once validated");
// In force, an answer is final.
assert_eq!(
yvv.err("POST", &format!("/api/typos/{tid}/refuse"), None).await,
(StatusCode::CONFLICT, "settled".into())
);
// Nobody answers: three days later it applies by itself.
let e2 = unit(&doc, "E2")["inForce"].clone();
let e2_id = e2["id"].as_str().expect("id").to_owned();
let e2_fixed = e2["body"].as_str().expect("body").replace('\'', "\u{2019}");
chiara.fix(&e2_id, e2["title"].as_str().expect("t"), &e2_fixed).await;
state.clock.advance(3 * 86_400 - 60);
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
state.clock.advance(120);
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 1);
let d = chiara.detail(&e2_id).await;
assert_eq!(d["proposal"]["body"], e2_fixed.as_str());
assert_eq!(last(&d["history"])["typo"]["answeredBy"], Value::Null);
assert_eq!(last(&d["history"])["typo"]["deadline"], true);
assert_eq!(corrections().await, 2);
let m = yvv.messages().await;
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
// An administrator's own fix on a starting text applies at once.
let i1 = unit(&doc, "I1")["inForce"].clone();
let i1_id = i1["id"].as_str().expect("id");
let body = i1["body"].as_str().expect("body").replace('\'', "\u{2019}");
assert_eq!(yvv.fix(i1_id, i1["title"].as_str().expect("t"), &body).await["edit"], "typo");
}
#[tokio::test]
async fn on_a_text_in_force_by_an_author_she_answers_and_a_refusal_is_final() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
// W = 2 in the test group: two voices for make it pass.
for c in [&mut chiara, &mut yvv] {
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
}
assert_eq!(chiara.detail(&p).await["proposal"]["status"], "in_force");
let tid = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
assert_eq!(chiara.detail(&p).await["proposal"]["body"], VOTED);
// In force: no going back on the answer.
let back = chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await;
assert_eq!(back, (StatusCode::CONFLICT, "settled".into()));
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), Vec::<&str>::new());
}
/// Two fixes waiting on one text: each is judged against the voted text; once one
/// applies, the other is written over a text that is gone — stale, « recharge ».
#[tokio::test]
async fn two_pending_typos_on_one_text_the_second_goes_stale() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut luna = chiara.other();
luna.login("Luna", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
let t1 = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
let w2 = luna.fix(&p, TITLE, FIX_2).await;
assert_eq!(w2["edit"], "typo_pending", "each one slip from the voted text");
let t2 = w2["typo"]["id"].as_str().expect("id").to_owned();
chiara.ok("POST", &format!("/api/typos/{t1}/validate"), None).await;
let d = chiara.detail(&p).await;
let second = d["typos"].as_array().expect("t").iter().find(|t| t["id"] == t2.as_str()).expect("t2").clone();
assert_eq!((second["status"].as_str(), gestures(&second).len()), (Some("stale"), 0));
let late = chiara.err("POST", &format!("/api/typos/{t2}/validate"), None).await;
assert_eq!(late, (StatusCode::CONFLICT, "changed".into()));
let m = chiara.messages().await;
let msg =
m["messages"].as_array().expect("m").iter().find(|x| x["typo"]["id"] == t2.as_str()).expect("msg").clone();
assert_eq!((msg["outcome"].as_str(), gestures(&msg).len()), (Some("stale"), 0));
// The deadline never applies it.
state.clock.advance(4 * 86_400);
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
// Written again over the text of now, it waits as any other.
assert_eq!(luna.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await["edit"], "typo_pending");
}
#[tokio::test]
async fn a_change_of_substance_or_a_withdrawal_leaves_pending_typos_stale() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
let t = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
chiara
.fix(&p, TITLE, "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.")
.await;
assert_eq!(chiara.detail(&p).await["typos"][0]["status"], "stale");
assert_eq!(
chiara.err("POST", &format!("/api/typos/{t}/validate"), None).await,
(StatusCode::CONFLICT, "changed".into())
);
let q = chiara.propose(&doc, "Autre", VOTED).await;
yvv.fix(&q, "Autre", FIX_1).await;
chiara.ok("POST", &format!("/api/proposals/{q}/withdraw"), None).await;
let stale: i64 = {
let d = chiara.detail(&q).await;
d["typos"].as_array().expect("t").iter().filter(|t| t["status"] == "stale").count() as i64
};
assert_eq!(stale, 1);
}
#[tokio::test]
async fn translations_stay_editorial() {
let (mut chiara, _) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let doc = chiara.doc("essai").await;
let p = chiara.propose(&doc, TITLE, VOTED).await;
yvv.ok(
"PUT",
&format!("/api/proposals/{p}/translations/en"),
Some(json!({ "title": "Reciprocity", "body": "I only certify people who keep these commitments." })),
)
.await;
let d = chiara.detail(&p).await;
assert_eq!(d["proposal"]["translations"]["en"]["title"], "Reciprocity", "without any validation");
assert_eq!(d["typos"].as_array().expect("t").len(), 0);
assert_eq!(chiara.messages().await["messages"].as_array().expect("m").len(), 0);
}
/// A text without author: only the administrators who open its group are told,
/// and a message shows only while its recipient opens the group.
#[tokio::test]
async fn only_administrators_who_open_the_group_are_told() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", true).await;
let mut luna = chiara.other();
luna.login("Luna", true).await;
let leave = |who: &'static str| {
let db = state.db.clone();
async move {
sqlx::query("DELETE FROM memberships WHERE identity_id = ? AND group_slug = 'essai'")
.bind(who)
.execute(&db)
.await
.expect("leave");
}
};
leave("dev:luna").await;
let doc = chiara.doc("essai").await;
let e1 = unit(&doc, "E1")["inForce"].clone();
let body = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
let w = chiara.fix(e1["id"].as_str().expect("id"), e1["title"].as_str().expect("t"), &body).await;
assert_eq!(w["typo"]["answerer"], "admin");
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
assert_eq!(luna.messages().await["messages"].as_array().expect("m").len(), 0, "Luna does not open the test group");
leave("dev:yvv").await;
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0, "no longer shown");
}
+66 -1
View File
@@ -16,7 +16,14 @@
//! - where the electorate is the web of trust (Monnaie libre), only its members
//! vote (2026-10-21, independent review: one person, one vote) — through the
//! Ğ1 chain, one transaction per vote, for the identity that signs it, member
//! when the vote is confirmed (2026-10-30, ADR 0008).
//! when the vote is confirmed (2026-10-30, ADR 0008);
//! - a typo fix to someone's version waits for that author; to an authorless
//! version (a seeded text), for an administrator — `answers_typo`;
//! - an objection is lifted by the person who raised it, or by an
//! administrator; it is reiterated by the person who raised it only —
//! `lifts_objection` / `reiterates_objection`;
//! - each person reads their own messages only — `reads_message`
//! (2026-10-31, lot F, ADR 0010).
use serde::{Deserialize, Serialize};
@@ -150,6 +157,33 @@ pub fn manages_access(identity: &Identity) -> bool {
identity.admin
}
/// Who answers a typo fix someone else wrote on a version (2026-10-30, L13):
/// its author; for a text without author (a starting text), an administrator of
/// trustinWallet. Whoever answers for a version also applies their own fix at once.
pub fn answers_typo(identity: &Identity, version_author: Option<&str>) -> bool {
match version_author {
Some(author) => identity.id == author,
None => identity.admin,
}
}
/// Who lifts an objection (2026-10-30, L12): the person who raised it, or an
/// administrator of trustinWallet.
pub fn lifts_objection(identity: &Identity, objector: &str) -> bool {
identity.id == objector || identity.admin
}
/// Who reiterates an objection: only the person who raised it — an
/// administrator lifts, never speaks for someone else.
pub fn reiterates_objection(identity: &Identity, objector: &str) -> bool {
identity.id == objector
}
/// A message of trustinWallet is read by its recipient only.
pub fn reads_message(identity: &Identity, recipient: &str) -> bool {
identity.id == recipient
}
/// The groups a fresh deployment starts with. Ordered as displayed.
pub fn default_groups() -> Vec<Group> {
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
@@ -251,6 +285,37 @@ mod tests {
assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try");
}
#[test]
fn the_author_answers_a_typo_and_an_administrator_for_a_starting_text() {
let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
assert!(answers_typo(&chiara, Some("chiara")));
assert!(!answers_typo(&other, Some("chiara")));
assert!(!answers_typo(&admin, Some("chiara")), "an administrator never answers for an author");
assert!(answers_typo(&admin, None), "a starting text has no author: an administrator answers");
assert!(!answers_typo(&other, None));
}
#[test]
fn the_objector_lifts_or_reiterates_an_administrator_only_lifts() {
let objector = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
let other = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
assert!(lifts_objection(&objector, "luna") && reiterates_objection(&objector, "luna"));
assert!(lifts_objection(&admin, "luna"));
assert!(!reiterates_objection(&admin, "luna"));
assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna"));
}
#[test]
fn a_message_is_read_by_its_recipient_only() {
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
let luna = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
assert!(reads_message(&luna, "luna"));
assert!(!reads_message(&admin, "luna"), "not even an administrator");
}
#[test]
fn no_atelier_du_canal() {
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));