forked from yvv/decision
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user