//! The quorum (ADR 0013). Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition //! par défaut, variable modifiable par l'auteur initial du texte… c'est plutôt un //! quorum » ; 2026-11-03, below the quorum: « Ils comptent comme non ». Each level has //! its quorum — « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, 1 everywhere in the test //! group; the author of an article's first version sets its own, « avant le 1er vote ». //! An existing base gets the quorums by level through a guarded amendment, decided once. use axum::body::Body; use axum::http::{Request, StatusCode, header}; use http_body_util::BodyExt; use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; use serde_json::{Value, json}; use tower::ServiceExt; /// The quorums of Monnaie libre by level (Yvv, 2026-11-02). const QUORUMS: [u64; 5] = [12, 12, 50, 50, 50]; /// W of Monnaie libre in the tests (`Config::for_tests`, no chain read). const W: u64 = 5745; struct Client { router: axum::Router, cookie: Option, } impl Client { fn of(state: &AppState) -> Self { Self { router: app(state.clone()), cookie: None } } fn other(&self) -> Self { Self { router: self.router.clone(), cookie: None } } async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); if let Some(c) = &self.cookie { req = req.header(header::COOKIE, c); } let req = match body { Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), None => req.body(Body::empty()), } .expect("request"); let res = self.router.clone().oneshot(req).await.expect("response"); let status = res.status(); if let Some(set) = res.headers().get(header::SET_COOKIE) { let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); if raw.starts_with("ld_session=") { self.cookie = Some(raw); } } let bytes = res.into_body().collect().await.expect("body").to_bytes(); (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) } async fn ok(&mut self, method: &str, uri: &str, body: Option) -> Value { let (s, v) = self.call(method, uri, body).await; assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}"); v } async fn get(&mut self, uri: &str) -> Value { self.ok("GET", uri, None).await } async fn login(&mut self, name: &str, admin: bool) { self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; } async fn vote(&mut self, proposal: &str, choice: Option<&str>) -> Value { self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await } async fn doc(&mut self, group: &str) -> Value { let id = self.get(&format!("/api/groups/{group}")).await["docs"][0]["id"].as_str().expect("doc").to_owned(); self.get(&format!("/api/docs/{id}")).await } /// `PUT /units/{id}/quorum`. async fn set(&mut self, unit: &str, quorum: Value) -> (StatusCode, Value) { self.call("PUT", &format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": quorum }))).await } } fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } fn unit_by_id<'a>(doc: &'a Value, id: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["id"] == id).expect("unit") } fn numbers(levels: &Value, key: &str) -> Vec { levels.as_array().expect("levels").iter().map(|l| l[key].as_u64().expect("number")).collect() } /// G of each level, as served. fn gradients(doc: &Value) -> Vec { doc["levels"].as_array().expect("levels").iter().map(|l| l["gradient"].clone()).collect() } /// (quorum, quorumDefault, canSetQuorum) of a unit. fn quorum_of(u: &Value) -> (Option, Option, Option) { (u["quorum"].as_u64(), u["quorumDefault"].as_u64(), u["canSetQuorum"].as_bool()) } /// (quorum, cast, required, yesNeeded, noNeeded, missing, reached) of an evaluation. fn figures(e: &Value) -> (u64, u64, u64, u64, u64, u64, bool) { let n = |k: &str| e[k].as_u64().unwrap_or_else(|| panic!("{k}: {e}")); (n("quorum"), n("cast"), n("required"), n("yesNeeded"), n("noNeeded"), n("missing"), e["reached"] == true) } async fn fresh() -> AppState { fresh_with(Config::for_tests()).await } async fn fresh_with(cfg: Config) -> AppState { let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(cfg, db).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } #[tokio::test] async fn each_level_has_its_quorum_and_every_evaluation_says_it() { let state = fresh().await; let mut c = Client::of(&state); c.login("Chiara", false).await; let doc = c.doc("monnaie-libre").await; assert_eq!(doc["doc"]["quorums"], json!(QUORUMS)); assert_eq!(numbers(&doc["levels"], "quorum"), QUORUMS); // I1, a seed proposal at level 2: its level's quorum, 12. Nobody voted: judged on 12 // voters, the 12 missing counted as against — 9 « pour » pass (the control figure). let i1 = unit(&doc, "I1"); assert_eq!(quorum_of(i1), (None, Some(12), Some(false)), "not the author, no administrator"); assert_eq!(figures(&i1["proposals"][0]["evaluation"]), (12, 0, 9, 9, 0, 9, false)); // E3 at level 3: the quorum 50, 39 « pour » at no vote. let e3 = unit(&doc, "E3"); assert_eq!(e3["inertia"], 3); assert_eq!(quorum_of(e3), (None, Some(50), Some(false))); assert_eq!(figures(&e3["proposals"][0]["evaluation"]), (50, 0, 39, 39, 0, 39, false)); // The detail says the same, the five levels on their quorums. let p = i1["proposals"][0]["id"].as_str().expect("i1"); let d = c.get(&format!("/api/proposals/{p}")).await; assert_eq!(figures(&d["proposal"]["evaluation"]), (12, 0, 9, 9, 0, 9, false)); assert_eq!(numbers(&d["levels"], "quorum"), QUORUMS); assert_eq!(numbers(&d["levels"], "required"), [8, 9, 39, 43, 46]); // The test group: 1 everywhere. let essai = c.doc("essai").await; assert_eq!(essai["doc"]["quorums"], json!([1, 1, 1, 1, 1])); assert_eq!(quorum_of(unit(&essai, "E3")), (None, Some(1), Some(false))); assert_eq!(unit(&essai, "I1")["proposals"][0]["evaluation"]["quorum"], 1); // What is published carries them (document.json). let id = doc["doc"]["id"].as_str().expect("id"); let st = ld_server::docs::load_state(&state, id).await.expect("state"); let files = ld_server::publish::render(&state, &st).await.expect("render"); let json: Value = serde_json::from_str(&files.iter().find(|(n, _)| n == "document.json").expect("document.json").1) .expect("json"); assert_eq!(json["quorums"], json!(QUORUMS)); } #[tokio::test] async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() { let state = fresh().await; let mut chiara = Client::of(&state); chiara.login("Chiara", false).await; let mut luna = chiara.other(); luna.login("Luna", false).await; let doc = chiara.doc("essai").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let created = chiara .ok( "POST", &format!("/api/docs/{doc_id}/proposals"), Some(json!({ "section": "fondamental", "title": "Nouveau", "body": "Un nouvel article.", "submit": true })), ) .await; let (u, text) = (created["unitId"].as_str().expect("unit").to_owned(), created["id"].as_str().expect("id").to_owned()); // « Tout nouvel article commence à ce cran » (F1.2, « Par défaut »): the default level, 3. assert_eq!(unit_by_id(&chiara.doc("essai").await, &u)["inertia"], 3); // Its author may, another member may not; the test group: its level's quorum, 1. assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (None, Some(1), Some(true))); assert_eq!(quorum_of(unit_by_id(&luna.doc("essai").await, &u)), (None, Some(1), Some(false))); let (s, v) = luna.set(&u, json!(2)).await; assert_eq!((s, v["error"].as_str()), (StatusCode::FORBIDDEN, Some("forbidden"))); // From 1 in the test group to W (2 members). for bad in [json!(0), json!(3), json!(-1), json!(1.5)] { let (s, v) = chiara.set(&u, bad.clone()).await; assert!(s == StatusCode::BAD_REQUEST || s == StatusCode::UNPROCESSABLE_ENTITY, "{bad}: {s} {v}"); if s == StatusCode::BAD_REQUEST { assert_eq!(v["error"], "quorum", "{bad}"); } } let (s, v) = chiara.set(&u, json!(2)).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v, json!({ "quorum": 2, "quorumDefault": 1, "canSetQuorum": true })); let doc = chiara.doc("essai").await; let mine = unit_by_id(&doc, &u); assert_eq!(quorum_of(mine), (Some(2), Some(1), Some(true))); assert_eq!(mine["proposals"][0]["evaluation"]["quorum"], 2); // A change of its inertia is judged on it too. let inertia = chiara.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await; let d = chiara.get(&format!("/api/proposals/{}", inertia["id"].as_str().expect("id"))).await; assert_eq!(d["proposal"]["evaluation"]["quorum"], 2); assert_eq!(numbers(&d["levels"], "quorum"), [2; 5], "the article's own quorum, at every level"); // Back to its level's, then 2 again: each change is journaled. let (s, v) = chiara.set(&u, Value::Null).await; assert_eq!((s, &v), (StatusCode::OK, &json!({ "quorum": null, "quorumDefault": 1, "canSetQuorum": true }))); chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await; let journal: Vec<(Option, Option, String)> = sqlx::query_as( "SELECT q.from_quorum, q.to_quorum, i.display_name FROM quorum_changes q JOIN identities i ON i.id = q.identity_id WHERE q.unit_id = ? ORDER BY q.id", ) .bind(&u) .fetch_all(&state.db) .await .expect("journal"); let chiara_name = "Chiara".to_owned(); assert_eq!( journal, [(None, Some(2), chiara_name.clone()), (Some(2), None, chiara_name.clone()), (None, Some(2), chiara_name)] ); // The first vote: 1 « pour » of 2 voters on a quorum of 2 does not pass, and the // quorum is held from then on. let v = luna.vote(&text, Some("for")).await; assert!(v["entered"].as_array().expect("entered").is_empty(), "{v}"); assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(false))); let (s, v) = chiara.set(&u, json!(1)).await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted"))); // A vote on its change of inertia counts as well: it is the same article. assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query")); // Her vote withdrawn: in the test group (no buffer) it leaves no trace, and the author // sets the quorum again — « tant que l'article ne garde la trace d'aucun vote » (F1.2, // lot T2), not « ensuite, il ne change plus ». luna.vote(&text, None).await; assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query")); assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(true))); let (s, v) = chiara.set(&u, json!(1)).await; assert_eq!(s, StatusCode::OK, "{v}"); } /// 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 usual = (doc["doc"]["participation"].clone(), gradients(&doc)); 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); // « Elle ne dépend pas du quorum d'un article » (N1.1, lot T3c): the participation habituelle, // and G of each level, stay as they were (the reference rests on the document's, 2 × 50 / W). assert_eq!((doc["doc"]["participation"].clone(), gradients(&doc)), usual); assert!((doc["doc"]["participation"]["ratio"].as_f64().expect("p̃") - 100.0 / W as f64).abs() < 1e-12); // A refused ballot, an expired one, are no vote. ballot(&state, &doc_id, &p, "refused", 1).await; ballot(&state, &doc_id, &p, "expired", 2).await; assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query")); assert_eq!(admin.set(&u, json!(12)).await.0, StatusCode::OK); // A pending one is: the vote is cast, the chain has not said it yet. ballot(&state, &doc_id, &p, "pending", 3).await; assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query")); assert_eq!(quorum_of(unit(&admin.doc("monnaie-libre").await, "I1")), (Some(12), Some(12), Some(false))); let (s, v) = admin.set(&u, Value::Null).await; assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted"))); // A confirmed one, on another article. let e1 = unit(&doc, "E1"); let (u1, p1) = (e1["id"].as_str().expect("u").to_owned(), e1["proposals"][0]["id"].as_str().expect("p").to_owned()); assert!(!ld_server::quorum::a_vote_exists(&state, &u1).await.expect("query")); ballot(&state, &doc_id, &p1, "confirmed", 4).await; assert_eq!(admin.set(&u1, json!(30)).await.1["error"], "voted"); } // --------------------------------------------------------------------------- // An existing base: the quorum amendment // --------------------------------------------------------------------------- /// The seed production runs on 2026-11-02: no quorum anywhere, the C of before. fn production_config() -> Config { let mut cfg = Config::for_tests(); cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02"); cfg } async fn restart(before: &AppState, cfg: Config) -> AppState { let state = AppState::new(cfg, before.db.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } /// (group, from, to, outcome, reason) of each decision. type Decision = (String, Option, String, String, Option); async fn decisions(state: &AppState) -> Vec { sqlx::query_as( "SELECT d.group_slug, q.from_quorums, q.to_quorums, q.outcome, q.reason FROM quorum_amendments q JOIN documents d ON d.id = q.doc_id ORDER BY d.group_slug", ) .fetch_all(&state.db) .await .expect("decisions") } async fn stored(state: &AppState, group: &str) -> Option { sqlx::query_scalar("SELECT quorums FROM documents WHERE group_slug = ?") .bind(group) .fetch_one(&state.db) .await .expect("quorums") } fn row(group: &str, from: Option<&str>, to: &str, outcome: &str, reason: Option<&str>) -> Decision { (group.into(), from.map(Into::into), to.into(), outcome.into(), reason.map(Into::into)) } const ML: &str = "[12,12,50,50,50]"; const ESSAI: &str = "[1,1,1,1,1]"; #[tokio::test] async fn the_quorum_amendment_writes_the_quorums_by_level_once() { // Production as it runs: no quorum written; every level reads the C of before. let born = fresh_with(production_config()).await; assert_eq!((stored(&born, "monnaie-libre").await, stored(&born, "essai").await), (None, None)); assert!(decisions(&born).await.is_empty(), "a seed without quorums decides nothing"); let mut c = Client::of(&born); c.login("Chiara", false).await; assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50])); assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1])); // Started with today's seed: written, where none was. let state = restart(&born, Config::for_tests()).await; let first = decisions(&state).await; assert_eq!( first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::>(), [row("essai", None, ESSAI, "applied", None), row("monnaie-libre", None, ML, "applied", None)] ); assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some(ML)); assert_eq!(stored(&state, "essai").await.as_deref(), Some(ESSAI)); let mut c = Client::of(&state); c.login("Chiara", false).await; assert_eq!(unit(&c.doc("monnaie-libre").await, "I1")["proposals"][0]["evaluation"]["quorum"], 12); // Republished where the document publishes; never from the test group. let queued: Vec = sqlx::query_scalar( "SELECT d.group_slug FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'quorums%'", ) .fetch_all(&state.db) .await .expect("outbox"); assert_eq!(queued, ["monnaie-libre"]); // Once only. let again = restart(&state, Config::for_tests()).await; assert_eq!(decisions(&again).await, first); // A new base is born with them: present. let new = fresh().await; let present: Vec = decisions(&new).await.into_iter().map(|(_, _, _, o, _)| o).collect(); assert_eq!(present, ["present", "present"]); } #[tokio::test] async fn quorums_written_otherwise_are_kept_and_the_refusal_is_kept() { let born = fresh_with(production_config()).await; sqlx::query("UPDATE documents SET quorums = '[3,3,3,3,3]' WHERE group_slug = 'monnaie-libre'") .execute(&born.db) .await .expect("quorums"); let state = restart(&born, Config::for_tests()).await; let got = decisions(&state).await; assert_eq!( got.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::>(), [ row("essai", None, ESSAI, "applied", None), row("monnaie-libre", Some("[3,3,3,3,3]"), ML, "refused", Some("quorums_set")), ] ); assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some("[3,3,3,3,3]")); }