From 70dbd712c8f4071bd636c38742dce0c2a7fee026 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 01:13:52 +0100 Subject: [PATCH 1/4] =?UTF-8?q?v3=20:=20moteur=20des=20objections=20=C2=AB?= =?UTF-8?q?=20redit=20=C2=BB=20/=20=C2=AB=20contredit=20=C2=BB=20et=20du?= =?UTF-8?q?=20circuit=20des=20coquilles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ld-engine/objection.rs : la dernière arrivée (jamais une version en vigueur) n'est plus votable ; un cycle à la fois (réponse de l'auteur, puis lever ou réitérer) ; levée seule 3 jours après la réponse ; une révision de l'autre côté répond aussi ; l'administrateur lève, ne réitère jamais. - ld-engine/correction.rs : qui applique une coquille et quand (celui qui répond de la version l'applique, les autres attendent 3 jours au plus), l'auteur garde la main hors vigueur, deux coquilles en attente : la seconde devient caduque. - trustin-access : answers_typo, lifts_objection, reiterates_objection, reads_message. Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/correction.rs | 217 ++++++++++++++ crates/ld-engine/src/lib.rs | 2 + crates/ld-engine/src/objection.rs | 460 +++++++++++++++++++++++++++++ crates/trustin-access/src/lib.rs | 58 ++++ 4 files changed, 737 insertions(+) create mode 100644 crates/ld-engine/src/correction.rs create mode 100644 crates/ld-engine/src/objection.rs diff --git a/crates/ld-engine/src/correction.rs b/crates/ld-engine/src/correction.rs new file mode 100644 index 0000000..3c5c911 --- /dev/null +++ b/crates/ld-engine/src/correction.rs @@ -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::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 { + 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 { + (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); + } +} diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 945d854..b84e883 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -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; diff --git a/crates/ld-engine/src/objection.rs b/crates/ld-engine/src/objection.rs new file mode 100644 index 0000000..bc36290 --- /dev/null +++ b/crates/ld-engine/src/objection.rs @@ -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::Redit, Self::Contredit].into_iter().find(|k| k.as_str() == raw) + } +} + +/// One version of the pair, as it stands when the objection is raised. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Side<'a> { + pub id: &'a str, + /// When it arrived (submitted), as a sortable stamp (RFC 3339 UTC). + pub arrived_at: &'a str, + /// The order it was written in, for versions of the same instant (a seed). + pub seq: i64, + pub in_force: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PairError { + /// A version does not object to itself. + Same, + /// Both are in force: nothing to hold back; a proposal changes one of them. + BothInForce, +} + +fn arrival<'a>(s: &Side<'a>) -> (&'a str, i64) { + (s.arrived_at, s.seq) +} + +/// The version an objection makes non-votable: the latest arrival of the two, +/// unless it is in force — then the other one. +pub fn target<'a>(a: &Side<'a>, b: &Side<'a>) -> Result<&'a str, PairError> { + if a.id == b.id { + return Err(PairError::Same); + } + if a.in_force && b.in_force { + return Err(PairError::BothInForce); + } + let latest = match arrival(a).cmp(&arrival(b)) { + Ordering::Greater => a, + Ordering::Less => b, + // Same instant, same rank: the larger id, so that the answer never depends on the order given. + Ordering::Equal => { + if a.id > b.id { + a + } else { + b + } + } + }; + let other = if latest.id == a.id { b } else { a }; + Ok(if latest.in_force { other.id } else { latest.id }) +} + +/// Why an objection was lifted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Lift { + /// By the person who raised it. + Objector, + /// By an administrator of trustinWallet. + Admin, + /// Nobody answered within the days given: lifted by itself. + Deadline, + /// One of the two versions was withdrawn. + Withdrawn, + /// A contradiction signal of before 2026-10-31 that lapsed when the text changed. + Revision, +} + +impl Lift { + pub fn as_str(self) -> &'static str { + match self { + Self::Objector => "objector", + Self::Admin => "admin", + Self::Deadline => "deadline", + Self::Withdrawn => "withdrawn", + Self::Revision => "revision", + } + } + + pub fn parse(raw: &str) -> Option { + [Self::Objector, Self::Admin, Self::Deadline, Self::Withdrawn, Self::Revision] + .into_iter() + .find(|l| l.as_str() == raw) + } +} + +/// Where an objection stands. Cycles count from 1: a reiteration opens the next. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Phase { + /// Waiting for the author of the target to answer. No deadline: the version + /// stays non-votable until the author answers or the objection is lifted. + Author { + cycle: u32, + }, + /// The author answered: waiting for the objector, until `due` (sortable stamp). + Objector { + cycle: u32, + due: String, + }, + Lifted(Lift), +} + +impl Phase { + pub fn standing(&self) -> bool { + !matches!(self, Self::Lifted(_)) + } + + pub fn cycle(&self) -> Option { + match self { + Self::Author { cycle } | Self::Objector { cycle, .. } => Some(*cycle), + Self::Lifted(_) => None, + } + } +} + +/// Who acts on an objection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Who { + /// The person who raised it (an administrator who raised it acts as such). + Objector, + /// An administrator of trustinWallet who did not raise it. + Admin, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event<'a> { + /// The author answered (`answers`); `due` = now + `ANSWER_DAYS`. + Answered { + due: &'a str, + }, + Lift(Who), + Reiterate(Who), + /// The clock, as a sortable stamp. + Tick { + now: &'a str, + }, + /// One of the two versions was withdrawn. + Withdrawn, +} + +/// What a step asks the server to do besides storing the phase. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Effect { + /// Ask the objector: lift or reiterate, by the deadline of the phase. + AskObjector, + /// Tell the author of the target (again): answer the objection. + TellAuthor, + /// The objection ended: every message about it is done. + Ended, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Step { + pub phase: Phase, + pub effects: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// The objection no longer stands. + Lifted, + /// Only the objector (or an administrator, to lift) may. + NotYours, + /// Reiterating answers an answer of the author: there is none yet. + NothingToReiterate, +} + +/// One step of an objection. `Ok(None)`: nothing changes (an answer while the +/// objector is already asked — one cycle at a time —, a tick before the +/// deadline, an answer once lifted). +pub fn step(phase: &Phase, event: &Event<'_>) -> Result, Refusal> { + let ended = |lift| Some(Step { phase: Phase::Lifted(lift), effects: vec![Effect::Ended] }); + match (phase, event) { + (Phase::Lifted(_), Event::Answered { .. } | Event::Tick { .. } | Event::Withdrawn) => Ok(None), + (Phase::Lifted(_), Event::Lift(_) | Event::Reiterate(_)) => Err(Refusal::Lifted), + (Phase::Author { cycle }, Event::Answered { due }) => Ok(Some(Step { + phase: Phase::Objector { cycle: *cycle, due: (*due).to_owned() }, + effects: vec![Effect::AskObjector], + })), + (Phase::Objector { .. }, Event::Answered { .. }) => Ok(None), + (_, Event::Lift(Who::Objector)) => Ok(ended(Lift::Objector)), + (_, Event::Lift(Who::Admin)) => Ok(ended(Lift::Admin)), + (_, Event::Lift(Who::Other) | Event::Reiterate(Who::Admin | Who::Other)) => Err(Refusal::NotYours), + (Phase::Author { .. }, Event::Reiterate(Who::Objector)) => Err(Refusal::NothingToReiterate), + (Phase::Objector { cycle, .. }, Event::Reiterate(Who::Objector)) => { + Ok(Some(Step { phase: Phase::Author { cycle: cycle + 1 }, effects: vec![Effect::TellAuthor] })) + } + (Phase::Objector { due, .. }, Event::Tick { now }) if due.as_str() <= *now => Ok(ended(Lift::Deadline)), + (Phase::Author { .. } | Phase::Objector { .. }, Event::Tick { .. }) => Ok(None), + (Phase::Author { .. } | Phase::Objector { .. }, Event::Withdrawn) => Ok(ended(Lift::Withdrawn)), + } +} + +/// Which side of the pair a change touches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Touched { + /// The version the objection holds back. + Target, + /// The version it is said to repeat or contradict. + Other, +} + +/// A change made to the pair. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Change { + /// A substantive revision (a new revision, votes to reconfirm). + Revision(Touched), + /// A typo fix: the meaning stays, nothing is answered. + Typo(Touched), + /// A proposal linked to the objection was submitted (a draft answers nothing). + LinkedProposal, +} + +/// Does this change answer the objection (and ask the objector)? +pub fn answers(change: Change) -> bool { + match change { + Change::Revision(_) | Change::LinkedProposal => true, + Change::Typo(_) => false, + } +} + +/// Who may answer with a linked proposal: the author of the target; anyone of +/// the group when the target has no author (a starting text). +pub fn may_link(target_author: Option<&str>, who: &str) -> bool { + target_author.is_none_or(|a| a == who) +} + +/// Where a linked proposal may go: on the article the target repeats or +/// contradicts (« modifier l'article visé »), or on the target's own article (a +/// reworded version, for a target without author). +pub fn link_fits(unit: &str, target_unit: &str, other_unit: &str) -> bool { + unit == other_unit || unit == target_unit +} + +/// Is a version votable? A version in force always is; any other is not while a +/// standing objection holds it back. `held` = the targets of the standing objections. +pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool { + in_force || !held.contains(&id) +} + +#[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); + } +} diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 2361538..387f6d4 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -150,6 +150,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 { let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group { @@ -251,6 +278,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"))); From 3f1f968f16dd247b324f504f264f09c8a4c0b61c Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 02:45:48 +0100 Subject: [PATCH 2/4] =?UTF-8?q?v3=20:=20serveur=20des=20objections,=20des?= =?UTF-8?q?=20coquilles=20valid=C3=A9es=20et=20de=20la=20messagerie=20(lot?= =?UTF-8?q?=20F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Objections « redit » / « contredit » à la place des signalements : la dernière arrivée des deux versions n'est plus votable (repli, panier, chaîne : un vote déjà scellé est gardé mais ne la fait pas entrer) ; un texte en vigueur ne l'est jamais. L'auteur répond en modifiant l'article visé (proposition liée) ou en reformulant ; la personne qui objecte lève ou réitère depuis ses messages ; sans réponse sous 3 jours, l'objection est levée. Un administrateur peut lever. Coquilles : la sienne passe tout de suite ; celle d'un autre attend l'auteur (valider / refuser), passe seule au bout de 3 jours, et l'auteur garde la main tant que le texte n'est pas en vigueur. En vigueur, seule une coquille validée corrige (auteur de la version, administrateur pour un texte semé). Deux coquilles en attente : la seconde se mesure au texte voté ; à l'application, celle qui ne colle plus est périmée. Messagerie dans l'application (boîte, non-lus, gestes, échéance), chaque personne ne lit que les siens. Échéances par le balayage existant, avec rattrapage au démarrage et horloge injectable (route de dev). Migration 0008 additive : les signalements deviennent des « contredit », sans perte. Co-Authored-By: Claude Opus 5.5 --- .../0008_objections_typos_messages.sql | 93 +++ crates/ld-server/src/adoption.rs | 7 +- crates/ld-server/src/ballots.rs | 4 + crates/ld-server/src/clock.rs | 57 ++ crates/ld-server/src/deadlines.rs | 57 ++ crates/ld-server/src/docs.rs | 45 +- crates/ld-server/src/lib.rs | 41 +- crates/ld-server/src/messages.rs | 238 +++++++ crates/ld-server/src/objections.rs | 582 +++++++++++++++ crates/ld-server/src/proposals.rs | 243 ++----- crates/ld-server/src/seed_amend.rs | 12 +- crates/ld-server/src/typos.rs | 477 +++++++++++++ crates/ld-server/tests/api.rs | 53 +- crates/ld-server/tests/ballots.rs | 30 +- crates/ld-server/tests/objections.rs | 660 ++++++++++++++++++ crates/ld-server/tests/typos.rs | 384 ++++++++++ crates/trustin-access/src/lib.rs | 9 +- 17 files changed, 2754 insertions(+), 238 deletions(-) create mode 100644 crates/ld-server/migrations/0008_objections_typos_messages.sql create mode 100644 crates/ld-server/src/clock.rs create mode 100644 crates/ld-server/src/deadlines.rs create mode 100644 crates/ld-server/src/messages.rs create mode 100644 crates/ld-server/src/objections.rs create mode 100644 crates/ld-server/src/typos.rs create mode 100644 crates/ld-server/tests/objections.rs create mode 100644 crates/ld-server/tests/typos.rs diff --git a/crates/ld-server/migrations/0008_objections_typos_messages.sql b/crates/ld-server/migrations/0008_objections_typos_messages.sql new file mode 100644 index 0000000..b6a1324 --- /dev/null +++ b/crates/ld-server/migrations/0008_objections_typos_messages.sql @@ -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); diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs index 951a11a..5ae4194 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -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 = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await { Ok(ids) => ids, Err(e) => { diff --git a/crates/ld-server/src/ballots.rs b/crates/ld-server/src/ballots.rs index 561b1f3..44c9989 100644 --- a/crates/ld-server/src/ballots.rs +++ b/crates/ld-server/src/ballots.rs @@ -104,6 +104,10 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult> { 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) diff --git a/crates/ld-server/src/clock.rs b/crates/ld-server/src/clock.rs new file mode 100644 index 0000000..123f47b --- /dev/null +++ b/crates/ld-server/src/clock.rs @@ -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, +} + +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); + } +} diff --git a/crates/ld-server/src/deadlines.rs b/crates/ld-server/src/deadlines.rs new file mode 100644 index 0000000..70988e1 --- /dev/null +++ b/crates/ld-server/src/deadlines.rs @@ -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 { + 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, Json(b): Json) -> AppResult> { + 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, + }))) +} diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 994ac0c..fe53d28 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -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 { + 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 { let has = granted(state, &group.slug, &user.id).await?; @@ -253,7 +259,9 @@ pub struct DocState { pub units: Vec, pub proposals: Vec, pub tallies: HashMap, + /// Versions held back by a standing objection: not votable, never entering into force. pub blocked: HashSet, + /// Standing objections touching each version, either side. pub signal_counts: HashMap, pub electorate: u32, pub electorate_source: &'static str, @@ -331,32 +339,9 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { } } - #[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 = 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( @@ -396,6 +381,11 @@ impl DocState { self.tallies.get(id).copied().unwrap_or_default() } + /// 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 { let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); self.units @@ -546,7 +536,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"), diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 1a1a614..b4eb474 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -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, /// What is coming in the tools of each group (`seeds/upcoming/`), read at start. pub upcoming: Arc, + /// 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(), + }) } } @@ -127,9 +142,17 @@ pub fn api_router() -> Router { .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)) @@ -210,6 +233,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"); } diff --git a/crates/ld-server/src/messages.rs b/crates/ld-server/src/messages.rs new file mode 100644 index 0000000..8941a57 --- /dev/null +++ b/crates/ld-server/src/messages.rs @@ -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 { + 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, + read_at: Option, + done_at: Option, + outcome: Option, +} + +#[derive(sqlx::FromRow)] +struct DocLine { + group_slug: String, + slug: String, + title: String, +} + +async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult> { + 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, User(me): User) -> AppResult> { + 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, User(me): User) -> AppResult> { + 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, User(me): User, Path(id): Path) -> AppResult> { + 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, User(me): User) -> AppResult> { + 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 }))) +} diff --git a/crates/ld-server/src/objections.rs b/crates/ld-server/src/objections.rs new file mode 100644 index 0000000..4be4f5c --- /dev/null +++ b/crates/ld-server/src/objections.rs @@ -0,0 +1,582 @@ +//! 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, + pub lifted_by: Option, + pub kind: String, + /// The version held back (not votable). + pub held_id: Option, + pub phase: String, + pub cycle: i64, + pub due_at: Option, + pub answered_at: Option, + pub answer: Option, + pub answer_id: Option, + pub lift: Option, +} + +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 { + 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 { + 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> { + 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, HashMap)> { + 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 = 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> { + Ok(sqlx::query_scalar::<_, Option>("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 { + 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 { + 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, + User(user): User, + Path(id): Path, + Json(b): Json, +) -> AppResult> { + 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 { + 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, User(user): User, Path(id): Path) -> AppResult> { + 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, + User(user): User, + Path(id): Path, +) -> AppResult> { + act(&state, &user, &id, false).await?; + Ok(Json(json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// What answers an objection +// --------------------------------------------------------------------------- + +/// A version changed: the objections around it whose author it answers move on. +pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool) -> 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" }; + store(state, &o, &step, proposal_id, Some((answer, proposal_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: it answers the objection. +pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &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?; + } + 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)> { + 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, 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 { + 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> { + 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)>> { + 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))) +} diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 220c60f..d246ead 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -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 { +pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult { 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 { } /// 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, } /// 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).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).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 = + 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).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, author_invited: Option, created_at: String, + typo_id: Option, + answered_by: Option, + answered_name: Option, } 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, - lifted_by: Option, - } - 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.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, - User(user): User, - Path(id): Path, - Json(b): Json, -) -> AppResult> { - 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, - User(user): User, - Path(id): Path, -) -> AppResult> { - 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 // --------------------------------------------------------------------------- diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index 6552e91..a67ffeb 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -71,8 +71,10 @@ pub struct Facts { pub revisions: Vec, /// 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"), diff --git a/crates/ld-server/src/typos.rs b/crates/ld-server/src/typos.rs new file mode 100644 index 0000000..8c12936 --- /dev/null +++ b/crates/ld-server/src/typos.rs @@ -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, + 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, + pub settled_by: Option, +} + +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 { + 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 { + 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> { + 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 = 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> { + 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> { + 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, + User(user): User, + Path(id): Path, +) -> AppResult> { + 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, User(user): User, Path(id): Path) -> AppResult> { + 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 { + 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, 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 { + 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> { + 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)>> { + 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 = + 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))) +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 3563864..9eec240 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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 } diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index b05e7d1..bd0d0b7 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -625,17 +625,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 = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; for (n, account) in keys.iter().enumerate() { @@ -646,6 +644,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. @@ -664,8 +683,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"); } // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs new file mode 100644 index 0000000..3068bf7 --- /dev/null +++ b/crates/ld-server/tests/objections.rs @@ -0,0 +1,660 @@ +//! 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, +} + +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) -> (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 { + 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) -> (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"); + + // 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"); + 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) = { + 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 = 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) = 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::>().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, Option); + let rows: Vec = 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) + ); +} diff --git a/crates/ld-server/tests/typos.rs b/crates/ld-server/tests/typos.rs new file mode 100644 index 0000000..69b7776 --- /dev/null +++ b/crates/ld-server/tests/typos.rs @@ -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, +} + +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) -> (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 { + 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) -> (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"); +} diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 387f6d4..5c54455 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -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}; From 83f8ca09810292772b4fe2cc4b9c41fbdd110e9f Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 04:21:20 +0100 Subject: [PATCH 3/4] =?UTF-8?q?v3=20:=20interface=20des=20objections,=20de?= =?UTF-8?q?s=20coquilles=20valid=C3=A9es=20et=20de=20la=20messagerie=20;?= =?UTF-8?q?=20contr=C3=B4les=20C28=E2=80=93C31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une version retenue par une objection le dit sous l'article, à chacun selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé » pour une redite, « Reformuler » pour une contradiction) ; la personne qui objecte, lever ou réitérer avant l'échéance. Le panier la montre sans choix et ne l'envoie jamais. « Détails » : faire une objection (redit / contredit), les objections et où elles en sont, les coquilles proposées par d'autres avec leur diff resserré, valider / refuser / revenir, l'historique dit qui a validé. Une coquille se propose sur toute version ouverte ou en vigueur. Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message traité n'a plus de geste. Répondre à une objection lit son message. Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste. Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/src/objections.rs | 19 +- crates/ld-server/src/proposals.rs | 6 +- crates/ld-server/tests/objections.rs | 2 + web/e2e/helpers.ts | 43 +++ web/e2e/objections.spec.ts | 223 +++++++++++ web/e2e/reader.spec.ts | 6 +- web/src/lib/basket.ts | 16 +- web/src/lib/components/BallotPanel.svelte | 29 +- web/src/lib/components/DeepList.svelte | 2 +- web/src/lib/components/DocPanel.svelte | 18 +- web/src/lib/components/Icon.svelte | 4 +- web/src/lib/components/ProposeView.svelte | 23 +- web/src/lib/components/Sidebar.svelte | 42 ++ web/src/lib/components/VersionDetails.svelte | 358 ++++++++++++++---- web/src/lib/components/VoteFold.svelte | 104 ++++- web/src/lib/diff.ts | 31 ++ web/src/lib/doc.svelte.ts | 24 ++ web/src/lib/i18n/en.ts | 127 ++++++- web/src/lib/i18n/es.ts | 127 ++++++- web/src/lib/i18n/fr.ts | 127 ++++++- web/src/lib/objections.ts | 28 ++ web/src/lib/session.svelte.ts | 28 ++ web/src/lib/types.ts | 86 ++++- web/src/lib/versions.ts | 3 +- web/src/routes/+layout.svelte | 21 +- .../routes/g/[group]/docs/[doc]/+page.svelte | 37 +- web/src/routes/messages/+page.svelte | 285 ++++++++++++++ web/tests/unit/basket.test.ts | 26 +- web/tests/unit/diff.test.ts | 45 +++ web/tests/unit/objections.test.ts | 37 ++ web/tests/unit/versions.test.ts | 2 +- 31 files changed, 1732 insertions(+), 197 deletions(-) create mode 100644 web/e2e/objections.spec.ts create mode 100644 web/src/lib/diff.ts create mode 100644 web/src/lib/objections.ts create mode 100644 web/src/routes/messages/+page.svelte create mode 100644 web/tests/unit/diff.test.ts create mode 100644 web/tests/unit/objections.test.ts diff --git a/crates/ld-server/src/objections.rs b/crates/ld-server/src/objections.rs index 4be4f5c..9ae259f 100644 --- a/crates/ld-server/src/objections.rs +++ b/crates/ld-server/src/objections.rs @@ -405,8 +405,9 @@ pub async fn reiterate( // What answers an objection // --------------------------------------------------------------------------- -/// A version changed: the objections around it whose author it answers move on. -pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool) -> AppResult<()> { +/// 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) }; @@ -416,7 +417,9 @@ pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool) - 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" }; - store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await?; + if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? { + messages::read_subject(state, by, Topic::Objection, &o.id).await?; + } } } Ok(()) @@ -443,15 +446,17 @@ pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str Ok(()) } -/// A proposal linked to an objection was submitted: it answers the objection. -pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str) -> AppResult<()> { +/// 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?; + 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(()) } diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index d246ead..e6c1961 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -298,7 +298,7 @@ pub async fn create( .execute(&state.db) .await?; if b.submit { - objections::on_linked(&state, oid, &id).await?; + objections::on_linked(&state, oid, &id, &user.id).await?; } } Ok(Json(json!({ "id": id, "unitId": unit_id }))) @@ -389,7 +389,7 @@ pub async fn edit( record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).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).await?; + objections::on_change(&state, &id, true, &user.id).await?; typos::on_text_changed(&state, &id).await?; Ok(Json(json!({ "edit": "substantive", "revision": revision }))) } @@ -431,7 +431,7 @@ pub async fn submit( let linked: Option = 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).await?; + 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 }))) diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs index 3068bf7..79af219 100644 --- a/crates/ld-server/tests/objections.rs +++ b/crates/ld-server/tests/objections.rs @@ -216,6 +216,7 @@ async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector 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; @@ -263,6 +264,7 @@ async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() { "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"); diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index b9c95a8..c4d7c8b 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -69,6 +69,49 @@ export async function shot(page: Page, name: string) { if (dir) await page.screenshot({ path: `${dir}/${name}.png` }); } +const CSRF = { 'x-requested-with': 'libredecision' }; + +/** The document of the test group, as the server gives it. */ +export async function testDoc(page: Page): Promise<{ + id: string; + units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[]; +}> { + const g = await (await page.request.get('/api/groups/essai')).json(); + const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); + return { id: d.doc.id, units: d.units }; +} + +/** A new article proposed by the person of `page`, submitted to the vote; returns its id. */ +export async function propose(page: Page, title: string, body: string): Promise { + const doc = await testDoc(page); + const r = await page.request.post(`/api/docs/${doc.id}/proposals`, { + data: { kind: 'text', unitId: null, title, body, submit: true }, + headers: CSRF, + }); + if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`); + return (await r.json()).id; +} + +/** Moves the server's clock forward (dev login only) and passes the deadlines at once. */ +export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> { + const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF }); + if (!r.ok()) throw new Error(`clock ${r.status()}`); + return r.json(); +} + +/** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */ +export async function shots(page: Page, name: string) { + if (!process.env.LD_SHOTS) return; + const size = page.viewportSize(); + await page.waitForTimeout(400); + await shot(page, `${name}-bureau`); + await page.setViewportSize({ width: 412, height: 915 }); + await page.waitForTimeout(200); + await shot(page, `${name}-mobile`); + if (size) await page.setViewportSize(size); + await page.waitForTimeout(200); +} + /** The control door of the simulated chain, found where the page finds its node. */ export async function chain(page: Page, docPath = DOC) { const [, , group] = docPath.split('/'); diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts new file mode 100644 index 0000000..b940ae7 --- /dev/null +++ b/web/e2e/objections.spec.ts @@ -0,0 +1,223 @@ +import { expect, test, type Page } from '@playwright/test'; +import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers'; + +// Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit » +// holds back the later of two versions until it is lifted; the author answers, +// the objector lifts or reiterates from the box « Messages », and with no answer +// within 3 days it is lifted. A typo fix to someone else's text waits for its +// author (an administrator for a seed text) or for 3 days. Each control has its +// own people and its own proposals, in the test group. + +const DAY = 86_400; + +/** The menu's « Messages », with its number of unread messages. */ +async function openBox(page: Page, unread: number) { + const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); + const link = nav.getByRole('link', { name: `Messages, ${unread} ${unread > 1 ? 'non lus' : 'non lu'}` }); + await expect(link).toBeVisible(); + await expect(link.locator('.unread')).toHaveText(String(unread)); + await link.click(); + await expect(page.getByRole('heading', { level: 1, name: 'Messages' })).toBeVisible(); +} + +/** The details of a version, in the panel. */ +async function details(page: Page, id: string) { + await page.goto(`${TEST_DOC}?onglet=details&version=${id}`); + const panel = page.locator('aside.panel'); + await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true'); + return panel; +} + +/** From the details of a version: an objection against an article in force. */ +async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target: string, reason: string) { + const panel = await details(page, id); + await panel.getByText('Faire une objection').click(); + await panel.locator(`.kinds label:has(input[value="${kind === 'Redit' ? 'redit' : 'contredit'}"])`).click(); + await expect(panel.getByRole('radio', { name: new RegExp(`^${kind}`) })).toBeChecked(); + await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` }); + await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason); + await panel.getByRole('button', { name: "Envoyer l'objection" }).click(); + await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible(); + return panel; +} + +test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => { + const aude = await person(browser, 'Aude'); + const oscar = await person(browser, 'Oscar'); + const vera = await person(browser, 'Vera'); + const title = 'Une seule clé par personne'; + const id = await propose(aude, title, "Je m'engage à n'avoir qu'un seul compte membre : une seule clé certifiante par être humain."); + + // Oscar: it repeats « Unicité du compte ». + const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.'); + await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`); + await expect(panel.getByText('cette version ne se vote plus')).toBeVisible(); + await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible(); + await shots(oscar, 'f1-objection-vue-par-qui-objecte'); + + // Vera, who votes: held back, said so, nothing to press; the list marks it. + await vera.goto(`${TEST_DOC}?vote=${id}`); + const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` }); + await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible(); + await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible(); + await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); + await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection'); + await fold.scrollIntoViewIfNeeded(); + await shots(vera, 'f2-objection-vue-par-qui-vote'); + + // Aude: a message, and the way to answer — modify the article repeated. + await aude.goto(TEST_DOC); + await openBox(aude, 1); + const raised = aude.locator('li.message', { hasText: 'Oscar fait une objection' }); + await expect(raised).toContainText(`ta version « ${title} » redit « Unicité du compte »`); + await expect(raised).toContainText("Pour répondre, modifie l'article visé."); + await shots(aude, 'f3-messages-auteur'); + await raised.getByRole('button', { name: "Modifier l'article visé" }).click(); + await expect(aude).toHaveURL(/onglet=proposer/); + await expect(aude).toHaveURL(/objection=/); + const sheet = aude.locator('aside.panel'); + await expect(sheet.getByText("Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.")).toBeVisible(); + const body = sheet.getByLabel('Texte', { exact: true }); + await expect(body).toHaveValue(/Je m.engage sur l.honneur/); + await body.fill(`${await body.inputValue()}\n\nUne seule clé certifiante par être humain, et un seul compte membre.`); + await shots(aude, 'f4-reponse-auteur'); + await sheet.getByRole('button', { name: 'Soumettre au vote' }).click(); + await expect(aude.getByText('Proposition soumise au vote.')).toBeVisible(); + + // Oscar: the answer, to lift or reiterate, with its deadline. + await oscar.goto(TEST_DOC); + await openBox(oscar, 1); + const answered = oscar.locator('li.message', { hasText: 'a reçu une réponse' }); + await expect(answered).toContainText(`Ton objection sur « ${title} » a reçu une réponse.`); + await expect(answered.getByText(/^échéance : /)).toBeVisible(); + await expect(answered.getByRole('button', { name: "Réitérer l'objection" })).toBeVisible(); + await shots(oscar, 'f5-messages-qui-objecte'); + await answered.getByRole('button', { name: "Lever l'objection" }).click(); + await expect(oscar.getByText('Objection levée.')).toBeVisible(); + // Dealt with: no gesture any more, and read. + await expect(answered.getByText('objection levée', { exact: true })).toBeVisible(); + await expect(answered.locator('.btn')).toHaveCount(0); + await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible(); + + // Vera: it is voted on again. + await vera.reload(); + await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); + await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0); +}); + +test('C29 — « contredit » : l’auteur reformule, sans réponse de la personne qui objecte l’objection est levée à l’échéance', async ({ browser }) => { + const aude = await person(browser, 'Aude'); + const oscar = await person(browser, 'Oscar'); + const vera = await person(browser, 'Vera'); + const title = 'Certification à sens unique'; + const id = await propose(aude, title, "Je certifie une personne sans attendre qu'elle me certifie à son tour."); + await raise(oscar, id, 'Contredit', 'Réciprocité', 'Réciprocité demande que la certification aille dans les deux sens.'); + + // Aude, from the vote under the article: reword it. + await aude.goto(`${TEST_DOC}?vote=${id}`); + const fold = aude.getByRole('region', { name: `Le vote de « ${title} »` }); + await expect(fold.getByText('Elle contredit « Réciprocité ».')).toBeVisible(); + await expect(fold.getByText("Pour qu'elle se vote à nouveau, reformule-la.", { exact: false })).toBeVisible(); + await fold.scrollIntoViewIfNeeded(); + await shots(aude, 'f8-objection-vue-par-l-auteur'); + await fold.getByRole('button', { name: 'Reformuler' }).click(); + await expect(aude).toHaveURL(/modifier=1/); + const panel = aude.locator('aside.panel'); + const body = panel.getByLabel('Texte', { exact: true }); + await expect(body).toBeVisible(); + await body.fill("Je certifie une personne et j'attends qu'elle me certifie à son tour, comme le veut la réciprocité."); + await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); + await expect(aude.getByText('Nouvelle version enregistrée : les votants devront reconfirmer.')).toBeVisible(); + await expect(fold.getByText(/^Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le /)).toBeVisible(); + + // Oscar says nothing; three days pass on the server's clock. + const swept = await advanceClock(oscar, 3 * DAY + 60); + expect(swept.lifted).toBeGreaterThanOrEqual(1); + + // Vera: votable again; the objection says why it was lifted. + const panelV = await details(vera, id); + await expect(panelV.locator('.objection')).toContainText('levée : 3 jours sans suite'); + await vera.goto(`${TEST_DOC}?vote=${id}`); + await expect(vera.getByRole('region', { name: `Le vote de « ${title} »` }).getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); + + // Oscar's message: dealt with, by the deadline. + await oscar.goto('/messages'); + const answered = oscar.locator('li.message', { hasText: `Ton objection sur « ${title} »` }); + await expect(answered.getByText('objection levée : 3 jours sans suite')).toBeVisible(); + await expect(answered.locator('.btn')).toHaveCount(0); +}); + +test('C30 — la coquille d’un autre sur une proposition : en attente, puis validée par l’auteur depuis ses messages', async ({ browser }) => { + const aude = await person(browser, 'Aude'); + const vera = await person(browser, 'Vera'); + const title = 'Clé unique et vivante'; + const id = await propose(aude, title, "Je m'engage à n'utiliser qu'une seule clé certifiante, tenue par moi seul."); + + // Vera fixes the apostrophes: it waits for Aude, the text stays as it was. + const panel = await details(vera, id); + await panel.getByText('Corriger une coquille').click(); + const body = panel.getByLabel('Texte', { exact: true }); + await body.fill((await body.inputValue()).replaceAll("'", '’')); + await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); + await expect(vera.getByText(/^Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le /)).toBeVisible(); + const fix = panel.locator('li.fix'); + await expect(fix).toContainText('proposée par Vera'); + await expect(fix).toContainText("en attente : s'applique d'elle-même le"); + await expect(fix.locator('ins')).toHaveCount(3); + await expect(panel.locator('.prose').first()).toContainText("Je m'engage à n'utiliser"); + await shots(vera, 'f6-coquille-en-attente'); + + // Aude validates it from her box. + await aude.goto(TEST_DOC); + await openBox(aude, 1); + const msg = aude.locator('li.message', { hasText: `Vera propose une correction de coquille dans ta version « ${title} ».` }); + await expect(msg.locator('ins')).toHaveCount(3); + await expect(msg.getByText(/^échéance : /)).toBeVisible(); + await msg.getByRole('button', { name: 'Valider' }).click(); + await expect(aude.getByText('Correction appliquée.')).toBeVisible(); + await expect(msg.getByText('correction appliquée', { exact: true })).toBeVisible(); + await expect(msg.locator('.btn')).toHaveCount(0); + + // The text carries the fix; its history says who wrote it and who let it in. + const after = await details(vera, id); + await expect(after.locator('.prose').first()).toContainText('Je m’engage à n’utiliser'); + await expect(after.locator('.history')).toContainText('coquille corrigée'); + await expect(after.locator('.history')).toContainText('par Vera'); + await expect(after.locator('.history')).toContainText('validée par Aude'); + await expect(after.locator('li.fix')).toContainText('appliquée · par Aude'); +}); + +test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s’applique', async ({ browser }) => { + // An administrator opens the group first: the message goes to them (a seed text has no author). + const ada = await person(browser, 'Ada', true); + await ada.goto(TEST_DOC); + const vera = await person(browser, 'Vera'); + await vera.goto(TEST_DOC); + const purpose = vera.locator('section.text article.unit', { hasText: "Raison d'être" }); + await purpose.hover(); + await purpose.getByRole('button', { name: 'Le vote' }).click(); + await purpose.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); + const panel = vera.locator('aside.panel'); + await panel.getByText('Corriger une coquille').click(); + const body = panel.getByLabel('Texte', { exact: true }); + await body.fill((await body.inputValue()).replace("C'est le seul", 'C’est le seul')); + await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); + await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible(); + // In force, only a validated fix changes the text. + await expect(purpose).toContainText("C'est le seul et unique propos"); + await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le"); + + await ada.reload(); + await openBox(ada, 1); + const msg = ada.locator('li.message', { hasText: "Vera propose une correction de coquille dans « Raison d'être », un texte du seed" }); + await shots(ada, 'f7-messages-administrateur'); + await msg.getByRole('button', { name: 'Valider' }).click(); + await expect(ada.getByText('Correction appliquée.')).toBeVisible(); + + await vera.reload(); + await expect(purpose).toContainText('C’est le seul et unique propos'); + const doc = await testDoc(vera); + const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être"); + const after = await details(vera, unit?.inForce?.id ?? ''); + await expect(after.locator('.history')).toContainText('validée par Ada'); +}); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index b105d87..8a45ad1 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -90,13 +90,15 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr }); test('C6 — une coquille se corrige sans vote', async ({ page }) => { - await signIn(page, 'Chiara'); + // A seed text has no author: an administrator's fix applies at once (someone + // else's waits for an administrator, or for 3 days: C31). + await signIn(page, 'Yvv', true); await page.goto(TEST_DOC); const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); await unicity.hover(); await unicity.getByRole('button', { name: 'Le vote' }).click(); // What is not the vote lives in the panel, tab « Détails ». - await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click(); + await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); const panel = page.locator('aside.panel'); await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true'); await panel.getByText('Corriger une coquille').click(); diff --git a/web/src/lib/basket.ts b/web/src/lib/basket.ts index cb37a23..1b937ee 100644 --- a/web/src/lib/basket.ts +++ b/web/src/lib/basket.ts @@ -17,6 +17,8 @@ export interface Slot { versions: Proposal[]; /** Open changes of its inertia: another question, never filled by a grouped gesture. */ inertia: Proposal[]; + /** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */ + held: Proposal[]; } export interface Section { @@ -32,12 +34,13 @@ export function sections(units: Unit[]): Section[] { const open = unit.proposals.filter((p) => p.status === 'open'); const versions = [ ...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []), - ...open.filter((p) => p.kind !== 'inertia'), + ...open.filter((p) => p.kind !== 'inertia' && p.votable !== false), ]; const inertia = open.filter((p) => p.kind === 'inertia'); - if (!versions.length && !inertia.length) continue; + const held = open.filter((p) => p.kind !== 'inertia' && p.votable === false); + if (!versions.length && !inertia.length && !held.length) continue; const last = out.at(-1); - const slot = { unit, versions, inertia }; + const slot = { unit, versions, inertia, held }; if (last && last.key === unit.section) last.slots.push(slot); else out.push({ key: unit.section, slots: [slot] }); } @@ -59,9 +62,14 @@ export function waiting(slots: Slot[], picks: Picks): Slot[] { return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks)); } +/** The versions one may vote now: those a choice of the basket is sent for. */ +export function votableIds(all: Section[]): Set { + return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); +} + /** Empties the basket of these articles only. */ export function clearScope(slots: Slot[], picks: Picks): Picks { - const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))); + const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia, ...s.held].map((p) => p.id))); return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id))); } diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index ed10023..b39daed 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -3,8 +3,9 @@ // section entière, ou un article avec son titre, ou séparément »): choices // article by article, « tout pour » / « tout contre » for a section or the whole // document — never for an article whose versions compete —, then one gesture. + // A version held back by an objection is shown, with nothing to choose. import { onMount } from 'svelte'; - import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket'; + import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket'; import type { DocState } from '$lib/doc.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { relationOf, titleOf } from '$lib/labels'; @@ -37,7 +38,7 @@ const access = $derived(view?.vote); const all = $derived(sections(view?.units ?? [])); const slots = $derived(all.flatMap((s) => s.slots)); - const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)))); + const known = $derived(votableIds(all)); const toSend = $derived(lines(basket.picks, known)); const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id))))); const left = $derived(waiting(slots, basket.picks)); @@ -51,7 +52,7 @@ }); function headline(s: Slot): string { - const text = s.versions.find((p) => p.kind === 'text'); + const text = [...s.versions, ...s.held].find((p) => p.kind === 'text'); return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code); } @@ -123,7 +124,7 @@ {#each s.slots as slot (slot.unit.id)} {@const title = headline(slot)} {@const several = slot.versions.length > 1} - {@const first = slot.versions[0] ?? slot.inertia[0]} + {@const first = slot.versions[0] ?? slot.inertia[0] ?? slot.held[0]}
  • {#if several}

    {t('basket.versions')}

    {/if} @@ -145,6 +146,15 @@ /> {/each} + {#each slot.held as v (v.id)} +
    + + {labelOf(v, slot)} + {t('tally.short', { for: v.tally.for, against: v.tally.against })} + + {t('basket.held')} +
    + {/each}
  • {/each} @@ -279,6 +289,17 @@ .small { font-size: 0.8rem; } + .version.held .label { + color: var(--ink-soft); + } + .held-note { + padding: 0.25rem 0.7rem; + border-radius: var(--r-pill); + background: var(--ochre-wash); + color: var(--ochre); + font-size: 0.8rem; + font-weight: 750; + } .send { position: sticky; bottom: -1.6rem; diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index ac68677..2edd612 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -62,7 +62,7 @@ {#snippet marks(p: Proposal)} {#if p.evaluation?.wellRated}{t('deep.wellrated')}{/if} - {#if p.blocked}{t('deep.blocked')}{/if} + {#if p.blocked}{t('deep.held')}{/if} {#if p.myVoteStale}{t('deep.stale')}{/if} {#if p.mine}{t('deep.mine')}{/if} {/snippet} diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index 5dc1ccf..3a7fa38 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -5,7 +5,9 @@ import type { DocState, PanelState } from '$lib/doc.svelte'; import { t } from '$lib/i18n/index.svelte'; import { relationOf, titleOf } from '$lib/labels'; + import { answerPanel } from '$lib/objections'; import type { BasketState } from '$lib/picks.svelte'; + import { session } from '$lib/session.svelte'; import type { Lang } from '$lib/types'; import type { Line } from '$lib/vote'; import BallotPanel from './BallotPanel.svelte'; @@ -77,7 +79,7 @@
    {#each tabs as [key, label] (key)} - + {/each}
    @@ -91,8 +93,13 @@ {doc} proposalId={voteId} {lang} + edit={!!panel.edit} onvote={(id) => onvote(id, true)} - onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })} + onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null, edit: false })} + onanswer={(o) => { + const next = answerPanel(o, session.me?.id ?? null); + if (next) onchange(next); + }} /> {:else if panel.tab === 'proposals' && unit}
      @@ -103,7 +110,7 @@ {v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} {titleOf(v, unit)} - {v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if} + {v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · {t('deep.held')}{/if} @@ -116,6 +123,8 @@ unitId={panel.unitId} draftId={panel.draftId ?? null} prefill={panel.prefill ?? null} + from={panel.objectionId ? (panel.proposalId ?? null) : null} + objectionId={panel.objectionId ?? null} ondone={(r) => r.proposalId && r.submitted ? onvote(r.proposalId, true) @@ -223,6 +232,9 @@ .small { font-size: 0.82rem; } + .held { + color: var(--ochre); + } @keyframes enter { from { transform: translateX(16px); diff --git a/web/src/lib/components/Icon.svelte b/web/src/lib/components/Icon.svelte index 527ecd4..35446b1 100644 --- a/web/src/lib/components/Icon.svelte +++ b/web/src/lib/components/Icon.svelte @@ -1,6 +1,6 @@ diff --git a/web/src/lib/components/ProposeView.svelte b/web/src/lib/components/ProposeView.svelte index a90b1a4..ce1bff2 100644 --- a/web/src/lib/components/ProposeView.svelte +++ b/web/src/lib/components/ProposeView.svelte @@ -16,12 +16,18 @@ unitId, draftId = null, prefill = null, + from = null, + objectionId = null, ondone, }: { doc: DocState; unitId: string | null; draftId?: string | null; prefill?: { title: string; body: string } | null; + /** The version whose text the new one starts from (an answer to an objection). */ + from?: string | null; + /** The objection this proposal answers (ADR 0010): linked to it when created. */ + objectionId?: string | null; ondone: (r: { proposalId: string | null; submitted: boolean }) => void; } = $props(); @@ -42,16 +48,18 @@ // (Re)initialise only when the panel is pointed elsewhere — never on a reload, // which would wipe what the person is typing. $effect(() => { - const [u0, d0, pf] = [unitId, draftId, prefill]; + const [u0, d0, pf, f0] = [unitId, draftId, prefill, from]; + void objectionId; untrack(() => { const u = doc.unit(u0); const d = doc.proposal(d0); + const f = doc.proposal(f0); target = u0; savedId = d0; mode = u0 ? 'replace' : 'new'; after = doc.inForce.at(-1)?.id ?? ''; - title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? ''; - body = pf?.body ?? d?.body ?? u?.inForce?.body ?? ''; + title = pf?.title ?? d?.title ?? f?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? ''; + body = pf?.body ?? d?.body ?? f?.body ?? u?.inForce?.body ?? ''; error = ''; }); }); @@ -93,6 +101,7 @@ title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title, body: mode === 'remove' ? '' : body, submit: false, + ...(objectionId && mode !== 'new' ? { objectionId } : {}), }); savedId = r.id; return r.id; @@ -152,6 +161,7 @@
      + {#if objectionId && mode !== 'new' && !draft}

      {t('objection.linked')}

      {/if} {#if !draft}
      {#if unit} @@ -249,6 +259,13 @@ .assist .btn { justify-self: start; } + .linked { + padding: 0.7rem 0.9rem; + border-radius: var(--r-input); + background: var(--ochre-wash); + font-weight: 650; + font-size: 0.9rem; + } .small { font-size: 0.84rem; } diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index d59f75f..7e2323b 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -57,6 +57,17 @@ onnavigate?.()}> {t('nav.groups')} + onnavigate?.()} + > + {t('nav.messages')} + {#if session.unread}{/if} + {/if}
        @@ -146,6 +157,37 @@ .groups.current .eyebrow { color: var(--ink); } + .messages { + display: flex; + align-items: center; + gap: 0.55rem; + min-height: 2.5rem; + margin-top: -0.9rem; + padding: 0.5rem 0.7rem; + border-radius: var(--r-input); + color: var(--ink); + font-weight: 700; + text-decoration: none; + transition: background-color 0.15s ease; + } + .messages:hover, + .messages.current { + background: var(--water-wash); + } + .messages.current { + color: var(--water); + } + .unread { + margin-left: auto; + min-width: 1.6rem; + padding: 0.05rem 0.45rem; + border-radius: var(--r-pill); + background: var(--ochre); + color: var(--on-water); + font-size: 0.78rem; + font-weight: 800; + text-align: center; + } .tools { list-style: none; margin: 0; diff --git a/web/src/lib/components/VersionDetails.svelte b/web/src/lib/components/VersionDetails.svelte index 0f31741..7a75a8a 100644 --- a/web/src/lib/components/VersionDetails.svelte +++ b/web/src/lib/components/VersionDetails.svelte @@ -1,14 +1,17 @@ @@ -184,6 +240,7 @@ {#if p.seedMinimal}{t('doc.seed')} {:else if p.confirmedAt}{t('doc.confirmed')} {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} + {#if p.blocked}{t('deep.held')}{/if} {#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}

        @@ -202,14 +259,14 @@ {/if} - {#if votable} + {#if live}

        {t('tally.short', { for: p.tally.for, against: p.tally.against })} ·

        {/if} - {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable} + {#if p.kind === 'text' && live}
        {p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')} @@ -227,6 +284,30 @@
        {/if} + {#if detail.typos.length} +
        +

        {t('typo.list')}

        +
          + {#each detail.typos as fix (fix.id)} +
        • + +

          + {t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)} +

          +

          {typoState(fix)}

          + {#if fix.gestures.length} +
          + {#each fix.gestures as g (g)} + + {/each} +
          + {/if} +
        • + {/each} +
        +
        + {/if} + {#if lang !== source && p.kind === 'text'}
        @@ -247,7 +328,10 @@
          {#each detail.history.slice(1) as h (h.at + h.revision)}
        1. -

          {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}

          +

          + {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if} + {#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if} +

          {#if h.diff}{/if}
        2. {/each} @@ -255,33 +339,69 @@
        {/if} - {#if p.kind === 'text' && votable} -
        -

        {t('signal.title')}

        - {#each detail.signals as s (s.id)} -
        -

        {s.other?.title ?? ''} — {s.reason}

        -

        - {t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)} - {#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if} + {#if p.kind === 'text' && (live || detail.objections.length)} +

        +

        {t('objection.title')}

        + {#each detail.objections as o (o.id)} + {@const standing = o.phase !== 'lifted'} +
        +

        + {t(`objection.kind.${o.kind}`)} + {t(`objection.says.${o.kind}`, { held: o.heldVersion?.title ?? '', other: o.other.title ?? '' })}

        - {#if s.mine && !s.liftedAt}{/if} +

        {o.reason}

        +

        + {t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)} + · {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if} +

        + {#if standing && o.held}

        {t('objection.held.here')}

        {/if} + {#if standing} +
        + {#if o.iAnswer && !o.mine && o.phase === 'author'} + + {/if} + {#if o.canLift} + + {/if} + {#if o.canReiterate} + + {/if} +
        + {/if}
        {:else} -

        {t('signal.none')}

        +

        {t('objection.none')}

        {/each} -
        - {t('signal.add')} -
        -

        {t('signal.note')}

        - - - -
        -
        + {#if live && objectionTargets.length} +
        + {t('objection.add')} +
        +

        {t('objection.note')}

        +
        + {#each ['redit', 'contredit'] as const as k (k)} + + {/each} +
        + + + +
        +
        + {/if}
        {/if} @@ -375,18 +495,88 @@ display: grid; gap: 0.6rem; } - .signal { + .fixes { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.6rem; + } + .fix, + .objection { padding: 0.6rem 0.8rem; border-radius: var(--r-input); - background: var(--ochre-wash); display: grid; - gap: 0.2rem; + gap: 0.25rem; } - .signal.lifted { + .fix { + background: var(--water-wash); + } + .fix.pending .state { + font-weight: 750; + color: var(--water); + } + .fix.refused, + .fix.stale { + background: var(--surface-2); + opacity: 0.8; + } + .objection { + background: var(--ochre-wash); + } + .objection.lifted { background: var(--surface-2); opacity: 0.75; } - .signal .btn { - justify-self: start; + .reason { + font-family: var(--font-text); + font-style: italic; + color: var(--ink-soft); + font-size: 0.92rem; + } + .held { + font-weight: 750; + color: var(--ochre); + } + .gestures { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.2rem; + } + .kinds { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.4rem; + } + .kinds label { + display: grid; + gap: 0.1rem; + padding: 0.55rem 0.8rem; + border-radius: var(--r-input); + background: var(--surface-2); + cursor: pointer; + font-size: 0.9rem; + } + .kinds label.on { + background: var(--ochre); + color: var(--on-water); + } + .kinds label.on .hint { + color: inherit; + opacity: 0.85; + } + .kinds input { + position: absolute; + opacity: 0; + pointer-events: none; + } + .kinds label:has(input:focus-visible) { + outline: 2px solid var(--water); + outline-offset: 2px; + } + .hint { + font-size: 0.8rem; + color: var(--ink-soft); } diff --git a/web/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index 4dc4e33..796e5ee 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -3,16 +3,20 @@ // vote et les autres. À combien il se rapproche du passage en vigueur. Façon // toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals — // never who voted what —, how far the threshold is, your vote and where it - // stands, then the inertia and the formula. The rest of a version (typos, - // translations, flags, history) lives in the panel, tab « Détails ». + // stands, then the inertia and the formula. A version held back by an + // objection says so, and tells each one what is theirs to do (ADR 0010). The + // rest of a version (typos, translations, objections, history) lives in the + // panel, tab « Détails ». import { onMount } from 'svelte'; import { api } from '$lib/api'; import type { DocState } from '$lib/doc.svelte'; import { i18n, t } from '$lib/i18n/index.svelte'; import { inertiaBadge, titleOf } from '$lib/labels'; + import { holding } from '$lib/objections'; + import { session } from '$lib/session.svelte'; import { toasts } from '$lib/toast.svelte'; - import type { LevelInfo, Proposal, Unit } from '$lib/types'; - import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote'; + import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types'; + import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote'; import Formula from './Formula.svelte'; import InertiaPicker from './InertiaPicker.svelte'; import WaterLevel from './WaterLevel.svelte'; @@ -24,6 +28,7 @@ onopen, onsettings, ondetails, + onanswer, onsign, }: { doc: DocState; @@ -33,6 +38,8 @@ onopen: (proposalId: string) => void; onsettings: (proposalId: string) => void; ondetails: (proposalId: string) => void; + /** The author of a version held back answers its objection: modify the article repeated, or reword. */ + onanswer: (objection: Objection) => void; /** On the Ğ1 chain: the page has the lines signed by the person's wallet. */ onsign: (lines: Line[]) => void; } = $props(); @@ -41,6 +48,7 @@ electorate: number; voteFloor: number; levels: LevelInfo[]; + objections: Objection[]; } let detail = $state(null); @@ -51,7 +59,9 @@ const view = $derived(doc.view); const access = $derived(view?.vote); const chain = $derived(access?.way === 'chain'); - const votable = $derived(p.status === 'open' || p.status === 'in_force'); + const live = $derived(p.status === 'open' || p.status === 'in_force'); + /** The objections holding this version back, and what each one asks of whom. */ + const held = $derived(p.blocked ? holding(detail?.objections ?? []) : []); const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit)); const voted = $derived(p.myVote !== null && !p.myVoteStale); const ballot = $derived(p.myBallot); @@ -60,6 +70,9 @@ const id = p.id; void p.tally.for; void p.tally.against; + void p.blocked; + void p.objections; + void p.revision; api .get(`/proposals/${id}`) .then((d) => { @@ -74,6 +87,22 @@ root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' }); }); + async function settle(o: Objection, gesture: 'lift' | 'reiterate') { + error = ''; + busy = true; + try { + const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`); + toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated')); + await doc.reload(); + announce(doc, r.entered ?? []); + void session.refreshUnread(); + } catch (e) { + error = errorText(e); + } finally { + busy = false; + } + } + async function cast(choice: 'for' | 'against' | null) { error = ''; const lines: Line[] = [{ proposalId: p.id, choice }]; @@ -105,7 +134,38 @@

        {inertiaBadge(p.votedAtInertia)}

      - {#if p.blocked}

      {t('vote.blocked')}

      {/if} + {#if p.blocked} +
      +

      {t('vote.held')}

      + {#each held as o (o.id)} +
      +

      + {t(o.kind === 'redit' ? 'vote.held.redit' : 'vote.held.contredit', { other: o.other.title ?? '' })} + · {t('objection.by', { name: o.objector ?? '' })} +

      +

      {o.reason}

      + {#if o.mine} +

      {o.phase === 'objector' ? t('vote.held.mine.asked', { date: i18n.date(o.dueAt) }) : t('vote.held.mine')}

      + {:else if o.phase === 'objector'} +

      {t('vote.held.waiting', { name: o.objector ?? '', date: i18n.date(o.dueAt) })}

      + {:else if o.iAnswer} +

      {t(o.kind === 'redit' ? 'vote.held.author.redit' : 'vote.held.author.contredit')}

      + {/if} +
      + {#if o.iAnswer && !o.mine && o.phase === 'author'} + + {/if} + {#if o.canLift} + + {/if} + {#if o.canReiterate} + + {/if} +
      +
      + {/each} +
      + {/if}
      {#if ballot?.status === 'pending'} @@ -125,8 +185,10 @@ {/if}
      - {#if !votable} + {#if !live}

      {t('vote.closed')}

      + {:else if p.blocked} + {:else if access && !access.open}

      {t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}

      {:else if access} @@ -247,6 +309,34 @@ font-weight: 700; font-size: 0.9rem; } + .held { + display: grid; + gap: 0.7rem; + padding: 0.85rem 1rem; + border-radius: var(--r-input); + background: var(--ochre-wash); + } + .lead { + font-weight: 750; + } + .objection { + display: grid; + gap: 0.35rem; + font-size: 0.92rem; + } + .reason { + font-family: var(--font-text); + font-style: italic; + color: var(--ink-soft); + } + .gestures { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + .gestures:empty { + display: none; + } .more { display: grid; gap: 0.9rem; diff --git a/web/src/lib/diff.ts b/web/src/lib/diff.ts new file mode 100644 index 0000000..89e88c3 --- /dev/null +++ b/web/src/lib/diff.ts @@ -0,0 +1,31 @@ +// A typo fix is a sign or two in a long text: shown around its changes only, +// a few words on each side, the rest cut between words. + +import type { DiffPart } from './types'; + +function head(text: string, n: number): string { + if (text.length <= n) return text; + let s = text.slice(0, n); + if (!/\s/.test(text[n] ?? ' ') && /\s/.test(s)) s = s.slice(0, s.search(/\s\S*$/)); + return s.trimEnd(); +} + +function tail(text: string, n: number): string { + if (text.length <= n) return text; + let s = text.slice(-n); + if (!/\s/.test(text[text.length - n - 1] ?? ' ') && /\s/.test(s)) s = s.slice(s.search(/\s/) + 1); + return s.trimStart(); +} + +/** The parts of a diff, the unchanged text kept to `context` signs around each change. */ +export function condense(parts: DiffPart[], context = 40): DiffPart[] { + if (!parts.some((p) => p.op !== 'same')) return parts; + const last = parts.length - 1; + return parts.map((p, i) => { + if (p.op !== 'same') return p; + if (i === 0) return p.text.length <= context ? p : { op: 'same', text: `… ${tail(p.text, context)}` }; + if (i === last) return p.text.length <= context ? p : { op: 'same', text: `${head(p.text, context)} …` }; + if (p.text.length <= 2 * context + 5) return p; + return { op: 'same', text: `${head(p.text, context)} … ${tail(p.text, context)}` }; + }); +} diff --git a/web/src/lib/doc.svelte.ts b/web/src/lib/doc.svelte.ts index 0217acb..d1418de 100644 --- a/web/src/lib/doc.svelte.ts +++ b/web/src/lib/doc.svelte.ts @@ -19,6 +19,30 @@ export interface PanelState { /** The basket, opened on one section. */ section?: string | null; prefill?: { title: string; body: string } | null; + /** A new proposal that answers this objection (ADR 0010). */ + objectionId?: string | null; + /** The details of one's own version, its edit form open: to reword it. */ + edit?: boolean; +} + +/** The tabs as the URL names them. */ +export const TABS: Record = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' }; +export const TAB_NAMES: Record = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' }; + +/** The query of a reader's URL: the unfolded vote and the panel. */ +export function panelSearch(panel: PanelState | null, vote: string | null): string { + const q = new URLSearchParams(); + if (vote) q.set('vote', vote); + if (panel) { + q.set('onglet', TAB_NAMES[panel.tab]); + if (panel.unitId) q.set('article', panel.unitId); + if (panel.proposalId) q.set('version', panel.proposalId); + if (panel.draftId) q.set('brouillon', panel.draftId); + if (panel.section) q.set('section', panel.section); + if (panel.objectionId) q.set('objection', panel.objectionId); + if (panel.edit) q.set('modifier', '1'); + } + return q.toString(); } export interface Shown { diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 07c867e..ca08c2d 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -11,6 +11,9 @@ export const en: Messages = { 'nav.mandates': 'Mandates', 'nav.protocols': 'Protocols', 'nav.groups': 'My groups', + 'nav.messages': 'Messages', + 'nav.messages.count.one': 'Messages, {n} unread', + 'nav.messages.count.other': 'Messages, {n} unread', 'nav.docs.count.one': '{label}, {n} document in progress', 'nav.docs.count.other': '{label}, {n} documents in progress', 'nav.access': 'Access', @@ -137,7 +140,7 @@ export const en: Messages = { 'deep.addition': 'new article', 'deep.removal': 'removal of “{title}”', 'deep.inertia': 'inertia to {level}', - 'deep.blocked': 'blocked', + 'deep.held': 'not open to vote: objection', 'deep.mine': 'your proposal', 'deep.stale': 'needs reconfirming', 'deep.wellrated': 'well-rated', @@ -198,7 +201,14 @@ export const en: Messages = { 'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.', 'vote.reconfirm': 'Reconfirm: {choice}', 'vote.private': 'Nobody sees your vote: only the totals are visible.', - 'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.", + 'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.', + 'vote.held.redit': 'It repeats “{other}”.', + 'vote.held.contredit': 'It contradicts “{other}”.', + 'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.', + 'vote.held.author.contredit': 'For it to be voted on again, reword it. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.', + 'vote.held.waiting': 'An answer came: {name} lifts or reiterates the objection by {date}; with no answer, it is lifted.', + 'vote.held.mine': 'You raised this objection: lift it when the answer suits you.', + 'vote.held.mine.asked': 'An answer came: lift your objection if it suits you, or reiterate it, by {date}. With no answer from you, it is lifted.', 'vote.entered': '“{title}” has come into force.', 'vote.entered.remove': '“{title}” has been removed from the text in force.', 'vote.entered.inertia': 'The inertia of “{title}” has changed.', @@ -217,9 +227,9 @@ export const en: Messages = { 'vote.choice.none': 'withdrawing your vote', 'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.', 'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.', - 'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.', + 'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and raise an objection.', 'vote.members': 'Only members of this group vote here.', - 'vote.details': 'Typos, translations, flags, history', + 'vote.details': 'Typos, translations, objections, history', 'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.', 'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.', 'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.', @@ -246,6 +256,7 @@ export const en: Messages = { 'basket.sent.other': '{n} votes saved.', 'basket.section': 'Vote on this section', 'basket.mine': 'your vote: {choice}', + 'basket.held': 'not open to vote: an objection holds it back', 'sign.title': 'Vote with your Ğ1 wallet', 'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', 'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', @@ -327,27 +338,98 @@ export const en: Messages = { 'history.typo': 'typo fixed', 'history.substantive': 'substantive change (votes to reconfirm)', 'history.amendment': 'starting text replaced', + 'history.revert': 'fix taken back by the author', 'history.by': 'by {name}', + 'history.typo.validated': 'validated by {name}', + 'history.typo.deadline': 'applied with no answer within 3 days', 'typo.title': 'Fix a typo', - 'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.', + 'typo.note': "Accents, capitals, typos, words run together or split: the fix is not voted on. On your own text, it applies at once. On someone else's, its author validates it (an administrator for a seed text); with no answer within 3 days, it applies. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.", 'typo.save': 'Save the fix', 'typo.done': 'Typo fixed, votes untouched.', 'typo.substantive': 'This edit affects the meaning: propose it as a change.', 'typo.asProposal': 'Make it a proposal', + 'typo.pending.author': 'Your fix waits for the author of the text: it applies when they validate it, or by itself on {date}.', + 'typo.pending.admin': 'Your fix waits for an administrator, as this text comes from the seed: it applies when they validate it, or by itself on {date}.', + 'typo.list': 'Suggested typo fixes', + 'typo.by': 'suggested by {name}', + 'typo.status.pending': 'waiting: applies by itself on {date}', + 'typo.status.applied': 'applied', + 'typo.status.refused': 'refused', + 'typo.status.stale': 'out of date: the text changed meanwhile', + 'typo.settled.by': 'by {name}', + 'typo.settled.deadline': 'with no answer within 3 days', + 'typo.validate': 'Validate', + 'typo.refuse': 'Refuse', + 'typo.undo': 'Go back to the text before', + 'typo.redo': 'Apply after all', + 'typo.validated': 'Fix applied.', + 'typo.refused': 'Fix refused.', - 'signal.title': 'Contradictions', - 'signal.none': 'No flags.', - 'signal.add': 'Flag a contradiction', - 'signal.target': 'Contradicts', - 'signal.reason': 'How these two texts contradict each other', - 'signal.send': 'Flag', - 'signal.sent': 'Flag saved.', - 'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.", - 'signal.lift': 'Lift', - 'signal.lifted': 'lifted', - 'signal.lifted.revision': 'lapsed: the text changed', - 'signal.by': 'by {name}', + 'objection.title': 'Objections', + 'objection.none': 'No objection.', + 'objection.add': 'Raise an objection', + 'objection.kind': 'Your objection', + 'objection.kind.redit': 'Repeats', + 'objection.kind.contredit': 'Contradicts', + 'objection.kind.redit.hint': 'this text repeats another article', + 'objection.kind.contredit.hint': 'this text says the opposite of another article', + 'objection.target': 'Which article?', + 'objection.reason.redit': 'How these two texts say the same thing', + 'objection.reason.contredit': 'How these two texts contradict each other', + 'objection.send': 'Send the objection', + 'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.', + 'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.', + 'objection.says.redit': '“{held}” repeats “{other}”', + 'objection.says.contredit': '“{held}” contradicts “{other}”', + 'objection.by': 'objection by {name}', + 'objection.held.here': 'this version cannot be voted on', + 'objection.phase.author': "waiting for the author's answer", + 'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself', + 'objection.answer.revision': 'answer: the held text was reworded', + 'objection.answer.other': 'answer: the other text changed', + 'objection.answer.proposal': 'answer: a linked proposal', + 'objection.lifted.objector': 'lifted by the person who raised it', + 'objection.lifted.admin': 'lifted by an administrator', + 'objection.lifted.deadline': 'lifted: 3 days without follow-up', + 'objection.lifted.withdrawn': 'lifted: the proposal was withdrawn', + 'objection.lifted.revision': 'lapsed: the text changed', + 'objection.lift': 'Lift the objection', + 'objection.lift.admin': 'Lift the objection (administrator)', + 'objection.reiterate': 'Reiterate the objection', + 'objection.done.lifted': 'Objection lifted.', + 'objection.done.reiterated': 'Objection reiterated: a new answer is awaited.', + 'objection.respond.redit': 'Change the article it repeats', + 'objection.respond.contredit': 'Reword', + 'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.', + 'messages.title': 'Messages', + 'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.', + 'messages.none': 'No messages for now.', + 'messages.readAll': 'Mark all as read', + 'messages.read': 'Mark as read', + 'messages.unread': 'unread', + 'messages.due': 'deadline: {date}', + 'messages.open': 'See it in the text', + 'messages.topic.objection': 'Objection', + 'messages.topic.typo': 'Typo', + 'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.', + 'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.', + 'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.', + 'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.', + 'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.', + 'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.', + 'messages.respond': 'Answer', + 'messages.outcome.answered': 'answered', + 'messages.outcome.reiterated': 'objection reiterated', + 'messages.outcome.lifted': 'objection lifted', + 'messages.outcome.admin': 'objection lifted by an administrator', + 'messages.outcome.deadline.objection': 'objection lifted: 3 days without follow-up', + 'messages.outcome.deadline.typo': 'fix applied: 3 days without an answer', + 'messages.outcome.withdrawn': 'proposal withdrawn', + 'messages.outcome.applied': 'fix applied', + 'messages.outcome.refused': 'fix refused', + 'messages.outcome.stale': 'fix out of date: the text changed', + 'messages.outcome.done': 'dealt with', 'propose.mode.replace': 'Change this text', 'propose.mode.remove': 'Remove this article', @@ -458,7 +540,7 @@ export const en: Messages = { 'error.internal': 'Something failed on our side. Try again in a moment.', 'error.title': 'The title is empty or too long (120 characters max).', 'error.body': 'The text is empty or too long (4,000 characters max).', - 'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).', + 'error.reason': 'Say in a few words what your objection rests on (10 to 600 characters).', 'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.", 'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.', 'error.same_unit': "These two texts target the same article: they can't be in force together anyway.", @@ -483,6 +565,15 @@ export const en: Messages = { 'error.author': 'This account is not a Ğ1 address.', 'error.not_votable': 'This version is no longer open for voting.', 'error.members_only': 'Only members of this group vote here.', + 'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.', + 'error.kind': 'Say whether this text repeats the other or contradicts it.', + 'error.other': 'Choose another article, in force or proposed.', + 'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.', + 'error.objection_exists': 'You already have an objection standing on these two texts.', + 'error.nothing_to_reiterate': 'Nothing to reiterate: no answer has come yet.', + 'error.lifted': 'This objection is already lifted.', + 'error.settled': 'This fix is already decided.', + 'error.link': 'This proposal cannot answer this objection: it targets another article, or the objection is lifted.', 'error.retry': 'Try again', 'common.cancel': 'Cancel', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 854aac4..f566441 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -11,6 +11,9 @@ export const es: Messages = { 'nav.mandates': 'Mandatos', 'nav.protocols': 'Protocolos', 'nav.groups': 'Mis grupos', + 'nav.messages': 'Mensajes', + 'nav.messages.count.one': 'Mensajes, {n} sin leer', + 'nav.messages.count.other': 'Mensajes, {n} sin leer', 'nav.docs.count.one': '{label}, {n} documento en curso', 'nav.docs.count.other': '{label}, {n} documentos en curso', 'nav.access': 'Accesos', @@ -137,7 +140,7 @@ export const es: Messages = { 'deep.addition': 'artículo nuevo', 'deep.removal': 'retirada de «{title}»', 'deep.inertia': 'inercia a {level}', - 'deep.blocked': 'bloqueada', + 'deep.held': 'no votable: objeción', 'deep.mine': 'tu propuesta', 'deep.stale': 'por reconfirmar', 'deep.wellrated': 'bien valorada', @@ -198,7 +201,14 @@ export const es: Messages = { 'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.', 'vote.reconfirm': 'Reconfirmar: {choice}', 'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.', - 'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.', + 'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.', + 'vote.held.redit': 'Repite «{other}».', + 'vote.held.contredit': 'Contradice «{other}».', + 'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.', + 'vote.held.author.contredit': 'Para que vuelva a votarse, reformúlala. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.', + 'vote.held.waiting': 'Llegó una respuesta: {name} levanta o reitera la objeción antes del {date}; sin respuesta, se levanta.', + 'vote.held.mine': 'Hiciste esta objeción: levántala cuando la respuesta te convenga.', + 'vote.held.mine.asked': 'Llegó una respuesta: levanta tu objeción si te convence, o reitérala, antes del {date}. Sin respuesta tuya, se levanta.', 'vote.entered': '«{title}» ha entrado en vigor.', 'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.', 'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.', @@ -217,9 +227,9 @@ export const es: Messages = { 'vote.choice.none': 'retirar tu voto', 'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.', 'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.', - 'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.', + 'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y hacer una objeción.', 'vote.members': 'Aquí solo votan los miembros de este grupo.', - 'vote.details': 'Erratas, traducciones, señalamientos, historial', + 'vote.details': 'Erratas, traducciones, objeciones, historial', 'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.', 'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.', 'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.', @@ -246,6 +256,7 @@ export const es: Messages = { 'basket.sent.other': '{n} votos registrados.', 'basket.section': 'Votar esta sección', 'basket.mine': 'tu voto: {choice}', + 'basket.held': 'no votable: una objeción la retiene', 'sign.title': 'Votar con tu monedero Ğ1', 'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', 'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', @@ -327,27 +338,98 @@ export const es: Messages = { 'history.typo': 'errata corregida', 'history.substantive': 'cambio de fondo (votos por reconfirmar)', 'history.amendment': 'texto de partida sustituido', + 'history.revert': 'corrección retirada por el autor', 'history.by': 'por {name}', + 'history.typo.validated': 'validada por {name}', + 'history.typo.deadline': 'aplicada sin respuesta en 3 días', 'typo.title': 'Corregir una errata', - 'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.', + 'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección no se vota. En tu texto, se aplica enseguida. En el de otra persona, su autor la valida (un administrador para un texto del seed); sin respuesta en 3 días, se aplica. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.', 'typo.save': 'Guardar la corrección', 'typo.done': 'Errata corregida, sin tocar los votos.', 'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.', 'typo.asProposal': 'Convertirlo en propuesta', + 'typo.pending.author': 'Tu corrección espera al autor del texto: se aplica cuando la valida, o sola el {date}.', + 'typo.pending.admin': 'Tu corrección espera a un administrador, porque este texto viene del seed: se aplica cuando la valida, o sola el {date}.', + 'typo.list': 'Erratas propuestas', + 'typo.by': 'propuesta por {name}', + 'typo.status.pending': 'en espera: se aplica sola el {date}', + 'typo.status.applied': 'aplicada', + 'typo.status.refused': 'rechazada', + 'typo.status.stale': 'caducada: el texto cambió entretanto', + 'typo.settled.by': 'por {name}', + 'typo.settled.deadline': 'sin respuesta en 3 días', + 'typo.validate': 'Validar', + 'typo.refuse': 'Rechazar', + 'typo.undo': 'Volver al texto anterior', + 'typo.redo': 'Aplicar al final', + 'typo.validated': 'Corrección aplicada.', + 'typo.refused': 'Corrección rechazada.', - 'signal.title': 'Contradicciones', - 'signal.none': 'Ningún aviso.', - 'signal.add': 'Señalar una contradicción', - 'signal.target': 'Contradice', - 'signal.reason': 'En qué se contradicen estos dos textos', - 'signal.send': 'Señalar', - 'signal.sent': 'Aviso registrado.', - 'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.', - 'signal.lift': 'Levantar', - 'signal.lifted': 'levantado', - 'signal.lifted.revision': 'sin efecto: el texto ha cambiado', - 'signal.by': 'por {name}', + 'objection.title': 'Objeciones', + 'objection.none': 'Ninguna objeción.', + 'objection.add': 'Hacer una objeción', + 'objection.kind': 'Tu objeción', + 'objection.kind.redit': 'Repite', + 'objection.kind.contredit': 'Contradice', + 'objection.kind.redit.hint': 'este texto repite otro artículo', + 'objection.kind.contredit.hint': 'este texto dice lo contrario de otro artículo', + 'objection.target': '¿Qué artículo?', + 'objection.reason.redit': 'En qué dicen lo mismo estos dos textos', + 'objection.reason.contredit': 'En qué se contradicen estos dos textos', + 'objection.send': 'Enviar la objeción', + 'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.', + 'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.', + 'objection.says.redit': '«{held}» repite «{other}»', + 'objection.says.contredit': '«{held}» contradice «{other}»', + 'objection.by': 'objeción de {name}', + 'objection.held.here': 'esta versión no se vota', + 'objection.phase.author': 'en espera de la respuesta del autor', + 'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola', + 'objection.answer.revision': 'respuesta: el texto retenido se reformuló', + 'objection.answer.other': 'respuesta: el otro texto cambió', + 'objection.answer.proposal': 'respuesta: una propuesta vinculada', + 'objection.lifted.objector': 'levantada por la persona que la hizo', + 'objection.lifted.admin': 'levantada por un administrador', + 'objection.lifted.deadline': 'levantada: 3 días sin continuación', + 'objection.lifted.withdrawn': 'levantada: la propuesta se retiró', + 'objection.lifted.revision': 'caída: el texto cambió', + 'objection.lift': 'Levantar la objeción', + 'objection.lift.admin': 'Levantar la objeción (administrador)', + 'objection.reiterate': 'Reiterar la objeción', + 'objection.done.lifted': 'Objeción levantada.', + 'objection.done.reiterated': 'Objeción reiterada: se espera una nueva respuesta.', + 'objection.respond.redit': 'Modificar el artículo que repite', + 'objection.respond.contredit': 'Reformular', + 'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.', + 'messages.title': 'Mensajes', + 'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.', + 'messages.none': 'Ningún mensaje por ahora.', + 'messages.readAll': 'Marcar todo como leído', + 'messages.read': 'Marcar como leído', + 'messages.unread': 'sin leer', + 'messages.due': 'plazo: {date}', + 'messages.open': 'Verlo en el texto', + 'messages.topic.objection': 'Objeción', + 'messages.topic.typo': 'Errata', + 'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.', + 'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.', + 'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.', + 'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.', + 'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».', + 'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.', + 'messages.respond': 'Responder', + 'messages.outcome.answered': 'respuesta dada', + 'messages.outcome.reiterated': 'objeción reiterada', + 'messages.outcome.lifted': 'objeción levantada', + 'messages.outcome.admin': 'objeción levantada por un administrador', + 'messages.outcome.deadline.objection': 'objeción levantada: 3 días sin continuación', + 'messages.outcome.deadline.typo': 'corrección aplicada: 3 días sin respuesta', + 'messages.outcome.withdrawn': 'propuesta retirada', + 'messages.outcome.applied': 'corrección aplicada', + 'messages.outcome.refused': 'corrección rechazada', + 'messages.outcome.stale': 'corrección caducada: el texto cambió', + 'messages.outcome.done': 'tratado', 'propose.mode.replace': 'Modificar este texto', 'propose.mode.remove': 'Retirar este artículo', @@ -458,7 +540,7 @@ export const es: Messages = { 'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.', 'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).', 'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).', - 'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).', + 'error.reason': 'Di en pocas palabras en qué se basa tu objeción (de 10 a 600 caracteres).', 'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.', 'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.', 'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.', @@ -483,6 +565,15 @@ export const es: Messages = { 'error.author': 'Esta cuenta no es una dirección Ğ1.', 'error.not_votable': 'Esta versión ya no está en votación.', 'error.members_only': 'Aquí solo votan los miembros de este grupo.', + 'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.', + 'error.kind': 'Di si este texto repite el otro o lo contradice.', + 'error.other': 'Elige otro artículo, en vigor o propuesto.', + 'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.', + 'error.objection_exists': 'Ya tienes una objeción en curso sobre estos dos textos.', + 'error.nothing_to_reiterate': 'Nada que reiterar: aún no ha llegado ninguna respuesta.', + 'error.lifted': 'Esta objeción ya está levantada.', + 'error.settled': 'Esta corrección ya está decidida.', + 'error.link': 'Esta propuesta no puede responder a esta objeción: apunta a otro artículo, o la objeción está levantada.', 'error.retry': 'Reintentar', 'common.cancel': 'Cancelar', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 6f65cad..91375cc 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -14,6 +14,9 @@ export const fr = { 'nav.mandates': 'Mandats', 'nav.protocols': 'Protocoles', 'nav.groups': 'Mes groupes', + 'nav.messages': 'Messages', + 'nav.messages.count.one': 'Messages, {n} non lu', + 'nav.messages.count.other': 'Messages, {n} non lus', 'nav.docs.count.one': '{label}, {n} doc en cours', 'nav.docs.count.other': '{label}, {n} docs en cours', 'nav.access': 'Accès', @@ -140,7 +143,7 @@ export const fr = { 'deep.addition': 'nouvel article', 'deep.removal': 'retrait de « {title} »', 'deep.inertia': 'inertie vers {level}', - 'deep.blocked': 'bloquée', + 'deep.held': 'non votable : objection', 'deep.mine': 'ta proposition', 'deep.stale': 'à reconfirmer', 'deep.wellrated': 'bien cotée', @@ -201,7 +204,14 @@ export const fr = { 'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.", 'vote.reconfirm': 'Reconfirmer : {choice}', 'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.', - 'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.", + 'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.", + 'vote.held.redit': 'Elle redit « {other} ».', + 'vote.held.contredit': 'Elle contredit « {other} ».', + 'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.", + 'vote.held.author.contredit': "Pour qu'elle se vote à nouveau, reformule-la. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.", + 'vote.held.waiting': "Une réponse est arrivée : {name} lève ou réitère l'objection d'ici le {date} ; sans réponse, elle est levée.", + 'vote.held.mine': 'Tu as fait cette objection : lève-la quand la réponse te convient.', + 'vote.held.mine.asked': "Une réponse est arrivée : lève ton objection si elle te convient, ou réitère-la, d'ici le {date}. Sans réponse de ta part, elle est levée.", 'vote.entered': '« {title} » est entré en vigueur.', 'vote.entered.remove': '« {title} » est retiré du texte en vigueur.', 'vote.entered.inertia': "L'inertie de « {title} » a changé.", @@ -220,9 +230,9 @@ export const fr = { 'vote.choice.none': 'retrait de ton vote', 'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.', 'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.", - 'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.", + 'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection.", 'vote.members': 'Seuls les membres de ce groupe votent ici.', - 'vote.details': 'Coquilles, traductions, signalements, historique', + 'vote.details': 'Coquilles, traductions, objections, historique', 'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.", 'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.", 'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.', @@ -249,6 +259,7 @@ export const fr = { 'basket.sent.other': '{n} votes enregistrés.', 'basket.section': 'Voter cette section', 'basket.mine': 'ton vote : {choice}', + 'basket.held': 'non votable : une objection la retient', 'sign.title': 'Voter avec ton portefeuille Ğ1', 'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', 'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', @@ -330,27 +341,98 @@ export const fr = { 'history.typo': 'coquille corrigée', 'history.substantive': 'changement de fond (votes à reconfirmer)', 'history.amendment': 'texte de départ remplacé', + 'history.revert': "correction retirée par l'auteur", 'history.by': 'par {name}', + 'history.typo.validated': 'validée par {name}', + 'history.typo.deadline': 'appliquée sans réponse sous 3 jours', 'typo.title': 'Corriger une coquille', - 'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.", + 'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction ne se vote pas. Sur ton texte, elle s'applique tout de suite. Sur celui d'une autre personne, son auteur la valide (un administrateur pour un texte du seed) ; sans réponse sous 3 jours, elle s'applique. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.", 'typo.save': 'Enregistrer la correction', 'typo.done': 'Coquille corrigée, sans toucher aux votes.', 'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.', 'typo.asProposal': 'En faire une proposition', + 'typo.pending.author': "Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le {date}.", + 'typo.pending.admin': "Ta correction attend un administrateur, ce texte venant du seed : elle s'applique quand il la valide, ou d'elle-même le {date}.", + 'typo.list': 'Coquilles proposées', + 'typo.by': 'proposée par {name}', + 'typo.status.pending': "en attente : s'applique d'elle-même le {date}", + 'typo.status.applied': 'appliquée', + 'typo.status.refused': 'refusée', + 'typo.status.stale': 'périmée : le texte a changé entre-temps', + 'typo.settled.by': 'par {name}', + 'typo.settled.deadline': 'sans réponse sous 3 jours', + 'typo.validate': 'Valider', + 'typo.refuse': 'Refuser', + 'typo.undo': "Revenir au texte d'avant", + 'typo.redo': 'Appliquer finalement', + 'typo.validated': 'Correction appliquée.', + 'typo.refused': 'Correction refusée.', - 'signal.title': 'Contradictions', - 'signal.none': 'Aucun signalement.', - 'signal.add': 'Signaler une contradiction', - 'signal.target': 'Contredit', - 'signal.reason': 'En quoi ces deux textes se contredisent', - 'signal.send': 'Signaler', - 'signal.sent': 'Signalement enregistré.', - 'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.", - 'signal.lift': 'Lever', - 'signal.lifted': 'levé', - 'signal.lifted.revision': 'tombé : le texte a changé', - 'signal.by': 'par {name}', + 'objection.title': 'Objections', + 'objection.none': 'Aucune objection.', + 'objection.add': 'Faire une objection', + 'objection.kind': 'Ton objection', + 'objection.kind.redit': 'Redit', + 'objection.kind.contredit': 'Contredit', + 'objection.kind.redit.hint': 'ce texte répète un autre article', + 'objection.kind.contredit.hint': "ce texte dit le contraire d'un autre article", + 'objection.target': 'Quel article ?', + 'objection.reason.redit': 'En quoi ces deux textes disent la même chose', + 'objection.reason.contredit': 'En quoi ces deux textes se contredisent', + 'objection.send': "Envoyer l'objection", + 'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.", + 'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.", + 'objection.says.redit': '« {held} » redit « {other} »', + 'objection.says.contredit': '« {held} » contredit « {other} »', + 'objection.by': 'objection de {name}', + 'objection.held.here': 'cette version ne se vote plus', + 'objection.phase.author': "en attente d'une réponse de l'auteur", + 'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même", + 'objection.answer.revision': 'réponse : le texte retenu a été reformulé', + 'objection.answer.other': "réponse : l'autre texte a changé", + 'objection.answer.proposal': 'réponse : une proposition liée', + 'objection.lifted.objector': "levée par la personne qui l'a faite", + 'objection.lifted.admin': 'levée par un administrateur', + 'objection.lifted.deadline': 'levée : 3 jours sans suite', + 'objection.lifted.withdrawn': 'levée : la proposition a été retirée', + 'objection.lifted.revision': 'tombée : le texte a changé', + 'objection.lift': "Lever l'objection", + 'objection.lift.admin': "Lever l'objection (administrateur)", + 'objection.reiterate': "Réitérer l'objection", + 'objection.done.lifted': 'Objection levée.', + 'objection.done.reiterated': 'Objection réitérée : une nouvelle réponse est attendue.', + 'objection.respond.redit': "Modifier l'article visé", + 'objection.respond.contredit': 'Reformuler', + 'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.", + 'messages.title': 'Messages', + 'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.', + 'messages.none': "Aucun message pour l'instant.", + 'messages.readAll': 'Tout marquer comme lu', + 'messages.read': 'Marquer comme lu', + 'messages.unread': 'non lu', + 'messages.due': 'échéance : {date}', + 'messages.open': 'Voir dans le texte', + 'messages.topic.objection': 'Objection', + 'messages.topic.typo': 'Coquille', + 'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.", + 'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.", + 'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.', + 'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.', + 'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».', + 'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.', + 'messages.respond': 'Répondre', + 'messages.outcome.answered': 'réponse donnée', + 'messages.outcome.reiterated': 'objection réitérée', + 'messages.outcome.lifted': 'objection levée', + 'messages.outcome.admin': 'objection levée par un administrateur', + 'messages.outcome.deadline.objection': 'objection levée : 3 jours sans suite', + 'messages.outcome.deadline.typo': 'correction appliquée : 3 jours sans réponse', + 'messages.outcome.withdrawn': 'proposition retirée', + 'messages.outcome.applied': 'correction appliquée', + 'messages.outcome.refused': 'correction refusée', + 'messages.outcome.stale': 'correction périmée : le texte a changé', + 'messages.outcome.done': 'traité', 'propose.mode.replace': 'Modifier ce texte', 'propose.mode.remove': 'Retirer cet article', @@ -461,7 +543,7 @@ export const fr = { 'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.', 'error.title': 'Le titre est vide ou trop long (120 signes au plus).', 'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).', - 'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).', + 'error.reason': 'Dis en quelques mots ce qui fonde ton objection (10 à 600 signes).', 'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.", 'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.', 'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.", @@ -486,6 +568,15 @@ export const fr = { 'error.author': "Ce compte n'est pas une adresse Ğ1.", 'error.not_votable': 'Cette version ne se vote plus.', 'error.members_only': 'Seuls les membres de ce groupe votent ici.', + 'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.", + 'error.kind': "Dis si ce texte redit l'autre ou le contredit.", + 'error.other': 'Choisis un autre article, en vigueur ou proposé.', + 'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.', + 'error.objection_exists': 'Tu as déjà une objection en cours sur ces deux textes.', + 'error.nothing_to_reiterate': "Rien à réitérer : aucune réponse n'est encore arrivée.", + 'error.lifted': 'Cette objection est déjà levée.', + 'error.settled': 'Cette correction est déjà tranchée.', + 'error.link': 'Cette proposition ne peut pas répondre à cette objection : elle vise un autre article, ou l’objection est levée.', 'error.retry': 'Réessayer', 'common.cancel': 'Annuler', diff --git a/web/src/lib/objections.ts b/web/src/lib/objections.ts new file mode 100644 index 0000000..3c2a298 --- /dev/null +++ b/web/src/lib/objections.ts @@ -0,0 +1,28 @@ +// Answering an objection (Yvv, 2026-10-30: « Pour ce qui constitue une +// répétition → modifier l'article cible ; pour une contradiction → +// reformuler »): where the author of the version held back is taken. + +import type { PanelState } from './doc.svelte'; +import type { Objection } from './types'; + +/** + * A repetition: a proposal that changes the article repeated, linked to the + * objection, starting from its text. A contradiction: one's own open version is + * reworded in place; a seeded one, by a linked proposal on its article. + */ +export function answerPanel(o: Objection, me: string | null): PanelState | null { + const held = o.heldVersion; + if (!held) return null; + if (o.kind === 'redit') { + return { tab: 'propose', unitId: o.other.unitId, proposalId: o.other.id, objectionId: o.id }; + } + if (me && held.authorId === me && held.status === 'open') { + return { tab: 'details', unitId: held.unitId, proposalId: held.id, edit: true }; + } + return { tab: 'propose', unitId: held.unitId, proposalId: held.id, objectionId: o.id }; +} + +/** The objections standing against a version, held back by them. */ +export function holding(objections: Objection[]): Objection[] { + return objections.filter((o) => o.held && o.phase !== 'lifted'); +} diff --git a/web/src/lib/session.svelte.ts b/web/src/lib/session.svelte.ts index fe32d1b..400dea8 100644 --- a/web/src/lib/session.svelte.ts +++ b/web/src/lib/session.svelte.ts @@ -1,11 +1,17 @@ import { api } from './api'; import type { Me } from './types'; +/** How often the number of unread messages is looked at again, while signed in. */ +const UNREAD_EVERY_MS = 60_000; + class Session { me = $state(null); ready = $state(false); g1 = $state(false); dev = $state(false); + /** Unread messages of one's box, next to « Messages » in the menu (ADR 0010). */ + unread = $state(0); + #timer: ReturnType | undefined; async load() { const [me, cfg] = await Promise.all([ @@ -16,11 +22,33 @@ class Session { this.g1 = cfg.g1; this.dev = cfg.dev; this.ready = true; + this.watch(); + } + + /** Looks at the box again: on a navigation, after a gesture, every minute. */ + async refreshUnread() { + if (!this.me) { + this.unread = 0; + return; + } + try { + this.unread = (await api.get<{ unread: number }>('/messages/unread')).unread; + } catch { + /* the count waits for the next look */ + } + } + + /** A sign-in on another page sets `me` directly: the count follows on the next look. */ + private watch() { + clearInterval(this.#timer); + void this.refreshUnread(); + this.#timer = setInterval(() => void this.refreshUnread(), UNREAD_EVERY_MS); } async logout() { await api.post('/auth/logout'); this.me = null; + this.unread = 0; } } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 01ff98d..52dd63e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -96,8 +96,12 @@ export interface Proposal { tally: Tally; evaluation: Evaluation | null; votedAtInertia: Inertia; + /** Held back by a standing objection: the latest arrival of the two (never a text in force). */ blocked: boolean; - signals: number; + /** May be voted now: open or in force, and not held back (ADR 0010). */ + votable: boolean; + /** Standing objections around this version. */ + objections: number; myVote: 'for' | 'against' | null; myVoteStale: boolean; /** « Ton vote » was cast on the Ğ1 chain (confirmed). */ @@ -215,3 +219,83 @@ export interface Entered { code: string; kind: 'text' | 'remove' | 'inertia'; } + +export type ObjectionKind = 'redit' | 'contredit'; + +/** A version as an objection names it. */ +export interface VersionRef { + id: string; + title: string | null; + status: Proposal['status'] | null; + unitId: string | null; + code: string | null; + author: string | null; + authorId: string | null; +} + +/** An objection, as the session sees it (ADR 0010). */ +export interface Objection { + id: string; + kind: ObjectionKind; + reason: string; + objector: string | null; + objectorInvited: boolean; + /** This session raised it. */ + mine: boolean; + at: string; + /** The author answers, then the objector lifts or reiterates; or it is lifted. */ + phase: 'author' | 'objector' | 'lifted'; + cycle: number; + /** While the objector is asked: past this, it is lifted. */ + dueAt: string | null; + answer: 'revision' | 'other' | 'proposal' | null; + answerId: string | null; + answeredAt: string | null; + lift: 'objector' | 'admin' | 'deadline' | 'withdrawn' | 'revision' | null; + liftedAt: string | null; + /** The latest arrival of the two, not votable while it stands. */ + heldVersion: VersionRef | null; + other: VersionRef; + /** This session may answer by a proposal linked to it (the author of the held version, or anyone for a seeded one). */ + iAnswer: boolean; + canLift: boolean; + canReiterate: boolean; + /** Around one version: whether that version is the one held back. */ + held?: boolean; +} + +/** A typo fix written by someone else than the author (ADR 0010). */ +export interface TypoFix { + id: string; + status: 'pending' | 'applied' | 'refused' | 'stale'; + writer: string | null; + writerInvited: boolean; + at: string; + dueAt: string; + settledAt: string | null; + /** A name, or `deadline` (passed without an answer), or `changed` (the text moved on). */ + settledBy: string | null; + /** Who answers: the author of the version, or an administrator for a seeded text. */ + answerer: 'author' | 'admin'; + diff: DiffPart[]; + gestures: ('validate' | 'refuse')[]; + version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null }; +} + +/** A message of one's box: about an objection or a typo fix, with its gestures now. */ +export interface Message { + id: string; + topic: 'objection' | 'typo'; + kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending'; + cycle: number; + createdAt: string; + dueAt: string | null; + read: boolean; + /** Dealt with: no gesture any more. */ + done: boolean; + outcome: string | null; + gestures: ('respond' | 'lift' | 'reiterate' | 'validate' | 'refuse')[]; + doc: { id: string; groupSlug: string; slug: string; title: LangMap }; + objection?: Objection; + typo?: TypoFix; +} diff --git a/web/src/lib/versions.ts b/web/src/lib/versions.ts index 24ef7c0..3b7d08e 100644 --- a/web/src/lib/versions.ts +++ b/web/src/lib/versions.ts @@ -22,7 +22,8 @@ export function level(p: Proposal): number { /** * Negative when `a` comes before `b`: the highest water, then more for, then fewer - * against. A blocked version comes after the others: it cannot enter into force. + * against. A version held back by an objection comes after the others: it is not + * votable, and cannot enter into force, until the objection is lifted. */ export function compare(a: Proposal, b: Proposal): number { return ( diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index b73cc5e..787e4e5 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -26,6 +26,7 @@ afterNavigate(() => { drawer = false; + if (session.ready) void session.refreshUnread(); }); @@ -43,7 +44,8 @@
      @@ -157,6 +159,7 @@ text-decoration: none; } .menu { + position: relative; display: grid; place-items: center; width: 2.5rem; @@ -167,5 +170,21 @@ box-shadow: var(--shadow); color: var(--ink); } + /* Unread messages, seen without opening the menu. */ + .badge { + position: absolute; + top: -0.3rem; + right: -0.3rem; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + border-radius: var(--r-pill); + background: var(--ochre); + color: var(--on-water); + font-size: 0.72rem; + font-weight: 800; + line-height: 1.25rem; + text-align: center; + } } diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index ea76c99..49f9a74 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -9,10 +9,12 @@ import UnitBlock from '$lib/components/UnitBlock.svelte'; import VoteFold from '$lib/components/VoteFold.svelte'; import WaveRule from '$lib/components/WaveRule.svelte'; - import { sections } from '$lib/basket'; - import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte'; + import { sections, votableIds } from '$lib/basket'; + import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte'; import { i18n, LANGS, t } from '$lib/i18n/index.svelte'; + import { answerPanel } from '$lib/objections'; import { BasketState } from '$lib/picks.svelte'; + import { session } from '$lib/session.svelte'; import type { Lang, Proposal, Unit } from '$lib/types'; import type { Line } from '$lib/vote'; import { articles, type Filter } from '$lib/versions'; @@ -24,23 +26,30 @@ $effect(() => { void doc.load(group, slug); }); - // The basket of this document, kept for the tab; versions no longer open to vote leave it. + // The basket of this document, kept for the tab; versions no longer open to vote + // (or held back by an objection) leave it. $effect(() => { const v = doc.view; if (!v) return; - const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); - basket.load(v.doc.id, known); + basket.load(v.doc.id, votableIds(sections(v.units))); }); // The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them. - const TABS: Record = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' }; - const TAB_NAMES: Record = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' }; let prefill = $state(null); const panel = $derived.by((): PanelState | null => { const q = page.url.searchParams; const tab = TABS[q.get('onglet') ?? '']; if (!tab) return null; - return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill }; + return { + unitId: q.get('article'), + proposalId: q.get('version'), + tab, + draftId: q.get('brouillon'), + section: q.get('section'), + prefill, + objectionId: q.get('objection'), + edit: q.get('modifier') === '1', + }; }); /** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */ const vote = $derived.by(() => { @@ -50,16 +59,7 @@ function go(nextPanel: PanelState | null, nextVote: string | null) { prefill = nextPanel?.prefill ?? null; - const q = new URLSearchParams(); - if (nextVote) q.set('vote', nextVote); - if (nextPanel) { - q.set('onglet', TAB_NAMES[nextPanel.tab]); - if (nextPanel.unitId) q.set('article', nextPanel.unitId); - if (nextPanel.proposalId) q.set('version', nextPanel.proposalId); - if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId); - if (nextPanel.section) q.set('section', nextPanel.section); - } - const search = q.toString(); + const search = panelSearch(nextPanel, nextVote); void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel), noScroll: true, @@ -122,6 +122,7 @@ onopen={(id) => go(panel, id)} onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })} ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })} + onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))} onsign={sign} /> {/snippet} diff --git a/web/src/routes/messages/+page.svelte b/web/src/routes/messages/+page.svelte new file mode 100644 index 0000000..e0bb3b0 --- /dev/null +++ b/web/src/routes/messages/+page.svelte @@ -0,0 +1,285 @@ + + +{t('messages.title')} · libreDecision + +{#if !session.me} + +{:else if box.error} + box.load()} /> +{:else} +
      +
      +

      {t('messages.title')}

      +

      {t('messages.intro')}

      + {#if unread} + + {/if} +
      + {#if error}{/if} + + {#if box.value} +
        + {#each box.value.messages as m (m.id)} +
      • +

        + {t(`messages.topic.${m.topic}`)} + {m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''} + {i18n.date(m.createdAt)} + {#if !m.read}{t('messages.unread')}{/if} +

        +

        {textOf(m)}

        + {#if m.objection?.reason && m.kind !== 'objection_answered'}

        {m.objection.reason}

        {/if} + {#if m.typo}{/if} + {#if m.done} +

        {outcomeOf(m)}

        + {:else if m.dueAt} +

        {t('messages.due', { date: i18n.date(m.dueAt) })}

        + {/if} +
        + {#if !m.done} + {#each m.gestures as g (g)} + + {/each} + {/if} + void read(m)}>{t('messages.open')} + {#if !m.read}{/if} +
        +
      • + {:else} +
      • {t('messages.none')}
      • + {/each} +
      + {:else} +

      {t('common.loading')}

      + {/if} +
      +{/if} + + diff --git a/web/tests/unit/basket.test.ts b/web/tests/unit/basket.test.ts index b0c54b0..4b636b8 100644 --- a/web/tests/unit/basket.test.ts +++ b/web/tests/unit/basket.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket'; +import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket'; import type { Proposal, Unit } from '../../src/lib/types'; function prop(id: string, unitId: string, over: Partial = {}): Proposal { @@ -7,7 +7,7 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed', label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3, - blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, + blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, ...over, }; } @@ -72,4 +72,26 @@ describe('the basket of a vote', () => { ]); expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 }); }); + + it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => { + // E5 has the text in force and a rival held back; E6 is a new article, held back. + const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [ + prop('e5r', 'E5', { blocked: true, votable: false }), + ]); + const e6 = unit('E6', 'technique', null, [prop('e6', 'E6', { blocked: true, votable: false })]); + const s = sections([e5, e6]); + const [slotE5, slotE6] = s[0]?.slots ?? []; + expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']); + expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']); + // A held article stays in sight, with nothing to choose. + expect(slotE6?.versions).toEqual([]); + expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']); + const all = s.flatMap((x) => x.slots); + // The held rival does not compete now: the text in force is the only choice. + expect(fill(all, 'for', {})).toEqual({ e5: 'for' }); + expect(waiting(all, {})).toEqual([]); + // A choice kept from before the objection is not sent. + expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]); + expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' }); + }); }); diff --git a/web/tests/unit/diff.test.ts b/web/tests/unit/diff.test.ts new file mode 100644 index 0000000..30a53c3 --- /dev/null +++ b/web/tests/unit/diff.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { condense } from '../../src/lib/diff'; +import type { DiffPart } from '../../src/lib/types'; + +const text = (parts: DiffPart[]) => parts.map((p) => (p.op === 'same' ? p.text : `[${p.op === 'added' ? '+' : '-'}${p.text}]`)).join(''); + +describe('a typo fix, told around its change', () => { + it('keeps a few words on each side of a change, cut between words', () => { + const parts: DiffPart[] = [ + { op: 'same', text: "Raison d'être\nDerrière une clé il n'y a qu'un seul être humain vivant. C" }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: "est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur." }, + ]; + expect(text(condense(parts, 24))).toBe("… être humain vivant. C[-'][+’]est le seul et unique …"); + }); + + it('two changes far apart: the middle is cut; close ones stay together', () => { + const far: DiffPart[] = [ + { op: 'same', text: 'Je m' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'engage à vérifier la clé publique avec la personne, en sa présence, avant toute certification de son compte, et à n' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'en rien faire sinon.' }, + ]; + expect(text(condense(far, 20))).toBe("Je m[-'][+’]engage à vérifier la … son compte, et à n[-'][+’]en rien faire sinon."); + const near: DiffPart[] = [ + { op: 'same', text: 'Je m' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'engage à n' }, + { op: 'removed', text: "'" }, + { op: 'added', text: '’' }, + { op: 'same', text: 'utiliser qu' }, + ]; + expect(condense(near, 20)).toEqual(near); + }); + + it('nothing changed: the text as it is', () => { + const same: DiffPart[] = [{ op: 'same', text: 'Un texte.' }]; + expect(condense(same)).toEqual(same); + }); +}); diff --git a/web/tests/unit/objections.test.ts b/web/tests/unit/objections.test.ts new file mode 100644 index 0000000..1e2c147 --- /dev/null +++ b/web/tests/unit/objections.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { answerPanel, holding } from '../../src/lib/objections'; +import type { Objection, VersionRef } from '../../src/lib/types'; + +const ref = (id: string, over: Partial = {}): VersionRef => ({ + id, title: id, status: 'open', unitId: `u-${id}`, code: id.toUpperCase(), author: null, authorId: null, ...over, +}); + +function objection(over: Partial = {}): Objection { + return { + id: 'o1', kind: 'redit', reason: 'Ces deux textes disent la même chose.', objector: 'Yvv', objectorInvited: false, + mine: false, at: '', phase: 'author', cycle: 1, dueAt: null, answer: null, answerId: null, answeredAt: null, + lift: null, liftedAt: null, heldVersion: ref('k1', { author: 'Chiara', authorId: 'dev:chiara' }), + other: ref('e2', { status: 'in_force' }), iAnswer: true, canLift: false, canReiterate: false, held: true, ...over, + }; +} + +describe('answering an objection', () => { + it('a repetition: a proposal linked to it, on the article repeated, from its text', () => { + expect(answerPanel(objection(), 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-e2', proposalId: 'e2', objectionId: 'o1' }); + }); + + it('a contradiction: one rewords one’s own version in place', () => { + expect(answerPanel(objection({ kind: 'contredit' }), 'dev:chiara')).toEqual({ tab: 'details', unitId: 'u-k1', proposalId: 'k1', edit: true }); + }); + + it('a contradiction on a seeded version: a linked proposal on its article', () => { + const o = objection({ kind: 'contredit', heldVersion: ref('k1') }); + expect(answerPanel(o, 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-k1', proposalId: 'k1', objectionId: 'o1' }); + }); + + it('nothing held back, nothing to answer; a lifted objection holds nothing', () => { + expect(answerPanel(objection({ heldVersion: null }), 'dev:chiara')).toBeNull(); + const list = [objection(), objection({ id: 'o2', phase: 'lifted' }), objection({ id: 'o3', held: false })]; + expect(holding(list).map((o) => o.id)).toEqual(['o1']); + }); +}); diff --git a/web/tests/unit/versions.test.ts b/web/tests/unit/versions.test.ts index 5276c76..383086a 100644 --- a/web/tests/unit/versions.test.ts +++ b/web/tests/unit/versions.test.ts @@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, - votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, + votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, ...over, }; } From 06c99cb0db0a492b473a4d665416aa85b92f90d0 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 05:19:02 +0100 Subject: [PATCH 4/4] =?UTF-8?q?v3=20:=20ADR=200010=20(objections,=20coquil?= =?UTF-8?q?les=20valid=C3=A9es,=20messagerie)=20;=20chantier,=20contr?= =?UTF-8?q?=C3=B4les=20C28=E2=80=93C31,=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0010 remplace 0004 et complète 0003 (renvois dans les deux). CHANTIER : « Corriger n'est pas voter » et « Deux articles qui se répètent ou se contredisent » réécrits avec le tableau des gestes (qui, quoi, effet, échéance), la boîte « Messages », deux limites connues. CONTROLES : C6 suit la règle, C24 son nouveau texte, C28–C31, le passage du lot F. CLAUDE.md : l'invariant de l'objection et de la coquille validée, les nouveaux modules, 29 contrôles. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 33 +++-- docs/adr/0003-corriger-n-est-pas-voter.md | 6 +- docs/adr/0004-contradictions-signalees.md | 6 + .../0010-objections-et-coquilles-validees.md | 124 ++++++++++++++++++ docs/adr/README.md | 3 +- docs/chantiers/acte-certification/CHANTIER.md | 70 +++++++--- .../chantiers/acte-certification/CONTROLES.md | 11 +- 7 files changed, 222 insertions(+), 31 deletions(-) create mode 100644 docs/adr/0010-objections-et-coquilles-validees.md diff --git a/CLAUDE.md b/CLAUDE.md index 1f188c4..6aa1cc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,19 +36,26 @@ crates/ ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane, # référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité, # longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier - # fait foi, jugement d'une transaction), address (SS58, préfixe g1) + # fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection + # (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction + # (la coquille d'un autre : attendre, valider, refuser, l'échéance) trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou - # chaîne), groupes par défaut + # chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message, + # groupes par défaut ld-server/ migrations/ # schéma SQLite — additif seulement src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations, - # dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler, - # traduire) · adoption.rs (balayage, verrou) · participation.rs (p̃, une fois par + # dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, + # traduire) · objections.rs (faire, lever, réitérer, la version retenue) · typos.rs + # (les coquilles des autres) · messages.rs (la boîte « Messages ») · deadlines.rs + # (les échéances de 3 jours) · clock.rs (horloge injectable) · adoption.rs + # (balayage, verrou) · participation.rs (p̃, une fois par # jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs · # seed_amend.rs / inertia_amend.rs (amendements gardés) · ballots.rs (vote groupé, # bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments) # · web.rs (sert web/build) - tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) + tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) · + # objections.rs · typos.rs (lot F, horloge avancée, migration 0008) seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ; # inertie 3, E1/E2 à 4) # upcoming/.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage @@ -57,13 +64,15 @@ web/src/ lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier), # ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList, Seal… lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts - lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence + lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues + lib/objections.ts # où l'auteur répond à une objection · lib/diff.ts : le diff resserré d'une coquille routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) · # g/[group]/docs · g/[group]/docs/[doc] (lecteur, # vote déplié ?vote=, panneau ?onglet=panier|propositions|proposer|details|reglages) # · g/[group]/decisions · g/[group]/mandats · - # g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent -web/e2e/ # Playwright C1–C24 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et + # g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ; + # /groupes, /decisions… redirigent +web/e2e/ # Playwright C1–C24, C28–C31 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et # chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml @@ -85,7 +94,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace -cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 25 contrôles +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 29 contrôles LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud @@ -100,8 +109,10 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la - **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans `ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`. - **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; - une coquille ne demande pas de vote, un changement de fond si ; un signalement de - contradiction bloque ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ; + une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend + son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée + change le texte ; une objection rend la dernière arrivée non votable jusqu'à sa levée (jamais un + texte en vigueur ; ADR 0010) ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ; le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test** (chaîne simulée ; un essai réel est un acte « paye » d'Yvv). diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index af8a00c..9067022 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -1,5 +1,9 @@ # 0003 — Corriger n'est pas voter +> Complétée par [0010](0010-objections-et-coquilles-validees.md) (2026-10-31) : la règle de la +> coquille ne change pas ; la coquille d'un autre attend son auteur (un administrateur pour un +> texte du seed), ou 3 jours, et en vigueur seule une coquille validée change le texte. + **Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout membre peut corriger un texte en vigueur, qui est republié aussitôt. @@ -24,7 +28,7 @@ ou de fond), jamais contre la correction précédente : ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne. Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes -antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ. +antérieurs « à reconfirmer », réponse aux objections qui l'attendent (0010) ; refusé sur un texte en vigueur ou de départ. L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps donne un conflit. Les brouillons s'éditent librement. diff --git a/docs/adr/0004-contradictions-signalees.md b/docs/adr/0004-contradictions-signalees.md index afba9fb..ae2e282 100644 --- a/docs/adr/0004-contradictions-signalees.md +++ b/docs/adr/0004-contradictions-signalees.md @@ -1,5 +1,11 @@ # 0004 — Contradictions : signalées par les personnes, bloquantes +> **Remplacée** par [0010](0010-objections-et-coquilles-validees.md) (2026-10-31) : le +> signalement devient l'objection « contredit », à côté de « redit » ; elle retient la dernière +> arrivée des deux versions, qui ne se vote plus jusqu'à sa levée ; l'auteur répond, la personne +> qui objecte lève ou réitère, et sans réponse de sa part sous 3 jours elle est levée. Les +> signalements existants sont devenus des « contredit » (migration 0008). + **Contexte.** « Il faut que 2 unités modulaires ne puissent pas passer en vigueur si elles se contredisent. » La contradiction est une question de sens : aucune règle ne la détecte sans se tromper, et l'IA est écartée pour l'instant. diff --git a/docs/adr/0010-objections-et-coquilles-validees.md b/docs/adr/0010-objections-et-coquilles-validees.md new file mode 100644 index 0000000..7acd3d4 --- /dev/null +++ b/docs/adr/0010-objections-et-coquilles-validees.md @@ -0,0 +1,124 @@ +# 0010 — Objections « redit » / « contredit », coquilles validées, messagerie + +> Remplace [0004](0004-contradictions-signalees.md) (contradictions signalées) et complète +> [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille +> ne change pas. Décisions d'Yvv du 2026-10-30 (revue) ; la messagerie dans l'application et la +> coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch. +> Lot F, 2026-10-31. + +**Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à +qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte +d'un autre sans lui demander. Yvv, 2026-10-30 : + +- « 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". » +- « 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. » (« le texte en vote » : le texte en vigueur.) + +## Décision + +### Les objections — `crates/ld-engine/src/objection.rs` + +- Une objection dit qu'une version **redit** une autre (elle la répète) ou la **contredit**, + motif à l'appui (10 à 600 signes). Elle porte sur deux versions de texte de deux articles + différents, ouvertes ou en vigueur — jamais deux textes en vigueur (`both_in_force`). +- Elle retient **la dernière arrivée** des deux (date de soumission, à défaut de création, puis + l'ordre d'écriture) ; **jamais un texte en vigueur** : si la dernière arrivée est en vigueur, + c'est l'autre qui est retenue. La version retenue est fixée quand l'objection est faite. +- Tant que l'objection tient, la version retenue **ne se vote plus** (vote direct, vote groupé, + bulletin de la chaîne : `409 objection`) et **n'entre pas en vigueur**. Les votes déjà faits + restent ; un bulletin scellé avant l'objection et confirmé après compte, sans la faire entrer. +- Une personne n'a qu'une objection debout sur une même paire (`objection_exists`). +- **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé** + (une proposition liée à l'objection, sur l'article de l'autre version, partie de son texte) ; + pour une contradiction, il **reformule** (une révision de fond de sa version, ou une + proposition liée sur son propre article). Une révision de fond de l'une ou l'autre des deux + versions vaut réponse ; une coquille, non. Une version du seed n'a pas d'auteur : tout membre + du groupe peut y répondre par une proposition liée. +- Une réponse donne la main à **la personne qui objecte** : un message lui demande de **lever** + ou de **réitérer** dans les **3 jours** ; sans geste de sa part, l'objection est **levée** à + l'échéance. Un seul tour à la fois : une autre réponse pendant son tour ne change ni le + message ni l'échéance. Réitérer rend la main à l'auteur (tour suivant, sans échéance pour lui). +- Lever : la personne qui a fait l'objection, à tout moment, ou un administrateur de + trustinWallet. Réitérer : elle seule. Retirer une des deux versions met fin à l'objection. + Une levée relance l'adoption : la version libérée entre en vigueur si ses voix suffisent. +- Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`. + +| Qui | Quoi | Effet | Échéance | +|---|---|---|---| +| tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — | +| l'auteur de la version retenue (tout membre pour une version du seed) | modifier l'article visé (redit) ou reformuler (contredit) | la main passe à la personne qui objecte ; message « lever ou réitérer » | 3 jours pour elle | +| la personne qui objecte | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — | +| la personne qui objecte | réitérer | la main revient à l'auteur ; message « réponds à nouveau » | aucune pour l'auteur | +| personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — | +| un administrateur | lever | levée « par un administrateur » | — | +| l'auteur | retirer sa version | l'objection prend fin | — | + +### Les coquilles validées — `crates/ld-engine/src/correction.rs` + +La règle de la coquille (0003, `edit.rs`) ne change pas ; ce qui change, c'est qui la laisse +entrer. + +- Sur **son propre texte** (l'auteur de la version ; un administrateur pour un texte du seed), + la correction s'applique tout de suite, comme avant. +- Sur **celui d'un autre**, elle **attend** : un message à l'auteur de la version (aux + administrateurs qui ouvrent le groupe, pour un texte du seed) avec **valider / refuser** ; sans + réponse sous **3 jours**, elle s'applique d'elle-même. +- **Tant que le texte n'est pas en vigueur, l'auteur garde la main** : il peut revenir sur une + correction appliquée (révision « correction retirée ») ou appliquer finalement une correction + refusée, tant que le texte est celui sur lequel elle a été écrite. +- **En vigueur, seule une correction validée change le texte** : elle attend la validation (ou + l'échéance, qui vaut validation), et un refus est définitif. +- Deux corrections en attente sur un même texte : chacune est jugée contre le texte voté ; à + l'application, la correction doit tomber sur le texte qu'elle a lu — sinon (une autre + correction, une révision de fond ou un retrait est passé entre-temps) elle est **périmée** + et l'interface demande de recharger. +- Les traductions restent éditoriales : elles ne passent pas par une validation. +- L'historique dit qui a écrit la correction et qui l'a validée (ou qu'elle est passée à + l'échéance) ; la correction en attente se lit, avec son diff, dans « Détails ». +- Règle d'accès : `trustin_access::answers_typo`. + +| Qui | Quoi | Effet | Échéance | +|---|---|---|---| +| l'auteur de la version (un administrateur pour un texte du seed) | corriger une coquille | appliquée tout de suite, les votes restent | — | +| tout autre membre | corriger une coquille | en attente ; message à l'auteur (aux administrateurs pour un texte du seed) | 3 jours, puis appliquée | +| l'auteur (ou un administrateur pour le seed) | valider / refuser | appliquée / laissée de côté | — | +| l'auteur, texte pas encore en vigueur | revenir au texte d'avant, ou appliquer finalement | la main reste à l'auteur | — | +| texte en vigueur | — | seule une correction validée (ou passée à l'échéance) le change ; un refus est définitif | — | + +### La messagerie — `crates/ld-server/src/messages.rs` + +Une boîte **« Messages »** dans l'application, au menu, avec le nombre de non-lus (une pastille +sur le bouton du menu en mobile) ; pas de courriel. Un message porte un objet (une objection, une +coquille), son texte — dit par l'interface, dans la langue de la personne —, les gestes que +l'objet lui demande **maintenant** (répondre, lever ou réitérer, valider ou refuser) et son +échéance ; un message traité n'offre plus de geste et dit ce qu'il est devenu. Chacun ne lit +que les siens (`trustin_access::reads_message`) ; agir sur l'objet lit ses messages. Un message +ne se montre que tant que la personne ouvre le groupe dont il parle. + +### Les échéances — `crates/ld-server/src/deadlines.rs` + +Trois jours (`ANSWER_DAYS`). Le balayage d'adoption (toutes les 5 minutes) passe les +échéances, et une fois au démarrage : une échéance passée pendant un arrêt est prise dès la +reprise. L'horloge du serveur est injectable (`AppState::clock`) : les tests l'avancent, sans +attendre ; la route `/api/dev/clock` n'existe qu'avec la connexion de développement. + +### La migration 0008 + +Additive. Les signalements gardent leur table (`signals`) et y gagnent le genre, la version +retenue, le tour, l'échéance, la réponse et la cause de la levée ; chaque signalement existant +devient un **« contredit »** sans perte : levé, il le reste (par son auteur, un administrateur +ou une révision, selon `lifted_by`) ; debout, il retient la dernière arrivée selon la règle +ci-dessus (s'il visait deux textes aujourd'hui tous deux en vigueur, il ne retient rien et se +lève à la main). Aucun message n'est créé pour le passé. S'y ajoutent les tables `typos` (les +corrections des autres) et `messages`, et sur les révisions la correction d'origine et qui l'a +validée. + +**Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la +dernière arrivée, c'est à elle de s'expliquer. Une personne qui réitère sans fin garde une +version hors du vote : un administrateur peut lever. Une coquille d'un autre met jusqu'à trois +jours à paraître, et sur un texte du seed elle attend un administrateur. diff --git a/docs/adr/README.md b/docs/adr/README.md index b52ba84..0dcf4a9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,8 +8,9 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan | [0001](0001-rust-svelte.md) | Rust + Svelte, une image, SQLite | 2026-10-21 | | [0002](0002-vote-permanent.md) | Vote permanent : marge du texte en place et plancher C | 2026-10-21 | | [0003](0003-corriger-n-est-pas-voter.md) | Corriger n'est pas voter | 2026-10-21 | -| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 | +| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes (remplacée par 0010) | 2026-10-21 | | [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 | | [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 | | [0007](0007-inertie-cinq-crans.md) | Inertie à cinq crans, calée sur la participation du groupe (complète 0002) | 2026-10-30 | | [0008](0008-vote-par-transaction.md) | Le vote par transaction sur la chaîne Ğ1 : bulletin scellé, confirmation par l'indexeur, le dernier fait foi | 2026-10-30 | +| [0010](0010-objections-et-coquilles-validees.md) | Objections « redit » / « contredit », coquilles validées, messagerie (remplace 0004, complète 0003) | 2026-10-31 | diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 8fe23b2..8274bcf 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -9,7 +9,7 @@ La communauté Monnaie libre tient son Acte d'engagement Certification comme un **texte vivant** : lisible d'abord, modifiable par article, chaque modification passant par un vote dont le seuil suit la formule inertielle de la Ğ1 — sans qu'une coquille demande un vote, sans que deux -articles contradictoires soient en vigueur ensemble, et avec une publication automatique que +articles qui se répètent ou se contredisent soient en vigueur ensemble, et avec une publication automatique que les applications clientes (Cesium, Ğecko…) peuvent suivre. ## Ce que voit la personne @@ -40,7 +40,7 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. (enregistré, inscrit sur la chaîne Ğ1, en attente de la chaîne, à reconfirmer, non compté et pourquoi), puis l'inertie et la formule. Les autres gestes ouvrent un panneau latéral (une feuille sur mobile), lui aussi dans l'URL : **Panier**, **Propositions**, **Proposer**, - **Détails** d'une version (coquilles, traductions, signalements, historique) et + **Détails** d'une version (coquilles, traductions, objections, historique) et **« Réglages de la formule »** (W, C, B, la participation habituelle du groupe et les cinq crans), atteints depuis l'en-tête du document et depuis chaque vote. 5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro @@ -61,6 +61,11 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. 7. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ». 8. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ; une traduction devenue ancienne (le français a changé depuis) est signalée. +9. **Messages** (lot F, ADR 0010) : au menu, une boîte avec le nombre de non-lus (une pastille + sur le bouton du menu en mobile) ; ce qui attend un geste de la personne — une objection à + laquelle répondre, à lever ou à réitérer, une correction de coquille à valider —, chaque + message avec son objet, son texte, ses gestes et son échéance. Une version retenue par une + objection le dit sous l'article, à chacun selon son rôle. ## Les règles @@ -128,11 +133,19 @@ mot (« certifcation » → « certification »). Tout le reste passe par une pr ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement de sens. -| Geste | Qui | Effet sur les votes | -|---|---|---| -| Corriger une **coquille** | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour | -| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent | -| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version | +| Geste | Qui | Effet | Échéance | +|---|---|---|---| +| Corriger une **coquille** sur son propre texte | l'auteur de la version ; un administrateur sur un texte du seed | appliquée tout de suite : les votes restent, les traductions restent à jour | — | +| Corriger une **coquille** sur le texte d'un autre | tout membre du groupe | **en attente** : un message à l'auteur (aux administrateurs pour un texte du seed), qui valide ou refuse ; la correction se lit, avec son diff, dans « Détails » | 3 jours sans réponse : appliquée | +| Revenir sur une correction (ou l'appliquer finalement) | l'auteur, tant que le texte **n'est pas en vigueur** | le texte d'avant revient (ou la correction passe) | — | +| Corriger un texte **en vigueur** | tout membre ; l'auteur (un administrateur pour le seed) valide | **seule une correction validée** change le texte ; un refus est définitif | 3 jours sans réponse : appliquée | +| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les corrections en attente sont périmées ; elle répond aux objections qui attendent l'auteur | — | +| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version | — | + +Deux corrections en attente sur un même texte : chacune est jugée contre le texte voté ; à +l'application, celle qui ne tombe plus sur le texte qu'elle a lu est **périmée** (« recharge, +puis recommence »). Les traductions restent éditoriales, sans validation. L'historique dit qui a +écrit chaque correction et qui l'a validée, ou qu'elle est passée à l'échéance (ADR 0010). Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions @@ -170,14 +183,37 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u - une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »). -Moteur : `crates/ld-engine/src/edit.rs` ; règle d'écriture : `crates/ld-server/src/proposals.rs`. +Moteur : `crates/ld-engine/src/edit.rs` (la coquille) et `crates/ld-engine/src/correction.rs` (qui +la laisse entrer) ; écriture : `crates/ld-server/src/proposals.rs` et `crates/ld-server/src/typos.rs`. -### Deux articles qui se contredisent +### Deux articles qui se répètent ou se contredisent -Tout membre peut **signaler une contradiction** entre deux propositions, motif à l'appui. Tant -que le signalement tient, celle des deux qui n'est pas en vigueur **ne peut pas entrer en -vigueur**. Il se lève par son auteur (ou un administrateur), ou tombe quand la proposition -signalée est révisée sur le fond. +Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le +répète) ou elle le **contredit**, motif à l'appui. **Une objection rend la dernière arrivée des +deux versions non votable jusqu'à sa levée** — jamais un texte en vigueur : si la dernière +arrivée est en vigueur, c'est l'autre qui est retenue. Les votes déjà faits restent ; la version +retenue ne reçoit plus de vote (ni sous l'article, ni dans le panier, ni par la chaîne) et +n'entre pas en vigueur. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite, +il **modifie l'article visé** ; pour une contradiction, il **reformule**. La personne qui a fait +l'objection reçoit alors un message : **lever** ou **réitérer** ; sans réponse de sa part sous +**3 jours**, l'objection est levée. + +| Qui | Quoi | Effet | Échéance | +|---|---|---|---| +| tout membre du groupe | faire une objection « redit » ou « contredit », motif à l'appui | la dernière arrivée des deux versions (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — | +| l'auteur de la version retenue (tout membre pour une version du seed) | « Modifier l'article visé » (redit) : une proposition liée, sur l'autre article, partie de son texte ; « Reformuler » (contredit) : une révision de fond de sa version | la main passe à la personne qui objecte : message « lever ou réitérer » | 3 jours pour elle | +| la personne qui a fait l'objection | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — | +| la personne qui a fait l'objection | réitérer | la main revient à l'auteur : message « réponds à nouveau » | aucune pour l'auteur | +| personne | (3 jours sans geste après une réponse) | levée : « 3 jours sans suite » | — | +| un administrateur de trustinWallet | lever | levée « par un administrateur » | — | +| l'auteur | retirer sa version | l'objection prend fin | — | + +Une révision de fond de l'une ou l'autre des deux versions vaut réponse, une coquille non ; un +seul tour à la fois (une autre réponse pendant le tour de la personne qui objecte ne change ni le +message ni l'échéance) ; une personne n'a qu'une objection debout sur une même paire. Les +signalements de contradiction d'avant sont devenus des « contredit » (migration 0008). Moteur : +`crates/ld-engine/src/objection.rs` ; serveur : `crates/ld-server/src/objections.rs`, +`messages.rs`, `deadlines.rs` (les échéances, au balayage de 5 minutes et au démarrage) ; ADR 0010. ### Une décision par proposition @@ -206,7 +242,7 @@ du dépôt. Le groupe d'essai ne publie jamais. Un document seedé n'est jamais réinstancié : quand le seed change un texte de départ (le Préambule devenu Raison d'être), un **amendement de seed** le remplace dans une base existante, une seule fois, et seulement sur un article que personne n'a touché (*seed a minima* d'origine, -révision 1, aucun vote, aucune correction, aucune traduction modifiée, aucun signalement, texte +révision 1, aucun vote, aucune correction (ni en attente), aucune traduction modifiée, aucune objection, texte identique à l'empreinte attendue). Chaque décision est consignée (`seed_amendments`, et dans les journaux quand l'article garde son texte) ; appliqué, l'amendement se lit dans l'historique de l'article (« texte de départ remplacé », avec le diff) et le texte est republié @@ -215,7 +251,7 @@ l'article (« texte de départ remplacé », avec le diff) et le texte est repub ### Accès — trustinWallet Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter, -corriger, signaler) **sauf la gestion des accès**, réservée aux administrateurs de trustinWallet +corriger, faire une objection) **sauf la gestion des accès**, réservée aux administrateurs de trustinWallet (`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication. Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`. @@ -274,6 +310,10 @@ Les raisons longues sont dans `docs/adr/`. dans l'historique de l'article. - Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont republiées — le français fait foi. +- Une version retenue par une objection attend son auteur aussi longtemps qu'il ne répond pas ; + une personne qui réitère sans fin la garde hors du vote : un administrateur peut lever (ADR 0010). +- Les messages sont dans l'application, sans courriel : qui ne passe pas ne les voit pas, et la + correction ou l'objection qui l'attendait passe à l'échéance de 3 jours. ## Questions ouvertes (à Yvv) diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index e4da467..f349650 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -4,7 +4,7 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e `npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée. -Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22) se font dans le **groupe +Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C28–C31) se font dans le **groupe d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Les votes par la chaîne Ğ1 (C11, C17, C23) ne se jouent, pour l'automate, que sur une **chaîne simulée** (`web/e2e/chain-mock.mjs` : un faux nœud, un faux indexeur, un faux portefeuille) ; @@ -17,7 +17,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) | | C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie | | C5 | Entrée en vigueur | groupe d'essai : à deux, déplier le vote de « Les deux garanties réciproques », « Pour » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (2 pour · 0 contre) | -| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Coquilles, traductions, signalements, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » | +| C6 | Coquille sans vote | groupe d'essai, **en administratrice** (un texte du seed n'a pas d'auteur) : Unicité du compte → « Le vote » → « Coquilles, traductions, objections, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » (sans être administratrice, la correction attend : C31) | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît | | C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit | | C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 | @@ -36,7 +36,11 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « Seuil : … voix pour », « Il manque … », « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 | | C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour | | C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité, « Signer et envoyer (2) » ; puis sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous l'article « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre | -| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction. », pas de bouton, les totaux et le seuil restent ; dans le groupe d'essai, le panier et les boutons sont là | +| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et le seuil restent ; dans le groupe d'essai, le panier et les boutons sont là | +| C28 | Objection « redit » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera recharge | « Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée. » ; chez Oscar, « « … » redit « Unicité du compte » », « cette version ne se vote plus », « Lever l'objection » ; chez Vera, « Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent. », « Elle redit « Unicité du compte ». », pas de « Pour », la ligne marquée « non votable : objection » ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter | +| C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste | +| C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » | +| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » | ## Au-delà de la liste @@ -66,3 +70,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts | | 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts | | 2026-10-31 | automate | local, lot B (le vote déplié, le panier, le vote par la chaîne Ğ1 simulée) | C1–C24, M1 | 25 / 25 verts | +| 2026-10-31 | automate | local, lot F (objections « redit » / « contredit », coquilles validées, messages ; horloge du serveur avancée pour l'échéance) | C1–C24, C28–C31, M1 | 29 / 29 verts |