From 4d4adcc281c954ee7792fa1fdaf33fcb85fdd3c9 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 31 Oct 2026 10:00:29 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20relecture=20de=20cd7e683=20=E2=80=94?= =?UTF-8?q?=20coquilles,=20objections=20et=20retraits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le revirement de l'auteur sur une coquille (reprendre une correction appliquée, valider une correction refusée) ne s'écrit que sur une version encore ouverte : Settle porte while_open ; et la garde de statut de write_text marche enfin (?9 restait NULL, le statut est le 8e paramètre). - 0008 lève les signalements debout dont un côté est déjà retiré. - Une seule objection debout par personne et par paire (index unique partiel) ; envoyée plusieurs fois à la fois, les autres reçoivent objection_exists. - Un retrait relance l'adoption : la version que l'objection retenait entre aussitôt. Tests vus rouges d'abord. Boucle : 230 tests Rust, svelte-check 0, 55 vitest, e2e 35 / 35. Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/correction.rs | 30 +++-- .../0008_objections_typos_messages.sql | 13 ++ crates/ld-server/src/objections.rs | 9 +- crates/ld-server/src/proposals.rs | 4 +- crates/ld-server/src/typos.rs | 68 ++++++++++- crates/ld-server/tests/objections.rs | 112 +++++++++++++++++- .../chantiers/acte-certification/CONTROLES.md | 1 + web/src/lib/wallet/injected.ts | 3 +- 8 files changed, 220 insertions(+), 20 deletions(-) diff --git a/crates/ld-engine/src/correction.rs b/crates/ld-engine/src/correction.rs index 3c5c911..0526e73 100644 --- a/crates/ld-engine/src/correction.rs +++ b/crates/ld-engine/src/correction.rs @@ -88,6 +88,9 @@ pub enum TextChange { pub struct Settle { pub status: TypoStatus, pub text: TextChange, + /// Holds only while the version is not in force: the author's change of mind, + /// decided on a version read open, must not land on one that entered meanwhile. + pub while_open: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -98,21 +101,24 @@ pub enum TypoRefusal { /// An answer of the one who answers for the version. `in_force`: the version is in force now. pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result { - let to = |status, text| Ok(Settle { status, text }); + let to = |status, text, while_open| Ok(Settle { status, text, while_open }); match (status, answer) { - (TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write), - (TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep), + (TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write, false), + (TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep, false), // The author keeps the hand while the version is not in force. - (TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore), - (TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write), + (TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore, true), + (TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write, true), _ => Err(TypoRefusal::Settled), } } /// The clock (sortable stamps): a pending fix applies by itself at its deadline. pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option { - (status == TypoStatus::Pending && due <= now) - .then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write }) + (status == TypoStatus::Pending && due <= now).then_some(Settle { + status: TypoStatus::Applied, + text: TextChange::Write, + while_open: false, + }) } /// A text as a fix sees it: the revision it belongs to, its title and body. @@ -145,11 +151,11 @@ mod tests { for in_force in [false, true] { assert_eq!( answer(TypoStatus::Pending, Answer::Validate, in_force), - Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write }) + Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false }) ); assert_eq!( answer(TypoStatus::Pending, Answer::Refuse, in_force), - Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep }) + Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep, while_open: false }) ); } } @@ -159,11 +165,11 @@ mod tests { // A proposal: an applied fix may be taken back, a refused one validated after all. assert_eq!( answer(TypoStatus::Applied, Answer::Refuse, false), - Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore }) + Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore, while_open: true }) ); assert_eq!( answer(TypoStatus::Refused, Answer::Validate, false), - Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write }) + Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: true }) ); // In force: an answer is final. assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled)); @@ -182,7 +188,7 @@ mod tests { assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None); assert_eq!( tick(TypoStatus::Pending, due, due), - Some(Settle { status: TypoStatus::Applied, text: TextChange::Write }) + Some(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false }) ); // A deadline passed while the server was stopped is taken at the next tick. assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some()); diff --git a/crates/ld-server/migrations/0008_objections_typos_messages.sql b/crates/ld-server/migrations/0008_objections_typos_messages.sql index b6a1324..92b61e1 100644 --- a/crates/ld-server/migrations/0008_objections_typos_messages.sql +++ b/crates/ld-server/migrations/0008_objections_typos_messages.sql @@ -24,6 +24,14 @@ UPDATE signals SET phase = 'lifted', lift = CASE ELSE 'admin' END WHERE lifted_at IS NOT NULL; +-- A standing one whose other side left (withdrawn, or never submitted) ends: a +-- withdrawal ends the objection, which the rule of before did not do. +UPDATE signals SET phase = 'lifted', lift = 'withdrawn', lifted_by = 'withdrawn', + lifted_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE lifted_at IS NULL AND EXISTS ( + SELECT 1 FROM proposals p + WHERE p.id IN (signals.proposal_id, signals.target_proposal_id) AND p.status NOT IN ('open', 'in_force')); + -- 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. @@ -41,6 +49,11 @@ UPDATE signals SET held_id = ( 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); +-- One person, one standing objection on a pair, whichever way it was written +-- (relecture of cd7e683: sent four times at once, four stood). +CREATE UNIQUE INDEX signals_one_standing + ON signals(author_id, min(proposal_id, target_proposal_id), max(proposal_id, target_proposal_id)) + WHERE lifted_at IS NULL; -- A proposal written to answer an objection (« modifier l'article visé »). ALTER TABLE proposals ADD COLUMN objection_id TEXT; diff --git a/crates/ld-server/src/objections.rs b/crates/ld-server/src/objections.rs index 9ae259f..1634211 100644 --- a/crates/ld-server/src/objections.rs +++ b/crates/ld-server/src/objections.rs @@ -350,7 +350,7 @@ pub async fn raise( return Err(AppError::Conflict("objection_exists")); } let oid = util::id(); - sqlx::query( + let inserted = sqlx::query( "INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)", ) @@ -363,7 +363,12 @@ pub async fn raise( .bind(kind.as_str()) .bind(&held) .execute(&state.db) - .await?; + .await; + match inserted { + // The same one sent at the same instant: it stands already (`signals_one_standing`). + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Err(AppError::Conflict("objection_exists")), + other => other?, + }; 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?; } diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index a7e6e86..153ba54 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -445,7 +445,7 @@ pub async fn withdraw( User(user): User, Path(id): Path, ) -> AppResult> { - let (p, _) = guarded(&state, &user, &id).await?; + let (p, st) = guarded(&state, &user, &id).await?; if p.author_id.as_deref() != Some(user.id.as_str()) { return Err(AppError::Forbidden); } @@ -461,6 +461,8 @@ pub async fn withdraw( // Nothing left to object to, nor to correct. objections::on_withdrawn(&state, &id, &user.id).await?; typos::on_text_changed(&state, &id).await?; + // A version its objection held back may enter now, with its votes (relecture of cd7e683). + adoption::run(&state, &st.doc.id).await?; Ok(Json(json!({ "status": "withdrawn" }))) } diff --git a/crates/ld-server/src/typos.rs b/crates/ld-server/src/typos.rs index 8c12936..1c811e7 100644 --- a/crates/ld-server/src/typos.rs +++ b/crates/ld-server/src/typos.rs @@ -92,7 +92,7 @@ async fn write_text( 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)", + AND (?8 IS NULL OR status = ?8)", ) .bind(to.0) .bind(to.1) @@ -277,6 +277,8 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) - if claimed.rows_affected() != 1 { return Ok(Err("settled")); } + // The author's change of mind lands only on a version still open (relecture of cd7e683). + let only = s.while_open.then_some("open"); let written = match s.text { TextChange::Write => { write_text( @@ -284,7 +286,7 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) - &t.proposal_id, t.base(), (&t.title, &t.body), - None, + only, &t.writer_id, Some((&t.id, by)), "typo", @@ -298,7 +300,7 @@ async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) - &t.proposal_id, t.fixed(), (&t.base_title, &t.base_body), - None, + only, by, Some((&t.id, by)), "revert", @@ -475,3 +477,63 @@ pub async fn for_message( v["gestures"] = json!(gestures); Ok(Some((v, gestures))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Config, open_db, seed}; + + /// Relecture of cd7e683 (M1): the author takes back a fix applied, read while her + /// version was open; a vote puts it in force in between. The text in force stays. + #[tokio::test] + async fn a_change_of_mind_never_writes_over_a_text_just_in_force() { + 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"); + let now = "2026-10-31T00:00:00Z"; + let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as( + "SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id + WHERE p.status = 'in_force' AND p.kind = 'text' LIMIT 1", + ) + .fetch_one(&state.db) + .await + .expect("a version in force"); + sqlx::query( + "INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)", + ) + .bind(now) + .execute(&state.db) + .await + .expect("identity"); + // Its text is the one a fix wrote over « Un texte d'avant ». + 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 ('t1', ?, ?, 'dev:aude', 'dev:aude', ?, ?, 'Un texte d''avant', ?, ?, 'applied', ?, ?)", + ) + .bind(&id) + .bind(&doc) + .bind(revision) + .bind(&title) + .bind(&title) + .bind(&body) + .bind(now) + .bind(now) + .execute(&state.db) + .await + .expect("typo"); + let t = load(&state, "t1").await.expect("load"); + // What `answer` decided when it read the version, still open. + let s = correction::answer(TypoStatus::Applied, Answer::Refuse, false).expect("refuse"); + assert_eq!(settle(&state, &t, s, "dev:aude", now).await.expect("settle"), Err("changed")); + let kept: String = sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?") + .bind(&id) + .fetch_one(&state.db) + .await + .expect("body"); + assert_eq!(kept, body, "the text in force stays"); + let status: String = + sqlx::query_scalar("SELECT status FROM typos WHERE id = 't1'").fetch_one(&state.db).await.expect("status"); + assert_eq!(status, "applied", "nothing settled"); + } +} diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs index 79af219..f6ca5dd 100644 --- a/crates/ld-server/tests/objections.rs +++ b/crates/ld-server/tests/objections.rs @@ -476,6 +476,46 @@ async fn votes_already_cast_stay_but_a_held_version_cannot_enter() { assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters"); } +/// Withdrawing the other side frees the held version at once: with its votes, it +/// enters then, not at the next pass of the sweeper (relecture of cd7e683). +#[tokio::test] +async fn withdrawing_the_other_side_lets_the_held_version_enter_at_once() { + let (mut chiara, state) = Client::new().await; + chiara.login("Chiara", false).await; + let mut yvv = chiara.other(); + yvv.login("Yvv", false).await; + let mut zoe = chiara.other(); + zoe.login("Zoé", false).await; + let doc = chiara.doc("essai").await; + let a = yvv + .propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true })) + .await; + let b = chiara + .propose( + &doc, + json!({ "title": "Un seul compte", "body": "Une personne, un seul compte membre.", "submit": true }), + ) + .await; + let held = zoe.object(&b, &a, "redit", "Elle répète la proposition d'Yvv.").await; + assert_eq!(held["heldId"], b.as_str(), "the latest arrival"); + 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"); + } + yvv.ok("POST", &format!("/api/proposals/{a}/withdraw"), None).await; + let status: String = + sqlx::query_scalar("SELECT status FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b"); + assert_eq!(status, "in_force"); +} + #[tokio::test] async fn a_message_is_read_by_its_recipient_only() { let (mut chiara, _) = Client::new().await; @@ -511,6 +551,55 @@ async fn a_message_is_read_by_its_recipient_only() { } /// A deadline passed while the server was stopped is dealt with at the next start. +/// One person, one objection on a pair: sent several times at once, one stands +/// (relecture of cd7e683: four simultaneous requests made four). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_same_objection_sent_at_once_stands_once() { + let path = std::env::temp_dir().join(format!("ld-lot-f-twice-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let db = open_db(&format!("sqlite://{}", path.display())).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 sends = (0..8).map(|_| { + let mut c = Client { router: yvv.router.clone(), cookie: yvv.cookie.clone() }; + let (b, e1) = (b.clone(), e1.clone()); + tokio::spawn(async move { + c.call( + "POST", + &format!("/api/proposals/{b}/objections"), + Some(json!({ "otherId": e1, "kind": "redit", "reason": REPEATS })), + ) + .await + }) + }); + let mut answers = Vec::new(); + for s in sends.collect::>() { + let (status, v) = s.await.expect("join"); + answers.push((status, v["error"].as_str().unwrap_or_default().to_owned())); + } + assert_eq!(answers.iter().filter(|(s, _)| *s == StatusCode::OK).count(), 1, "{answers:?}"); + assert!( + answers.iter().all(|(s, e)| *s == StatusCode::OK || (*s == StatusCode::CONFLICT && e == "objection_exists")), + "{answers:?}" + ); + let standing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE lifted_at IS NULL") + .fetch_one(&state.db) + .await + .expect("count"); + assert_eq!(standing, 1); + state.db.close().await; + let _ = std::fs::remove_file(&path); +} + #[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())); @@ -618,6 +707,25 @@ async fn contradiction_signals_of_before_become_contredits() { 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; + // Standing against a version withdrawn since: the rule of before lifted nothing + // on a withdrawal; now a withdrawal ends the objection (relecture of cd7e683). + let spare: Vec = sqlx::query_scalar( + "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 p.status = 'open' AND p.id NOT IN (?, ?, ?) ORDER BY p.rowid LIMIT 2", + ) + .bind(&k1) + .bind(&e4) + .bind(&e5) + .fetch_all(&pool) + .await + .expect("two more"); + let (live, gone) = (spare[0].clone(), spare[1].clone()); + sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?") + .bind(&gone) + .execute(&pool) + .await + .expect("withdraw"); + signal("s5", live.clone(), gone.clone(), None).await; // The image of now starts on it. let mut all = sqlx::migrate!("./migrations"); all.set_ignore_missing(true); @@ -634,6 +742,7 @@ async fn contradiction_signals_of_before_become_contredits() { ("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())), + ("s5".into(), "contredit".into(), "lifted".into(), None, Some("withdrawn".into())), ], "K1 against the text in force; E5 written after E4 in the same second" ); @@ -645,7 +754,7 @@ async fn contradiction_signals_of_before_become_contredits() { .fetch_one(&pool) .await .expect("count"); - assert_eq!(kept, 4); + assert_eq!(kept, 5); // And the server reads them as objections: K1 and E5 are no longer votable. let st = ld_server::docs::load_state( &state, @@ -659,4 +768,5 @@ async fn contradiction_signals_of_before_become_contredits() { assert!( st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2) ); + assert!(!st.blocked.contains(&live), "nothing holds a version back against one withdrawn"); } diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 9afca61..1472cb0 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -80,3 +80,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-31 | automate | local, fusion de F avec B2 (`cd7e683`) ; C22 ne suppose plus un groupe d'essai de moins de 16 membres | C1–C24, C28–C33, M1 | 31 / 31 verts | | 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts | | 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) | +| 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) | diff --git a/web/src/lib/wallet/injected.ts b/web/src/lib/wallet/injected.ts index 9c7f01c..ce032f2 100644 --- a/web/src/lib/wallet/injected.ts +++ b/web/src/lib/wallet/injected.ts @@ -73,7 +73,8 @@ export function injectedNames(page: Page): string[] { /** What went wrong, in one of a few words the interface translates. */ export function failure(e: unknown): Failure { if (e instanceof WalletError) return e.code; - // polkadot-api's refusal by the node's pool (stale, bad proof, too late…): never sent. + // polkadot-api's refusal by the node's pool (stale, bad proof, too late…), before sending or once the + // finalized chain finds it invalid: it will never be included. // Its message is indented JSON, so the reason is read from the error itself. if (e instanceof Error && e.name === 'InvalidTxError') { const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase();