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/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/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 25af6ee..f673347 100644 --- a/crates/ld-server/src/ballots.rs +++ b/crates/ld-server/src/ballots.rs @@ -120,6 +120,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 7399a10..2d0908d 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( @@ -410,6 +395,11 @@ impl DocState { self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok() } + /// Open to a vote: open or in force, and not held back by an objection. + pub fn votable(&self, p: &ProposalRow) -> bool { + matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id) + } + pub fn unit_states(&self) -> Vec { let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); self.units @@ -560,7 +550,8 @@ fn proposal_json( "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), - "signals": st.signal_counts.get(&p.id).copied().unwrap_or(0), + "votable": st.votable(p), + "objections": st.signal_counts.get(&p.id).copied().unwrap_or(0), "myVote": mine.map(|v| v.choice.as_str()), "myVoteStale": mine.is_some_and(|v| v.revision != p.revision), "myVoteChain": mine.is_some_and(|v| v.source == "chain"), diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 3d3f765..737ac27 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(), + }) } } @@ -129,9 +144,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)) @@ -212,6 +235,12 @@ pub async fn run() -> anyhow::Result<()> { tracing::warn!(error = %e, "usual participation"); } auth::bootstrap(&state).await?; + // A deadline passed while the server was stopped is dealt with now. + match deadlines::sweep(&state).await { + Ok(s) if s != deadlines::Swept::default() => tracing::info!(?s, "deadlines passed while stopped"), + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "deadlines"), + } if let Err(e) = publish::ensure_key(&state).await { tracing::warn!(error = %e, "publication key"); } 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..9ae259f --- /dev/null +++ b/crates/ld-server/src/objections.rs @@ -0,0 +1,587 @@ +//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010). +//! +//! The rules are the engine's (`ld_engine::objection`): the latest arrival of +//! the pair, never a version in force, is no longer votable while the objection +//! stands; its author answers (a substantive revision, or a proposal linked to +//! the objection — a revision of the other side answers too); the objector is +//! then asked, by a message, to lift or reiterate; without an answer within 3 +//! days the objection is lifted (`due`, run by the sweeper and at start). +//! +//! Objections live in the table of the contradiction signals of before +//! (`signals`, migration 0008): an older image still reads them as signals, and +//! every lifting writes `lifted_at` / `lifted_by` as it did. + +use crate::AppState; +use crate::adoption; +use crate::auth::{IdentityRow, User}; +use crate::docs::{self, DocState, UnitRow}; +use crate::error::{AppError, AppResult}; +use crate::messages::{self, Topic}; +use crate::proposals; +use crate::util; +use axum::Json; +use axum::extract::{Path, State}; +use ld_engine::edit; +use ld_engine::objection::{ + self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; + +pub const MIN_REASON: usize = 10; +pub const MAX_REASON: usize = 600; + +#[derive(Clone, Debug, sqlx::FromRow)] +pub struct ObjectionRow { + pub id: String, + /// The version the objection was raised from. + pub proposal_id: String, + /// The version it was raised against. + pub target_proposal_id: String, + pub reason: String, + /// The objector. + pub author_id: String, + pub created_at: String, + pub lifted_at: Option, + 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 (by `by`): the objections around it whose author it +/// answers move on; the messages of the one who answered are read. +pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> { + for o in standing_around(state, proposal_id).await? { + let Some(side) = o.touches(proposal_id) else { continue }; + let change = if substantive { Change::Revision(side) } else { Change::Typo(side) }; + if !objection::answers(change) { + continue; + } + let due = state.clock.in_days(objection::ANSWER_DAYS); + if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? { + let answer = if side == Touched::Target { "revision" } else { "other" }; + if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? { + messages::read_subject(state, by, Topic::Objection, &o.id).await?; + } + } + } + Ok(()) +} + +/// May this person write a proposal linked to this objection, on this article? +pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> { + let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?; + let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?; + let unit_of = |id: String| async move { + sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?") + .bind(id) + .fetch_one(&state.db) + .await + }; + let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?); + let author = author_of(state, held).await?; + if !o.phase().standing() + || !objection::may_link(author.as_deref(), &user.id) + || !objection::link_fits(unit_id, &held_unit, &other_unit) + { + return Err(AppError::Invalid("link")); + } + Ok(()) +} + +/// A proposal linked to an objection was submitted (by `by`): it answers the objection. +pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> { + let o = load(state, objection_id).await?; + if !objection::answers(Change::LinkedProposal) { + return Ok(()); + } + let due = state.clock.in_days(objection::ANSWER_DAYS); + if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? + && store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await? + { + messages::read_subject(state, by, Topic::Objection, &o.id).await?; + } + Ok(()) +} + +/// A version was withdrawn: the objections around it end. +pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> { + for o in standing_around(state, proposal_id).await? { + if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? { + store(state, &o, &step, by, None).await?; + } + } + Ok(()) +} + +/// The objections whose deadline passed are lifted by themselves; returns how +/// many, and the documents touched. +pub async fn due(state: &AppState) -> AppResult<(usize, Vec)> { + 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 e58c441..e8910a4 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, &user.id).await?; + } + } Ok(Json(json!({ "id": id, "unitId": unit_id }))) } @@ -337,43 +357,8 @@ pub async fn edit( }; match kind { EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))), - EditKind::Typographic => { - // Anyone in the group may fix a typo of a seed text or of a text in force. - if !(is_author || p.origin == "seed" || p.status == "in_force") { - return Err(AppError::Forbidden); - } - // Written only over the text that was judged: an entry into force or another - // correction in between makes it a conflict, never a silent change. - let done = sqlx::query( - "UPDATE proposals SET title = ?, body = ?, updated_at = ? - WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?", - ) - .bind(title) - .bind(body) - .bind(&now) - .bind(&id) - .bind(p.revision) - .bind(&p.status) - .bind(&p.title) - .bind(&p.body) - .execute(&state.db) - .await?; - if done.rows_affected() != 1 { - return Err(AppError::Conflict("changed")); - } - record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?; - // A typo fix does not change the meaning: up-to-date translations stay up to date. - sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?") - .bind(util::source_hash(title, body)) - .bind(&id) - .bind(util::source_hash(&p.title, &p.body)) - .execute(&state.db) - .await?; - if p.status == "in_force" { - adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?; - } - Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) }))) - } + // Anyone in the group may fix a typo; who applies it, and when, is `typos`. + EditKind::Typographic => typos::write(&state, &user, &p, &st, title, body).await, EditKind::Substantive => { if p.status == "in_force" { return Err(AppError::Conflict("substantive_in_force")); @@ -402,14 +387,10 @@ pub async fn edit( return Err(AppError::Conflict("changed")); } record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?; - // Signals were about the former text: they lapse, their authors may signal again. - sqlx::query( - "UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL", - ) - .bind(&now) - .bind(&id) - .execute(&state.db) - .await?; + // It answers the objections around it (the objector judges again), and + // the typo fixes waiting on the former text no longer fit. + objections::on_change(&state, &id, true, &user.id).await?; + typos::on_text_changed(&state, &id).await?; Ok(Json(json!({ "edit": "substantive", "revision": revision }))) } } @@ -446,6 +427,12 @@ pub async fn submit( if initial.rows_affected() == 0 { record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?; } + // Written to answer an objection: submitted, it answers it (a draft answers nothing). + let linked: Option = + sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?; + if let Some(oid) = linked { + objections::on_linked(&state, &oid, &id, &user.id).await?; + } let entered = adoption::run(&state, &st.doc.id).await?; Ok(Json(json!({ "status": "open", "entered": entered }))) } @@ -468,6 +455,9 @@ pub async fn withdraw( .bind(&id) .execute(&state.db) .await?; + // Nothing left to object to, nor to correct. + objections::on_withdrawn(&state, &id, &user.id).await?; + typos::on_text_changed(&state, &id).await?; Ok(Json(json!({ "status": "withdrawn" }))) } @@ -557,66 +547,37 @@ pub async fn detail( author_name: Option, 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.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, - "blocked": st.blocked.contains(&p.id), + "blocked": st.blocked.contains(&p.id), "votable": st.votable(&p), "myVote": my_vote.as_ref().map(|v| &v.choice), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision), "myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot, @@ -647,7 +608,8 @@ pub async fn detail( "inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })), "diff": diff, "history": history, - "signals": signals, + "objections": objections, + "typos": typos, "electorate": st.electorate, "electorateSource": st.electorate_source, "voteFloor": st.doc.vote_floor, @@ -681,6 +643,10 @@ pub async fn vote( if !matches!(p.status.as_str(), "open" | "in_force") { return Err(AppError::Conflict("not_votable")); } + // Held back by an objection: its votes stay, no new one (ADR 0010). + if !st.votable(&p) { + return Err(AppError::Conflict("objection")); + } if !docs::may_vote(&state, &user, &st.group).await? { return Err(AppError::Conflict("members_only")); } @@ -696,81 +662,6 @@ pub async fn vote( Ok(Json(json!({ "entered": entered }))) } -// --------------------------------------------------------------------------- -// Contradiction signals -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SignalBody { - target_id: String, - reason: String, -} - -/// « These two texts contradict each other. » While the signal stands, the two -/// can never be in force together: the one not yet in force is blocked. -pub async fn signal( - State(state): State, - 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 c3f7b4f..c9b9205 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -711,17 +711,15 @@ async fn a_session_sees_its_own_ballots_only() { } #[tokio::test] -async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() { +async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let e4 = version(&doc, "E4", "0"); let e5 = version(&doc, "E5", "0"); - // A contradiction signalled on E5 against the text in force E1: E5 cannot enter. let reason = "Ces deux textes disent le contraire l'un de l'autre."; let e1 = version(&doc, "E1", "inForce"); - c.ok("POST", &format!("/api/proposals/{e5}/signals"), Some(json!({ "targetId": e1, "reason": reason }))).await; let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; for (n, account) in keys.iter().enumerate() { @@ -732,6 +730,27 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() { b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); + if n == 49 { + // The last ballot is signed; before the chain confirms it, an objection holds + // E5 back against the text in force E1 (ADR 0010): no new ballot may hold it. + let o = c + .ok( + "POST", + &format!("/api/proposals/{e5}/objections"), + Some(json!({ "otherId": e1, "kind": "contredit", "reason": reason })), + ) + .await; + assert_eq!(o["heldId"], e5.as_str()); + let id = doc["doc"]["id"].as_str().expect("id"); + let (s, v) = c + .call( + "POST", + &format!("/api/docs/{id}/ballots"), + Some(json!({ "lines": [{ "proposalId": e5, "choice": "for" }] })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("objection"))); + } last = b; if n % 10 == 9 { // A session holds at most 20 pending ballots: the background task confirms on the way. @@ -750,8 +769,11 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() { assert_eq!(s["entered"][0]["code"], "E4", "{s}"); let d = c.doc("monnaie-libre").await; assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str()); + // A ballot sealed before the objection counts, as a vote already cast: kept, but + // the version held back cannot enter into force. assert_eq!(tally(&d, &e5), (50, 0)); - assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back"); + assert_eq!(find(&d, &e5)["votable"], false); + assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); } // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs new file mode 100644 index 0000000..79af219 --- /dev/null +++ b/crates/ld-server/tests/objections.rs @@ -0,0 +1,662 @@ +//! Objections « redit » / « contredit » and the messages of trustinWallet +//! (2026-10-31, lot F, ADR 0010), end to end on an in-memory database. The +//! three days of a deadline pass on the server's clock, never with a sleep. + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, app, deadlines, open_db, seed}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +struct Client { + router: axum::Router, + cookie: Option, +} + +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"); + assert_eq!(m["unread"], 0, "answering reads one's message"); + + // Yvv lifts from his message: the proposal is votable again, the message offers nothing more. + yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await; + let d = yvv.doc("essai").await; + assert_eq!(find(&d, &b)["votable"], true); + let m = yvv.messages().await; + assert_eq!((m["unread"].as_i64(), gestures(&m["messages"][0]).len()), (Some(0), 0), "acting reads it"); + assert_eq!(m["messages"][0]["outcome"], "lifted"); + yvv.ok("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await; + // Lifted is lifted. + let again = yvv.err("POST", &format!("/api/objections/{oid}/lift"), None).await; + assert_eq!(again, (StatusCode::CONFLICT, "lifted".into())); +} + +/// C29, by the API: a « contredit » answered by a rewording; one cycle at a time; +/// a reiteration tells the author again; without an answer, lifted at the deadline. +#[tokio::test] +async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() { + let (mut chiara, state) = Client::new().await; + chiara.login("Chiara", false).await; + let mut yvv = chiara.other(); + yvv.login("Yvv", false).await; + let doc = chiara.doc("essai").await; + let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned(); + let title = "Certifier largement"; + let body = "Je m'engage à certifier aussi des personnes qui ne respectent pas ces engagements."; + let b = chiara.propose(&doc, json!({ "title": title, "body": body, "submit": true })).await; + let oid = yvv.object(&b, &e2, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned(); + let phase = + |d: &Value| (d["objections"][0]["phase"].as_str().map(str::to_owned), d["objections"][0]["cycle"].as_i64()); + + // A typo fix answers nothing. + let typo = chiara + .ok( + "PATCH", + &format!("/api/proposals/{b}"), + Some(json!({ "title": title, "body": body.replace('\'', "\u{2019}") })), + ) + .await; + assert_eq!(typo["edit"], "typo"); + assert_eq!(phase(&chiara.detail(&b).await), (Some("author".into()), Some(1))); + + // Rewording answers: the objector is asked. + let reworded = + "Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées."; + let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await; + assert_eq!(r["edit"], "substantive"); + assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message"); + let det = yvv.detail(&b).await; + assert_eq!(phase(&det), (Some("objector".into()), Some(1))); + assert_eq!(det["objections"][0]["answer"], "revision"); + let due = det["objections"][0]["dueAt"].as_str().expect("due").to_owned(); + + // One cycle at a time: another rewording changes neither the messages nor the deadline. + state.clock.advance(3600); + let again = format!("{reworded} Et à les revoir."); + chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": again }))).await; + assert_eq!(yvv.detail(&b).await["objections"][0]["dueAt"], due.as_str()); + assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1); + + // Reiterated: the objection stands, the author is told again, a second cycle opens. + yvv.ok("POST", &format!("/api/objections/{oid}/reiterate"), None).await; + assert_eq!(phase(&yvv.detail(&b).await), (Some("author".into()), Some(2))); + assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], false); + let m = chiara.messages().await; + let kinds: Vec<&str> = m["messages"].as_array().expect("m").iter().filter_map(|x| x["kind"].as_str()).collect(); + assert_eq!(kinds, ["objection_reiterated", "objection_raised"], "newest first"); + assert_eq!(gestures(&m["messages"][0]), ["respond"]); + let m = yvv.messages().await; + assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("reiterated"), 0)); + + // A new answer, then nobody: three days later it is lifted by itself. + let third = "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements."; + chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": third }))).await; + assert_eq!(phase(&yvv.detail(&b).await), (Some("objector".into()), Some(2))); + state.clock.advance(3 * 86_400 - 60); + deadlines::sweep(&state).await.expect("sweep"); + assert_eq!(yvv.detail(&b).await["objections"][0]["phase"], "objector", "not yet"); + state.clock.advance(120); + let swept = deadlines::sweep(&state).await.expect("sweep"); + assert_eq!(swept.lifted, 1); + let det = yvv.detail(&b).await; + assert_eq!( + (det["objections"][0]["phase"].as_str(), det["objections"][0]["lift"].as_str()), + (Some("lifted"), Some("deadline")) + ); + assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], true); + let m = yvv.messages().await; + assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0)); +} + +#[tokio::test] +async fn a_rewording_of_the_other_side_asks_the_objector_too() { + let (mut luna, _) = Client::new().await; + luna.login("Luna", false).await; + let mut chiara = luna.other(); + chiara.login("Chiara", false).await; + let mut yvv = luna.other(); + yvv.login("Yvv", false).await; + let doc = luna.doc("essai").await; + let a = luna + .propose( + &doc, + json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs.", "submit": true }), + ) + .await; + let b = chiara + .propose( + &doc, + json!({ "title": "Injoignable", "body": "Je ne suis pas tenu de rester joignable.", "submit": true }), + ) + .await; + let o = yvv.object(&a, &b, "contredit", "Ces deux articles disent le contraire.").await; + assert_eq!(o["heldId"], b.as_str(), "the latest arrival, whichever side the objection was raised from"); + // Luna rewords the earlier one: the pair changed, the objector judges again. + luna.ok( + "PATCH", + &format!("/api/proposals/{a}"), + Some(json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs, sauf absence annoncée." })), + ) + .await; + let det = yvv.detail(&b).await; + assert_eq!( + (det["objections"][0]["phase"].as_str(), det["objections"][0]["answer"].as_str()), + (Some("objector"), Some("other")) + ); + assert_eq!(yvv.messages().await["messages"][0]["kind"], "objection_answered"); +} + +#[tokio::test] +async fn an_administrator_lifts_but_never_reiterates_and_withdrawing_ends_it() { + let (mut chiara, _) = Client::new().await; + chiara.login("Chiara", false).await; + let mut yvv = chiara.other(); + yvv.login("Yvv", false).await; + let mut admin = chiara.other(); + admin.login("Admin", true).await; + let doc = chiara.doc("essai").await; + let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned(); + let b = chiara + .propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true })) + .await; + let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned(); + chiara + .ok( + "PATCH", + &format!("/api/proposals/{b}"), + Some(json!({ "title": "Un compte", "body": "Un seul compte membre par être humain." })), + ) + .await; + // An administrator never speaks for the objector... + assert_eq!(admin.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await.0, StatusCode::FORBIDDEN); + // ...but may lift. + admin.ok("POST", &format!("/api/objections/{oid}/lift"), None).await; + let det = admin.detail(&b).await; + assert_eq!(det["objections"][0]["lift"], "admin"); + assert_eq!(yvv.messages().await["messages"][0]["outcome"], "admin"); + + // Withdrawing either version ends a standing objection. + let c = chiara + .propose(&doc, json!({ "title": "Deux comptes", "body": "Deux comptes membres par personne.", "submit": true })) + .await; + let oid2 = yvv.object(&c, &e1, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned(); + chiara.ok("POST", &format!("/api/proposals/{c}/withdraw"), None).await; + let lifted: (String, Option) = { + 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 2361538..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}; @@ -150,6 +157,33 @@ pub fn manages_access(identity: &Identity) -> bool { identity.admin } +/// Who answers a typo fix someone else wrote on a version (2026-10-30, L13): +/// its author; for a text without author (a starting text), an administrator of +/// trustinWallet. Whoever answers for a version also applies their own fix at once. +pub fn answers_typo(identity: &Identity, version_author: Option<&str>) -> bool { + match version_author { + Some(author) => identity.id == author, + None => identity.admin, + } +} + +/// Who lifts an objection (2026-10-30, L12): the person who raised it, or an +/// administrator of trustinWallet. +pub fn lifts_objection(identity: &Identity, objector: &str) -> bool { + identity.id == objector || identity.admin +} + +/// Who reiterates an objection: only the person who raised it — an +/// administrator lifts, never speaks for someone else. +pub fn reiterates_objection(identity: &Identity, objector: &str) -> bool { + identity.id == objector +} + +/// A message of trustinWallet is read by its recipient only. +pub fn reads_message(identity: &Identity, recipient: &str) -> bool { + identity.id == recipient +} + /// The groups a fresh deployment starts with. Ordered as displayed. pub fn default_groups() -> Vec { let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group { @@ -251,6 +285,37 @@ mod tests { assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try"); } + #[test] + fn the_author_answers_a_typo_and_an_administrator_for_a_starting_text() { + let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) }; + let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; + let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; + assert!(answers_typo(&chiara, Some("chiara"))); + assert!(!answers_typo(&other, Some("chiara"))); + assert!(!answers_typo(&admin, Some("chiara")), "an administrator never answers for an author"); + assert!(answers_typo(&admin, None), "a starting text has no author: an administrator answers"); + assert!(!answers_typo(&other, None)); + } + + #[test] + fn the_objector_lifts_or_reiterates_an_administrator_only_lifts() { + let objector = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; + let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; + let other = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) }; + assert!(lifts_objection(&objector, "luna") && reiterates_objection(&objector, "luna")); + assert!(lifts_objection(&admin, "luna")); + assert!(!reiterates_objection(&admin, "luna")); + assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna")); + } + + #[test] + fn a_message_is_read_by_its_recipient_only() { + let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; + let luna = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; + assert!(reads_message(&luna, "luna")); + assert!(!reads_message(&admin, "luna"), "not even an administrator"); + } + #[test] fn no_atelier_du_canal() { assert!(default_groups().iter().all(|g| !g.slug.contains("atelier"))); 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 d268784..e3180db 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`. @@ -278,6 +314,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 c87f490..b784480 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, C32, C33) 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 ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; 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 ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; 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 » | | C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page | | C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote--.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé | @@ -69,3 +73,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 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 B2 (vote par la chaîne : corrections de la relecture de `74f96be`, reçu), `c7d2791` | C1–C24, C32, C33, M1 | 27 / 27 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 | diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index edc36b8..ebf61fd 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -81,6 +81,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 94bd622..9781c8e 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} @@ -281,6 +291,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 fb65ad5..f518575 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'} @@ -126,8 +186,10 @@ {#if p.myVoteChain || ballot}{t('vote.mine.receipts')}{/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} @@ -248,6 +310,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 fb6fa40..2607e1c 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.', @@ -218,9 +228,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.', @@ -247,6 +257,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.', @@ -360,27 +371,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', @@ -491,7 +573,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.", @@ -516,6 +598,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 50159a8..f1e95eb 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.', @@ -218,9 +228,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.', @@ -247,6 +257,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.', @@ -360,27 +371,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', @@ -491,7 +573,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.', @@ -516,6 +598,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 8dc8803..6dbc123 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é.", @@ -221,9 +231,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.', @@ -250,6 +260,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é.', @@ -363,27 +374,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', @@ -494,7 +576,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.", @@ -519,6 +601,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 b00881f..8eef39e 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). */ @@ -229,3 +233,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, }; }