forked from yvv/decision
Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,14 @@ impl Scale {
|
||||
}
|
||||
}
|
||||
|
||||
/// The quorums an author may set (ADR 0013): from 2 — one voice never passes a text
|
||||
/// alone — to W; from 1 in the test group, where one voice passes to try things out. A
|
||||
/// tiny electorate never brings the lowest under 2.
|
||||
pub fn quorum_bounds(electorate: u32, test_group: bool) -> (u32, u32) {
|
||||
let lowest = if test_group { 1 } else { 2 };
|
||||
(lowest, electorate.max(lowest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -393,6 +401,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() {
|
||||
assert_eq!(quorum_bounds(5745, false), (2, 5745));
|
||||
assert_eq!(quorum_bounds(2, true), (1, 2));
|
||||
assert_eq!(quorum_bounds(1, true), (1, 1));
|
||||
assert_eq!(quorum_bounds(1, false), (2, 2), "never under two, even on a tiny electorate");
|
||||
// At the lowest bound outside the test group, one voice never passes, whatever the
|
||||
// level, the electorate and the usual participation.
|
||||
for w in [1, 2, 3, 10, 120, 5745, 7224] {
|
||||
let (lowest, _) = quorum_bounds(w, false);
|
||||
for p in [Participation::LOWEST, 0.1, Participation::HIGHEST] {
|
||||
let scale = Scale { electorate: w, participation: p, quorums: [lowest; 5] };
|
||||
for k in InertiaLevel::ALL {
|
||||
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, lowest).expect("valid");
|
||||
assert!(!lone.reached, "W {w}, p̃ {p}, k {}", k.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_the_quorum_one_vote_never_passes_in_monnaie_libre() {
|
||||
for voters in [1, 72, 120, 360, 3612] {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- The quorum (2026-11-03, ADR 0013). Additive only: nothing is dropped, nothing
|
||||
-- an older image needs is rewritten. (0010 is kept for lot V: the gap is meant.)
|
||||
--
|
||||
-- Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition par défaut,
|
||||
-- variable modifiable par l'auteur initial du texte… c'est plutôt un quorum ».
|
||||
-- The floor C of *for* votes put in front of the formula gives way to a quorum Q
|
||||
-- by level: from Q voters on, the share of the inertia applies; below it, the
|
||||
-- voters missing count as against (2026-11-03: « Ils comptent comme non »).
|
||||
-- `documents.vote_floor` stays: the reference quorum Q₀, read for the reference
|
||||
-- participation p0 = 2Q₀/W only (never an article's quorum), and as every
|
||||
-- level's quorum while `quorums` is NULL. An older image ignores both columns.
|
||||
|
||||
-- The quorum of each level, 1 to 5, as JSON `[Q1, Q2, Q3, Q4, Q5]`: the default
|
||||
-- of an article at that level (Yvv: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre,
|
||||
-- 1 everywhere in the test group). NULL: a base the quorum amendment
|
||||
-- (src/quorum_amend.rs) has not reached yet.
|
||||
ALTER TABLE documents ADD COLUMN quorums TEXT;
|
||||
|
||||
-- The quorum of an article, set by the author of its first version before the
|
||||
-- first vote (src/quorum.rs; Yvv: « L'auteur, avant le 1er vote »). NULL: the
|
||||
-- quorum of its level. A change of its inertia is judged on it too.
|
||||
ALTER TABLE units ADD COLUMN quorum INTEGER;
|
||||
|
||||
-- Every change of an article's own quorum: who, from what, to what.
|
||||
CREATE TABLE quorum_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
from_quorum INTEGER, -- NULL: the quorum of its level
|
||||
to_quorum INTEGER, -- NULL: back to the quorum of its level
|
||||
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX quorum_changes_unit ON quorum_changes(unit_id);
|
||||
|
||||
-- Quorum amendments (src/quorum_amend.rs): the quorums by level of the seed,
|
||||
-- written into an existing document that holds none, decided once per document.
|
||||
-- Each decision is kept here: applied, refused (and why), or present.
|
||||
CREATE TABLE quorum_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
from_quorums TEXT, -- what the document held: NULL, or its JSON
|
||||
to_quorums TEXT NOT NULL, -- the seed's, JSON
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -190,14 +190,15 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
||||
Ok(entered)
|
||||
}
|
||||
|
||||
/// What an adoption keeps of the formula it was judged by. The four letters
|
||||
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
|
||||
/// `constant`: read as they were written); the level and the scale join them.
|
||||
/// What an adoption keeps of the formula it was judged by. The letters stay where
|
||||
/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before
|
||||
/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum
|
||||
/// (ADR 0013), the level and the scale join them.
|
||||
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
|
||||
let level = st.voting_level(unit, p);
|
||||
let f = st.scale().params(level);
|
||||
let f = st.params(unit, p);
|
||||
serde_json::json!({
|
||||
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant,
|
||||
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
|
||||
"level": level, "share_pct": level.share_pct(),
|
||||
"participation": st.participation.ratio, "participation_source": st.participation.source,
|
||||
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
|
||||
|
||||
@@ -15,7 +15,7 @@ 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};
|
||||
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -196,8 +196,21 @@ pub struct DocRow {
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub source_lang: String,
|
||||
/// The reference quorum Q₀ (C before 2026-11-03): read for the reference
|
||||
/// participation p0 = 2Q₀/W only, and as every level's quorum while `quorums` is NULL.
|
||||
pub vote_floor: f64,
|
||||
pub publish_path: Option<String>,
|
||||
/// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum
|
||||
/// amendment (`quorum_amend`).
|
||||
pub quorums: Option<String>,
|
||||
}
|
||||
|
||||
impl DocRow {
|
||||
/// The quorum of each level, 1 to 5: the document's, or, in a base the quorum
|
||||
/// amendment has not reached yet, its reference quorum at every level — the C of before.
|
||||
pub fn quorums(&self) -> [u32; 5] {
|
||||
self.quorums.as_deref().and_then(|raw| serde_json::from_str(raw).ok()).unwrap_or([whole(self.vote_floor); 5])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
@@ -208,6 +221,16 @@ pub struct UnitRow {
|
||||
pub position: f64,
|
||||
pub inertia: String,
|
||||
pub in_force_id: Option<String>,
|
||||
/// Its own quorum, set by the author of its first version before the first vote
|
||||
/// (`quorum`); NULL: the quorum of its level.
|
||||
pub quorum: Option<i64>,
|
||||
}
|
||||
|
||||
impl UnitRow {
|
||||
/// Its own quorum, if its author set one.
|
||||
pub fn own_quorum(&self) -> Option<u32> {
|
||||
self.quorum.and_then(|q| u32::try_from(q).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
@@ -294,7 +317,8 @@ pub struct DocState {
|
||||
|
||||
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
|
||||
sqlx::query_as::<_, DocRow>(
|
||||
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?",
|
||||
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, quorums
|
||||
FROM documents WHERE id = ?",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -321,7 +345,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
let doc = load_doc(state, doc_id).await?;
|
||||
let group = load_group(state, &doc.group_slug).await?;
|
||||
let units = sqlx::query_as::<_, UnitRow>(
|
||||
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
"SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -397,9 +421,25 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// What this document's votes are judged by: W, C and p̃.
|
||||
/// What this document's votes are judged by: W, p̃ and the quorum of each level.
|
||||
pub fn scale(&self) -> Scale {
|
||||
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
|
||||
Scale { electorate: self.electorate, participation: self.participation.ratio, quorums: self.doc.quorums() }
|
||||
}
|
||||
|
||||
/// The quorum an article is judged on, and its versions with it: its own, else its
|
||||
/// level's (`Scale::quorum`).
|
||||
pub fn quorum_of(&self, unit: &UnitRow) -> u32 {
|
||||
self.scale().quorum(inertia_of(unit), unit.own_quorum())
|
||||
}
|
||||
|
||||
/// The quorum of the article's level: what it is judged on without one of its own.
|
||||
pub fn quorum_default(&self, unit: &UnitRow) -> u32 {
|
||||
self.scale().quorum(inertia_of(unit), None)
|
||||
}
|
||||
|
||||
/// The letters a proposal is judged by: its level and its article's quorum.
|
||||
pub fn params(&self, unit: &UnitRow, p: &ProposalRow) -> FormulaParams {
|
||||
compose::params_for(&bare(unit), p.kind(), &self.scale())
|
||||
}
|
||||
|
||||
/// p̃ · W, as a whole number of voters.
|
||||
@@ -422,7 +462,7 @@ impl DocState {
|
||||
|
||||
/// How far the threshold is, as shown: on `shown_tally`.
|
||||
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
threshold::evaluate(self.shown_tally(&p.id), self.electorate, &self.params(unit, p)).ok()
|
||||
}
|
||||
|
||||
/// Open to a vote: open or in force, and not held back by an objection.
|
||||
@@ -511,6 +551,7 @@ impl DocState {
|
||||
id: u.id.clone(),
|
||||
position: u.position,
|
||||
inertia: inertia_of(u),
|
||||
quorum: u.own_quorum(),
|
||||
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) {
|
||||
Some(order) => VersionText {
|
||||
id: p.id.clone(),
|
||||
@@ -563,13 +604,12 @@ impl DocState {
|
||||
}
|
||||
|
||||
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
threshold::evaluate(self.tally(&p.id), self.electorate, &self.params(unit, p)).ok()
|
||||
}
|
||||
|
||||
/// The level a proposal is voted at — a change of inertia inherits its article's.
|
||||
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
|
||||
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
|
||||
compose::voting_level(&us, p.kind())
|
||||
compose::voting_level(&bare(unit), p.kind())
|
||||
}
|
||||
|
||||
/// The usual participation, as the screen tells it.
|
||||
@@ -583,22 +623,36 @@ impl DocState {
|
||||
})
|
||||
}
|
||||
|
||||
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
|
||||
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
|
||||
/// The five levels on this scale: M, G, s_k, the quorum, and the *for* votes asked at
|
||||
/// `cast` votes — on an article's own quorum if it has one (`own`), else each level's.
|
||||
pub fn levels_json(&self, cast: u32, own: Option<u32>) -> Vec<serde_json::Value> {
|
||||
let scale = self.scale();
|
||||
InertiaLevel::ALL
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let params = scale.params(*k);
|
||||
let quorum = scale.quorum(*k, own);
|
||||
let params = scale.params(*k, quorum);
|
||||
json!({
|
||||
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
|
||||
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
|
||||
"sharePct": k.share_pct(), "quorum": quorum, "cast": cast,
|
||||
"required": scale.required(cast, *k, quorum).ok(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit as the engine sees it, without its text: its level and its own quorum.
|
||||
fn bare(unit: &UnitRow) -> UnitState {
|
||||
UnitState {
|
||||
id: unit.id.clone(),
|
||||
position: unit.position,
|
||||
inertia: inertia_of(unit),
|
||||
quorum: unit.own_quorum(),
|
||||
in_force: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-negative count, rounded to the nearest whole.
|
||||
fn whole(x: f64) -> u32 {
|
||||
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
|
||||
@@ -705,6 +759,7 @@ pub async fn document(
|
||||
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
|
||||
|
||||
let changes = inertia_changes(&state, &id).await?;
|
||||
let rights = crate::quorum::Rights::of(&state, &id).await?;
|
||||
let unit_json = |u: &UnitRow| {
|
||||
let of_unit: Vec<_> = visible
|
||||
.iter()
|
||||
@@ -723,11 +778,18 @@ pub async fn document(
|
||||
.as_ref()
|
||||
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
|
||||
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
|
||||
json!({
|
||||
let mut j = json!({
|
||||
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
|
||||
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
})
|
||||
});
|
||||
// Its quorum (ADR 0013): its own or `null`, its level's, and who may set it.
|
||||
if let (Some(o), serde_json::Value::Object(q)) =
|
||||
(j.as_object_mut(), crate::quorum::unit_json(&st, &rights, &user, u))
|
||||
{
|
||||
o.extend(q);
|
||||
}
|
||||
j
|
||||
};
|
||||
let units: Vec<_> = st.articles().map(unit_json).collect();
|
||||
// The modules (ADR 0009): the title of the document, the order of each section.
|
||||
@@ -757,7 +819,7 @@ pub async fn document(
|
||||
"doc": {
|
||||
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
|
||||
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(),
|
||||
"electorate": st.electorate, "electorateSource": st.electorate_source,
|
||||
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
|
||||
"participation": st.participation_json(),
|
||||
@@ -766,7 +828,7 @@ pub async fn document(
|
||||
"units": units,
|
||||
"modules": { "title": title, "orders": orders },
|
||||
// The five levels, with the *for* votes asked at the usual participation.
|
||||
"levels": st.levels_json(st.usual_voters()),
|
||||
"levels": st.levels_json(st.usual_voters(), None),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ pub mod overview;
|
||||
pub mod participation;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod quorum;
|
||||
pub mod quorum_amend;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod seed_proposal_amend;
|
||||
@@ -161,6 +163,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/messages/{id}/read", post(messages::read))
|
||||
.route("/dev/clock", post(deadlines::dev_clock))
|
||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||
.route("/units/{id}/quorum", put(quorum::set))
|
||||
.route("/units/{id}/order", post(order::propose))
|
||||
.route("/units/{id}/title", post(doc_title::propose))
|
||||
.route("/feedback", post(access::send_feedback))
|
||||
|
||||
@@ -622,7 +622,7 @@ pub async fn detail(
|
||||
"voteFloor": st.doc.vote_floor,
|
||||
"participation": st.participation_json(),
|
||||
// The five levels, with the *for* votes each would ask at this version's participation.
|
||||
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
|
||||
"levels": st.levels_json(st.shown_tally(&p.id).cast(), unit.own_quorum()),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
|
||||
let json = json!({
|
||||
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
|
||||
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
|
||||
"voteFloor": st.doc.vote_floor, "units": units, "modules": modules,
|
||||
"voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(), "units": units, "modules": modules,
|
||||
});
|
||||
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
|
||||
Ok(files)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! The quorum of an article (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition par défaut, variable
|
||||
//! modifiable par l'auteur initial du texte » — « L'auteur, avant le 1er vote ». Each
|
||||
//! level has its quorum (`documents.quorums`); an article judged on another one holds
|
||||
//! it in `units.quorum`, set here by the author of its first version (an administrator
|
||||
//! for a text of the seed, `trustin_access::sets_quorum`), and only while no vote
|
||||
//! exists on it (`voted_units`). From 2 to W; from 1 in the test group
|
||||
//! (`ld_engine::inertia::quorum_bounds`). Each change is journaled (`quorum_changes`).
|
||||
//! An article's own quorum is not published: `document.json` carries the quorums by level.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::docs::{self, DocState, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::inertia::quorum_bounds;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::SqliteConnection;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// **« Un vote existe »** — the units of a document on which a vote exists: the one
|
||||
/// place this is asked (ADR 0013; lot V extends it here, nowhere else).
|
||||
///
|
||||
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
||||
/// of its inertia; any revision) holds
|
||||
/// - a vote of the server (`votes`: a vote of a managed group, or the line of a ballot
|
||||
/// of the chain once confirmed, written there by the confirmation);
|
||||
/// - a line of a ballot of the chain, **even pending** — signed, not yet read on the
|
||||
/// chain (Yvv, 2026-11-03) — or confirmed; a refused or expired ballot is no vote;
|
||||
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
||||
/// its votes are withdrawn.
|
||||
///
|
||||
/// A vote of the server withdrawn before any adoption leaves no row (`votes` keeps the
|
||||
/// last choice only): from then on, no vote exists (a known limit, ADR 0013).
|
||||
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
||||
let ids: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT p.unit_id FROM votes v
|
||||
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1
|
||||
UNION
|
||||
SELECT p.unit_id FROM ballot_lines l
|
||||
JOIN ballots b ON b.id = l.ballot_id
|
||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1 AND b.status IN ('pending', 'confirmed')
|
||||
UNION
|
||||
SELECT a.unit_id FROM adoptions a WHERE a.doc_id = ?1",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
Ok(ids.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Does a vote exist on this unit (`voted_units`)?
|
||||
pub async fn a_vote_exists(state: &AppState, unit_id: &str) -> AppResult<bool> {
|
||||
let doc_id = doc_of_unit(state, unit_id).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
Ok(voted_units(&mut conn, &doc_id).await?.contains(unit_id))
|
||||
}
|
||||
|
||||
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// The author of each unit's first version (`None`: a text of the seed, without author).
|
||||
/// A first version is a text — of an article or a module —, never a removal or a change
|
||||
/// of inertia, whatever became of it since.
|
||||
pub async fn first_authors(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Option<String>>> {
|
||||
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT p.unit_id, p.author_id FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ? AND p.kind NOT IN ('remove', 'inertia') ORDER BY p.created_at, p.rowid",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
for (unit, author) in rows {
|
||||
out.entry(unit).or_insert(author);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a person may do with the quorums of a document, read once for its view.
|
||||
pub struct Rights {
|
||||
first_authors: HashMap<String, Option<String>>,
|
||||
voted: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Rights {
|
||||
pub async fn of(state: &AppState, doc_id: &str) -> AppResult<Self> {
|
||||
let first_authors = first_authors(state, doc_id).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let voted = voted_units(&mut conn, doc_id).await?;
|
||||
Ok(Self { first_authors, voted })
|
||||
}
|
||||
|
||||
fn first_author(&self, unit: &UnitRow) -> Option<&str> {
|
||||
self.first_authors.get(&unit.id).and_then(|a| a.as_deref())
|
||||
}
|
||||
|
||||
/// May `user` set the quorum of `unit` now (`trustin_access::sets_quorum`)?
|
||||
pub fn sets(&self, user: &IdentityRow, unit: &UnitRow) -> bool {
|
||||
trustin_access::sets_quorum(&user.to_identity(), self.first_author(unit), self.voted.contains(&unit.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quorum of a unit, as its view says it: its own (`null`: its level's), its level's,
|
||||
/// and whether `user` may set it.
|
||||
pub fn unit_json(st: &DocState, rights: &Rights, user: &IdentityRow, unit: &UnitRow) -> serde_json::Value {
|
||||
json!({
|
||||
"quorum": unit.own_quorum(), "quorumDefault": st.quorum_default(unit), "canSetQuorum": rights.sets(user, unit),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QuorumBody {
|
||||
/// A whole number of voters; `null`: back to the quorum of its level.
|
||||
quorum: Option<i64>,
|
||||
}
|
||||
|
||||
/// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`.
|
||||
pub async fn set(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<QuorumBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let doc_id = doc_of_unit(&state, &unit_id).await?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
let authors = first_authors(&state, &doc_id).await?;
|
||||
let first = authors.get(&unit.id).and_then(|a| a.as_deref());
|
||||
let identity = user.to_identity();
|
||||
if !trustin_access::sets_quorum(&identity, first, false) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let (lowest, highest) = quorum_bounds(st.electorate, st.group.slug == trustin_access::TEST_GROUP);
|
||||
let to = match b.quorum {
|
||||
None => None,
|
||||
Some(q) if q >= i64::from(lowest) && q <= i64::from(highest) => Some(q),
|
||||
Some(_) => return Err(AppError::Invalid("quorum")),
|
||||
};
|
||||
let mut tx = state.db.begin().await?;
|
||||
// Asked within the writing transaction: a vote that arrives first wins.
|
||||
let voted = voted_units(&mut tx, &doc_id).await?.contains(&unit.id);
|
||||
if !trustin_access::sets_quorum(&identity, first, voted) {
|
||||
return Err(AppError::Conflict("voted"));
|
||||
}
|
||||
if unit.quorum != to {
|
||||
let now = util::now();
|
||||
sqlx::query("UPDATE units SET quorum = ? WHERE id = ?").bind(to).bind(&unit.id).execute(&mut *tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_changes (unit_id, doc_id, from_quorum, to_quorum, identity_id, at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&unit.id)
|
||||
.bind(&doc_id)
|
||||
.bind(unit.quorum)
|
||||
.bind(to)
|
||||
.bind(&user.id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tracing::info!(doc = %doc_id, unit = %unit.code, from = ?unit.quorum, to = ?to, by = %user.id, "quorum set");
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({ "quorum": to, "quorumDefault": st.quorum_default(unit), "canSetQuorum": true })))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Quorum amendments — the quorums by level a seed now gives a document, written into
|
||||
//! a document born without them (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, the test group at 1
|
||||
//! everywhere. A new base gets them from the seed (`quorums` of each instance). An
|
||||
//! existing base — production holds two instances of the certification act — reads
|
||||
//! `vote_floor` at every level until then (the C of before: 50 and 1), and gets them
|
||||
//! through an amendment listed here: written **only where the document holds none**;
|
||||
//! nothing else is touched (no article's own quorum, no vote, no text).
|
||||
//!
|
||||
//! Like the other amendments of the seed, each document is decided once, at the first
|
||||
//! start that knows the amendment, and the decision is kept in `quorum_amendments`
|
||||
//! (applied, refused and why, or present) and in the logs. Applied where the document
|
||||
//! publishes, the publication queue gets a row (`document.json` carries the quorums).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, quorums_json};
|
||||
use crate::util;
|
||||
|
||||
/// The quorums by level to write into every document instantiated from a seed.
|
||||
pub struct QuorumAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The quorums of each group's instance: the seed's today, which must say so.
|
||||
pub quorums: &'static [(&'static str, [u32; 5])],
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[QuorumAmendment] = &[QuorumAmendment {
|
||||
id: "2026-11-03-quorums-par-cran",
|
||||
doc: "acte-engagement-certification",
|
||||
quorums: &[("monnaie-libre", [12, 12, 50, 50, 50]), ("essai", [1, 1, 1, 1, 1])],
|
||||
reason: "quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013)",
|
||||
}];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with these quorums: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: `found` is what the document holds.
|
||||
pub fn verdict(found: Option<&str>, wanted: &[u32; 5]) -> Verdict {
|
||||
match found {
|
||||
None => Verdict::Apply,
|
||||
Some(raw) if serde_json::from_str::<[u32; 5]>(raw).ok().as_ref() == Some(wanted) => Verdict::Present,
|
||||
// Written otherwise: kept as it is.
|
||||
Some(_) => Verdict::Refuse("quorums_set"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[QuorumAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some(wanted) = seed_says(seeds, a, &group) else { continue };
|
||||
let Some(v) = decide(state, a, &wanted, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, quorums = ?wanted, "quorums written"),
|
||||
Verdict::Present => tracing::info!(amendment = a.id, %group, "quorums: already there"),
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, reason = why, "quorums refused: the document keeps its own")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must give the group's instance the quorums the amendment writes: a seed
|
||||
/// edited later never reaches a base by this way, and a seed without them decides nothing.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &QuorumAmendment, group: &str) -> Option<[u32; 5]> {
|
||||
let wanted = a.quorums.iter().find(|(g, _)| *g == group).map(|(_, q)| *q)?;
|
||||
let declared = seeds
|
||||
.iter()
|
||||
.find(|s| s.slug == a.doc)
|
||||
.and_then(|s| s.instances.iter().find(|i| i.group == group))
|
||||
.and_then(|i| i.quorums);
|
||||
if declared == Some(wanted) {
|
||||
Some(wanted)
|
||||
} else {
|
||||
tracing::warn!(amendment = a.id, doc = a.doc, %group, "quorum amendment: the seed does not give these quorums, nothing decided");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &QuorumAmendment,
|
||||
wanted: &[u32; 5],
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<Verdict>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM quorum_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let found: Option<String> =
|
||||
sqlx::query_scalar("SELECT quorums FROM documents WHERE id = ?").bind(doc_id).fetch_one(&mut *tx).await?;
|
||||
let v = verdict(found.as_deref(), wanted);
|
||||
let now = util::now();
|
||||
let to = quorums_json(wanted);
|
||||
if v == Verdict::Apply {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ? AND quorums IS NULL")
|
||||
.bind(&to)
|
||||
.bind(doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_amendments (amendment_id, doc_id, from_quorums, to_quorums, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(&found)
|
||||
.bind(&to)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ML: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
#[test]
|
||||
fn written_where_none_kept_where_other() {
|
||||
assert_eq!(verdict(None, &ML), Verdict::Apply);
|
||||
assert_eq!(verdict(Some("[12,12,50,50,50]"), &ML), Verdict::Present);
|
||||
assert_eq!(verdict(Some("[12, 12, 50, 50, 50]"), &ML), Verdict::Present, "the same numbers");
|
||||
assert_eq!(verdict(Some("[3,3,3,3,3]"), &ML), Verdict::Refuse("quorums_set"));
|
||||
assert_eq!(verdict(Some("not json"), &ML), Verdict::Refuse("quorums_set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amendment_says_what_the_seed_says() {
|
||||
let seed: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../seeds/acte-engagement-certification.json")).expect("seed");
|
||||
for (group, quorums) in AMENDMENTS[0].quorums {
|
||||
let inst = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == *group);
|
||||
assert_eq!(inst.map(|i| i["quorums"].clone()), Some(serde_json::json!(quorums)), "{group}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,18 @@
|
||||
//! section, ADR 0009), a module amendment (`module_amend`), built from what the
|
||||
//! document holds; a starting text in force the seed now puts forward as a seed
|
||||
//! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an
|
||||
//! article nobody has touched.
|
||||
//! article nobody has touched; the quorums by level the seed now gives a document
|
||||
//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::inertia_amend;
|
||||
use crate::module_amend;
|
||||
use crate::modules;
|
||||
use crate::quorum_amend;
|
||||
use crate::seed_amend;
|
||||
use crate::seed_proposal_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use ld_engine::inertia::{InertiaLevel, quorum_bounds};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -63,8 +65,13 @@ pub struct SeedModule {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeedInstance {
|
||||
pub group: String,
|
||||
/// The reference quorum Q₀ (the C of before): p0 = 2Q₀/W while too few proposals are
|
||||
/// voted on; every level's quorum where `quorums` is missing.
|
||||
pub vote_floor: f64,
|
||||
pub publish_path: Option<String>,
|
||||
/// The quorum of each level, 1 to 5 (ADR 0013): the default of an article at that level.
|
||||
#[serde(default)]
|
||||
pub quorums: Option<[u32; 5]>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -134,6 +141,7 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?;
|
||||
// After the text amendments: a starting text replaced first, then put forward.
|
||||
seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?;
|
||||
quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -191,8 +199,15 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
}
|
||||
|
||||
/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL`
|
||||
/// alone; every other article says its level.
|
||||
/// alone; every other article says its level. A quorum is never under the lowest an
|
||||
/// author may set (`quorum_bounds`): 2, or 1 in the test group.
|
||||
fn check(seed: &SeedDoc) -> anyhow::Result<()> {
|
||||
for inst in &seed.instances {
|
||||
let (lowest, _) = quorum_bounds(1, inst.group == trustin_access::TEST_GROUP);
|
||||
if inst.quorums.is_some_and(|q| q.iter().any(|q| *q < lowest)) {
|
||||
anyhow::bail!("{}: a quorum under {lowest}", inst.group);
|
||||
}
|
||||
}
|
||||
for u in &seed.units {
|
||||
if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") {
|
||||
anyhow::bail!("{}: a seed proposal is open, never in force", u.code);
|
||||
@@ -229,6 +244,14 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// The quorums by level, written apart: only where the seed gives them (ADR 0013).
|
||||
if let Some(q) = &inst.quorums {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ?")
|
||||
.bind(quorums_json(q))
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for unit in &seed.units {
|
||||
let unit_id = util::id();
|
||||
sqlx::query(
|
||||
@@ -319,3 +342,8 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The quorums by level as `documents.quorums` holds them: `[12,12,50,50,50]`.
|
||||
pub fn quorums_json(quorums: &[u32; 5]) -> String {
|
||||
serde_json::to_string(quorums).unwrap_or_else(|_| "null".into())
|
||||
}
|
||||
|
||||
@@ -83,6 +83,22 @@ async fn doc_id(c: &mut Client, group: &str) -> String {
|
||||
v["docs"][0]["id"].as_str().expect("doc").to_owned()
|
||||
}
|
||||
|
||||
/// The quorum of an article set to 2, as its author — an administrator, for a text of the
|
||||
/// seed — sets it before the first vote (ADR 0013). In the test group, where the quorum
|
||||
/// of every level is 1 and one voice passes, one voice of two then no longer does.
|
||||
async fn two_voices(state: &AppState, group: &str, code: &str) {
|
||||
let n = sqlx::query(
|
||||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)",
|
||||
)
|
||||
.bind(code)
|
||||
.bind(group)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("quorum")
|
||||
.rows_affected();
|
||||
assert_eq!(n, 1, "{group} {code}");
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
@@ -150,15 +166,15 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published(
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
// Dev identities are members of every group: W(essai) = 2, C = 1.
|
||||
// Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level
|
||||
// (ADR 0013): one voice passes in the test group.
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert_eq!(e3["proposals"][0]["evaluation"]["quorum"], 1);
|
||||
let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||
let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough");
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(v["entered"][0]["code"], "E3");
|
||||
assert_eq!(v["entered"][0]["code"], "E3", "one voice in the test group");
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert_eq!(e3["inForce"]["id"], p.as_str());
|
||||
@@ -191,9 +207,11 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let rival = v["id"].as_str().expect("id").to_owned();
|
||||
a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
// The quorum 1 of the test group (ADR 0013): its *for* against nothing replaces the seed.
|
||||
let (_, v) = a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(v["entered"][0]["kind"], "text");
|
||||
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "already in force");
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e1 = unit(&doc, "E1");
|
||||
assert_eq!(e1["inForce"]["id"], rival.as_str());
|
||||
@@ -204,7 +222,7 @@ 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::production().await;
|
||||
let (mut a, state) = Client::production().await;
|
||||
// An administrator of trustinWallet answers for the starting texts (no author).
|
||||
a.login("Yvv", true).await;
|
||||
let mut b = a.with(None);
|
||||
@@ -315,6 +333,8 @@ async fn editing_is_not_voting() {
|
||||
let essai = doc_id(&mut a, "essai").await;
|
||||
let (_, essai_doc) = a.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||
let uid = unit(&essai_doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
// Votes stay on it while it is edited: one voice of two does not make it pass.
|
||||
two_voices(&state, "essai", "E2").await;
|
||||
let (_, v) = a
|
||||
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
|
||||
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
|
||||
@@ -362,10 +382,11 @@ async fn editing_is_not_voting() {
|
||||
/// the full cycle: tests/objections.rs).
|
||||
#[tokio::test]
|
||||
async fn an_objection_holds_back_the_version_that_is_not_in_force() {
|
||||
let (mut a, _) = Client::production().await;
|
||||
let (mut a, state) = Client::production().await;
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.with(None);
|
||||
b.login("Chiara", false).await;
|
||||
two_voices(&state, "essai", "K1").await;
|
||||
let id = doc_id(&mut a, "essai").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
@@ -1157,7 +1178,10 @@ async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) = match touch {
|
||||
// A vote that does not confirm it (the quorum 2: one voice is not enough), so
|
||||
// the refusal says the vote itself, not a confirmation.
|
||||
Touch::Vote => {
|
||||
two_voices(&before, "essai", "I1").await;
|
||||
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.
|
||||
|
||||
@@ -268,6 +268,22 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// The quorum of an article set to 2, as its author — an administrator, for a text of the
|
||||
/// seed — sets it before the first vote (ADR 0013). In the test group, where the quorum
|
||||
/// of every level is 1 and one voice passes, one voice of two then no longer does.
|
||||
async fn two_voices(state: &AppState, group: &str, code: &str) {
|
||||
let n = sqlx::query(
|
||||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)",
|
||||
)
|
||||
.bind(code)
|
||||
.bind(group)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("quorum")
|
||||
.rows_affected();
|
||||
assert_eq!(n, 1, "{group} {code}");
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
@@ -373,8 +389,11 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
|
||||
.await
|
||||
.expect("votes");
|
||||
assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}");
|
||||
// One voice never makes a text pass (C = 50).
|
||||
assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50);
|
||||
// One voice never makes a text pass: at level 2, the quorum 12, the 11 voters missing
|
||||
// counted as against — 9 « pour » are asked (ADR 0013).
|
||||
let e = &find(&doc, &i1)["evaluation"];
|
||||
assert_eq!((e["quorum"].as_u64(), e["required"].as_u64(), e["yesNeeded"].as_u64()), (Some(12), Some(9), Some(9)));
|
||||
assert_eq!(e["reached"], false);
|
||||
// Someone else sees the totals, not the vote.
|
||||
let mut yvv = c.other();
|
||||
yvv.login("Yvv").await;
|
||||
@@ -404,7 +423,9 @@ async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() {
|
||||
assert_eq!(s["status"], "confirmed");
|
||||
assert_eq!(s["entered"], json!([]));
|
||||
let d = c.ok("GET", &format!("/api/proposals/{p}"), None).await;
|
||||
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
|
||||
// Level 3, the quorum 50: 39 « pour » of 50 voters, the 49 missing counted as against.
|
||||
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
|
||||
assert_eq!(d["proposal"]["evaluation"]["required"], 39);
|
||||
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
|
||||
assert_eq!(d["proposal"]["myVote"], "for");
|
||||
assert_eq!(d["proposal"]["myVoteChain"], true);
|
||||
@@ -711,7 +732,7 @@ async fn a_session_sees_its_own_ballots_only() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() {
|
||||
async fn thirty_nine_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;
|
||||
@@ -720,7 +741,11 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
|
||||
let e5 = version(&doc, "E5", "0");
|
||||
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
|
||||
let e1 = version(&doc, "E1", "0");
|
||||
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||
// Level 3, the quorum 50 (ADR 0013): 39 « pour » with no « contre ».
|
||||
let needed = find(&doc, &e4)["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded");
|
||||
assert_eq!(needed, 39);
|
||||
let needed = u8::try_from(needed).expect("n");
|
||||
let keys: Vec<String> = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||
let mut last = Value::Null;
|
||||
for (n, account) in keys.iter().enumerate() {
|
||||
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
|
||||
@@ -730,7 +755,7 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
|
||||
b["comment"].as_str().expect("c"),
|
||||
Signer { account, identity: Some((index, "Membre", true)) },
|
||||
);
|
||||
if n == 49 {
|
||||
if n + 1 == keys.len() {
|
||||
// The last ballot is signed; before the chain confirms it, an objection holds
|
||||
// E5 back against the seed proposal E1, written before it (ADR 0010, 0011): no new ballot may hold it.
|
||||
let o = c
|
||||
@@ -756,11 +781,12 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
|
||||
// A session holds at most 20 pending ballots: the background task confirms on the way.
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
}
|
||||
if n == 48 {
|
||||
if n + 2 == keys.len() {
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let d = c.doc("monnaie-libre").await;
|
||||
assert_eq!(tally(&d, &e4), (49, 0));
|
||||
assert!(find(&d, &e4)["enteredAt"].is_null(), "49 voices: below C = 50");
|
||||
assert_eq!(tally(&d, &e4), (38, 0));
|
||||
assert!(find(&d, &e4)["enteredAt"].is_null(), "38 voices: one short of the 39 the quorum 50 asks");
|
||||
assert_eq!(find(&d, &e4)["evaluation"]["missing"], 1);
|
||||
}
|
||||
}
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
@@ -771,7 +797,7 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
|
||||
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_eq!(tally(&d, &e5), (39, 0));
|
||||
assert_eq!(find(&d, &e5)["votable"], false);
|
||||
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
|
||||
}
|
||||
@@ -1208,7 +1234,7 @@ async fn a_name_known_only_from_a_chain_vote_stays_free() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() {
|
||||
async fn voices_on_the_chain_bring_an_order_then_a_title_into_force() {
|
||||
// The modules (ADR 0009) are voted like articles: in a basket, in one transaction.
|
||||
let indexer = Indexer::start().await;
|
||||
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||
@@ -1245,8 +1271,16 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() {
|
||||
.expect("title")
|
||||
.to_owned();
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||
let mut last = Value::Null;
|
||||
// Each on the quorum of its level (ADR 0013): the order at level 2 (quorum 12) asks 9
|
||||
// « pour », the title at level 4 (quorum 50) 43.
|
||||
let mut needed = Vec::new();
|
||||
for id in [&o, &t] {
|
||||
let d = c.ok("GET", &format!("/api/proposals/{id}"), None).await;
|
||||
needed.push(d["proposal"]["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded"));
|
||||
}
|
||||
assert_eq!(needed, [9, 43]);
|
||||
let keys: Vec<String> = (1..=43u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||
let mut sent = Vec::new();
|
||||
for (n, account) in keys.iter().enumerate() {
|
||||
let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await;
|
||||
let index = 20_000 + u32::try_from(n).expect("n");
|
||||
@@ -1255,22 +1289,26 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() {
|
||||
b["comment"].as_str().expect("c"),
|
||||
Signer { account, identity: Some((index, "Membre", true)) },
|
||||
);
|
||||
last = b;
|
||||
sent.push(b);
|
||||
if n % 10 == 9 {
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
}
|
||||
}
|
||||
ballots::sweep(&state).await.expect("sweep");
|
||||
let s = c.status(&last).await;
|
||||
assert_eq!(s["status"], "confirmed");
|
||||
let mut entered: Vec<(&str, &str)> = s["entered"]
|
||||
.as_array()
|
||||
.expect("entered")
|
||||
.iter()
|
||||
.map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default()))
|
||||
.collect();
|
||||
entered.sort_unstable();
|
||||
assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}");
|
||||
// What entered at each confirmation, in the order of the chain.
|
||||
let mut entered: Vec<(usize, String, String)> = Vec::new();
|
||||
for (n, b) in sent.iter().enumerate() {
|
||||
let s = c.status(b).await;
|
||||
assert_eq!(s["status"], "confirmed", "{s}");
|
||||
for e in s["entered"].as_array().expect("entered") {
|
||||
entered.push((n + 1, e["code"].as_str().expect("code").into(), e["kind"].as_str().expect("kind").into()));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
entered,
|
||||
[(9, "ordre.conclusion".into(), "order".into()), (43, "titre".into(), "title".into())],
|
||||
"the ninth voice, then the forty-third"
|
||||
);
|
||||
let d = c.doc("monnaie-libre").await;
|
||||
let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p"));
|
||||
assert!(p2 < p1, "K2 now reads before K1");
|
||||
@@ -1392,6 +1430,9 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() {
|
||||
a.login("Yvv").await;
|
||||
let mut b = a.other();
|
||||
b.login("Chiara").await;
|
||||
for code in ["E4", "E5", "E6"] {
|
||||
two_voices(&state, "essai", code).await;
|
||||
}
|
||||
let doc = a.doc("essai").await;
|
||||
let id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
|
||||
@@ -1400,7 +1441,7 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() {
|
||||
]);
|
||||
let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
|
||||
assert_eq!(v["count"], 3);
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice never makes a text pass");
|
||||
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two, on a quorum of 2");
|
||||
// All or nothing: one line that does not hold, and no line is written.
|
||||
let (s, _) = b
|
||||
.call(
|
||||
|
||||
@@ -9,7 +9,7 @@ use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_engine::inertia::{InertiaLevel, Scale, gradient};
|
||||
use ld_engine::threshold::required_ratio;
|
||||
use ld_engine::threshold::{judged_voters, required_ratio};
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -95,6 +95,9 @@ fn level(k: u8) -> InertiaLevel {
|
||||
/// The level seed proposals start at (ADR 0011).
|
||||
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
|
||||
|
||||
/// The quorums of Monnaie libre by level (ADR 0013, Yvv, 2026-11-02).
|
||||
const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1, E2 in force, E1 and E2 at 4.
|
||||
fn production_seed_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
|
||||
@@ -137,22 +140,26 @@ async fn the_five_levels_and_the_scale_are_served() {
|
||||
assert_eq!(numbers(levels, "level"), [1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert_eq!(numbers(levels, "majorityPct"), [50.0, 50.0, 55.0, 60.0, 66.0]);
|
||||
assert_eq!(numbers(levels, "sharePct"), [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||||
let scale = Scale { electorate: 5745, floor: 50.0, participation: ratio };
|
||||
// The quorums by level (ADR 0013, Yvv: « 12 · 12 · 50 · 50 · 50 »).
|
||||
assert_eq!(numbers(levels, "quorum"), [12.0, 12.0, 50.0, 50.0, 50.0]);
|
||||
let scale = Scale { electorate: 5745, participation: ratio, quorums: QUORUMS };
|
||||
for (l, k) in levels.as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
|
||||
assert!((l["gradient"].as_f64().expect("G") - gradient(k, ratio)).abs() < 1e-12);
|
||||
// At the usual participation, C plus s_k of the votes beyond C.
|
||||
// At the usual participation, above every quorum: s_k of the voters, nothing in front.
|
||||
assert_eq!(l["cast"], 100);
|
||||
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k).expect("valid"))));
|
||||
let at_usual = 50.0 + k.share_pct() / 100.0 * 50.0;
|
||||
assert_eq!(l["required"].as_f64(), Some(at_usual.ceil()));
|
||||
let q = scale.quorum(k, None);
|
||||
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k, q).expect("valid"))));
|
||||
assert_eq!(l["required"].as_f64(), Some(k.share_pct().ceil()));
|
||||
}
|
||||
assert_eq!(numbers(levels, "required"), [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||||
|
||||
// A version: the five levels at its own participation, and the level it is voted at.
|
||||
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
|
||||
let d = c.get(&format!("/api/proposals/{e1}")).await;
|
||||
assert_eq!(d["proposal"]["votedAtInertia"], LOW);
|
||||
assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]);
|
||||
assert_eq!(numbers(&d["levels"], "required"), [50.0; 5], "nothing passes under C");
|
||||
// No vote: judged on the quorum, the voters missing counted as against (ADR 0013).
|
||||
assert_eq!(numbers(&d["levels"], "required"), [8.0, 9.0, 39.0, 43.0, 46.0], "the quorum, not C");
|
||||
assert_eq!(d["participation"]["source"], "reference");
|
||||
assert_eq!(d["electorateSource"], "wot");
|
||||
}
|
||||
@@ -180,9 +187,9 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled(
|
||||
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
|
||||
(Some(5), Some(u64::from(LOW)))
|
||||
);
|
||||
// W(essai) = 2: both vote for, the article goes to level 5.
|
||||
a.vote(&p, "for").await;
|
||||
let v = b.vote(&p, "for").await;
|
||||
// W(essai) = 2, the quorum 1 everywhere in the test group (ADR 0013): one voice
|
||||
// passes, the article goes to level 5.
|
||||
let v = a.vote(&p, "for").await;
|
||||
assert_eq!(v["entered"][0]["kind"], "inertia");
|
||||
let doc = a.doc("essai").await;
|
||||
let e2u = unit(&doc, "E2");
|
||||
@@ -200,11 +207,12 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled(
|
||||
.await
|
||||
.expect("adoption");
|
||||
let params: Value = serde_json::from_str(¶ms).expect("json");
|
||||
let judged = Scale { electorate: 2, floor: 1.0, participation: 0.5 }.params(level(LOW));
|
||||
let judged = Scale { electorate: 2, participation: 0.5, quorums: [1; 5] }.params(level(LOW), 1);
|
||||
assert_eq!(
|
||||
(params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()),
|
||||
(Some(u64::from(LOW)), Some(judged.majority_pct), Some(level(LOW).share_pct()))
|
||||
);
|
||||
assert_eq!((params["quorum"].as_u64(), params.get("constant")), (Some(1), None), "the quorum, no C");
|
||||
assert_eq!(
|
||||
(params["participation"].as_f64(), params["participation_source"].as_str()),
|
||||
(Some(0.5), Some("reference"))
|
||||
@@ -254,9 +262,9 @@ async fn the_usual_participation_is_the_median_of_five_voted_proposals() {
|
||||
}
|
||||
// Thresholds follow: the proposal with 4 voters is judged on this scale.
|
||||
let d = first.get(&format!("/api/proposals/{}", open[4])).await;
|
||||
let scale = Scale { electorate: 10, floor: 1.0, participation: 0.3 };
|
||||
let scale = Scale { electorate: 10, participation: 0.3, quorums: [1; 5] };
|
||||
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
|
||||
assert!((ratio - required_ratio(10, 4, &scale.params(level(3)))).abs() < 1e-12);
|
||||
assert!((ratio - required_ratio(10, 4, &scale.params(level(3), 1))).abs() < 1e-12);
|
||||
|
||||
// A new vote does not move it within the day…
|
||||
people[4].vote(&open[0], "against").await;
|
||||
@@ -623,10 +631,13 @@ async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4()
|
||||
let d = c.get(&format!("/api/proposals/{e1_ml}")).await;
|
||||
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
|
||||
let p = d["participation"]["ratio"].as_f64().expect("p");
|
||||
let scale = Scale { electorate: 5745, floor: 50.0, participation: p };
|
||||
assert!((ratio - required_ratio(5745, 1, &scale.params(level(4)))).abs() < 1e-12);
|
||||
assert!((ratio - required_ratio(5745, 1, &scale.params(level(3)))).abs() > 1e-6, "not at the old level");
|
||||
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
|
||||
// One voter, under the quorum of level 4 (50): judged on 50 voters, 49 counted against.
|
||||
let scale = Scale { electorate: 5745, participation: p, quorums: QUORUMS };
|
||||
let voters = judged_voters(1, 50);
|
||||
assert!((ratio - required_ratio(5745, voters, &scale.params(level(4), 50))).abs() < 1e-12);
|
||||
assert!((ratio - required_ratio(5745, voters, &scale.params(level(3), 50))).abs() > 1e-6, "not at the old level");
|
||||
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
|
||||
assert_eq!(d["proposal"]["evaluation"]["required"], 43);
|
||||
// Republished where the document publishes; never from the test group.
|
||||
let queued: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id
|
||||
@@ -666,8 +677,11 @@ async fn an_inertia_changed_by_a_vote_keeps_its_level() {
|
||||
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(json!({ "target": 5 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let p = v["id"].as_str().expect("id").to_owned();
|
||||
a.vote(&p, "for").await;
|
||||
assert_eq!(b.vote(&p, "for").await["entered"][0]["kind"], "inertia");
|
||||
// One voice of the two: before the quorum amendment, the C = 1 of the test group is
|
||||
// read as the quorum of every level (ADR 0013).
|
||||
assert_eq!(a.vote(&p, "for").await["entered"][0]["kind"], "inertia");
|
||||
let (s, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("not_votable")), "already decided");
|
||||
|
||||
let state = restart(&pool).await;
|
||||
assert_eq!(
|
||||
|
||||
@@ -117,6 +117,21 @@ fn codes(doc: &Value, ids: &Value) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Two people of the test group (W = 2, C = 1): two « for » adopt.
|
||||
/// The quorum of a module or an article set to 2, as its author — an administrator, for a
|
||||
/// text of the seed — sets it before the first vote (ADR 0013). In the test group, where
|
||||
/// the quorum of every level is 1 and one voice passes, one voice of two then no longer does.
|
||||
async fn two_voices(state: &AppState, code: &str) {
|
||||
let n = sqlx::query(
|
||||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = 'essai')",
|
||||
)
|
||||
.bind(code)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("quorum")
|
||||
.rows_affected();
|
||||
assert_eq!(n, 1, "{code}");
|
||||
}
|
||||
|
||||
async fn two() -> (Client, Client, AppState) {
|
||||
let (mut a, state) = Client::new().await;
|
||||
a.login("Yvv").await;
|
||||
@@ -191,7 +206,8 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() {
|
||||
let (mut yvv, mut chiara, _) = two().await;
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
two_voices(&state, "ordre.conclusion").await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let conclusion = order_of(&doc, "conclusion").clone();
|
||||
let module = conclusion["id"].as_str().expect("id").to_owned();
|
||||
@@ -241,7 +257,9 @@ async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_f
|
||||
|
||||
#[tokio::test]
|
||||
async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
|
||||
let (mut yvv, mut chiara, _) = two().await;
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
// The order stays under vote with one voice.
|
||||
two_voices(&state, "ordre.inertie").await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned();
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
@@ -400,6 +418,7 @@ async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected()
|
||||
#[tokio::test]
|
||||
async fn another_title_enters_into_force_and_its_translations_are_to_review() {
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
two_voices(&state, "titre").await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned();
|
||||
let uri = format!("/api/units/{module}/title");
|
||||
|
||||
@@ -14,10 +14,14 @@ struct Client {
|
||||
|
||||
impl Client {
|
||||
async fn new() -> Self {
|
||||
Self::with_state().await.0
|
||||
}
|
||||
|
||||
async fn with_state() -> (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), cookie: None }
|
||||
(Self { router: app(state.clone()), cookie: None }, state)
|
||||
}
|
||||
|
||||
fn other(&self) -> Self {
|
||||
@@ -91,6 +95,21 @@ fn version(doc: &Value, code: &str, which: &str) -> String {
|
||||
v["id"].as_str().expect("id").to_owned()
|
||||
}
|
||||
|
||||
/// The quorum of an article of the test group set to 2, as its author — an administrator,
|
||||
/// for a text of the seed — sets it before the first vote (ADR 0013): where the quorum of
|
||||
/// every level is 1 and one voice passes, one voice of two then no longer does.
|
||||
async fn two_voices(state: &AppState, code: &str) {
|
||||
let n = sqlx::query(
|
||||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = 'essai')",
|
||||
)
|
||||
.bind(code)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("quorum")
|
||||
.rows_affected();
|
||||
assert_eq!(n, 1, "{code}");
|
||||
}
|
||||
|
||||
async fn decisions(c: &mut Client, group: &str) -> Value {
|
||||
c.get(&format!("/api/groups/{group}/decisions")).await
|
||||
}
|
||||
@@ -139,8 +158,11 @@ async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() {
|
||||
async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
|
||||
// In the test group, which votes on the server (Monnaie libre votes on the chain:
|
||||
// tests/ballots.rs counts « sans ton vote » there).
|
||||
let mut c = Client::new().await;
|
||||
let (mut c, state) = Client::with_state().await;
|
||||
c.login("Chiara", false).await;
|
||||
// Someone else, who will not vote: W(essai) = 2.
|
||||
let mut yvv = c.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let d = decisions(&mut c, "essai").await;
|
||||
assert_eq!(d["canVote"], true);
|
||||
let docs = d["docs"].as_array().expect("docs");
|
||||
@@ -154,14 +176,15 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
|
||||
assert_eq!(docs[0]["withoutMyVote"], 41);
|
||||
|
||||
let doc = the_doc(&mut c, "essai").await;
|
||||
// Votes that adopt nothing: the quorum 2 (ADR 0013), one voice of two.
|
||||
two_voices(&state, "E3").await;
|
||||
two_voices(&state, "I1").await;
|
||||
c.vote(&version(&doc, "E3", "0"), "for").await;
|
||||
c.vote(&version(&doc, "E3", "1"), "against").await;
|
||||
c.vote(&version(&doc, "I1", "0"), "for").await;
|
||||
let d = decisions(&mut c, "essai").await;
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39)));
|
||||
// Someone else has not voted yet.
|
||||
let mut yvv = c.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41);
|
||||
}
|
||||
|
||||
@@ -171,11 +194,11 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut chiara = yvv.other();
|
||||
chiara.login("Chiara", false).await;
|
||||
// Dev identities are members of every group: W(essai) = 2, C = 1; two « for » adopt.
|
||||
// Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level
|
||||
// (ADR 0013): one « for » adopts.
|
||||
let doc = the_doc(&mut yvv, "essai").await;
|
||||
let e3 = version(&doc, "E3", "0");
|
||||
yvv.vote(&e3, "for").await;
|
||||
let v = chiara.vote(&e3, "for").await;
|
||||
let v = yvv.vote(&e3, "for").await;
|
||||
assert_eq!(v["entered"][0]["code"], "E3");
|
||||
let d = decisions(&mut yvv, "essai").await;
|
||||
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40)));
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The quorum (ADR 0013). Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition
|
||||
//! par défaut, variable modifiable par l'auteur initial du texte… c'est plutôt un
|
||||
//! quorum » ; 2026-11-03, below the quorum: « Ils comptent comme non ». Each level has
|
||||
//! its quorum — « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, 1 everywhere in the test
|
||||
//! group; the author of an article's first version sets its own, « avant le 1er vote ».
|
||||
//! An existing base gets the quorums by level through a guarded amendment, decided once.
|
||||
|
||||
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, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// The quorums of Monnaie libre by level (Yvv, 2026-11-02).
|
||||
const QUORUMS: [u64; 5] = [12, 12, 50, 50, 50];
|
||||
/// W of Monnaie libre in the tests (`Config::for_tests`, no chain read).
|
||||
const W: u64 = 5745;
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn of(state: &AppState) -> Self {
|
||||
Self { router: app(state.clone()), cookie: None }
|
||||
}
|
||||
|
||||
fn other(&self) -> Self {
|
||||
Self { router: self.router.clone(), cookie: None }
|
||||
}
|
||||
|
||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (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>) -> Value {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
async fn get(&mut self, uri: &str) -> Value {
|
||||
self.ok("GET", uri, None).await
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str, admin: bool) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
||||
}
|
||||
|
||||
async fn vote(&mut self, proposal: &str, choice: Option<&str>) -> Value {
|
||||
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await
|
||||
}
|
||||
|
||||
async fn doc(&mut self, group: &str) -> Value {
|
||||
let id = self.get(&format!("/api/groups/{group}")).await["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||
self.get(&format!("/api/docs/{id}")).await
|
||||
}
|
||||
|
||||
/// `PUT /units/{id}/quorum`.
|
||||
async fn set(&mut self, unit: &str, quorum: Value) -> (StatusCode, Value) {
|
||||
self.call("PUT", &format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": quorum }))).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 unit_by_id<'a>(doc: &'a Value, id: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["id"] == id).expect("unit")
|
||||
}
|
||||
|
||||
fn numbers(levels: &Value, key: &str) -> Vec<u64> {
|
||||
levels.as_array().expect("levels").iter().map(|l| l[key].as_u64().expect("number")).collect()
|
||||
}
|
||||
|
||||
/// (quorum, quorumDefault, canSetQuorum) of a unit.
|
||||
fn quorum_of(u: &Value) -> (Option<u64>, Option<u64>, Option<bool>) {
|
||||
(u["quorum"].as_u64(), u["quorumDefault"].as_u64(), u["canSetQuorum"].as_bool())
|
||||
}
|
||||
|
||||
/// (quorum, cast, required, yesNeeded, noNeeded, missing, reached) of an evaluation.
|
||||
fn figures(e: &Value) -> (u64, u64, u64, u64, u64, u64, bool) {
|
||||
let n = |k: &str| e[k].as_u64().unwrap_or_else(|| panic!("{k}: {e}"));
|
||||
(n("quorum"), n("cast"), n("required"), n("yesNeeded"), n("noNeeded"), n("missing"), e["reached"] == true)
|
||||
}
|
||||
|
||||
async fn fresh() -> AppState {
|
||||
fresh_with(Config::for_tests()).await
|
||||
}
|
||||
|
||||
async fn fresh_with(cfg: Config) -> AppState {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(cfg, db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
state
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_level_has_its_quorum_and_every_evaluation_says_it() {
|
||||
let state = fresh().await;
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Chiara", false).await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
assert_eq!(doc["doc"]["quorums"], json!(QUORUMS));
|
||||
assert_eq!(numbers(&doc["levels"], "quorum"), QUORUMS);
|
||||
// I1, a seed proposal at level 2: its level's quorum, 12. Nobody voted: judged on 12
|
||||
// voters, the 12 missing counted as against — 9 « pour » pass (the control figure).
|
||||
let i1 = unit(&doc, "I1");
|
||||
assert_eq!(quorum_of(i1), (None, Some(12), Some(false)), "not the author, no administrator");
|
||||
assert_eq!(figures(&i1["proposals"][0]["evaluation"]), (12, 0, 9, 9, 0, 9, false));
|
||||
// E3 at level 3: the quorum 50, 39 « pour » at no vote.
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert_eq!(e3["inertia"], 3);
|
||||
assert_eq!(quorum_of(e3), (None, Some(50), Some(false)));
|
||||
assert_eq!(figures(&e3["proposals"][0]["evaluation"]), (50, 0, 39, 39, 0, 39, false));
|
||||
// The detail says the same, the five levels on their quorums.
|
||||
let p = i1["proposals"][0]["id"].as_str().expect("i1");
|
||||
let d = c.get(&format!("/api/proposals/{p}")).await;
|
||||
assert_eq!(figures(&d["proposal"]["evaluation"]), (12, 0, 9, 9, 0, 9, false));
|
||||
assert_eq!(numbers(&d["levels"], "quorum"), QUORUMS);
|
||||
assert_eq!(numbers(&d["levels"], "required"), [8, 9, 39, 43, 46]);
|
||||
|
||||
// The test group: 1 everywhere.
|
||||
let essai = c.doc("essai").await;
|
||||
assert_eq!(essai["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
|
||||
assert_eq!(quorum_of(unit(&essai, "E3")), (None, Some(1), Some(false)));
|
||||
assert_eq!(unit(&essai, "I1")["proposals"][0]["evaluation"]["quorum"], 1);
|
||||
|
||||
// What is published carries them (document.json).
|
||||
let id = doc["doc"]["id"].as_str().expect("id");
|
||||
let st = ld_server::docs::load_state(&state, id).await.expect("state");
|
||||
let files = ld_server::publish::render(&state, &st).await.expect("render");
|
||||
let json: Value = serde_json::from_str(&files.iter().find(|(n, _)| n == "document.json").expect("document.json").1)
|
||||
.expect("json");
|
||||
assert_eq!(json["quorums"], json!(QUORUMS));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() {
|
||||
let state = fresh().await;
|
||||
let mut chiara = Client::of(&state);
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut luna = chiara.other();
|
||||
luna.login("Luna", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let created = chiara
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "section": "fondamental", "title": "Nouveau", "body": "Un nouvel article.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
let (u, text) =
|
||||
(created["unitId"].as_str().expect("unit").to_owned(), created["id"].as_str().expect("id").to_owned());
|
||||
// Its author may, another member may not; the test group: its level's quorum, 1.
|
||||
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (None, Some(1), Some(true)));
|
||||
assert_eq!(quorum_of(unit_by_id(&luna.doc("essai").await, &u)), (None, Some(1), Some(false)));
|
||||
let (s, v) = luna.set(&u, json!(2)).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::FORBIDDEN, Some("forbidden")));
|
||||
// From 1 in the test group to W (2 members).
|
||||
for bad in [json!(0), json!(3), json!(-1), json!(1.5)] {
|
||||
let (s, v) = chiara.set(&u, bad.clone()).await;
|
||||
assert!(s == StatusCode::BAD_REQUEST || s == StatusCode::UNPROCESSABLE_ENTITY, "{bad}: {s} {v}");
|
||||
if s == StatusCode::BAD_REQUEST {
|
||||
assert_eq!(v["error"], "quorum", "{bad}");
|
||||
}
|
||||
}
|
||||
let (s, v) = chiara.set(&u, json!(2)).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v, json!({ "quorum": 2, "quorumDefault": 1, "canSetQuorum": true }));
|
||||
let doc = chiara.doc("essai").await;
|
||||
let mine = unit_by_id(&doc, &u);
|
||||
assert_eq!(quorum_of(mine), (Some(2), Some(1), Some(true)));
|
||||
assert_eq!(mine["proposals"][0]["evaluation"]["quorum"], 2);
|
||||
// A change of its inertia is judged on it too.
|
||||
let inertia = chiara.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await;
|
||||
let d = chiara.get(&format!("/api/proposals/{}", inertia["id"].as_str().expect("id"))).await;
|
||||
assert_eq!(d["proposal"]["evaluation"]["quorum"], 2);
|
||||
assert_eq!(numbers(&d["levels"], "quorum"), [2; 5], "the article's own quorum, at every level");
|
||||
// Back to its level's, then 2 again: each change is journaled.
|
||||
let (s, v) = chiara.set(&u, Value::Null).await;
|
||||
assert_eq!((s, &v), (StatusCode::OK, &json!({ "quorum": null, "quorumDefault": 1, "canSetQuorum": true })));
|
||||
chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await;
|
||||
let journal: Vec<(Option<i64>, Option<i64>, String)> = sqlx::query_as(
|
||||
"SELECT q.from_quorum, q.to_quorum, i.display_name FROM quorum_changes q
|
||||
JOIN identities i ON i.id = q.identity_id WHERE q.unit_id = ? ORDER BY q.id",
|
||||
)
|
||||
.bind(&u)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("journal");
|
||||
let chiara_name = "Chiara".to_owned();
|
||||
assert_eq!(
|
||||
journal,
|
||||
[(None, Some(2), chiara_name.clone()), (Some(2), None, chiara_name.clone()), (None, Some(2), chiara_name)]
|
||||
);
|
||||
|
||||
// The first vote: 1 « pour » of 2 voters on a quorum of 2 does not pass, and the
|
||||
// quorum is held from then on.
|
||||
let v = luna.vote(&text, Some("for")).await;
|
||||
assert!(v["entered"].as_array().expect("entered").is_empty(), "{v}");
|
||||
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(false)));
|
||||
let (s, v) = chiara.set(&u, json!(1)).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
|
||||
// A vote on its change of inertia counts as well: it is the same article.
|
||||
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
|
||||
}
|
||||
|
||||
/// A ballot of the chain on a proposal, as the server writes it when a wallet asks.
|
||||
async fn ballot(state: &AppState, doc: &str, proposal: &str, status: &str, n: u32) {
|
||||
let id = format!("ballot-{status}-{n}");
|
||||
sqlx::query(
|
||||
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status,
|
||||
created_at, expires_at)
|
||||
VALUES (?, ?, (SELECT id FROM identities LIMIT 1), 'salt', ?, ?, 'g1vote', 1, ?,
|
||||
'2026-11-03T00:00:00Z', '2026-11-04T00:00:00Z')",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(doc)
|
||||
.bind(format!("digest-{id}"))
|
||||
.bind(format!("remark-{id}"))
|
||||
.bind(status)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("ballot");
|
||||
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES (?, ?, 1, 'for')")
|
||||
.bind(&id)
|
||||
.bind(proposal)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("line");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_monnaie_libre_a_quorum_holds_two_voices_and_a_ballot_counts_even_pending() {
|
||||
let state = fresh().await;
|
||||
let mut admin = Client::of(&state);
|
||||
admin.login("Yvv", true).await;
|
||||
let mut chiara = admin.other();
|
||||
chiara.login("Chiara", false).await;
|
||||
let doc = admin.doc("monnaie-libre").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let i1 = unit(&doc, "I1");
|
||||
let (u, p) = (i1["id"].as_str().expect("u").to_owned(), i1["proposals"][0]["id"].as_str().expect("p").to_owned());
|
||||
// A text of the seed has no author: an administrator answers for it.
|
||||
assert_eq!(quorum_of(i1), (None, Some(12), Some(true)));
|
||||
let (s, _) = chiara.set(&u, json!(20)).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
// At least 2: one voice never passes a text alone; at most W.
|
||||
for bad in [1, 0, W + 1] {
|
||||
let (s, v) = admin.set(&u, json!(bad)).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("quorum")), "{bad}");
|
||||
}
|
||||
for good in [2, W, 20] {
|
||||
let (s, v) = admin.set(&u, json!(good)).await;
|
||||
assert_eq!((s, v["quorum"].as_u64()), (StatusCode::OK, Some(good)), "{v}");
|
||||
}
|
||||
let doc = admin.doc("monnaie-libre").await;
|
||||
assert_eq!(unit(&doc, "I1")["proposals"][0]["evaluation"]["quorum"], 20);
|
||||
// A refused ballot, an expired one, are no vote.
|
||||
ballot(&state, &doc_id, &p, "refused", 1).await;
|
||||
ballot(&state, &doc_id, &p, "expired", 2).await;
|
||||
assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
|
||||
assert_eq!(admin.set(&u, json!(12)).await.0, StatusCode::OK);
|
||||
// A pending one is: the vote is cast, the chain has not said it yet.
|
||||
ballot(&state, &doc_id, &p, "pending", 3).await;
|
||||
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
|
||||
assert_eq!(quorum_of(unit(&admin.doc("monnaie-libre").await, "I1")), (Some(12), Some(12), Some(false)));
|
||||
let (s, v) = admin.set(&u, Value::Null).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
|
||||
// A confirmed one, on another article.
|
||||
let e1 = unit(&doc, "E1");
|
||||
let (u1, p1) = (e1["id"].as_str().expect("u").to_owned(), e1["proposals"][0]["id"].as_str().expect("p").to_owned());
|
||||
assert!(!ld_server::quorum::a_vote_exists(&state, &u1).await.expect("query"));
|
||||
ballot(&state, &doc_id, &p1, "confirmed", 4).await;
|
||||
assert_eq!(admin.set(&u1, json!(30)).await.1["error"], "voted");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// An existing base: the quorum amendment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The seed production runs on 2026-11-02: no quorum anywhere, the C of before.
|
||||
fn production_config() -> Config {
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
|
||||
cfg
|
||||
}
|
||||
|
||||
async fn restart(before: &AppState, cfg: Config) -> AppState {
|
||||
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
state
|
||||
}
|
||||
|
||||
/// (group, from, to, outcome, reason) of each decision.
|
||||
type Decision = (String, Option<String>, String, String, Option<String>);
|
||||
|
||||
async fn decisions(state: &AppState) -> Vec<Decision> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, q.from_quorums, q.to_quorums, q.outcome, q.reason
|
||||
FROM quorum_amendments q JOIN documents d ON d.id = q.doc_id ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions")
|
||||
}
|
||||
|
||||
async fn stored(state: &AppState, group: &str) -> Option<String> {
|
||||
sqlx::query_scalar("SELECT quorums FROM documents WHERE group_slug = ?")
|
||||
.bind(group)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("quorums")
|
||||
}
|
||||
|
||||
fn row(group: &str, from: Option<&str>, to: &str, outcome: &str, reason: Option<&str>) -> Decision {
|
||||
(group.into(), from.map(Into::into), to.into(), outcome.into(), reason.map(Into::into))
|
||||
}
|
||||
|
||||
const ML: &str = "[12,12,50,50,50]";
|
||||
const ESSAI: &str = "[1,1,1,1,1]";
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_quorum_amendment_writes_the_quorums_by_level_once() {
|
||||
// Production as it runs: no quorum written; every level reads the C of before.
|
||||
let born = fresh_with(production_config()).await;
|
||||
assert_eq!((stored(&born, "monnaie-libre").await, stored(&born, "essai").await), (None, None));
|
||||
assert!(decisions(&born).await.is_empty(), "a seed without quorums decides nothing");
|
||||
let mut c = Client::of(&born);
|
||||
c.login("Chiara", false).await;
|
||||
assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50]));
|
||||
assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1]));
|
||||
|
||||
// Started with today's seed: written, where none was.
|
||||
let state = restart(&born, Config::for_tests()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(
|
||||
first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
|
||||
[row("essai", None, ESSAI, "applied", None), row("monnaie-libre", None, ML, "applied", None)]
|
||||
);
|
||||
assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some(ML));
|
||||
assert_eq!(stored(&state, "essai").await.as_deref(), Some(ESSAI));
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Chiara", false).await;
|
||||
assert_eq!(unit(&c.doc("monnaie-libre").await, "I1")["proposals"][0]["evaluation"]["quorum"], 12);
|
||||
// Republished where the document publishes; never from the test group.
|
||||
let queued: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT d.group_slug FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'quorums%'",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(queued, ["monnaie-libre"]);
|
||||
// Once only.
|
||||
let again = restart(&state, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again).await, first);
|
||||
|
||||
// A new base is born with them: present.
|
||||
let new = fresh().await;
|
||||
let present: Vec<String> = decisions(&new).await.into_iter().map(|(_, _, _, o, _)| o).collect();
|
||||
assert_eq!(present, ["present", "present"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quorums_written_otherwise_are_kept_and_the_refusal_is_kept() {
|
||||
let born = fresh_with(production_config()).await;
|
||||
sqlx::query("UPDATE documents SET quorums = '[3,3,3,3,3]' WHERE group_slug = 'monnaie-libre'")
|
||||
.execute(&born.db)
|
||||
.await
|
||||
.expect("quorums");
|
||||
let state = restart(&born, Config::for_tests()).await;
|
||||
let got = decisions(&state).await;
|
||||
assert_eq!(
|
||||
got.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::<Vec<_>>(),
|
||||
[
|
||||
row("essai", None, ESSAI, "applied", None),
|
||||
row("monnaie-libre", Some("[3,3,3,3,3]"), ML, "refused", Some("quorums_set")),
|
||||
]
|
||||
);
|
||||
assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some("[3,3,3,3,3]"));
|
||||
}
|
||||
@@ -665,11 +665,14 @@ async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() {
|
||||
a.login("Yvv", false).await;
|
||||
let mut b = a.other();
|
||||
b.login("Chiara", false).await;
|
||||
// Two members: W = 2, C = 1 — one voice is not enough (B^W keeps R above 1).
|
||||
// Two members: W = 2, the quorum 1 at every level of the test group (ADR 0013) — one
|
||||
// voice passes; one against holds a voice for (the share M of two voters).
|
||||
let doc = a.doc("essai").await;
|
||||
let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned();
|
||||
let v = b.vote(&i1, "against").await;
|
||||
assert!(v["entered"].as_array().expect("entered").is_empty());
|
||||
let v = a.vote(&i1, "for").await;
|
||||
assert!(v["entered"].as_array().expect("entered").is_empty(), "one of two is not enough");
|
||||
assert!(v["entered"].as_array().expect("entered").is_empty(), "one for, one against: not enough");
|
||||
let v = b.vote(&i1, "for").await;
|
||||
assert_eq!(v["entered"][0]["code"], "I1");
|
||||
let doc = a.doc("essai").await;
|
||||
@@ -687,8 +690,8 @@ async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() {
|
||||
.expect("adoption");
|
||||
assert_eq!(adopted, ("text".to_owned(), 2, 2));
|
||||
|
||||
// From 16 members on, B^W no longer lifts R above C = 1: one voice passes a text in
|
||||
// the test group (ADR 0007), whatever the level: R = C + B^W at one vote cast.
|
||||
// Whatever the number of members, one voice against none passes in the test group: at
|
||||
// 16, as at 2 (the quorum 1; before 2026-11-03, C = 1 + B^W held it under 16).
|
||||
for i in 0..14 {
|
||||
let mut m = a.other();
|
||||
m.login(&format!("Membre {i}"), false).await;
|
||||
|
||||
@@ -127,6 +127,10 @@ async fn someone_elses_typo_on_a_proposal_waits_for_its_author() {
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
||||
// Its author sets the quorum 2 before the first vote (ADR 0013): in the test group, one
|
||||
// voice of two then no longer makes it pass, and it stays hers to answer for.
|
||||
let u = chiara.detail(&p).await["unit"]["id"].as_str().expect("unit").to_owned();
|
||||
chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await;
|
||||
yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
|
||||
let w = yvv.fix(&p, TITLE, FIX_1).await;
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
//! 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).
|
||||
//! (2026-10-31, lot F, ADR 0010);
|
||||
//! - the quorum of an article is set by the author of its first version — for a
|
||||
//! text of the seed, by an administrator — and only before the first vote —
|
||||
//! `sets_quorum` (2026-11-03, ADR 0013).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -184,6 +187,24 @@ pub fn reads_message(identity: &Identity, recipient: &str) -> bool {
|
||||
identity.id == recipient
|
||||
}
|
||||
|
||||
/// Who sets the quorum of an article (Yvv, 2026-11-02: « L'auteur, avant le 1er vote »,
|
||||
/// ADR 0013): the author of its first version; for an article without author (a text of
|
||||
/// the seed), an administrator of trustinWallet — never one for an author. Once a vote
|
||||
/// exists on the article (`voted`, the server's `quorum::a_vote_exists`), nobody: the
|
||||
/// votes cast were judged on it.
|
||||
pub fn sets_quorum(identity: &Identity, first_author: Option<&str>, voted: bool) -> bool {
|
||||
if voted {
|
||||
return false;
|
||||
}
|
||||
match first_author {
|
||||
Some(author) => identity.id == author,
|
||||
None => identity.admin,
|
||||
}
|
||||
}
|
||||
|
||||
/// The test group: hidden, the quorum 1 at every level — the one place one voice passes.
|
||||
pub const TEST_GROUP: &str = "essai";
|
||||
|
||||
/// The groups a fresh deployment starts with. Ordered as displayed.
|
||||
pub fn default_groups() -> Vec<Group> {
|
||||
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
|
||||
@@ -204,7 +225,7 @@ pub fn default_groups() -> Vec<Group> {
|
||||
GroupPolicy::Managed,
|
||||
false,
|
||||
),
|
||||
g("essai", "Groupe d'essai", None, GroupPolicy::Managed, true),
|
||||
g(TEST_GROUP, "Groupe d'essai", None, GroupPolicy::Managed, true),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -308,6 +329,20 @@ mod tests {
|
||||
assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_author_sets_the_quorum_before_the_first_vote_an_administrator_for_the_seed() {
|
||||
let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) };
|
||||
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
||||
let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) };
|
||||
assert!(sets_quorum(&chiara, Some("chiara"), false));
|
||||
assert!(!sets_quorum(&chiara, Some("chiara"), true), "after the first vote, nobody");
|
||||
assert!(!sets_quorum(&other, Some("chiara"), false));
|
||||
assert!(!sets_quorum(&admin, Some("chiara"), false), "an administrator never sets it for an author");
|
||||
assert!(sets_quorum(&admin, None, false), "a text of the seed: an administrator");
|
||||
assert!(!sets_quorum(&admin, None, true));
|
||||
assert!(!sets_quorum(&other, None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_is_read_by_its_recipient_only() {
|
||||
let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend), jamais en réinstanciant le document.",
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document.",
|
||||
"slug": "acte-engagement-certification",
|
||||
"title": {
|
||||
"fr": "Acte d'engagement Certification",
|
||||
@@ -16,11 +16,13 @@
|
||||
{
|
||||
"group": "monnaie-libre",
|
||||
"voteFloor": 50,
|
||||
"quorums": [12, 12, 50, 50, 50],
|
||||
"publishPath": "monnaie-libre/acte-engagement-certification"
|
||||
},
|
||||
{
|
||||
"group": "essai",
|
||||
"voteFloor": 1,
|
||||
"quorums": [1, 1, 1, 1, 1],
|
||||
"publishPath": null
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user