diff --git a/crates/ld-engine/src/objection.rs b/crates/ld-engine/src/objection.rs index bc36290..8e9f82c 100644 --- a/crates/ld-engine/src/objection.rs +++ b/crates/ld-engine/src/objection.rs @@ -17,6 +17,10 @@ //! is changed by a proposal. The target is fixed when the objection is raised; //! - the votes already cast on it stay, but it cannot enter into force //! (`votable` and the adoption: a non-votable version is blocked); +//! - while it is held, one may still **vote against it or take one's vote +//! back**, never add a *for* (`may_cast`, Yvv 2026-11-01: « Retirer ou +//! "contre" »): it can only lose votes, and once lifted it enters with the +//! votes still there; //! - its author **answers**: a substantive revision of it, or a proposal linked //! to the objection (on the article it repeats, for a « redit »; a reworded //! version, for a version without author). A substantive revision of **the @@ -31,6 +35,7 @@ //! lift too (never reiterate: the objection is not theirs); withdrawing either //! version ends it. +use crate::ballot::Choice; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -296,6 +301,16 @@ pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool { in_force || !held.contains(&id) } +/// May this choice be cast on a version? `held` = a standing objection holds it +/// back now (never a version in force, `votable`). Yvv, 2026-11-01, to « pendant +/// qu'une objection retient une version, que permet-on ? »: « Retirer ou +/// "contre" ». On a held version one may take one's vote back or vote against, +/// never add a *for*: it can only lose votes while it is held, and once lifted it +/// enters with the votes still there. A version not held takes any choice. +pub fn may_cast(held: bool, choice: Choice) -> bool { + !held || choice != Choice::For +} + #[cfg(test)] mod tests { use super::*; @@ -357,6 +372,17 @@ mod tests { assert!(votable("x", true, &["x"]), "a version in force is never made non-votable"); } + #[test] + fn a_held_version_takes_against_and_withdrawal_never_for() { + // Yvv, 2026-11-01: « Retirer ou "contre" » — it can only lose votes while held. + assert!(!may_cast(true, Choice::For), "never a new *for* while held"); + assert!(may_cast(true, Choice::Against), "against, yes"); + assert!(may_cast(true, Choice::None), "taking one's vote back, yes"); + for c in [Choice::For, Choice::Against, Choice::None] { + assert!(may_cast(false, c), "a version not held takes any choice: {c:?}"); + } + } + #[test] fn the_full_cycle_answer_reiterate_answer_lift() { let start = Phase::Author { cycle: 1 }; diff --git a/crates/ld-server/src/ballots.rs b/crates/ld-server/src/ballots.rs index f673347..818dafd 100644 --- a/crates/ld-server/src/ballots.rs +++ b/crates/ld-server/src/ballots.rs @@ -90,7 +90,7 @@ pub struct BallotBody { author: Option, } -/// A line checked against the document: a votable version, its revision now. +/// A line checked against the document: a version open to its choice, its revision now. pub(crate) struct Cast { pub proposal_id: String, pub revision: i64, @@ -105,7 +105,9 @@ fn parse_choice(raw: Option<&str>) -> AppResult { } /// The lines of a vote, read against the document: at least one, each version -/// once, each one of this document and votable. +/// once, each one of this document and open to its choice — a version held back +/// by an objection takes against or a withdrawal, never a *for* (`DocState::admits`). +/// One line refused refuses them all. fn casts(st: &DocState, lines: &[LineBody]) -> AppResult> { if lines.is_empty() || lines.len() > MAX_LINES { return Err(AppError::Invalid("lines")); @@ -120,11 +122,13 @@ 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) { + let choice = parse_choice(l.choice.as_deref())?; + // Held back by an objection: no new *for*, on the server or on the chain + // (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »). + if !st.admits(p, choice) { return Err(AppError::Conflict("objection")); } - out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? }); + out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice }); } Ok(out) } @@ -600,7 +604,9 @@ async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str) } /// The ballot counts: written for the identity of the signer, line by line, -/// where no more recent ballot of that identity decided already. +/// where no more recent ballot of that identity decided already. A ballot sealed +/// before an objection counts as it was signed — a *for* too, as a vote already +/// cast —, and the version held back still cannot enter (ADR 0010). async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> { let voter = format!("idty:{index}"); let position = found.comment.position; diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 96ebfda..af2d971 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -12,6 +12,7 @@ use crate::participation; use crate::util::{self, LangMap}; use axum::Json; use axum::extract::{Path, Query, State}; +use ld_engine::ballot::Choice; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::inertia::{InertiaLevel, Participation, Scale}; use ld_engine::threshold::{Evaluation, Tally}; @@ -265,7 +266,8 @@ pub struct DocState { pub module_kinds: HashMap, pub proposals: Vec, pub tallies: HashMap, - /// Versions held back by a standing objection: not votable, never entering into force. + /// Versions held back by a standing objection: against or a withdrawal only, never + /// a new *for* (`admits`), and never entering into force. pub blocked: HashSet, /// Standing objections touching each version, either side. pub signal_counts: HashMap, @@ -353,7 +355,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { } // 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). + // in force): no new *for*, 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?; @@ -414,6 +416,14 @@ impl DocState { matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id) } + /// May `choice` be cast on `p` now — by one vote, a grouped one or a ballot of the + /// chain? Open or in force; held back by an objection, against or a withdrawal + /// only, never a *for* (`ld_engine::objection::may_cast`, Yvv 2026-11-01). + pub fn admits(&self, p: &ProposalRow, choice: Choice) -> bool { + matches!(p.status.as_str(), "open" | "in_force") + && ld_engine::objection::may_cast(self.blocked.contains(&p.id), choice) + } + /// The module a unit is, if it is one. pub fn module_of(&self, unit_id: &str) -> Option { self.module_kinds.get(unit_id).copied() diff --git a/crates/ld-server/src/objections.rs b/crates/ld-server/src/objections.rs index 1634211..e7dc934 100644 --- a/crates/ld-server/src/objections.rs +++ b/crates/ld-server/src/objections.rs @@ -1,8 +1,9 @@ //! 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 pair, never a version in force, takes no new *for* while the objection +//! stands — against or a withdrawal only (`may_cast`, Yvv 2026-11-01) — and +//! cannot enter into force; 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). diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs index 7ed6f21..20ffe8b 100644 --- a/crates/ld-server/src/overview.rs +++ b/crates/ld-server/src/overview.rs @@ -138,7 +138,8 @@ pub async fn decisions( /// or a seed a minima), the open versions, and whether `me` holds a valid vote /// on one of them. A change of inertia is not a version of the text; the order /// of a section of fewer than two articles has nothing to decide; a version held -/// by an objection is nobody's to vote while it is held (ADR 0010). +/// by an objection is not counted as one to vote while it is held: it takes no +/// new *for*, only against or a withdrawal (ADR 0010). async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { #[derive(sqlx::FromRow)] struct VersionRow { diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 153ba54..62b24f6 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -648,19 +648,20 @@ 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")); - } let choice = match b.choice.as_deref() { Some("for") => Choice::For, Some("against") => Choice::Against, None => Choice::None, Some(_) => return Err(AppError::Invalid("choice")), }; + // Held back by an objection: its votes stay; against or a withdrawal, never a + // new *for* (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »). + if !st.admits(&p, choice) { + return Err(AppError::Conflict("objection")); + } + if !docs::may_vote(&state, &user, &st.group).await? { + return Err(AppError::Conflict("members_only")); + } let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice }; ballots::write_server_votes(&state, &user.id, &[cast]).await?; let entered = adoption::run(&state, &st.doc.id).await?; diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index f872710..ceb714b 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -776,6 +776,52 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); } +/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a +/// ballot against it or a withdrawal, never a *for* — alone or among free lines. +#[tokio::test] +async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() { + 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 doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let (e1, e4, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E4", "0"), version(&doc, "E5", "0")); + let o = c + .ok( + "POST", + &format!("/api/proposals/{e5}/objections"), + Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })), + ) + .await; + assert_eq!(o["heldId"], e5.as_str()); + let uri = format!("/api/docs/{doc_id}/ballots"); + let refused = (StatusCode::CONFLICT, Some("objection")); + for lines in [ + json!([{ "proposalId": e5, "choice": "for" }]), + json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }]), + ] { + let (s, v) = c.call("POST", &uri, Some(json!({ "lines": lines }))).await; + assert_eq!((s, v["error"].as_str()), refused, "{lines}"); + } + // Against the held one, for a free one: sealed, then confirmed by the chain. + let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("against"))], None).await; + indexer.pay(200, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(c.status(&b).await["status"], "confirmed"); + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e4), tally(&d, &e5)), ((1, 0), (0, 1))); + assert_eq!(find(&d, &e5)["myVote"], "against"); + // A withdrawal: the last ballot stands. + let w = c.ballot(&doc, &[(&e5, None)], None).await; + indexer.pay(201, comment_of(&w), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(c.status(&w).await["status"], "confirmed"); + let d = c.doc("monnaie-libre").await; + assert_eq!(tally(&d, &e5), (0, 0)); + assert!(find(&d, &e5)["myVote"].is_null()); + assert!(unit(&d, "E5")["inForce"].is_null(), "held, it never enters"); +} + // --------------------------------------------------------------------------- // After the independent review of 74f96be (lot B2): its probes, kept as tests // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs index f6ca5dd..082e005 100644 --- a/crates/ld-server/tests/objections.rs +++ b/crates/ld-server/tests/objections.rs @@ -142,7 +142,8 @@ async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector 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. + // Nobody votes *for* it any more: neither one vote nor a grouped one (against and a + // withdrawal still pass: `a_held_version_takes_against_and_withdrawal_never_for`). 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 @@ -476,6 +477,117 @@ async fn votes_already_cast_stay_but_a_held_version_cannot_enter() { assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters"); } +/// Yvv, 2026-11-01, « pendant qu'une objection retient une version, que permet-on ? » — +/// « Retirer ou "contre" »: on a held version, against and a withdrawal pass, by one vote +/// or a grouped one; a *for* never does, alone or among free lines (all or none). +#[tokio::test] +async fn a_held_version_takes_against_and_withdrawal_never_for() { + 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 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 e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned(); + let b = chiara + .propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true })) + .await; + zoe.object(&b, &e1, "redit", REPEATS).await; + // Yvv voted for it before the objection (written as it would have been then). + sqlx::query( + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) + VALUES (?, 'dev:yvv', 'for', 1, '2026-10-31T00:00:00Z')", + ) + .bind(&b) + .execute(&state.db) + .await + .expect("vote"); + let tally = |d: &Value| (find(d, &b)["tally"]["for"].as_u64(), find(d, &b)["tally"]["against"].as_u64()); + let vote = |c: Option<&str>| Some(json!({ "choice": c })); + let uri = format!("/api/proposals/{b}/vote"); + let refused = (StatusCode::CONFLICT, "objection".to_owned()); + + // One vote: never for — not even the one he already holds. + assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused); + assert_eq!(chiara.err("POST", &uri, vote(Some("for"))).await, refused); + // Against: his for becomes against. + assert_eq!(yvv.ok("POST", &uri, vote(Some("against"))).await["entered"], json!([])); + let d = yvv.doc("essai").await; + assert_eq!(tally(&d), (Some(0), Some(1))); + assert_eq!(find(&d, &b)["myVote"], "against"); + assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true))); + // And back to for: still not. + assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused); + // A withdrawal. + yvv.ok("POST", &uri, vote(None)).await; + let d = yvv.doc("essai").await; + assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true)); + + // Grouped: a for on the held version refuses the whole vote, the free line too. + let votes = format!("/api/docs/{doc_id}/votes"); + let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "for" }] }); + assert_eq!(chiara.err("POST", &votes, Some(mixed)).await, refused); + let d = chiara.doc("essai").await; + assert!(find(&d, &e2)["myVote"].is_null(), "all or none: nothing written"); + // Against the held one, for the free one: both written. + let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "against" }] }); + assert_eq!(chiara.ok("POST", &votes, Some(mixed)).await["count"], 2); + let d = chiara.doc("essai").await; + assert_eq!((find(&d, &e2)["myVote"].as_str(), find(&d, &b)["myVote"].as_str()), (Some("for"), Some("against"))); + assert_eq!(tally(&d), (Some(0), Some(1))); + // A grouped withdrawal on the held one. + chiara.ok("POST", &votes, Some(json!({ "lines": [{ "proposalId": b, "choice": null }] }))).await; + let d = chiara.doc("essai").await; + assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true)); +} + +/// While held, a version can only lose votes; lifted, it enters with the votes still +/// there — not with those it had when the objection was raised. +#[tokio::test] +async fn lifted_a_version_enters_only_with_the_votes_still_there() { + 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 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 = zoe.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned(); + // Every member of the group for it, as before the objection: enough to enter. + let members: Vec = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'") + .fetch_all(&state.db) + .await + .expect("members"); + assert_eq!(members.len(), 3); + 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"); + // While it is held, each one takes back or turns against. + let uri = format!("/api/proposals/{b}/vote"); + chiara.ok("POST", &uri, Some(json!({ "choice": "against" }))).await; + yvv.ok("POST", &uri, Some(json!({ "choice": null }))).await; + zoe.ok("POST", &uri, Some(json!({ "choice": "against" }))).await; + let lifted = zoe.ok("POST", &format!("/api/objections/{oid}/lift"), None).await; + assert_eq!(lifted["entered"], json!([]), "lifted: no for left, it does not enter"); + let d = chiara.doc("essai").await; + assert_eq!((find(&d, &b)["status"].as_str(), find(&d, &b)["votable"].as_bool()), (Some("open"), Some(true))); + assert_eq!(find(&d, &b)["tally"], json!({ "for": 0, "against": 2 })); +} + /// 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]