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:
Yvv
2026-11-03 05:00:41 +01:00
co-authored by Claude Opus 5.5
parent 37147d1f59
commit 01b7841472
20 changed files with 1205 additions and 95 deletions
+28
View File
@@ -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)
);
+6 -5
View File
@@ -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,
+79 -17
View File
@@ -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),
})))
}
+3
View File
@@ -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))
+1 -1
View File
@@ -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()),
})))
}
+1 -1
View File
@@ -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)
+176
View File
@@ -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 })))
}
+195
View File
@@ -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}");
}
}
}
+31 -3
View File
@@ -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())
}
+32 -8
View File
@@ -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.
+66 -25
View File
@@ -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(
+33 -19
View File
@@ -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(&params).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!(
+21 -2
View File
@@ -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");
+30 -7
View File
@@ -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)));
+404
View File
@@ -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]"));
}
+7 -4
View File
@@ -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;
+4
View File
@@ -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;
+37 -2
View File
@@ -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) };
+3 -1
View File
@@ -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
}
],