v3 : sur une version retenue, « contre » et retrait oui, « pour » non (moteur, serveur, chaîne)

Décision d'Yvv du 01/11/2026 : « Retirer ou "contre" ». Le moteur dit la règle
(objection::may_cast) ; le vote direct, le vote groupé et le bulletin de la chaîne
l'appliquent par DocState::admits : « pour » 409 objection, « contre » et retrait
acceptés, une ligne refusée refuse tout le vote. Un bulletin scellé avant
l'objection compte tel qu'il est signé ; une version retenue n'entre jamais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-02 00:03:45 +01:00
co-authored by Claude Opus 5.5
parent 61547c11c0
commit 238c7db93f
8 changed files with 222 additions and 19 deletions
+12 -6
View File
@@ -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 -2
View File
@@ -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()
+3 -2
View File
@@ -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).
+2 -1
View File
@@ -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 {
+8 -7
View File
@@ -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?;