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