Fusion de chantier/acte-certification (lots H et S) dans le lot V : le tampon et ses états, avec le quorum et les Propositions seed

- conflits résolus des deux côtés : kept_params garde le quorum (S) et la part simulée, la version déplacée, le cran d'avant (V) ; proposals.rs garde seedMinimal / seedProposal (S) et tallyPending / tallySimulated / myVoteState (V) ;
- simulation::reexamine juge sur le quorum gardé par l'adoption (params.quorum, à défaut celui de l'article) ;
- l'acte répété n'examine plus une adoption qui tient (params.reexamined) ;
- tests V repris sans texte en vigueur au départ (I1, E1 : la Proposition seed) et sur un quorum 2 (small_web) ; le test overview d'une personne invitée dans Monnaie libre suit la simulation (elle vote, chaîne fermée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 05:32:51 +01:00
co-authored by Claude Opus 5.5
66 changed files with 5134 additions and 701 deletions
+96 -35
View File
@@ -7,6 +7,17 @@ use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
/// The seed production runs on 2026-11-02 (3f10a0e): texts in force, seeds a minima.
fn production_seed_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
}
fn production_config() -> Config {
let mut cfg = Config::for_tests();
cfg.seed_dir = production_seed_dir();
cfg
}
struct Client {
router: axum::Router,
cookie: Option<String>,
@@ -16,8 +27,19 @@ struct Client {
impl Client {
async fn new() -> (Self, AppState) {
Self::seeded(Config::for_tests()).await
}
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
/// For the scenarios that need texts in force; the seed of today has none.
async fn production() -> (Self, AppState) {
Self::seeded(production_config()).await
}
async fn seeded(cfg: Config) -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
}
@@ -61,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")
}
@@ -88,7 +126,7 @@ async fn anonymous_sees_nothing_and_csrf_is_enforced() {
}
#[tokio::test]
async fn the_seed_a_minima_and_the_open_proposals() {
async fn the_seed_proposals_and_the_open_proposals() {
let (mut c, _) = Client::new().await;
c.login("Chiara", false).await;
let (_, groups) = c.call("GET", "/api/groups", None).await;
@@ -99,16 +137,10 @@ async fn the_seed_a_minima_and_the_open_proposals() {
let id = doc_id(&mut c, "monnaie-libre").await;
let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
assert_eq!(s, StatusCode::OK);
let in_force: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| !u["inForce"].is_null())
.filter_map(|u| u["code"].as_str())
.collect();
assert_eq!(in_force, ["I1", "E1", "E2"]);
let e1 = &unit(&doc, "E1")["inForce"];
assert_eq!(e1["seedMinimal"], true);
// No article in force: I1, E1 and E2 are seed proposals (ADR 0011), open, put forward.
assert!(doc["units"].as_array().expect("units").iter().all(|u| u["inForce"].is_null()));
let e1 = &unit(&doc, "E1")["proposals"][0];
assert_eq!((e1["seedProposal"].as_bool(), e1["status"].as_str()), (Some(true), Some("open")));
assert_eq!(e1["tally"]["for"], 0);
assert_eq!(doc["doc"]["voteFloor"], 50.0);
assert_eq!(doc["doc"]["electorate"], 5745);
@@ -119,8 +151,11 @@ async fn the_seed_a_minima_and_the_open_proposals() {
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
assert_eq!(e3["inertia"], 3);
// The two structural commitments start one level up (Yvv, 2026-10-30).
assert_eq!((unit(&doc, "E1")["inertia"].as_u64(), unit(&doc, "E2")["inertia"].as_u64()), (Some(4), Some(4)));
// The seed proposals start low (Yvv, 2026-11-02).
let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL);
for code in ["I1", "E1", "E2"] {
assert_eq!(unit(&doc, code)["inertia"].as_u64(), Some(low), "{code}");
}
// Translations seeded.
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
}
@@ -131,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());
@@ -155,7 +190,7 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published(
#[tokio::test]
async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
let (mut a, _) = Client::new().await;
let (mut a, _) = Client::production().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
@@ -172,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());
@@ -185,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::new().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);
@@ -296,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,
@@ -343,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::new().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();
@@ -389,7 +429,11 @@ async fn inertia_change_is_voted_at_the_level_of_the_article() {
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "max" }))).await;
assert_eq!(again["id"], p.as_str());
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(5), Some(4)));
let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL);
assert_eq!(
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
(Some(5), Some(low))
);
}
#[tokio::test]
@@ -400,7 +444,7 @@ async fn previews_and_assistant() {
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(v["units"].as_array().expect("u").len(), 33);
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes");
assert_eq!(v["units"].as_array().expect("u").len(), 0, "nothing in force, nothing well rated at zero votes");
let (_, v) = a
.call("POST", &format!("/api/docs/{id}/assist"), Some(json!({
"text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.",
@@ -745,7 +789,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
std::fs::create_dir_all(&dir).expect("dir");
let remote = dir.join("textes.git");
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
let mut cfg = Config::for_tests();
let mut cfg = production_config();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
@@ -753,6 +797,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
});
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
@@ -818,7 +863,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
path
};
let config = |remote: &std::path::Path| {
let mut cfg = Config::for_tests();
let mut cfg = production_config();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
@@ -826,6 +871,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
});
cfg
};
@@ -908,12 +954,18 @@ fn today_seed() -> Value {
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
}
/// The seed production runs, I1 in force: the amendment of 30/10 is replayed on it.
fn production_seed() -> Value {
let path = production_seed_dir().join("acte-engagement-certification.json");
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
}
/// The seed as it was before 2026-10-30: the Préambule in force.
fn first_seed_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ld-first-seed-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let mut seed = today_seed();
let mut seed = production_seed();
let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1");
for (lang, title, body) in PREAMBULE {
i1["versions"][0]["title"][lang] = json!(title);
@@ -933,8 +985,10 @@ async fn base_with_the_preambule(tag: &str, mut cfg: Config) -> (Client, AppStat
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
}
/// The same base, started again with today's seed (and its amendments).
async fn restart(before: &AppState, cfg: Config) -> (Client, AppState) {
/// The same base, started again with the seed of production (and its amendments): the
/// seed of today then puts I1 forward (tests/seed_proposals.rs).
async fn restart(before: &AppState, mut cfg: Config) -> (Client, AppState) {
cfg.seed_dir = production_seed_dir();
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
@@ -955,10 +1009,12 @@ fn outcomes(rows: &[(String, String, Option<String>, String)]) -> Vec<(&str, &st
rows.iter().map(|(g, o, r, _)| (g.as_str(), o.as_str(), r.as_deref())).collect()
}
/// I1 as it reads: its text in force, else its seed proposal (ADR 0011).
async fn i1_of(c: &mut Client, group: &str) -> Value {
let id = doc_id(c, group).await;
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
unit(&doc, "I1")["inForce"].clone()
let u = unit(&doc, "I1");
if u["inForce"].is_null() { u["proposals"][0].clone() } else { u["inForce"].clone() }
}
#[tokio::test]
@@ -969,13 +1025,13 @@ async fn a_new_base_starts_with_the_raison_d_etre() {
let i1 = i1_of(&mut c, group).await;
assert_eq!(i1["title"], "Raison d'être");
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}");
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(1), Some(true)));
assert_eq!((i1["revision"].as_i64(), i1["seedProposal"].as_bool()), (Some(1), Some(true)));
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
let tr = &i1["translations"][lang];
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
}
}
// Born with the text: nothing to replace, and it is said once.
// Born with the text, put forward: nothing to replace, and it is said once.
assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]);
}
@@ -988,7 +1044,8 @@ async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
let seed = today_seed();
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
let v = &i1["versions"][0];
assert_eq!(v["status"], "in_force");
// Today the starting text is a seed proposal (ADR 0011); the text is the same.
assert_eq!((v["status"].as_str(), v["seedProposal"].as_bool()), (Some("open"), Some(true)));
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today");
}
@@ -1001,7 +1058,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
std::fs::create_dir_all(&dir).expect("dir");
let remote = dir.join("textes.git");
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
let mut cfg = Config::for_tests();
let mut cfg = production_config();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
@@ -1009,6 +1066,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
});
let git = |args: &[&str]| {
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
@@ -1124,7 +1182,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.
+147 -91
View File
@@ -268,6 +268,39 @@ 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}");
}
/// A web of two members and the quorum 2 at every level of a document, for the test
/// only (ADR 0013): two voices pass, one never does.
async fn small_web(state: &AppState, doc_id: &str) {
sqlx::query("UPDATE documents SET vote_floor = 1, quorums = '[2,2,2,2,2]' WHERE id = ?")
.bind(doc_id)
.execute(&state.db)
.await
.expect("quorums");
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z')
ON CONFLICT(key) DO UPDATE SET value = '2'",
)
.execute(&state.db)
.await
.expect("web");
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
@@ -351,7 +384,7 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
assert_eq!(doc["vote"]["chain"]["amount"], 10);
assert_eq!(doc["vote"]["chain"]["test"], false);
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let e3 = version(&doc, "E3", "0");
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
let comment = b["comment"].as_str().expect("comment");
@@ -411,8 +444,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;
@@ -442,7 +478,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);
@@ -454,7 +492,7 @@ async fn a_silent_indexer_keeps_the_ballot_pending() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("comment"), alice());
indexer.silent(true);
@@ -480,7 +518,7 @@ async fn a_signer_who_is_not_a_member_is_refused_and_told() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let former = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(
100,
@@ -523,7 +561,7 @@ async fn a_wrong_amount_or_recipient_is_refused() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
// Bound to the wallet that signs: its own transaction without the transfer is refused, and told.
let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
@@ -552,7 +590,7 @@ async fn confirming_twice_changes_nothing() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
// The page and the background task at once.
@@ -579,8 +617,8 @@ async fn the_last_one_stands_in_the_order_of_the_chain() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let e1 = version(&doc, "E1", "inForce");
let i1 = version(&doc, "I1", "0");
let e1 = version(&doc, "E1", "0");
// Three ballots of the same person, signed in this order on the chain: for (I1, E1), against (I1), none (E1).
let first = c.ballot(&doc, &[(&i1, Some("for")), (&e1, Some("for"))], None).await;
let second = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
@@ -661,7 +699,7 @@ async fn a_bound_ballot_ignores_a_copy_of_its_remark_by_another_account() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
let comment = b["comment"].as_str().expect("c");
// Charlie copies the remark seen in the pool and gets it in first.
@@ -683,7 +721,7 @@ async fn a_ballot_not_seen_in_time_is_let_go() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let expires = b["expiresAt"].as_str().expect("expires");
assert!(
@@ -712,7 +750,7 @@ async fn a_ballot_the_wallet_refused_to_sign_is_let_go_at_once() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]);
@@ -745,7 +783,7 @@ async fn a_session_sees_its_own_ballots_only() {
let mut other = chiara.other();
other.login("Yvv").await;
let doc = chiara.doc("monnaie-libre").await;
let (i1, e1) = (version(&doc, "I1", "inForce"), version(&doc, "E1", "inForce"));
let (i1, e1) = (version(&doc, "I1", "0"), version(&doc, "E1", "0"));
// Both sign with the same wallet (one person, two sessions): two ballots, two versions.
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
let b = other.ballot(&doc, &[(&e1, Some("against"))], None).await;
@@ -763,7 +801,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;
@@ -771,20 +809,25 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
let e4 = version(&doc, "E4", "0");
let e5 = version(&doc, "E5", "0");
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
let e1 = version(&doc, "E1", "inForce");
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let e1 = version(&doc, "E1", "0");
// 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;
let mut luna = c.other();
luna.login("Luna").await;
for (n, account) in keys.iter().enumerate() {
// The fiftieth voice comes from another session: its intention is seen at once.
let asker = if n == 49 { &mut luna } else { &mut c };
// The last voice comes from another session: its intention is seen at once.
let last_one = n + 1 == keys.len();
let asker = if last_one { &mut luna } else { &mut c };
let b = asker.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
if n == 49 {
// Fifty voices shown, forty-nine proven: nothing enters before the proof.
if last_one {
// Thirty-nine voices shown, thirty-eight proven: nothing enters before the proof.
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((50, 0), (1, 0)));
assert_eq!(counted(&state, &d, &e4).await, (49, 0));
assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((39, 0), (1, 0)));
assert_eq!(counted(&state, &d, &e4).await, (38, 0));
let id = d["doc"]["id"].as_str().expect("id");
assert!(ld_server::adoption::run(&state, id).await.expect("run").is_empty());
assert_ne!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
@@ -795,9 +838,9 @@ 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 text in force E1 (ADR 0010): no new ballot may hold it.
// E5 back against the seed proposal E1, written before it (ADR 0010, 0011): no new ballot may hold it.
let o = c
.ok(
"POST",
@@ -821,11 +864,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");
@@ -836,7 +880,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());
// Sealed before the objection, proven after it: its *for* on E5 is set aside
// (ADR 0012, revising 0010) — its *for* on E4 counts. Luna is told.
assert_eq!(tally(&d, &e5), (49, 0));
assert_eq!(tally(&d, &e5), (38, 0));
assert_eq!(find(&d, &e5)["votable"], false);
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
let outcomes: Vec<(String, Option<String>)> =
@@ -863,7 +907,7 @@ async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() {
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (e1, e4, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E4", "0"), version(&doc, "E5", "0"));
let (e1, e4, e5) = (version(&doc, "E1", "0"), version(&doc, "E4", "0"), version(&doc, "E5", "0"));
let o = c
.ok(
"POST",
@@ -934,7 +978,7 @@ async fn a_long_author_is_refused_at_once() {
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
// The body may hold 256 KiB: an author of 250 000 characters passes it.
for n in [80_000usize, 250_000] {
let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) });
@@ -953,7 +997,7 @@ async fn the_background_task_reaches_every_pending_ballot() {
let (mut a, state) = Client::new(chain_config(&indexer)).await;
a.login("Anna").await;
let doc = a.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let mut b = a.other();
b.login("Bruno").await;
let mut c = a.other();
@@ -988,7 +1032,7 @@ async fn letting_ballots_go_does_not_lift_the_cap() {
a.login("Anna").await;
let doc = a.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
for _ in 0..20 {
let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await;
a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await;
@@ -1043,7 +1087,7 @@ async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]);
indexer.add(
@@ -1070,7 +1114,7 @@ async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
indexer.pay(100, comment_of(&b), alice());
@@ -1090,7 +1134,7 @@ async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
// A Ğecko code shown, never paid; then a vote from the wallet, confirmed.
c.ballot(&doc, &[(&i1, Some("against"))], None).await;
let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
@@ -1113,7 +1157,7 @@ async fn a_comment_counts_once_its_block_is_final() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await;
indexer.finalized(Some(99));
@@ -1147,7 +1191,7 @@ async fn copies_never_hide_the_real_comment() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
for n in 0..120 {
indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
@@ -1180,7 +1224,7 @@ async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
age(&state, "expires_at", -5, id_of(&b)).await;
indexer.pay(100, comment_of(&b), alice());
@@ -1205,7 +1249,7 @@ async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let (i1, e3) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0"));
let (i1, e3) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"));
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
indexer.pay(100, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
@@ -1264,7 +1308,7 @@ async fn a_name_known_only_from_a_chain_vote_stays_free() {
let (mut admin, state) = Client::new(chain_config(&indexer)).await;
admin.login_admin("Yvv").await;
let doc = admin.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })]
{
let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await;
@@ -1289,7 +1333,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;
@@ -1326,8 +1370,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");
@@ -1336,22 +1388,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");
@@ -1380,7 +1436,7 @@ async fn without_a_vote_account_monnaie_libre_votes_in_simulation_and_the_test_g
assert!(doc["vote"]["chain"].is_null());
assert_eq!(doc["canVote"], true);
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let (s, v) = c
.call(
"POST",
@@ -1392,7 +1448,7 @@ async fn without_a_vote_account_monnaie_libre_votes_in_simulation_and_the_test_g
let essai = c.doc("essai").await;
assert_eq!((essai["vote"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true)));
assert_eq!(essai["vote"]["simulated"], false);
let p = version(&essai, "I1", "inForce");
let p = version(&essai, "I1", "0");
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
let essai = c.doc("essai").await;
assert_eq!(mine(&essai, &p), (Some("for"), Some("recorded")));
@@ -1439,9 +1495,9 @@ async fn what_a_ballot_may_not_hold() {
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
let essai = c.doc("essai").await;
let elsewhere = version(&essai, "I1", "inForce");
let elsewhere = version(&essai, "I1", "0");
for (lines, author, code) in [
(json!([]), Value::Null, "lines"),
(
@@ -1478,6 +1534,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"));
@@ -1486,7 +1545,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(
@@ -1528,7 +1587,7 @@ async fn a_simulated_vote_in_monnaie_libre_is_seen_at_once_in_my_vote_and_the_to
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (i1, e3, e4) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0"), version(&doc, "E4", "0"));
let (i1, e3, e4) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"), version(&doc, "E4", "0"));
// One vote, without a wallet: written by the server, simulated.
let v = c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!((v["simulated"].as_bool(), v["entered"].as_array().map(Vec::len)), (Some(true), Some(0)));
@@ -1591,18 +1650,7 @@ async fn shown_but_never_adopted_when_simulated_votes_are_switched_off_for_adopt
a.login("Anna").await;
let doc = a.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
// A small floor and a small web, for the test only: two voices may pass.
sqlx::query("UPDATE documents SET vote_floor = 1 WHERE id = ?")
.bind(&doc_id)
.execute(&state.db)
.await
.expect("floor");
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z') ON CONFLICT(key) DO UPDATE SET value = '2'",
)
.execute(&state.db)
.await
.expect("web");
small_web(&state, &doc_id).await;
let e4 = version(&doc, "E4", "0");
let mut b = a.other();
b.login("Bruno").await;
@@ -1626,14 +1674,10 @@ async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_wh
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
// A small floor and a small web, for the test only: two voices pass, one never does.
sqlx::query("UPDATE documents SET vote_floor = 1 WHERE id = ?").bind(&doc_id).execute(&state.db).await.expect("floor");
sqlx::query("INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z') ON CONFLICT(key) DO UPDATE SET value = '2'")
.execute(&state.db)
.await
.expect("web");
small_web(&state, &doc_id).await;
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
let seed_i1 = version(&doc, "I1", "inForce");
// The seed proposal of I1 (ADR 0011), and a rival written before the votes.
let seed_i1 = version(&doc, "I1", "0");
let i1_unit = unit(&doc, "I1")["id"].as_str().expect("unit").to_owned();
let rival = c
.ok(
@@ -1645,18 +1689,26 @@ async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_wh
.as_str()
.expect("rival")
.to_owned();
// The chain open: one proven voice on E5, one on E6 — not enough to enter.
for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })] {
let b = c.ballot(&doc, &[(p, Some("for"))], None).await;
// The chain open: one proven voice on E5, one on E6 — not enough to enter; two on
// the seed proposal of I1, which enters on proven votes alone.
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, charlie)] {
let b = c.ballot(&doc, &[(p, Some("for")), (&seed_i1, Some("for"))], None).await;
indexer.pay(block, comment_of(&b), signer);
}
ballots::sweep(&state).await.expect("sweep");
// The chain closed: Luna and Bruno vote in simulation; E4, E5, E6 and the rival of I1 enter.
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str());
assert!(unit(&d, "E5")["inForce"].is_null() && unit(&d, "E6")["inForce"].is_null());
// The chain closed: Luna, Bruno and Marc vote in simulation; E4, E5, E6 enter, and
// the rival of I1, three voices against two, takes the place of the seed proposal.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
let mut luna = c.other();
luna.login("Luna").await;
let mut bruno = c.other();
bruno.login("Bruno").await;
let mut marc = c.other();
marc.login("Marc").await;
let lines = json!([
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" },
{ "proposalId": e6, "choice": "for" }, { "proposalId": rival, "choice": "for" },
@@ -1664,6 +1716,7 @@ async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_wh
luna.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
let lines = json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": rival, "choice": "for" }]);
bruno.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
marc.ok("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
let d = c.doc("monnaie-libre").await;
for (code, id) in [("E4", &e4), ("E5", &e5), ("E6", &e6), ("I1", &rival)] {
assert_eq!(unit(&d, code)["inForce"]["id"], id.as_str(), "{code}");
@@ -1681,7 +1734,7 @@ async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_wh
ballots::sweep(&state).await.expect("sweep");
// Told first, nothing changed.
let told = ld_server::simulation::discard(&state, false).await.expect("dry run");
assert_eq!((told.applied, told.ballots, told.votes), (false, 2, 6));
assert_eq!((told.applied, told.ballots, told.votes), (false, 3, 7));
let verdicts = |list: &[ld_server::simulation::Reexamined]| {
let mut v: Vec<(String, bool, bool)> = list.iter().map(|r| (r.code.clone(), r.holds, r.returned)).collect();
v.sort();
@@ -1715,9 +1768,12 @@ async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_wh
.await
.expect("left");
assert_eq!(left, (0, 0));
let adopted: Vec<String> =
let mut adopted: Vec<String> =
sqlx::query_scalar("SELECT proposal_id FROM adoptions").fetch_all(&state.db).await.expect("adoptions");
assert_eq!(adopted, [e6.clone()], "the adoptions that do not hold are void");
adopted.sort();
let mut kept = vec![seed_i1.clone(), e6.clone()];
kept.sort();
assert_eq!(adopted, kept, "the adoptions that do not hold are void; those on proven votes alone stay");
// Once is enough.
let again = ld_server::simulation::discard(&state, true).await.expect("again");
assert_eq!((again.ballots, again.reexamined.len()), (0, 0));
@@ -1735,7 +1791,7 @@ async fn an_intention_never_confirmed_is_set_aside_and_told() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
// A vote proven first: « contre ».
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
indexer.pay(100, comment_of(&first), alice());
@@ -1786,7 +1842,7 @@ async fn two_votes_of_one_identity_from_two_sessions_the_chain_keeps_the_last()
let mut other = chiara.other();
other.login("Yvv").await;
let doc = chiara.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
// One person, one wallet, two sessions: « pour » here, « contre » there.
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
let b = other.ballot(&doc, &[(&i1, Some("against"))], None).await;
@@ -1827,7 +1883,7 @@ async fn a_for_proven_while_an_objection_holds_the_version_is_set_aside() {
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let (e1, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E5", "0"));
let (e1, e5) = (version(&doc, "E1", "0"), version(&doc, "E5", "0"));
// Proven first: « contre » on E5.
let before = c.ballot(&doc, &[(&e5, Some("against"))], None).await;
indexer.pay(100, comment_of(&before), alice());
@@ -1873,7 +1929,7 @@ async fn a_waiting_intention_counts_as_my_vote_in_decisions() {
let doc = c.doc("monnaie-libre").await;
let without = |d: &Value| d["docs"][0]["withoutMyVote"].as_u64();
let before = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
let i1 = version(&doc, "I1", "inForce");
let i1 = version(&doc, "I1", "0");
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
assert_eq!(after, before.map(|n| n - 1));
@@ -0,0 +1,791 @@
{
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). 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 (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).",
"slug": "acte-engagement-certification",
"title": {
"fr": "Acte d'engagement Certification",
"en": "Certification Commitment Act",
"es": "Acta de compromiso de Certificación"
},
"summary": {
"fr": "Ce que chaque membre de la toile de confiance Ğ1 s'engage à respecter quand il certifie. Texte modulaire sous vote permanent : chaque article entre en vigueur dès qu'il atteint son seuil.",
"en": "What every member of the Ğ1 web of trust commits to uphold when certifying. A modular text under permanent vote: each article comes into force as soon as it reaches its threshold.",
"es": "Lo que todo miembro de la red de confianza Ğ1 se compromete a respetar cuando certifica. Texto modular en votación permanente: cada artículo entra en vigor en cuanto alcanza su umbral."
},
"sourceLang": "fr",
"instances": [
{
"group": "monnaie-libre",
"voteFloor": 50,
"publishPath": "monnaie-libre/acte-engagement-certification"
},
{
"group": "essai",
"voteFloor": 1,
"publishPath": null
}
],
"modules": {
"title": {
"inertia": 4
},
"order": {
"inertia": 2
}
},
"units": [
{
"code": "I1",
"section": "introduction",
"position": 1,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "in_force",
"title": {
"fr": "Raison d'être",
"en": "Purpose",
"es": "Razón de ser"
},
"body": {
"fr": "*« Derrière une clé il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé certifiante. »*\n\nC'est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur. Nous pouvons nous fier à l'unicité et l'intégrité de tout geste signé par cette clé. C'est l'enjeu de la certification.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre.",
"en": "*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”*\n\nThis is the one and only purpose of certification, which gives our fiduciary web its value. We can rely on the uniqueness and integrity of every action signed with this key. That is what is at stake in certification.\n\nThis commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded.",
"es": "*«Detrás de una clave no hay más que un solo ser humano vivo; detrás de un ser humano, no hay más que una sola clave certificadora.»*\n\nEste es el propósito único y exclusivo de la certificación, que da su valor a nuestra red fiduciaria. Podemos fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. Eso es lo que está en juego en la certificación.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre."
}
}
]
},
{
"code": "I2",
"section": "introduction",
"position": 2,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Les deux garanties réciproques",
"en": "The two reciprocal guarantees",
"es": "Las dos garantías recíprocas"
},
"body": {
"fr": "La Certification repose sur deux garanties réciproques :\n\n**1.** Derrière une clé publique créatrice de monnaie se trouve un **être humain vivant**.\n\n**2.** Derrière cet être humain se trouve **une seule et unique** clé publique créatrice de monnaie.\n\nLa Certification est un acte technique et fiduciaire, pas un acte d'adhésion morale ou de sympathie.",
"en": "Certification rests on two reciprocal guarantees:\n\n**1.** Behind a money-creating public key there is a **living human being**.\n\n**2.** Behind this human being there is **one single and unique** money-creating public key.\n\nCertification is a technical and fiduciary act, not an act of moral endorsement or of sympathy.",
"es": "La Certificación se basa en dos garantías recíprocas:\n\n**1.** Detrás de una clave pública creadora de moneda hay un **ser humano vivo**.\n\n**2.** Detrás de este ser humano hay **una sola y única** clave pública creadora de moneda.\n\nLa Certificación es un acto técnico y fiduciario, no un acto de adhesión moral ni de simpatía."
}
}
]
},
{
"code": "T1",
"section": "fondamental",
"position": 3,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Engagements fondamentaux",
"en": "Fundamental commitments",
"es": "Compromisos fundamentales"
},
"body": {
"fr": "Les engagements fondamentaux ci-dessous constituent le socle irréductible de l'acte d'engagement. Ils sont pris sur l'honneur par tout membre de la toile de confiance.",
"en": "The fundamental commitments below form the irreducible foundation of the commitment act. They are made on their honour by every member of the web of trust.",
"es": "Los compromisos fundamentales que siguen constituyen la base irreductible del acta de compromiso. Todo miembro de la red de confianza los asume por su honor."
}
}
]
},
{
"code": "E1",
"section": "fondamental",
"position": 4,
"inertia": 4,
"versions": [
{
"label": "1.0.0",
"status": "in_force",
"title": {
"fr": "Unicité du compte",
"en": "Uniqueness of the account",
"es": "Unicidad de la cuenta"
},
"body": {
"fr": "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.",
"en": "I commit on my honour to have, now and ever, no more than one single and unique account co-creating G1 money.",
"es": "Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta cocreadora de moneda G1."
}
}
]
},
{
"code": "E2",
"section": "fondamental",
"position": 5,
"inertia": 4,
"versions": [
{
"label": "1.0.0",
"status": "in_force",
"title": {
"fr": "Réciprocité",
"en": "Reciprocity",
"es": "Reciprocidad"
},
"body": {
"fr": "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent scrupuleusement ces deux présents engagements fondamentaux.",
"en": "I commit on my honour to certify only natural persons who scrupulously respect these two fundamental commitments.",
"es": "Me comprometo por mi honor a certificar únicamente a personas físicas que respeten escrupulosamente estos dos compromisos fundamentales."
}
}
]
},
{
"code": "T2",
"section": "technique",
"position": 6,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Engagements techniques",
"en": "Technical commitments",
"es": "Compromisos técnicos"
},
"body": {
"fr": "Les engagements techniques définissent les obligations pratiques et vérifiables du certificateur pour garantir la qualité de la toile de confiance.",
"en": "The technical commitments define the practical and verifiable obligations of the certifier to guarantee the quality of the web of trust.",
"es": "Los compromisos técnicos definen las obligaciones prácticas y verificables del certificador para garantizar la calidad de la red de confianza."
}
}
]
},
{
"code": "E3",
"section": "technique",
"position": 7,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Connaissance suffisante",
"en": "Sufficient knowledge",
"es": "Conocimiento suficiente"
},
"body": {
"fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas « avoir vu » ; c'est assurer à la communauté G1 que je pourrai la contacter facilement et être en mesure de repérer un double-compte ou tout autre problème.",
"en": "I have made sure that I know the person who manages this public key well enough. Knowing well enough does not mean “having seen”; it means assuring the G1 community that I will be able to contact them easily and to detect a duplicate account or any other problem.",
"es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública. Conocer suficientemente no significa «haber visto»; significa asegurar a la comunidad G1 que podré contactarla fácilmente y estar en condiciones de detectar una cuenta duplicada o cualquier otro problema."
}
},
{
"label": "0.4.0-draft",
"status": "open",
"title": {
"fr": "Connaissance suffisante",
"en": "Sufficient knowledge",
"es": "Conocimiento suficiente"
},
"body": {
"fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter par au moins deux canaux indépendants, je suis en mesure de repérer un double-compte, et je m'engage à signaler tout doute aux experts de la communauté. « Avoir vu » quelqu'un ne suffit pas.",
"en": "I have made sure that I know the person who manages this public key well enough: I can get back in touch with them through at least two independent channels, I am able to detect a duplicate account, and I commit to reporting any doubt to the community experts. “Having seen” someone is not enough.",
"es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública: puedo volver a contactarla por al menos dos canales independientes, estoy en condiciones de detectar una cuenta duplicada y me comprometo a comunicar cualquier duda a los expertos de la comunidad. «Haber visto» a alguien no basta."
}
}
]
},
{
"code": "E4",
"section": "technique",
"position": 8,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Vérification personnelle de la clé",
"en": "Personal verification of the key",
"es": "Verificación personal de la clave"
},
"body": {
"fr": "J'ai personnellement vérifié que c'est bien cette clé publique que je m'apprête à certifier, en la comparant avec la personne concernée et non par un intermédiaire.",
"en": "I have personally verified that this is indeed the public key I am about to certify, by comparing it with the person concerned and not through an intermediary.",
"es": "He verificado personalmente que esta es efectivamente la clave pública que me dispongo a certificar, comparándola con la persona interesada y no a través de un intermediario."
}
}
]
},
{
"code": "E5",
"section": "technique",
"position": 9,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Joignabilité réciproque",
"en": "Mutual reachability",
"es": "Contacto recíproco"
},
"body": {
"fr": "Je suis joignable rapidement et facilement par mes certifieurs, et je peux joindre rapidement et facilement les personnes que je certifie, par plusieurs moyens de communication différents et indépendants (physique, téléphone, email, messagerie, etc.).",
"en": "I can be reached quickly and easily by my certifiers, and I can quickly and easily reach the people I certify, through several different and independent means of communication (in person, phone, email, messaging, etc.).",
"es": "Soy localizable de forma rápida y sencilla por mis certificadores, y puedo localizar de forma rápida y sencilla a las personas que certifico, por varios medios de comunicación diferentes e independientes (en persona, teléfono, correo electrónico, mensajería, etc.)."
}
}
]
},
{
"code": "E6",
"section": "technique",
"position": 10,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Document de révocation",
"en": "Revocation document",
"es": "Documento de revocación"
},
"body": {
"fr": "J'ai vérifié avec la personne certifiée qu'elle a bien généré son document de révocation de compte et qu'elle le conserve en lieu sûr.",
"en": "I have checked with the certified person that they have indeed generated their account revocation document and that they keep it in a safe place.",
"es": "He verificado con la persona certificada que ha generado efectivamente su documento de revocación de cuenta y que lo conserva en un lugar seguro."
}
}
]
},
{
"code": "E7",
"section": "technique",
"position": 11,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Rencontre physique ou vérification multi-canaux",
"en": "In-person meeting or multi-channel verification",
"es": "Encuentro en persona o verificación multicanal"
},
"body": {
"fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par plusieurs moyens de communication différents et indépendants : courrier + réseau social + forum + email + visio + téléphone.",
"en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through several different and independent means of communication: postal mail + social network + forum + email + video call + phone.",
"es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por varios medios de comunicación diferentes e independientes: correo postal + red social + foro + correo electrónico + videollamada + teléfono."
}
},
{
"label": "0.4.0-draft",
"status": "open",
"title": {
"fr": "Rencontre physique ou vérification multi-canaux",
"en": "In-person meeting or multi-channel verification",
"es": "Encuentro en persona o verificación multicanal"
},
"body": {
"fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par au moins trois moyens de communication différents et indépendants, dont un échange en visio en direct.",
"en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through at least three different and independent means of communication, including a live video call.",
"es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por al menos tres medios de comunicación diferentes e independientes, entre ellos una videollamada en directo."
}
}
]
},
{
"code": "T3",
"section": "technique",
"position": 12,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Conseils et bonnes pratiques",
"en": "Advice and good practices",
"es": "Consejos y buenas prácticas"
},
"body": {
"fr": "Les pratiques suivantes sont fortement recommandées pour garantir la qualité et la sécurité de la toile de confiance.",
"en": "The following practices are strongly recommended to guarantee the quality and security of the web of trust.",
"es": "Las siguientes prácticas se recomiendan encarecidamente para garantizar la calidad y la seguridad de la red de confianza."
}
}
]
},
{
"code": "E8",
"section": "technique",
"position": 13,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Ne jamais certifier seul",
"en": "Never certify alone",
"es": "No certificar nunca en solitario"
},
"body": {
"fr": "Ne certifiez jamais seul, mais accompagné d'au moins un autre membre de la toile de confiance G1, pour garantir un double contrôle.",
"en": "Never certify alone, but accompanied by at least one other member of the G1 web of trust, to guarantee a double check.",
"es": "No certifique nunca en solitario, sino junto con al menos otro miembro de la red de confianza G1, para garantizar un doble control."
}
}
]
},
{
"code": "E9",
"section": "technique",
"position": 14,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Vérification des certifications existantes",
"en": "Verification of existing certifications",
"es": "Verificación de las certificaciones existentes"
},
"body": {
"fr": "Avant toute Certification, vérifiez si le compte a déjà reçu des certifications et de qui elles proviennent. Contactez les certifieurs existants en cas de doute. Si un certifieurs existant ne connaît pas la personne, alertez immédiatement les experts de la communauté.",
"en": "Before any Certification, check whether the account has already received certifications and who they come from. Contact the existing certifiers if in doubt. If an existing certifier does not know the person, alert the community experts immediately.",
"es": "Antes de cualquier Certificación, verifique si la cuenta ya ha recibido certificaciones y de quién proceden. En caso de duda, contacte con los certificadores existentes. Si un certificador existente no conoce a la persona, alerte de inmediato a los expertos de la comunidad."
}
}
]
},
{
"code": "E10",
"section": "technique",
"position": 15,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Vérification de maîtrise du compte",
"en": "Verification of account control",
"es": "Verificación del control de la cuenta"
},
"body": {
"fr": "Vérifiez la maîtrise du compte par un transfert-test : envoyez quelques G1 et demandez le renvoi, afin de vous assurer que la personne contrôle bien sa clé privée.",
"en": "Verify control of the account with a test transfer: send a few G1 and ask for them to be sent back, to make sure that the person really controls their private key.",
"es": "Verifique el control de la cuenta mediante una transferencia de prueba: envíe unos pocos G1 y pida que se los devuelvan, para asegurarse de que la persona controla realmente su clave privada."
}
}
]
},
{
"code": "E11",
"section": "technique",
"position": 16,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Transmission et compréhension du document",
"en": "Transmission and understanding of the document",
"es": "Transmisión y comprensión del documento"
},
"body": {
"fr": "Vérifiez que vos contacts ont bien étudié et compris le présent acte d'engagement dans sa version à jour avant de procéder à la Certification.",
"en": "Check that your contacts have indeed studied and understood the current version of this commitment act before proceeding with the Certification.",
"es": "Verifique que sus contactos han estudiado y comprendido efectivamente la versión vigente de la presente acta de compromiso antes de proceder a la Certificación."
}
}
]
},
{
"code": "K1",
"section": "conclusion",
"position": 17,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Règles abrégées de la toile de confiance",
"en": "Abridged rules of the web of trust",
"es": "Reglas abreviadas de la red de confianza"
},
"body": {
"fr": "**Paramètres protocolaires en vigueur :**\n\n- Stock de **100 certifications** possibles\n- 1 certification émissible tous les **5 jours**\n- Nouveau compte valide si **>= 5 certifications** reçues en **2 mois**\n- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n- Sentinelle : membre ayant reçu et émis >= Y[N] certifs (Y = ceil(N^(1/5)))\n- Certifications actives valables **2 ans**\n- Renouvellement de l'accord tous les **12 mois**\n\n*Note : le vote porte sur l'inclusion de ces règles dans le document, pas sur les valeurs des variables protocolaires elles-mêmes, qui sont fixées par le protocole Duniter.*",
"en": "**Protocol parameters in force:**\n\n- Stock of **100 certifications** available\n- 1 certification can be issued every **5 days**\n- New account valid if **>= 5 certifications** received within **2 months**\n- Distance rule: **<= 5 steps** from **80% of the sentries**\n- Sentry: member who has received and issued >= Y[N] certifications (Y = ceil(N^(1/5)))\n- Active certifications valid for **2 years**\n- Renewal of the agreement every **12 months**\n\n*Note: the vote concerns the inclusion of these rules in the document, not the values of the protocol variables themselves, which are set by the Duniter protocol.*",
"es": "**Parámetros de protocolo vigentes:**\n\n- Reserva de **100 certificaciones** posibles\n- 1 certificación emitible cada **5 días**\n- Cuenta nueva válida si recibe **>= 5 certificaciones** en **2 meses**\n- Regla de distancia: **<= 5 pasos** del **80% de los centinelas**\n- Centinela: miembro que ha recibido y emitido >= Y[N] certificaciones (Y = ceil(N^(1/5)))\n- Certificaciones activas válidas durante **2 años**\n- Renovación del acuerdo cada **12 meses**\n\n*Nota: la votación se refiere a la inclusión de estas reglas en el documento, no a los valores de las variables del protocolo en sí, que fija el protocolo Duniter.*"
}
}
]
},
{
"code": "K2",
"section": "conclusion",
"position": 18,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Monnaie G1",
"en": "G1 currency",
"es": "Moneda G1"
},
"body": {
"fr": "**Paramètres monétaires :**\n\n- 1 Dividende Universel (DU) par personne par jour\n- Réévaluation à chaque équinoxe : `DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note : le vote porte sur l'inclusion de ces paramètres dans le document, pas sur les valeurs monétaires elles-mêmes, qui découlent de la TRM et du bloc 0.*",
"en": "**Monetary parameters:**\n\n- 1 Universal Dividend (UD) per person per day\n- Revaluation at each equinox: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` with c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note: the vote concerns the inclusion of these parameters in the document, not the monetary values themselves, which derive from the RTM and block 0.*",
"es": "**Parámetros monetarios:**\n\n- 1 Dividendo Universal (DU) por persona y día\n- Revalorización en cada equinoccio: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` con c = 4.88%\n- DU(0) = 10.00 G1\n\n*Nota: la votación se refiere a la inclusión de estos parámetros en el documento, no a los valores monetarios en sí, que se derivan de la TRM y del bloque 0.*"
}
}
]
},
{
"code": "X1",
"section": "annexe",
"position": 19,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Annexe 1 : Intégration logicielle",
"en": "Annex 1: Software integration",
"es": "Anexo 1: Integración en el software"
},
"body": {
"fr": "Les obligations pour les logiciels implémentant la Certification G1.",
"en": "The obligations for software implementing G1 Certification.",
"es": "Las obligaciones del software que implementa la Certificación G1."
}
}
]
},
{
"code": "X1.1",
"section": "annexe",
"position": 20,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Dépôt de référence",
"en": "Reference repository",
"es": "Repositorio de referencia"
},
"body": {
"fr": "Le texte de référence de l'acte d'engagement Certification est hébergé dans le dépôt git officiel : https://git.duniter.org/documents/g1_monetary_license\n\nLes applications Cesium, Gecko et toute application de Certification doivent pointer vers ce dépôt pour afficher la version en vigueur.",
"en": "The reference text of the Certification commitment act is hosted in the official git repository: https://git.duniter.org/documents/g1_monetary_license\n\nThe Cesium and Gecko applications, and any Certification application, must point to this repository to display the version in force.",
"es": "El texto de referencia del acta de compromiso de Certificación está alojado en el repositorio git oficial: https://git.duniter.org/documents/g1_monetary_license\n\nLas aplicaciones Cesium, Gecko y cualquier aplicación de Certificación deben apuntar a este repositorio para mostrar la versión vigente."
}
}
]
},
{
"code": "X1.2",
"section": "annexe",
"position": 21,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Obligation de transmission",
"en": "Transmission obligation",
"es": "Obligación de transmisión"
},
"body": {
"fr": "Tout logiciel G1 permettant la Certification doit transmettre le présent acte d'engagement au certifié et en afficher les paramètres du bloc 0 de la blockchain Duniter.",
"en": "Any G1 software that enables Certification must transmit this commitment act to the certified person and display the block 0 parameters of the Duniter blockchain.",
"es": "Todo software G1 que permita la Certificación debe transmitir la presente acta de compromiso a la persona certificada y mostrar los parámetros del bloque 0 de la blockchain Duniter."
}
}
]
},
{
"code": "X1.3",
"section": "annexe",
"position": 22,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Logiciel auditable",
"en": "Auditable software",
"es": "Software auditable"
},
"body": {
"fr": "Tout logiciel utilisé pour la Certification ou la création monétaire doit être publié sous licence libre, afin de permettre son audit par la communauté.",
"en": "Any software used for Certification or money creation must be published under a free licence, so that it can be audited by the community.",
"es": "Todo software utilizado para la Certificación o la creación monetaria debe publicarse bajo licencia libre, para permitir su auditoría por parte de la comunidad."
}
}
]
},
{
"code": "X2",
"section": "annexe",
"position": 23,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Annexe 2 : Questions à la Certification",
"en": "Annex 2: Questions for Certification",
"es": "Anexo 2: Preguntas para la Certificación"
},
"body": {
"fr": "Liste de questions à présenter dans les logiciels de Certification.Inspirée de la checklist de la Charte 1.0 et des discussions forum (topic 32412).",
"en": "List of questions to be presented in Certification software. Inspired by the checklist of Charter 1.0 and by forum discussions (topic 32412).",
"es": "Lista de preguntas que deben presentarse en el software de Certificación. Inspirada en la lista de verificación de la Carta 1.0 y en los debates del foro (tema 32412)."
}
}
]
},
{
"code": "X2.1",
"section": "annexe",
"position": 24,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Questions piège (réponse attendue : NON)",
"en": "Trap questions (expected answer: NO)",
"es": "Preguntas trampa (respuesta esperada: NO)"
},
"body": {
"fr": "**Si OUI à l'une de ces questions, la Certification doit être refusée.**\n\n- La personne m'a contacté uniquement pour obtenir ma Certification\n- Je certifie sous la pression ou pour faire plaisir\n- Je n'ai aucun moyen de vérifier l'identité de la personne\n- La personne refuse de me fournir plusieurs moyens de contact\n- Je soupçonne que la personne possède déjà un compte membre\n- Je ne connais aucun de ses autres certifieurs\n- La personne ne comprend pas ce qu'est la monnaie libre\n- La personne n'a pas lu le présent acte d'engagement",
"en": "**If YES to any of these questions, the Certification must be refused.**\n\n- The person contacted me only to obtain my Certification\n- I am certifying under pressure or to please someone\n- I have no way of verifying the person's identity\n- The person refuses to give me several means of contact\n- I suspect that the person already has a member account\n- I do not know any of their other certifiers\n- The person does not understand what libre currency is\n- The person has not read this commitment act",
"es": "**Si la respuesta es SÍ a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- La persona me ha contactado únicamente para obtener mi Certificación\n- Certifico bajo presión o por complacer\n- No tengo ningún medio para verificar la identidad de la persona\n- La persona se niega a proporcionarme varios medios de contacto\n- Sospecho que la persona ya posee una cuenta de miembro\n- No conozco a ninguno de sus otros certificadores\n- La persona no entiende qué es la moneda libre\n- La persona no ha leído la presente acta de compromiso"
}
}
]
},
{
"code": "X2.2",
"section": "annexe",
"position": 25,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Questions identité (réponse attendue : OUI)",
"en": "Identity questions (expected answer: YES)",
"es": "Preguntas de identidad (respuesta esperada: SÍ)"
},
"body": {
"fr": "**Si NON à l'une de ces questions, la Certification doit être refusée.**\n\n- Je connais cette personne suffisamment pour la recontacter\n- J'ai vérifié personnellement le lien personne / clé publique\n- La personne est une personne physique vivante (pas une entité morale)\n- Je pourrais reconnaître cette personne si je la croisais\n- J'ai au moins 2 moyens de contact différents pour cette personne",
"en": "**If NO to any of these questions, the Certification must be refused.**\n\n- I know this person well enough to get back in touch with them\n- I have personally verified the person / public key link\n- The person is a living natural person (not a legal entity)\n- I would recognise this person if I came across them\n- I have at least 2 different means of contact for this person",
"es": "**Si la respuesta es NO a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- Conozco a esta persona suficientemente para volver a contactarla\n- He verificado personalmente el vínculo persona / clave pública\n- La persona es una persona física viva (no una persona jurídica)\n- Reconocería a esta persona si me la cruzara\n- Tengo al menos 2 medios de contacto diferentes para esta persona"
}
}
]
},
{
"code": "X2.3",
"section": "annexe",
"position": 26,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Questions sécurité (réponse attendue : OUI)",
"en": "Security questions (expected answer: YES)",
"es": "Preguntas de seguridad (respuesta esperada: SÍ)"
},
"body": {
"fr": "**Si NON, la Certification est déconseillée.**\n\n- La personne a généré son document de révocation\n- La personne maîtrise effectivement son compte (test de transfert effectué)\n- La personne sait où retrouver le présent acte d'engagement dans sa version à jour",
"en": "**If NO, the Certification is not recommended.**\n\n- The person has generated their revocation document\n- The person actually controls their account (test transfer carried out)\n- The person knows where to find the current version of this commitment act",
"es": "**Si la respuesta es NO, se desaconseja la Certificación.**\n\n- La persona ha generado su documento de revocación\n- La persona controla efectivamente su cuenta (transferencia de prueba realizada)\n- La persona sabe dónde encontrar la versión vigente de la presente acta de compromiso"
}
}
]
},
{
"code": "F1",
"section": "formule",
"position": 27,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Formule de vote",
"en": "Voting formula",
"es": "Fórmula de votación"
},
"body": {
"fr": "La formule qui régit l'adoption ou le rejet de chaque modification du présent document. Référence : g1vote (https://g1vote-view-237903.pages.duniter.org/).",
"en": "The formula that governs the adoption or rejection of each amendment to this document. Reference: g1vote (https://g1vote-view-237903.pages.duniter.org/).",
"es": "La fórmula que rige la adopción o el rechazo de cada modificación del presente documento. Referencia: g1vote (https://g1vote-view-237903.pages.duniter.org/)."
}
}
]
},
{
"code": "F1.1",
"section": "formule",
"position": 28,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Formule du seuil WoT",
"en": "WoT threshold formula",
"es": "Fórmula del umbral WoT"
},
"body": {
"fr": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nOù :\n- **C** = constante de base (plancher fixe)\n- **B** = exposant de base (B^W tend vers 0)\n- **W** = taille de la toile de confiance (électeurs éligibles)\n- **T** = total des votes exprimés (pour + contre)\n- **M** = ratio de majorité cible (M = majority_pct / 100)\n- **G** = exposant du gradient d'inertie\n\n**Comportement :** Faible participation → quasi-unanimité requise. Forte participation → majorité simple M suffit.",
"en": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nWhere:\n- **C** = base constant (fixed floor)\n- **B** = base exponent (B^W tends towards 0)\n- **W** = size of the web of trust (eligible voters)\n- **T** = total votes cast (for + against)\n- **M** = target majority ratio (M = majority_pct / 100)\n- **G** = inertia gradient exponent\n\n**Behaviour:** Low participation → near-unanimity required. High participation → simple majority M is enough.",
"es": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nDonde:\n- **C** = constante de base (mínimo fijo)\n- **B** = exponente de base (B^W tiende a 0)\n- **W** = tamaño de la red de confianza (votantes habilitados)\n- **T** = total de votos emitidos (a favor + en contra)\n- **M** = proporción de mayoría objetivo (M = majority_pct / 100)\n- **G** = exponente del gradiente de inercia\n\n**Comportamiento:** Baja participación → se requiere casi unanimidad. Alta participación → basta la mayoría simple M."
}
}
]
},
{
"code": "F1.2",
"section": "formule",
"position": 29,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Paramètres par défaut",
"en": "Default parameters",
"es": "Parámetros por defecto"
},
"body": {
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.",
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.\n- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.",
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3."
}
}
]
},
{
"code": "F1.3",
"section": "formule",
"position": 30,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Processus de dépôt officiel",
"en": "Official filing process",
"es": "Proceso de depósito oficial"
},
"body": {
"fr": "Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n1. Le texte de remplacement est intégré au document officiel\n2. Le hash IPFS du document mis à jour est calculé\n3. Le hash est ancré on-chain via `system.remark`\n4. Le dépôt git officiel est synchronisé\n5. Les applications (Cesium, Gecko) mettent à jour automatiquement",
"en": "When an alternative proposal reaches the adoption threshold:\n\n1. The replacement text is incorporated into the official document\n2. The IPFS hash of the updated document is computed\n3. The hash is anchored on-chain via `system.remark`\n4. The official git repository is synchronised\n5. The applications (Cesium, Gecko) update automatically",
"es": "Cuando una propuesta alternativa alcanza el umbral de adopción:\n\n1. El texto de sustitución se integra en el documento oficial\n2. Se calcula el hash IPFS del documento actualizado\n3. El hash se ancla on-chain mediante `system.remark`\n4. Se sincroniza el repositorio git oficial\n5. Las aplicaciones (Cesium, Gecko) se actualizan automáticamente"
}
}
]
},
{
"code": "N1",
"section": "inertie",
"position": 31,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Réglage de l'inertie",
"en": "Inertia setting",
"es": "Ajuste de la inercia"
},
"body": {
"fr": "Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.\n\nTout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.",
"en": "Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.\n\nAny member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.",
"es": "Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.\n\nCualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4."
}
}
]
},
{
"code": "N1.1",
"section": "inertie",
"position": 32,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Niveaux d'inertie",
"en": "Inertia levels",
"es": "Niveles de inercia"
},
"body": {
"fr": "À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T/W)^G)`\n\n**Les cinq crans** : la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.\n- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.",
"en": "At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T/W)^G)`\n\n**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.\n- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.\n- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.",
"es": "En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`\n\n**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.\n- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.\n- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103."
}
}
]
},
{
"code": "O1",
"section": "ordonnancement",
"position": 33,
"inertia": 3,
"versions": [
{
"label": "1.0.0",
"status": "open",
"title": {
"fr": "Ordonnancement du document",
"en": "Ordering of the document",
"es": "Ordenación del documento"
},
"body": {
"fr": "L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.\n\nUn nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.\n\nLe titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.",
"en": "The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.\n\nA new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.\n\nThe title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.",
"es": "El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.\n\nUn artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.\n\nEl título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto."
}
}
]
}
]
}
+84 -41
View File
@@ -1,13 +1,15 @@
//! Inertia in five levels (ADR 0007): the levels and the scale served, the
//! usual participation of a group, the change of inertia voted at the level
//! of its article, the migration of the four words of before, and the
//! amendment that raises E1 and E2 to level 4 in an existing base.
//! amendment that raised E1 and E2 to level 4 in an existing base (30/10; since
//! 2026-11-02 the seed puts them forward at a low level, ADR 0011: the scenarios
//! of 30/10 are replayed on the seed production ran then).
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;
@@ -90,6 +92,17 @@ fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
/// 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")
}
fn numbers(levels: &Value, key: &str) -> Vec<f64> {
levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect()
}
@@ -99,16 +112,16 @@ async fn the_five_levels_and_the_scale_are_served() {
let (mut c, _) = Client::new().await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// 3 everywhere, 4 for the two structural commitments.
let raised: Vec<&str> = doc["units"]
// 3 everywhere, low for the three seed proposals (ADR 0011).
let other: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| u["inertia"] != 3)
.map(|u| u["code"].as_str().expect("code"))
.collect();
assert_eq!(raised, ["E1", "E2"]);
assert!(unit(&doc, "E1")["inertia"] == 4 && unit(&doc, "E2")["inertia"] == 4);
assert_eq!(other, ["I1", "E1", "E2"]);
assert!(["I1", "E1", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW));
// No vote yet: the reference, 2C/W = 100 voters out of 5745.
let p = &doc["doc"]["participation"];
@@ -127,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")["inForce"]["id"].as_str().expect("e1").to_owned();
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"], 4);
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");
}
@@ -160,25 +177,28 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled(
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(bad.clone()))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("inertia")), "{bad}");
}
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(4)))).await;
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(LOW)))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("same_inertia")));
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(1)))).await;
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(5)))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
let d = a.get(&format!("/api/proposals/{p}")).await;
assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(1), Some(4)));
// W(essai) = 2: both vote for, the article goes to level 1.
a.vote(&p, "for").await;
let v = b.vote(&p, "for").await;
assert_eq!(
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
(Some(5), Some(u64::from(LOW)))
);
// 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");
assert_eq!(e2u["inertia"], 1);
assert_eq!(e2u["inertia"], 5);
let changes = e2u["inertiaChanges"].as_array().expect("changes");
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(4), Some(1), Some("vote"))
(Some(u64::from(LOW)), Some(5), Some("vote"))
);
// The adoption keeps the level it was judged at, and the scale.
let params: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = ?")
@@ -187,15 +207,17 @@ 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, 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(4), Some(60.0), Some(83.0))
(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"))
);
assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(4), 0.5)).abs() < 1e-12));
assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(LOW), 0.5)).abs() < 1e-12));
}
#[tokio::test]
@@ -240,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;
@@ -358,9 +380,9 @@ async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() {
// The base as production holds it on 2026-10-30, then the new version
// ---------------------------------------------------------------------------
/// The seed as it was then: no modules yet (ADR 0009 came after).
/// The seed as it was then: no modules yet (ADR 0009 came after), I1, E1 and E2 in force.
fn seed_of_the_time(tag: &str) -> std::path::PathBuf {
let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
let today = production_seed_dir().join("acte-engagement-certification.json");
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
seed.as_object_mut().expect("object").remove("modules");
let dir = std::env::temp_dir().join(format!("ld-levels-seed-{tag}-{}", std::process::id()));
@@ -397,8 +419,11 @@ async fn start_new_version(pool: &SqlitePool) -> AppState {
restart(pool).await
}
/// Started again with the seed production ran after 30/10 (E1 and E2 at 4).
async fn restart(pool: &SqlitePool) -> AppState {
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let mut cfg = Config::for_tests();
cfg.seed_dir = production_seed_dir();
let state = AppState::new(cfg, pool.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
@@ -449,11 +474,26 @@ fn decided(rows: &[Decision]) -> Vec<(&str, &str, &str, Option<&str>)> {
rows.iter().map(|(g, u, o, r)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref())).collect()
}
/// Since 2026-11-02 the seed gives E1 and E2 the low level of seed proposals: the
/// raise of 30/10 is superseded, decided in production and nowhere else.
#[tokio::test]
async fn a_new_base_starts_with_e1_and_e2_at_level_4() {
let (_, state) = Client::new().await;
async fn a_new_base_starts_low_and_the_raise_of_30_10_decides_nothing() {
let (mut c, state) = Client::new().await;
assert_eq!(decided(&inertia_decisions(&state.db).await), []);
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n");
assert_eq!(changes, 0, "born low: nothing changed");
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
assert!(["E1", "E2"].iter().all(|code| unit(&doc, code)["inertia"] == LOW));
// On the seed of production, the raise is still there to decide: present at birth.
let mut cfg = Config::for_tests();
cfg.seed_dir = production_seed_dir();
let db = open_db("sqlite::memory:").await.expect("db");
let born = AppState::new(cfg, db).await.expect("state");
seed::ensure(&born).await.expect("seed");
assert_eq!(
decided(&inertia_decisions(&state.db).await),
decided(&inertia_decisions(&born.db).await),
[
("essai", "E1", "present", None),
("essai", "E2", "present", None),
@@ -461,9 +501,6 @@ async fn a_new_base_starts_with_e1_and_e2_at_level_4() {
("monnaie-libre", "E2", "present", None),
]
);
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n");
assert_eq!(changes, 0, "born at 4: nothing changed");
}
#[tokio::test]
@@ -594,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
@@ -637,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!(
+39 -9
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;
@@ -183,14 +198,16 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() {
let id = doc["doc"]["id"].as_str().expect("id");
let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(all["units"].as_array().expect("units").len(), 33);
// The list of the group: the articles in force; no module has an open proposal yet.
// The list of the group: the articles in force — none, I1, E1 and E2 are seed proposals
// (ADR 0011), three more open versions —; no module has an open proposal yet.
let g = c.ok("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(3), Some(32)));
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35)));
}
#[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();
@@ -240,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();
@@ -250,7 +269,8 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
.expect("id")
.to_owned();
yvv.vote(&p, "for").await;
// The list of the group counts it among the proposals in progress, never among the articles.
// The list of the group counts it among the proposals in progress, never among the articles
// (none in force: I1, E1 and E2 are seed proposals, ADR 0011).
let g = yvv.ok("GET", "/api/groups/essai", None).await;
let before = doc["units"]
.as_array()
@@ -259,7 +279,7 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|x| x["status"] == "open").count() as u64);
assert_eq!(
(g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()),
(Some(3), Some(before.sum::<u64>() + 1))
(Some(0), Some(before.sum::<u64>() + 1))
);
// A new article enters the section: at its end, and at the end of the order under vote.
let created = chiara
@@ -398,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");
@@ -492,8 +513,17 @@ async fn a_new_article_goes_at_the_end_of_the_section_asked_for() {
// A base born without modules, as production holds it, then the new version
// ---------------------------------------------------------------------------
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a
/// minima. Since that day the seed puts them forward as seed proposals (ADR 0011,
/// tests/seed_proposals.rs); the addition of the modules is replayed on the seed of then.
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
}
fn seed_without_modules(tag: &str) -> std::path::PathBuf {
let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
let today = production_config().seed_dir.join("acte-engagement-certification.json");
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
seed.as_object_mut().expect("object").remove("modules");
let dir = std::env::temp_dir().join(format!("ld-modules-seed-{tag}-{}", std::process::id()));
@@ -554,7 +584,7 @@ async fn an_existing_document_receives_its_modules_once_from_what_it_holds() {
let reads = read_order(&c.doc("essai").await, "conclusion");
// The new version starts: each document receives its modules, from what it holds.
let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state");
let state = AppState::new(production_config(), before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
let rows: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT d.group_slug, m.outcome, m.reason, m.created FROM module_amendments m JOIN documents d ON d.id = m.doc_id
@@ -619,7 +649,7 @@ async fn an_existing_document_receives_its_modules_once_from_what_it_holds() {
assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"]));
// Once only: the next start adds nothing.
let again = AppState::new(Config::for_tests(), state.db.clone()).await.expect("state");
let again = AppState::new(production_config(), state.db.clone()).await.expect("state");
seed::ensure(&again).await.expect("seed");
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n");
+14 -4
View File
@@ -9,15 +9,25 @@ use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, app, deadli
use serde_json::{Value, json};
use tower::ServiceExt;
/// The seed of production before its seed proposal amendment (ADR 0011): texts in force.
fn production() -> 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
}
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
/// These scenarios need texts in force; the seed of today has none.
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
let state = AppState::new(production(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
}
@@ -670,7 +680,7 @@ async fn the_same_objection_sent_at_once_stands_once() {
let path = std::env::temp_dir().join(format!("ld-lot-f-twice-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = open_db(&format!("sqlite://{}", path.display())).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
let state = AppState::new(production(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let mut chiara = Client { router: app(state.clone()), cookie: None };
chiara.login("Chiara", false).await;
@@ -719,7 +729,7 @@ async fn a_deadline_passed_while_stopped_is_taken_at_start() {
let url = format!("sqlite://{}", path.display());
let (b, oid) = {
let db = open_db(&url).await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
let state = AppState::new(production(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let mut chiara = Client { router: app(state.clone()), cookie: None };
chiara.login("Chiara", false).await;
@@ -776,7 +786,7 @@ async fn contradiction_signals_of_before_become_contredits() {
let mut before = sqlx::migrate!("./migrations");
before.migrations = before.migrations.iter().filter(|m| m.version < 8).cloned().collect::<Vec<_>>().into();
before.run(&pool).await.expect("up to 0007");
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let state = AppState::new(production(), pool.clone()).await.expect("state");
// The documents as the image of before wrote them (its amendments read tables of now).
seed::documents(&state).await.expect("seed");
let id = |code: &'static str, which: &'static str| {
+38 -13
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,28 +158,33 @@ 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");
assert_eq!(docs.len(), 1);
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
// 3 seeds a minima + 30 open articles, the two variants counting once; the title of the
// document and the order of 7 sections (« ordonnancement » holds one article: nothing to decide).
// 3 seed proposals (ADR 0011) + 30 open articles, the two variants counting once; the title
// of the document and the order of 7 sections (« ordonnancement » holds one article: nothing
// to decide).
assert_eq!(docs[0]["toVote"], 41);
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", "inForce"), "for").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);
}
@@ -170,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)));
@@ -239,15 +263,16 @@ async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
}
#[tokio::test]
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
async fn while_the_chain_is_closed_a_guest_votes_in_simulation_and_has_a_count_of_ones_own() {
let mut admin = Client::new().await;
admin.login("Yvv", true).await;
let mut guest = admin.other();
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
// No vote account: Monnaie libre votes in simulation, for whoever opens it (ADR 0012).
let d = decisions(&mut guest, "monnaie-libre").await;
assert_eq!(d["canVote"], false);
assert_eq!(d["canVote"], true);
assert_eq!(d["docs"][0]["toVote"], 41);
assert!(d["docs"][0]["withoutMyVote"].is_null());
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
// In the test group, the same person votes.
let d = decisions(&mut guest, "essai").await;
assert_eq!(d["canVote"], true);
+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]"));
}
+709
View File
@@ -0,0 +1,709 @@
//! Propositions seed (Yvv, 2026-11-02, ADR 0011): the three first modules of the
//! certification act — I1 « Raison d'être », E1 Unicité du compte, E2 Réciprocité —
//! keep a status of their own to start the vote, but they are not in force. A new
//! base is born with them open; an existing base gets them through a guarded
//! amendment, only where nobody has touched the text. Monnaie libre publishes no
//! document without any article in force: the queue waits for the first text.
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;
const CODES: [&str; 3] = ["I1", "E1", "E2"];
/// The level seed proposals start at (Yvv, 2026-11-02: « une inertie basse »).
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
const GROUPS: [&str; 2] = ["essai", "monnaie-libre"];
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 get(&mut self, uri: &str) -> Value {
let (s, v) = self.call("GET", uri, None).await;
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
v
}
async fn login(&mut self, name: &str, admin: bool) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
let (s, v) =
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v
}
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
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
/// Every version shown of the document: in force, open, of articles and modules.
fn versions(doc: &Value) -> Vec<&Value> {
let m = &doc["modules"];
doc["units"]
.as_array()
.expect("units")
.iter()
.chain(std::iter::once(&m["title"]))
.chain(m["orders"].as_array().expect("orders").iter())
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
.filter(|v| !v.is_null())
.collect()
}
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 a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() {
let state = fresh().await;
let mut c = Client::of(&state);
c.login("Chiara", false).await;
for group in GROUPS {
let doc = c.doc(group).await;
let in_force: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| !u["inForce"].is_null())
.filter_map(|u| u["code"].as_str())
.collect();
assert!(in_force.is_empty(), "{group}: no article in force, {in_force:?}");
for code in CODES {
let u = unit(&doc, code);
let p = &u["proposals"][0];
assert_eq!(
(p["status"].as_str(), p["origin"].as_str(), p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()),
(Some("open"), Some("seed"), Some(true), Some(false)),
"{group} {code}"
);
assert_eq!((p["revision"].as_i64(), p["tally"]["for"].as_u64()), (Some(1), Some(0)), "{group} {code}");
assert_eq!(p["votable"], true, "{group} {code}");
assert_eq!(u["inertia"], LOW, "{group} {code}: a low level to start");
assert!(p["translations"]["en"]["title"].is_string(), "{group} {code}: translated");
}
assert_eq!(unit(&doc, "I1")["proposals"][0]["title"], "Raison d'être");
// Only those three; the title and the orders stay seeds a minima, in force.
let marked: Vec<&Value> = versions(&doc).into_iter().filter(|v| v["seedProposal"] == true).collect();
assert_eq!(marked.len(), 3, "{group}");
let title = &doc["modules"]["title"]["inForce"];
assert_eq!((title["seedMinimal"].as_bool(), title["seedProposal"].as_bool()), (Some(true), Some(false)));
assert!(
doc["modules"]["orders"].as_array().expect("orders").iter().all(|o| o["inForce"]["seedMinimal"] == true)
);
// The counts: no article in force, three more open versions; still 41 modules to vote.
let g = c.get(&format!("/api/groups/{group}")).await;
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35)), "{group}");
let d = c.get(&format!("/api/groups/{group}/decisions")).await;
assert_eq!(d["docs"][0]["toVote"], 41, "{group}");
// The preview of the text in force is empty; « if everything passes » shows them.
let id = doc["doc"]["id"].as_str().expect("id");
let p = c.get(&format!("/api/docs/{id}/preview")).await;
assert_eq!(p["units"], json!([]), "{group}");
let all = c.get(&format!("/api/docs/{id}/preview?mode=all")).await;
assert!(all["units"].as_array().expect("units").iter().any(|u| u["title"] == "Raison d'être"));
}
// Born with them: nothing to amend, and it is said once per article and group.
let decided: Vec<(String, String, String)> = sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome FROM seed_proposal_amendments s
JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code",
)
.fetch_all(&state.db)
.await
.expect("decisions");
assert_eq!(decided.len(), 6, "{decided:?}");
assert!(decided.iter().all(|(_, _, o)| o == "present"), "{decided:?}");
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("changes");
assert_eq!(changes, 0, "born at the low level: nothing changed");
// The Raison d'être of a new base is still the text its own amendment names.
let i1: Vec<String> = sqlx::query_scalar(
"SELECT outcome FROM seed_amendments WHERE amendment_id = '2026-10-30-i1-raison-d-etre' ORDER BY doc_id",
)
.fetch_all(&state.db)
.await
.expect("i1");
assert_eq!(i1, ["present", "present"]);
}
// ---------------------------------------------------------------------------
// A base made like production (02/11): I1 at revision 2 (the Préambule, then the
// « Raison d'être » amendment of 30/10), E1 and E2 at revision 1, all three in
// force as seeds a minima, in both groups.
// ---------------------------------------------------------------------------
const PREAMBULE: [(&str, &str, &str); 3] = [
(
"fr",
"Préambule",
"Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
),
(
"en",
"Preamble",
"This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
),
(
"es",
"Preámbulo",
"La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado.",
),
];
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a
/// minima, I1 and E1 at level 3 and 4.
fn production_seed() -> Value {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.json");
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
}
/// The seed of production, with I1 the Préambule as before 2026-10-30.
fn seed_dir(tag: &str, preambule: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ld-seed-proposals-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let mut seed = production_seed();
if preambule {
let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1");
for (lang, title, body) in PREAMBULE {
i1["versions"][0]["title"][lang] = json!(title);
i1["versions"][0]["body"][lang] = json!(body);
}
}
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// Production as of 2026-11-02: born with the Préambule, then started with the seed it
/// runs (its amendments: I1 becomes the Raison d'être).
async fn production(tag: &str, cfg: Config) -> AppState {
let mut first = cfg.clone();
first.seed_dir = seed_dir(&format!("{tag}-first"), true);
let db = open_db("sqlite::memory:").await.expect("db");
let born = AppState::new(first, db).await.expect("state");
seed::documents(&born).await.expect("first seed");
let mut then = cfg;
then.seed_dir = seed_dir(&format!("{tag}-then"), false);
let state = AppState::new(then, born.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed of production");
state
}
/// The same base, started again with today's seed (and its amendments).
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
}
#[derive(Debug, PartialEq, sqlx::FromRow)]
struct Version {
id: String,
status: String,
revision: i64,
title: String,
body: String,
in_force: bool,
}
async fn version(state: &AppState, group: &str, code: &str) -> Version {
sqlx::query_as(
"SELECT p.id, p.status, p.revision, p.title, p.body, IFNULL(u.in_force_id = p.id, 0) AS in_force
FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = ? AND u.code = ? AND p.origin = 'seed' AND p.kind = 'text'",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("version")
}
async fn kinds(state: &AppState, id: &str) -> Vec<String> {
sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
.bind(id)
.fetch_all(&state.db)
.await
.expect("revisions")
}
/// Each decision on the seed proposals: (group, unit, outcome, reason, level outcome,
/// level reason, at).
type Decision = (String, String, String, Option<String>, String, Option<String>, String);
async fn decisions(state: &AppState) -> Vec<Decision> {
sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome, s.reason, s.level_outcome, s.level_reason, s.at
FROM seed_proposal_amendments s JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code",
)
.fetch_all(&state.db)
.await
.expect("decisions")
}
type Outcome<'a> = (&'a str, &'a str, &'a str, Option<&'a str>, &'a str, Option<&'a str>);
fn outcomes(rows: &[Decision]) -> Vec<Outcome<'_>> {
rows.iter()
.map(|(g, u, o, r, lo, lr, _)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref(), lo.as_str(), lr.as_deref()))
.collect()
}
async fn unit_id(state: &AppState, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("unit")
}
async fn level_of(state: &AppState, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.inertia FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("level")
}
/// Production on 2026-10-30 (orch): open changes of inertia at zero votes, I1 → 5 and
/// E1 → 5. Written here in Monnaie libre only, so both answers of the rule are seen.
async fn open_inertia_proposals(state: &AppState) {
let mut c = Client::of(state);
c.login("Oscar", false).await;
for code in ["I1", "E1"] {
let u = unit_id(state, "monnaie-libre", code).await;
let (s, v) = c.call("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
}
#[tokio::test]
async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once() {
let before = production("applied", Config::for_tests()).await;
open_inertia_proposals(&before).await;
// As production holds them.
let mut kept = Vec::new();
for group in GROUPS {
for code in CODES {
let v = version(&before, group, code).await;
assert_eq!((v.status.as_str(), v.in_force), ("in_force", true), "{group} {code}");
let expected: &[&str] = if code == "I1" { &["initial", "amendment"] } else { &["initial"] };
assert_eq!(kinds(&before, &v.id).await, expected, "{group} {code}");
assert_eq!(v.revision, if code == "I1" { 2 } else { 1 }, "{group} {code}");
assert_eq!(level_of(&before, group, code).await, if code == "I1" { "3" } else { "4" });
kept.push(v);
}
}
let translations = |state: AppState| async move {
sqlx::query_as::<_, (String, String, String, String, String)>(
"SELECT proposal_id, lang, title, body, source_hash FROM translations ORDER BY proposal_id, lang",
)
.fetch_all(&state.db)
.await
.expect("translations")
};
let translated = translations(before.clone()).await;
let state = restart(&before, Config::for_tests()).await;
let first = decisions(&state).await;
// Every text becomes a seed proposal; the level goes low where nobody touched it:
// an open change of inertia by a member keeps it (I1 and E1 in Monnaie libre).
assert_eq!(
outcomes(&first),
[
("essai", "E1", "applied", None, "applied", None),
("essai", "E2", "applied", None, "applied", None),
("essai", "I1", "applied", None, "applied", None),
("monnaie-libre", "E1", "applied", None, "refused", Some("inertia_proposal")),
("monnaie-libre", "E2", "applied", None, "applied", None),
("monnaie-libre", "I1", "applied", None, "refused", Some("inertia_proposal")),
]
);
for (i, group) in GROUPS.iter().enumerate() {
for (j, code) in CODES.iter().enumerate() {
let was = &kept[i * CODES.len() + j];
let v = version(&state, group, code).await;
// The same version, its text and revision kept: open now, and not in force.
assert_eq!(v.id, was.id, "{group} {code}");
assert_eq!((v.status.as_str(), v.in_force), ("open", false), "{group} {code}");
assert_eq!((&v.title, &v.body, v.revision), (&was.title, &was.body, was.revision), "{group} {code}");
let featured: (i64, Option<String>, Option<String>) =
sqlx::query_as("SELECT seed_featured, entered_at, confirmed_at FROM proposals WHERE id = ?")
.bind(&v.id)
.fetch_one(&state.db)
.await
.expect("mark");
assert_eq!(featured, (1, None, None), "{group} {code}");
// Its history says the change of status, after what it had.
let mut expected: Vec<&str> = if *code == "I1" { vec!["initial", "amendment"] } else { vec!["initial"] };
expected.push("seed_proposal");
assert_eq!(kinds(&state, &v.id).await, expected, "{group} {code}");
let in_force: Option<String> = sqlx::query_scalar("SELECT in_force_id FROM units WHERE id = ?")
.bind(unit_id(&state, group, code).await)
.fetch_one(&state.db)
.await
.expect("unit");
assert_eq!(in_force, None, "{group} {code}");
let held = *group == "monnaie-libre" && *code != "E2";
let level = match (held, *code) {
(true, "I1") => "3".to_owned(),
(true, _) => "4".to_owned(),
_ => LOW.to_string(),
};
assert_eq!(level_of(&state, group, code).await, level, "{group} {code}");
}
}
// Translations untouched.
assert_eq!(translations(state.clone()).await, translated);
// The article says its new level, as a starting setting of the text.
let changes: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT d.group_slug, u.code, c.from_level, c.to_level, c.amendment_id FROM inertia_changes c
JOIN units u ON u.id = c.unit_id JOIN documents d ON d.id = c.doc_id
WHERE c.cause = 'amendment' ORDER BY d.group_slug, u.code",
)
.fetch_all(&state.db)
.await
.expect("changes");
let line = |g: &str, c: &str, from: &str| {
let id = format!("2026-11-02-{}-proposition-seed", c.to_lowercase());
(g.to_owned(), c.to_owned(), from.to_owned(), LOW.to_string(), Some(id))
};
assert_eq!(
changes,
[
line("essai", "E1", "4"),
line("essai", "E2", "4"),
line("essai", "I1", "3"),
line("monnaie-libre", "E2", "4")
]
);
let mut c = Client::of(&state);
c.login("Chiara", false).await;
for group in GROUPS {
let doc = c.doc(group).await;
for code in CODES {
let p = &unit(&doc, code)["proposals"][0];
assert_eq!(
(p["seedProposal"].as_bool(), p["status"].as_str()),
(Some(true), Some("open")),
"{group} {code}"
);
assert!(unit(&doc, code)["inForce"].is_null());
let (_, d) = c.call("GET", &format!("/api/proposals/{}", p["id"].as_str().expect("id")), None).await;
let last = d["history"].as_array().expect("history").last().cloned().expect("a line");
assert_eq!((last["kind"].as_str(), last["author"].as_str()), (Some("seed_proposal"), None), "{d}");
assert!(last["diff"].is_null(), "a change of status changes no text");
}
let e2 = unit(&doc, "E2")["inertiaChanges"].as_array().expect("changes").clone();
assert_eq!(
(e2[0]["from"].as_u64(), e2[0]["to"].as_u64(), e2[0]["cause"].as_str()),
(Some(4), Some(u64::from(LOW)), Some("amendment"))
);
}
// Monnaie libre republishes, once per article; the test group never.
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
WHERE o.reason LIKE 'Proposition seed%' ORDER BY o.id",
)
.fetch_all(&state.db)
.await
.expect("outbox");
assert_eq!(
queued,
CODES
.iter()
.map(|c| ("monnaie-libre".to_owned(), format!("Proposition seed : {c} sort de la vigueur")))
.collect::<Vec<_>>()
);
// Decided once: a second start changes nothing.
let again = restart(&state, Config::for_tests()).await;
assert_eq!(decisions(&again).await, first);
for group in GROUPS {
for code in CODES {
let v = version(&again, group, code).await;
assert_eq!(kinds(&again, &v.id).await.iter().filter(|k| *k == "seed_proposal").count(), 1);
}
}
let n: (i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM outbox WHERE reason LIKE 'Proposition seed%'),
(SELECT COUNT(*) FROM inertia_changes WHERE cause = 'amendment')",
)
.fetch_one(&again.db)
.await
.expect("counts");
assert_eq!(n, (3, 4));
}
#[tokio::test]
async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() {
let before = production("refused", Config::for_tests()).await;
// In the test group someone voted against E1: there it stays in force, at its level.
let mut c = Client::of(&before);
c.login("Vera", false).await;
let e1 = version(&before, "essai", "E1").await;
let v = c.vote(&e1.id, "against").await;
assert!(v["entered"].as_array().expect("entered").is_empty());
let state = restart(&before, Config::for_tests()).await;
let first = decisions(&state).await;
assert_eq!(
outcomes(&first),
[
("essai", "E1", "refused", Some("votes"), "refused", Some("not_a_seed_proposal")),
("essai", "E2", "applied", None, "applied", None),
("essai", "I1", "applied", None, "applied", None),
("monnaie-libre", "E1", "applied", None, "applied", None),
("monnaie-libre", "E2", "applied", None, "applied", None),
("monnaie-libre", "I1", "applied", None, "applied", None),
]
);
let kept = version(&state, "essai", "E1").await;
assert_eq!((kept.status.as_str(), kept.in_force, kept.revision), ("in_force", true, 1));
assert_eq!(kinds(&state, &kept.id).await, ["initial"], "no line: nothing changed");
assert_eq!(level_of(&state, "essai", "E1").await, "4");
let mut c = Client::of(&state);
c.login("Vera", false).await;
let doc = c.doc("essai").await;
let e1 = &unit(&doc, "E1")["inForce"];
assert_eq!((e1["seedMinimal"].as_bool(), e1["seedProposal"].as_bool()), (Some(true), Some(false)));
// Decided once: the vote withdrawn, a later start does not look again.
let (s, v) = c.call("POST", &format!("/api/proposals/{}/vote", kept.id), Some(json!({ "choice": null }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let again = restart(&state, Config::for_tests()).await;
assert_eq!(decisions(&again).await, first);
assert!(version(&again, "essai", "E1").await.in_force);
}
// ---------------------------------------------------------------------------
// Publication: no document without any article in force (default kept while Yvv decides)
// ---------------------------------------------------------------------------
struct Remote {
dir: std::path::PathBuf,
bare: std::path::PathBuf,
}
impl Remote {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("ld-seed-proposals-pub-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let bare = dir.join("textes.git");
let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git");
assert!(ok.success());
Self { dir, bare }
}
fn config(&self, without_articles: bool) -> Config {
let mut cfg = Config::for_tests();
cfg.data_dir = self.dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: self.bare.display().to_string(),
branch: "main".into(),
key: self.dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles,
});
cfg
}
fn show(&self, file: &str) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&self.bare)
.args(["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")])
.output()
.expect("git");
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
}
}
impl Drop for Remote {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
async fn outbox(state: &AppState) -> Vec<(String, i64, Option<String>, Option<String>)> {
sqlx::query_as("SELECT reason, attempts, last_error, done_at FROM outbox ORDER BY id")
.fetch_all(&state.db)
.await
.expect("outbox")
}
#[tokio::test]
async fn a_document_without_any_article_in_force_waits_and_goes_out_with_the_first_text() {
let remote = Remote::new("suspended");
let state = fresh_with(remote.config(false)).await;
ld_server::publish::pass(&state).await.expect("pass");
assert_eq!(remote.show("fr.md"), None, "nothing went out");
// The queue stays, not as a failure: no attempt counted, no error.
assert_eq!(outbox(&state).await, [("publication initiale".to_owned(), 0, None, None)]);
let mut c = Client::of(&state);
c.login("Chiara", false).await;
let doc = c.doc("monnaie-libre").await;
assert_eq!(doc["doc"]["publication"]["state"], "suspended");
// A restart queues nothing new and sends nothing.
ld_server::publish::resync(&state).await.expect("resync");
ld_server::publish::pass(&state).await.expect("pass");
assert_eq!(remote.show("fr.md"), None);
assert_eq!(outbox(&state).await.len(), 1);
// The first text enters (fifty voices of the Ğ1 web of trust, written here as they
// land after the chain): the document goes out, with it alone.
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
for i in 0..60 {
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES (?, 'g1', ?, 1, '2026-11-02T00:00:00Z')")
.bind(format!("idty:{}", 9000 + i))
.bind(format!("Membre {i}"))
.execute(&state.db)
.await
.expect("identity");
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, ?, 'for', 1, '2026-11-02T00:00:00Z', 'chain')")
.bind(&e1)
.bind(format!("idty:{}", 9000 + i))
.execute(&state.db)
.await
.expect("vote");
}
let entered = ld_server::adoption::run(&state, &doc_id).await.expect("adoption");
assert_eq!(entered.iter().map(|e| e.code.as_str()).collect::<Vec<_>>(), ["E1"]);
ld_server::publish::pass(&state).await.expect("pass");
let fr = remote.show("fr.md").expect("published");
assert!(fr.contains("## Unicité du compte"), "{fr}");
assert!(!fr.contains("Raison d'être") && !fr.contains("## Réciprocité"), "only the text in force: {fr}");
let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 1);
assert_eq!(json["units"][0]["status"], "adopted");
assert!(outbox(&state).await.iter().all(|(_, _, _, done)| done.is_some()), "the whole queue went out");
let doc = c.doc("monnaie-libre").await;
assert_eq!(doc["doc"]["publication"]["state"], "done");
}
#[tokio::test]
async fn publishing_a_document_without_any_article_in_force_is_one_switch_away() {
let remote = Remote::new("switch");
let state = fresh_with(remote.config(true)).await;
ld_server::publish::pass(&state).await.expect("pass");
let fr = remote.show("fr.md").expect("published anyway");
assert!(fr.contains("# Acte d'engagement Certification") && !fr.contains("## "), "{fr}");
let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json");
assert_eq!(json["units"], json!([]));
assert!(outbox(&state).await.iter().all(|(_, _, _, done)| done.is_some()));
}
// ---------------------------------------------------------------------------
// A seed proposal enters like any proposal
// ---------------------------------------------------------------------------
#[tokio::test]
async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() {
let state = fresh().await;
let mut a = Client::of(&state);
a.login("Yvv", false).await;
let mut b = a.other();
b.login("Chiara", false).await;
// 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 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;
let p = &unit(&doc, "I1")["inForce"];
assert_eq!(p["id"], i1.as_str());
assert!(p["enteredAt"].is_string());
assert_eq!((p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()), (Some(false), Some(false)));
let g = a.get("/api/groups/essai").await;
assert_eq!(g["docs"][0]["inForce"], 1);
let adopted: (String, i64, i64) =
sqlx::query_as("SELECT kind, votes_for, electorate FROM adoptions WHERE proposal_id = ?")
.bind(&i1)
.fetch_one(&state.db)
.await
.expect("adoption");
assert_eq!(adopted, ("text".to_owned(), 2, 2));
// 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;
}
let doc = a.doc("essai").await;
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
let v = a.vote(&e1, "for").await;
assert_eq!(v["entered"][0]["code"], "E1", "16 members: one voice");
let electorate: i64 = sqlx::query_scalar("SELECT electorate FROM adoptions WHERE proposal_id = ?")
.bind(&e1)
.fetch_one(&state.db)
.await
.expect("adoption");
assert_eq!(electorate, 16);
}
+11 -1
View File
@@ -15,9 +15,15 @@ struct Client {
}
impl Client {
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
/// These scenarios need texts in force; the seed of today has none.
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
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");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
}
@@ -121,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;