forked from yvv/decision
Fusion du lot G : sur une version retenue par une objection, « contre » et retrait oui, « pour » non (Yvv, 01/11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,7 @@ pub struct BallotBody {
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Choice> {
|
||||
}
|
||||
|
||||
/// 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<Vec<Cast>> {
|
||||
if lines.is_empty() || lines.len() > MAX_LINES {
|
||||
return Err(AppError::Invalid("lines"));
|
||||
@@ -120,11 +122,13 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
|
||||
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;
|
||||
|
||||
@@ -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<String, ModuleKind>,
|
||||
pub proposals: Vec<ProposalRow>,
|
||||
pub tallies: HashMap<String, Tally>,
|
||||
/// 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<String>,
|
||||
/// Standing objections touching each version, either side.
|
||||
pub signal_counts: HashMap<String, i64>,
|
||||
@@ -353,7 +355,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
}
|
||||
|
||||
// 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<ModuleKind> {
|
||||
self.module_kinds.get(unit_id).copied()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<Vec<Module>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VersionRow {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<String> = 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]
|
||||
|
||||
Reference in New Issue
Block a user