Files
decision/crates/ld-server/tests/quorum.rs
T
YvvandClaude Opus 5.5 01b7841472 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>
2026-11-03 05:00:41 +01:00

405 lines
18 KiB
Rust

//! 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]"));
}