diff --git a/crates/ld-engine/src/inertia.rs b/crates/ld-engine/src/inertia.rs index 231dbfa..72d34fb 100644 --- a/crates/ld-engine/src/inertia.rs +++ b/crates/ld-engine/src/inertia.rs @@ -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] { diff --git a/crates/ld-server/migrations/0011_quorum.sql b/crates/ld-server/migrations/0011_quorum.sql new file mode 100644 index 0000000..9d43b7b --- /dev/null +++ b/crates/ld-server/migrations/0011_quorum.sql @@ -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) +); diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs index f418f69..76629be 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -190,14 +190,15 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { 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, diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 707dcfa..349cb9d 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -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, + /// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum + /// amendment (`quorum_amend`). + pub quorums: Option, +} + +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, + /// 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, +} + +impl UnitRow { + /// Its own quorum, if its author set one. + pub fn own_quorum(&self) -> Option { + 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 { 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 { 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 { } 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 { - 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 { - 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 { + /// 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) -> Vec { 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), }))) } diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 6425be2..6a46f52 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -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 { .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)) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 75408ae..79bb0aa 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -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()), }))) } diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index eb36490..6d69dac 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -392,7 +392,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result sqlx::Result> { + let ids: Vec = 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 { + 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 { + 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>> { + let rows: Vec<(String, Option)> = 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>, + voted: HashSet, +} + +impl Rights { + pub async fn of(state: &AppState, doc_id: &str) -> AppResult { + 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, +} + +/// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`. +pub async fn set( + State(state): State, + User(user): User, + Path(unit_id): Path, + Json(b): Json, +) -> AppResult> { + 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 }))) +} diff --git a/crates/ld-server/src/quorum_amend.rs b/crates/ld-server/src/quorum_amend.rs new file mode 100644 index 0000000..6b54292 --- /dev/null +++ b/crates/ld-server/src/quorum_amend.rs @@ -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> { + let mut out = Vec::new(); + for a in list { + let docs: Vec<(String, String, Option)> = + 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> { + 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 = + 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}"); + } + } +} diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index ebf0609..89bb0b3 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -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, + /// 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 { } /// 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()) +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 9aaeaf4..1a06bce 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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. diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index 23f4d95..b77b129 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -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 = (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 = (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 = (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 = (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( diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index 38d634d..06b1ef1 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -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!( diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index 77797cb..e853df6 100644 --- a/crates/ld-server/tests/modules.rs +++ b/crates/ld-server/tests/modules.rs @@ -117,6 +117,21 @@ fn codes(doc: &Value, ids: &Value) -> Vec { } /// 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"); diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index 7ab72c2..7ec6756 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -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))); diff --git a/crates/ld-server/tests/quorum.rs b/crates/ld-server/tests/quorum.rs new file mode 100644 index 0000000..dbbbf38 --- /dev/null +++ b/crates/ld-server/tests/quorum.rs @@ -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, +} + +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) -> (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 { + 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 { + 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, Option, Option) { + (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, Option, 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, Option); + +async fn decisions(state: &AppState) -> Vec { + 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 { + 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::>(), + [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 = 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 = 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::>(), + [ + 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]")); +} diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index a8fe523..281d4cf 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -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; diff --git a/crates/ld-server/tests/typos.rs b/crates/ld-server/tests/typos.rs index 8524498..2cbbb1d 100644 --- a/crates/ld-server/tests/typos.rs +++ b/crates/ld-server/tests/typos.rs @@ -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; diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 5c54455..b183965 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -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 { let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group { @@ -204,7 +225,7 @@ pub fn default_groups() -> Vec { 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) }; diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 1fc13e2..3086ebe 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -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 } ],