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
+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(