From 049f3c65b24aee00ec311c1d80d412a3dffca7f8 Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 05:40:03 +0100 Subject: [PATCH 01/24] =?UTF-8?q?Tableau=20:=20revue=20d'Yvv=20du=2002/11,?= =?UTF-8?q?=20lots=20S,=20V,=20U=20et=20H=20lanc=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 557e094..61379df 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -16,6 +16,21 @@ fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle ve - **F1.2, N1, N1.1, O1** réécrits (amendement de seed sur une proposition ouverte) ; - **licence** AGPL-3.0 (`76b8e9f`). +**Revue d'Yvv du 02/11 (ergonomie, Propositions seed, tampon des votes, accueil)**, armée d'avance par +« Et pousse. Déploie. ». Lots en cours, en copies de travail : +- **S** : I1, E1 et E2 deviennent des « Propositions seed », pas en vigueur, à une inertie basse. + Amendement gardé, ADR 0011, migration 0009. +- **V** : le tampon entre la base des votes et les indexeurs ; « je vois ce que j'ai fait sans + attendre » ; contrôle a posteriori (unique, le dernier qui vaut) ; vote simulé partout tant que la + chaîne est fermée. ADR 0012, migration 0010. +- **U** : le lecteur. Page courte ; ordonnancement par poignées en fin de section ; trois natures de + modules ; consoles sans doublon ; déplier ou panneau, une règle (note `UX-lecteur.md`). Relancé + après un blocage. +- **H** : l'accueil. Les trois points sous la clé de voûte, « boîte à outils » en premier ; le titre + centré, sur deux lignes. + +Puis : fusion, boucle, relecture indépendante, répétition, acte M6. + Yvv relit les quatre textes en ligne. Au prochain tour, avec ses retours : - N1.1 : G se calcule sur p̃ rapporté à W ; - étendre la garde de l'amendement à un texte déjà amendé. From d7f5f6d13c6f56a7e3022a160d97d4e63471a70a Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 07:33:02 +0100 Subject: [PATCH 02/24] =?UTF-8?q?Tableau=20:=20d=C3=A9cisions=20d'Yvv=20du?= =?UTF-8?q?=2002/11=20(cran=202,=20quorum=20par=20cran,=20vote=20simul?= =?UTF-8?q?=C3=A9,=20publication=20suspendue)=20;=20U=20et=20H=20relanc?= =?UTF-8?q?=C3=A9s=20;=20Q9,=20Q10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 41 +++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 61379df..909b8bb 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -17,17 +17,28 @@ fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle ve - **licence** AGPL-3.0 (`76b8e9f`). **Revue d'Yvv du 02/11 (ergonomie, Propositions seed, tampon des votes, accueil)**, armée d'avance par -« Et pousse. Déploie. ». Lots en cours, en copies de travail : -- **S** : I1, E1 et E2 deviennent des « Propositions seed », pas en vigueur, à une inertie basse. - Amendement gardé, ADR 0011, migration 0009. +« Et pousse. Déploie. ». Décisions d'Yvv du 02/11, de 06 h 26 à 07 h 05 (registre) : +- Propositions seed au **cran 2** ; +- **C devient un quorum** de votants par cran : 12 · 12 · 50 · 50 · 50 en Monnaie libre, 1 à + l'essai. Au-delà, la proportion suit la courbe des cinq crans. L'auteur initial change le quorum + de son article avant le premier vote ; +- l'écran dit, sans prendre de place, combien de « oui » font passer et combien de « non » + bloquent ; +- un vote simulé compte, puis s'efface à l'ouverture de la chaîne (acte « supprime ») ; +- la publication est suspendue jusqu'au premier texte en vigueur. + +Lots en cours, en copies de travail : +- **S** : I1, E1 et E2 deviennent des « Propositions seed », pas en vigueur, au cran 2 ; la + publication suspendue (ADR 0011, migration 0009). Puis le quorum : ADR 0013, migration 0011, + `PUT /units/{id}/quorum`, champs `quorum`, `yesNeeded` et `noNeeded`. - **V** : le tampon entre la base des votes et les indexeurs ; « je vois ce que j'ai fait sans attendre » ; contrôle a posteriori (unique, le dernier qui vaut) ; vote simulé partout tant que la - chaîne est fermée. ADR 0012, migration 0010. + chaîne est fermée, qui compte. ADR 0012, migration 0010. - **U** : le lecteur. Page courte ; ordonnancement par poignées en fin de section ; trois natures de - modules ; consoles sans doublon ; déplier ou panneau, une règle (note `UX-lecteur.md`). Relancé - après un blocage. + modules ; consoles sans doublon ; déplier ou panneau, une règle (note `UX-lecteur.md`) ; la ligne + oui / non et le réglage du quorum. Relancé le 02/11 vers 07 h 20, après une coupure réseau. - **H** : l'accueil. Les trois points sous la clé de voûte, « boîte à outils » en premier ; le titre - centré, sur deux lignes. + centré, sur deux lignes. Relancé le 02/11 vers 07 h 20, après une coupure réseau. Puis : fusion, boucle, relecture indépendante, répétition, acte M6. @@ -93,6 +104,8 @@ chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichag | Q6 | Ğ1Companion ne signe pas encore une transaction construite par une page : quelle extension pour le geste unique ? | **tranché** (01/11) : duniter-connect maintenant ; Ğ1Companion et trustinWallet sur la même interface ; Ğecko par lien et QR | | Q7 | Le compte qui reçoit les 0,1 Ğ1 : à qui, et que deviennent les sommes ? | **tranché** (01/11) : un compte « libreDecision » créé par Yvv dans Ğecko, les sommes au collectif ; attend son adresse publique | | Q8 | Le secret du vote tombe par recoupement : la chaîne montre qui vient de voter, et les totaux bougent aussitôt (relecture de `74f96be`) | **tranché** (01/11) : on ne dit rien ; totaux et son propre vote en direct, inchangés | +| Q9 | Sous le quorum, les votants qui manquent comptent-ils comme des « non » ? (Sinon, un « non » peut faire passer un texte en complétant le quorum.) | **défaut appliqué** : oui, ils comptent comme des « non » ; posé à Yvv le 02/11 | +| Q10 | Licence : AGPL-3.0 seulement, ou « ou toute version ultérieure » ? | **défaut appliqué** : AGPL-3.0-only (`76b8e9f`) ; posé à Yvv le 02/11 | Défauts posés par orch, sans question (Yvv peut les corriger) : - Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote @@ -121,15 +134,5 @@ Liste d'attente (relecture de `033b331+4d4adcc`, après M4) : - `place_at_end` coupe toujours le même intervalle : au 52e ajout dans une section, les positions se confondent ; - `publish.rs` : articles sur `tally`, modules sur `shown_tally` (identiques aujourd'hui). -À dire à Yvv : dans le groupe d'essai (C = 1), dès 16 membres une voix seule fait passer un texte, -la garde B^W passant sous la précision du calcul (ADR 0007). Monnaie libre (C = 50) n'est pas -concernée. Pour deux voix dans l'essai quel que soit W : C = 2. - -À demander à Yvv (relecture de `cd7e683`) : sur une version retenue par une objection, ni retirer -son vote ni passer à « contre » ; à la levée, l'adoption tourne aussitôt. Le garder, ou permettre de -retirer son vote et de voter « contre » sur une version retenue ? Défaut : tel quel (ADR 0010). - -Ensuite : Chaque mise en ligne (M1 à M4) attend « déploie » d'Yvv et une relecture -indépendante inscrite. La migration « Raison d'être » touche la base de production : elle ne -remplace le Préambule que s'il est intact (texte de départ, 0 vote, aucune correction), et le -dossier d'acte de M1 le dit. +Chaque mise en ligne attend « déploie » d'Yvv, ou le mandat d'orch, et une relecture indépendante +inscrite. From f4f8d5850e038e9ab3ce61431736e4a3f7b26d1a Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 07:41:31 +0100 Subject: [PATCH 03/24] Tableau : heure exacte de la relance de U et H Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 909b8bb..61741d7 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -36,9 +36,9 @@ Lots en cours, en copies de travail : chaîne est fermée, qui compte. ADR 0012, migration 0010. - **U** : le lecteur. Page courte ; ordonnancement par poignées en fin de section ; trois natures de modules ; consoles sans doublon ; déplier ou panneau, une règle (note `UX-lecteur.md`) ; la ligne - oui / non et le réglage du quorum. Relancé le 02/11 vers 07 h 20, après une coupure réseau. + oui / non et le réglage du quorum. Relancé le 02/11 à 07 h 26, après une coupure réseau. - **H** : l'accueil. Les trois points sous la clé de voûte, « boîte à outils » en premier ; le titre - centré, sur deux lignes. Relancé le 02/11 vers 07 h 20, après une coupure réseau. + centré, sur deux lignes. Relancé le 02/11 à 07 h 26, après une coupure réseau. Puis : fusion, boucle, relecture indépendante, répétition, acte M6. From 6228d3913ab8de31b3fceb1cc6ed839e34f81786 Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 07:50:31 +0100 Subject: [PATCH 04/24] =?UTF-8?q?WIP=20lot=20S=20:=20Propositions=20seed?= =?UTF-8?q?=20(I1,=20E1,=20E2=20ouvertes,=20cran=202),=20amendement=20gard?= =?UTF-8?q?=C3=A9,=20publication=20suspendue=20sans=20article=20en=20vigue?= =?UTF-8?q?ur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Travail en cours, sauvegardé à la demande d'orch : le moteur, le serveur et tests/seed_proposals.rs (verts) ; les autres tests et les e2e ne sont pas encore repris. Co-Authored-By: Claude Opus 5.5 --- .../migrations/0009_seed_proposals.sql | 51 ++ crates/ld-server/src/config.rs | 5 + crates/ld-server/src/docs.rs | 25 +- crates/ld-server/src/floor_amend.rs | 199 +++++ crates/ld-server/src/inertia_amend.rs | 33 +- crates/ld-server/src/lib.rs | 2 + crates/ld-server/src/proposals.rs | 4 +- crates/ld-server/src/publish.rs | 31 + crates/ld-server/src/seed.rs | 70 +- crates/ld-server/src/seed_amend.rs | 71 +- crates/ld-server/src/seed_proposal_amend.rs | 571 +++++++++++++ crates/ld-server/tests/api.rs | 3 + .../fixtures/seed-production-2026-11-02.json | 791 ++++++++++++++++++ crates/ld-server/tests/seed_proposals.rs | 698 ++++++++++++++++ seeds/acte-engagement-certification.json | 14 +- 15 files changed, 2513 insertions(+), 55 deletions(-) create mode 100644 crates/ld-server/migrations/0009_seed_proposals.sql create mode 100644 crates/ld-server/src/floor_amend.rs create mode 100644 crates/ld-server/src/seed_proposal_amend.rs create mode 100644 crates/ld-server/tests/fixtures/seed-production-2026-11-02.json create mode 100644 crates/ld-server/tests/seed_proposals.rs diff --git a/crates/ld-server/migrations/0009_seed_proposals.sql b/crates/ld-server/migrations/0009_seed_proposals.sql new file mode 100644 index 0000000..e7abc5a --- /dev/null +++ b/crates/ld-server/migrations/0009_seed_proposals.sql @@ -0,0 +1,51 @@ +-- Propositions seed (2026-11-02, ADR 0011). Additive only: nothing is dropped, +-- nothing an older image needs is rewritten. +-- +-- Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les +-- 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition +-- seed" (intégré par un ou deux clients pour amorcer le mouvement de vote). » +-- +-- A seed proposal is an open proposal of the seed put forward to start the vote +-- (I1, E1, E2 of the certification act). It is not in force: it enters like any +-- proposal, by its threshold. The mark stays on the row once written; what the +-- API calls `seedProposal` is read from it while the version is open and was +-- never voted into force (src/docs.rs). An older image ignores the column and +-- reads the version as an open proposal of the seed. +ALTER TABLE proposals ADD COLUMN seed_featured INTEGER NOT NULL DEFAULT 0; + +-- The starting texts in force made seed proposals in an existing base +-- (src/seed_proposal_amend.rs): decided once per document and article, and +-- only where nobody has touched the text. With the status, the article's level +-- of inertia goes low (Yvv, 2026-11-02: « l'amorcer avec une inertie basse »), +-- only where nobody has touched it either. Each decision is kept here, for the +-- status and for the level: applied, refused (and why), or present (the base +-- was born with the seed proposal, at the low level). +CREATE TABLE seed_proposal_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + unit_code TEXT NOT NULL, + proposal_id TEXT, -- the version examined: in force, or the seed proposal found + outcome TEXT NOT NULL, -- applied | refused | present (the status) + reason TEXT, -- why it was refused + from_level TEXT, -- the level found + to_level TEXT NOT NULL, -- the level seed proposals start at + level_outcome TEXT NOT NULL, -- applied | refused | present (the level) + level_reason TEXT, -- why it was refused + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); + +-- A floor C changed in an existing document (src/floor_amend.rs), should Yvv +-- lower it to start (question asked on 2026-11-02; none listed yet): decided +-- once per document, only if the floor is still the one expected. Each decision +-- is kept here: applied, refused (and why), or present. +CREATE TABLE floor_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + from_floor REAL, -- the floor found + to_floor REAL NOT NULL, + outcome TEXT NOT NULL, -- applied | refused | present + reason TEXT, -- why it was refused + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index cfa1cf5..6915938 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -19,6 +19,10 @@ pub struct Publish { pub key: PathBuf, pub author_name: String, pub author_email: String, + /// Publish a document even when none of its articles is in force + /// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default (orch, 2026-11-02, while Yvv + /// decides): the queue waits, and the first text that enters sends it (ADR 0011). + pub without_articles: bool, } /// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how @@ -110,6 +114,7 @@ impl Config { key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")), author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()), author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()), + without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"), }); let dev_login = flag("LD_DEV_LOGIN"); let vote = var("LD_VOTE_ACCOUNT").and_then(|account| { diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index af2d971..707dcfa 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -232,15 +232,29 @@ pub struct ProposalRow { pub submitted_at: Option, pub entered_at: Option, pub confirmed_at: Option, + /// Put forward by the seed to start the vote (ADR 0011): see `seed_proposal`. + pub seed_featured: bool, } pub const PROPOSAL_SELECT: &str = "SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin, p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at, - p.entered_at, p.confirmed_at + p.entered_at, p.confirmed_at, p.seed_featured FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; impl ProposalRow { + /// A seed a minima: in force from the seed, never voted into force nor confirmed + /// (the title and the orders of a new base; a text a seed proposal amendment left). + pub fn seed_minimal(&self) -> bool { + self.status == "in_force" && self.entered_at.is_none() && self.confirmed_at.is_none() && self.origin == "seed" + } + + /// A seed proposal (ADR 0011): open, from the seed, put forward to start the vote, + /// never voted into force. Not in force: it enters like any proposal. + pub fn seed_proposal(&self) -> bool { + self.seed_featured && self.status == "open" && self.origin == "seed" && self.entered_at.is_none() + } + pub fn kind(&self) -> ProposalKind { match self.kind.as_str() { "remove" => ProposalKind::Remove, @@ -659,7 +673,7 @@ fn proposal_json( "mine": p.author_id.as_deref() == Some(me), "revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at, "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, - "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", + "seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(), "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, @@ -797,10 +811,15 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult crate::publish::waits_for_a_text(state, doc_id).await?, + _ => false, + }; Ok(match last { None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }), Some(r) => json!({ - "state": if r.done_at.is_some() { "done" } else { "pending" }, + "state": if r.done_at.is_some() { "done" } else if suspended { "suspended" } else { "pending" }, "configured": state.cfg.publish.is_some(), "since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha, "attempts": r.attempts, "error": r.last_error.map(|_| "retrying"), diff --git a/crates/ld-server/src/floor_amend.rs b/crates/ld-server/src/floor_amend.rs new file mode 100644 index 0000000..4080ec8 --- /dev/null +++ b/crates/ld-server/src/floor_amend.rs @@ -0,0 +1,199 @@ +//! Floor amendments — the floor C of an existing document changed (ADR 0011). +//! +//! C is the minimal number of *for* votes below which nothing passes (ADR 0002): +//! 50 in Monnaie libre, whatever the level of inertia. A low level for the seed +//! proposals does not make a start with five people possible; whether C is +//! lowered to start is Yvv's question (orch, 2026-11-02). **None is listed**: +//! the floor stays. Should he lower it, one entry here does it, with the seed's +//! `voteFloor` of the group set to the same value (new bases read the seed): +//! +//! - decided once per document, at the first start that knows the amendment; +//! - only if the floor is still the one expected (`from`); already `to`: present; +//! - kept in `floor_amendments` and in the logs; where the document publishes, +//! the publication queue gets a row (`document.json` carries `voteFloor`). +//! +//! Every threshold of the document follows at the next pass of the sweeper, and +//! so does the reference participation (2C/W) while fewer than five proposals +//! are voted (ADR 0007). + +use crate::AppState; +use crate::seed::SeedDoc; +use crate::util; + +/// One floor to change, in the document a seed instantiated in a group. +pub struct FloorAmendment { + /// Stable identifier: the decision is kept under it. + pub id: &'static str, + /// Slug of the seed document. + pub doc: &'static str, + /// The group whose document it changes. + pub group: &'static str, + /// The floor it must still hold. + pub from: f64, + /// The floor it gets: the seed's today for the group, which must say so. + pub to: f64, + /// Reason given to the publication queue, and so to the texts repository. + pub reason: &'static str, +} + +/// None while Yvv has not decided (2026-11-02). +pub const AMENDMENTS: &[FloorAmendment] = &[]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// The floor is already there: nothing to do. + Present, + Refuse(&'static str), +} + +fn same(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +/// The rule, apart from the database. +pub fn verdict(found: Option, a: &FloorAmendment) -> Verdict { + match found { + None => Verdict::Refuse("no_document"), + Some(c) if same(c, a.to) => Verdict::Present, + Some(c) if !same(c, a.from) => Verdict::Refuse("floor_differs"), + Some(_) => Verdict::Apply, + } +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq)] +pub struct Outcome { + pub amendment: &'static str, + pub verdict: Verdict, +} + +/// Decides every amendment not yet decided. +pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[FloorAmendment]) -> anyhow::Result> { + let mut out = Vec::new(); + for a in list { + if !seed_says(seeds, a) { + continue; + } + let doc: Option<(String, f64, Option)> = + sqlx::query_as("SELECT id, vote_floor, publish_path FROM documents WHERE slug = ? AND group_slug = ?") + .bind(a.doc) + .bind(a.group) + .fetch_optional(&state.db) + .await?; + let mut tx = state.db.begin().await?; + let doc_id = doc.as_ref().map(|(id, _, _)| id.clone()); + if let Some(id) = &doc_id { + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM floor_amendments WHERE amendment_id = ? AND doc_id = ?") + .bind(a.id) + .bind(id) + .fetch_one(&mut *tx) + .await?; + if decided > 0 { + continue; + } + } + let mut v = verdict(doc.as_ref().map(|(_, c, _)| *c), a); + let now = util::now(); + if let (Verdict::Apply, Some((id, c, publish_path))) = (v, &doc) { + // Written only over the floor that was judged. + let done = sqlx::query("UPDATE documents SET vote_floor = ? WHERE id = ? AND vote_floor = ?") + .bind(a.to) + .bind(id) + .bind(c) + .execute(&mut *tx) + .await?; + if done.rows_affected() != 1 { + v = Verdict::Refuse("changed"); + } else if publish_path.is_some() { + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(id) + .bind(a.reason) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } + let Some(id) = doc_id else { + tracing::warn!(amendment = a.id, group = a.group, "floor amendment: no such document, nothing decided"); + continue; + }; + let (outcome, reason) = match v { + Verdict::Apply => ("applied", None), + Verdict::Present => ("present", None), + Verdict::Refuse(why) => ("refused", Some(why)), + }; + sqlx::query( + "INSERT INTO floor_amendments (amendment_id, doc_id, from_floor, to_floor, outcome, reason, at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(&id) + .bind(doc.as_ref().map(|(_, c, _)| *c)) + .bind(a.to) + .bind(outcome) + .bind(reason) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + match v { + Verdict::Refuse(why) => { + tracing::warn!(amendment = a.id, group = a.group, reason = why, "floor amendment refused: the floor stays") + } + _ => tracing::info!(amendment = a.id, group = a.group, from = a.from, to = a.to, outcome, "floor amendment"), + } + if v == Verdict::Apply { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, verdict: v }); + } + Ok(out) +} + +/// The seed must give the group this floor: new bases read it there. +fn seed_says(seeds: &[SeedDoc], a: &FloorAmendment) -> bool { + let floor = seeds + .iter() + .find(|s| s.slug == a.doc) + .and_then(|s| s.instances.iter().find(|i| i.group == a.group)) + .map(|i| i.vote_floor); + if floor.is_some_and(|c| same(c, a.to)) { + return true; + } + tracing::error!( + amendment = a.id, + group = a.group, + "floor amendment: the seed does not give this floor, nothing decided" + ); + false +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: FloorAmendment = FloorAmendment { + id: "test", + doc: "doc", + group: "monnaie-libre", + from: 50.0, + to: 10.0, + reason: "plancher C abaissé : 50 → 10", + }; + + #[test] + fn none_is_listed_while_yvv_decides() { + assert!(AMENDMENTS.is_empty()); + } + + #[test] + fn the_floor_changes_only_from_the_one_expected() { + assert_eq!(verdict(Some(50.0), &A), Verdict::Apply); + assert_eq!(verdict(Some(10.0), &A), Verdict::Present); + assert_eq!(verdict(Some(40.0), &A), Verdict::Refuse("floor_differs")); + assert_eq!(verdict(None, &A), Verdict::Refuse("no_document")); + } +} diff --git a/crates/ld-server/src/inertia_amend.rs b/crates/ld-server/src/inertia_amend.rs index 3af65c6..5a4e50f 100644 --- a/crates/ld-server/src/inertia_amend.rs +++ b/crates/ld-server/src/inertia_amend.rs @@ -22,6 +22,11 @@ //! logs. Applied, the level is written in one transaction with a line in //! `inertia_changes` (the article says so) and, where the document publishes, //! a row in the publication queue (`document.json` carries the levels). +//! +//! Since 2026-11-02 the seed gives E1 and E2 another starting level: put forward +//! as seed proposals, they start low (ADR 0011, `seed_proposal_amend`). The two +//! amendments of 30/10 are then superseded: decided in production, they decide +//! nothing more, and say so only in the debug logs. use crate::AppState; use crate::seed::SeedDoc; @@ -43,6 +48,9 @@ pub struct InertiaAmendment { pub to: u8, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, + /// The later decision whose level the seed gives instead, if any: this one then + /// decides nothing more, without an error. + pub superseded_by: Option<&'static str>, } pub const AMENDMENTS: &[InertiaAmendment] = &[ @@ -53,6 +61,7 @@ pub const AMENDMENTS: &[InertiaAmendment] = &[ from: 3, to: 4, reason: "inertie de départ relevée : E1, 3 → 4", + superseded_by: Some("2026-11-02-e1-proposition-seed"), }, InertiaAmendment { id: "2026-10-30-e2-inertie-4", @@ -61,6 +70,7 @@ pub const AMENDMENTS: &[InertiaAmendment] = &[ from: 3, to: 4, reason: "inertie de départ relevée : E2, 3 → 4", + superseded_by: Some("2026-11-02-e2-proposition-seed"), }, ]; @@ -148,7 +158,17 @@ pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool { let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit)); match level { - Some(u) if u.inertia.get() == a.to => true, + Some(u) if u.level().get() == a.to => true, + Some(u) if a.superseded_by.is_some() => { + tracing::debug!( + amendment = a.id, + unit = a.unit, + seed_level = u.level().get(), + superseded_by = a.superseded_by, + "inertia amendment superseded: nothing decided" + ); + false + } _ => { tracing::error!( amendment = a.id, @@ -267,8 +287,15 @@ async fn decide( mod tests { use super::*; - const A: InertiaAmendment = - InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée" }; + const A: InertiaAmendment = InertiaAmendment { + id: "test", + doc: "doc", + unit: "E1", + from: 3, + to: 4, + reason: "inertie de départ relevée", + superseded_by: None, + }; fn at(raw: &str) -> Facts { Facts { raw: raw.into(), ..Facts::default() } diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index db55f58..dc5fbac 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -17,6 +17,7 @@ pub mod deadlines; pub mod doc_title; pub mod docs; pub mod error; +pub mod floor_amend; pub mod indexer; pub mod inertia_amend; pub mod messages; @@ -30,6 +31,7 @@ pub mod proposals; pub mod publish; pub mod seed; pub mod seed_amend; +pub mod seed_proposal_amend; pub mod team; pub mod typos; pub mod util; diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 62b24f6..75408ae 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -570,7 +570,9 @@ pub async fn detail( let mut history = Vec::new(); let mut previous: Option<&Rev> = None; for r in &revs { + // A change of status (a seed proposal, ADR 0011) changes no text: no diff. let diff = previous + .filter(|_| r.kind != "seed_proposal") .map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body))); let typo = r.typo_id.as_ref().map(|t| { json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") }) @@ -601,7 +603,7 @@ pub async fn detail( "author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false), "mine": p.author_id.as_deref() == Some(user.id.as_str()), "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, - "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", + "seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(), "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), "votable": st.votable(&p), "myVote": my_vote.as_ref().map(|v| &v.choice), diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index c5e58c1..6a620ec 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -83,6 +83,20 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect(); let last = rows.iter().map(|d| d.id).max().unwrap_or(0); let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0); + if waits_for_a_text(state, &doc_id).await? { + // Not a failure: no attempt counted, no error. The queue stays; the first + // text that enters queues a row due now, and everything goes out with it. + sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL") + .bind(util::in_seconds(MAX_BACKOFF_SECS)) + .bind(&doc_id) + .execute(&state.db) + .await?; + tracing::info!( + doc = %doc_id, waiting = rows.len(), + "publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)" + ); + continue; + } match publish_doc(state, &doc_id).await { Ok(sha) => { sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?") @@ -109,6 +123,23 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { Ok(()) } +/// Does the document wait for a text before it is published? Yes when none of its +/// articles is in force — the seed proposals are not (ADR 0011) — unless the +/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default +/// is the orch's (2026-11-02), while Yvv decides: never a document without a text. +pub async fn waits_for_a_text(state: &AppState, doc_id: &str) -> anyhow::Result { + if state.cfg.publish.as_ref().is_none_or(|p| p.without_articles) { + return Ok(false); + } + let in_force: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND in_force_id IS NOT NULL", + ) + .bind(doc_id) + .fetch_one(&state.db) + .await?; + Ok(in_force == 0) +} + async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result { let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?; let known_hosts = state.cfg.data_dir.join("keys/known_hosts"); diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index ef6d42d..b2ab103 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -9,18 +9,29 @@ //! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia //! no vote changed; modules the seed declares (the title, the order of each //! section, ADR 0009), a module amendment (`module_amend`), built from what the -//! document holds. +//! document holds; a starting text in force the seed now puts forward as a seed +//! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an +//! article nobody has touched. use crate::AppState; use crate::inertia_amend; use crate::module_amend; use crate::modules; use crate::seed_amend; +use crate::seed_proposal_amend; use crate::util::{self, LangMap}; use ld_engine::inertia::InertiaLevel; use serde::Deserialize; use std::path::Path; +/// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « on +/// pouvait l'amorcer avec une inertie basse, pour que le nombre de votes initiaux soit +/// assez petit pour commencer l'expérience en mode prod » — level 2 (« basse »), or +/// level 1 (« très basse »): his answer awaited, 2 by default. The one place to change: +/// the seed gives no level to an article it puts forward, and the seed proposal +/// amendment (`seed_proposal_amend`) writes this one. +pub const SEED_PROPOSAL_LEVEL: u8 = 2; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SeedDoc { @@ -60,16 +71,38 @@ pub struct SeedUnit { pub code: String, pub section: Option, pub position: f64, - /// A level, 1 to 5 (a word of before is read as its level). - pub inertia: InertiaLevel, + /// A level, 1 to 5 (a word of before is read as its level). None for an article + /// the seed puts forward: it starts at `SEED_PROPOSAL_LEVEL`. + #[serde(default)] + pub inertia: Option, pub versions: Vec, } +impl SeedUnit { + /// Holds a seed proposal (ADR 0011). + pub fn puts_forward(&self) -> bool { + self.versions.iter().any(|v| v.seed_proposal) + } + + /// The level it starts at: its own, or the one of seed proposals. + pub fn level(&self) -> InertiaLevel { + match self.inertia { + Some(level) => level, + None => InertiaLevel::new(SEED_PROPOSAL_LEVEL).unwrap_or_default(), + } + } +} + #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SeedVersion { pub label: Option, - /// `in_force` (seed a minima) or `open` (proposal at zero votes). + /// `in_force` (seed a minima: the title and the orders) or `open` (a proposal at + /// zero votes). pub status: String, + /// A seed proposal (ADR 0011): open, put forward to start the vote, not in force. + #[serde(default)] + pub seed_proposal: bool, pub title: LangMap, pub body: LangMap, } @@ -98,6 +131,9 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> { seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?; inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?; module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?; + // After the text amendments: a starting text replaced first, then put forward. + seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?; + crate::floor_amend::apply(state, &seeds, crate::floor_amend::AMENDMENTS).await?; Ok(()) } @@ -130,6 +166,7 @@ pub async fn documents(state: &AppState) -> anyhow::Result> { async fn load_file(state: &AppState, path: &Path) -> anyhow::Result { let raw = tokio::fs::read_to_string(path).await?; let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; + check(&seed).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; for inst in &seed.instances { let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?") .bind(&inst.group) @@ -153,6 +190,22 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result { Ok(seed) } +/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL` +/// alone; every other article says its level. +fn check(seed: &SeedDoc) -> anyhow::Result<()> { + for u in &seed.units { + if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") { + anyhow::bail!("{}: a seed proposal is open, never in force", u.code); + } + match (u.puts_forward(), u.inertia.is_some()) { + (true, true) => anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code), + (false, false) => anyhow::bail!("{}: no level of inertia", u.code), + _ => {} + } + } + Ok(()) +} + async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> { let src = seed.source_lang.as_str(); let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default(); @@ -185,7 +238,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .bind(&unit.code) .bind(&unit.section) .bind(unit.position) - .bind(unit.inertia.as_db()) + .bind(unit.level().as_db()) .bind(&now) .execute(&mut *tx) .await?; @@ -218,6 +271,13 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .bind(&now) .execute(&mut *tx) .await?; + // The mark, written apart: only where the seed puts the version forward. + if v.seed_proposal && status == "open" { + sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?") + .bind(&pid) + .execute(&mut *tx) + .await?; + } if status == "in_force" { sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") .bind(&pid) diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index d8971b5..6bf0465 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -3,7 +3,9 @@ //! The text replaced is an article's version in force (a seed a minima), or, since //! 2026-11-02, one of its open proposals from the seed, named by its label: the //! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv). -//! The guards are the same. +//! The guards are the same. Since the same day, a starting text may be a seed +//! proposal instead of a text in force (ADR 0011): where an article has no text +//! in force, `Version::InForce` examines its seed proposal (a new base: present). //! //! A seeded document is never instantiated again: once in the database, it is //! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30: @@ -33,21 +35,12 @@ use sqlx::SqliteConnection; /// Which version of the article an amendment replaces. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Version { - /// Its text in force, a seed a minima. + /// Its starting text: in force (a seed a minima), or its seed proposal (ADR 0011). InForce, /// An open proposal of the seed, at zero votes, by its label. Open(&'static str), } -impl Version { - fn status(self) -> &'static str { - match self { - Version::InForce => "in_force", - Version::Open(_) => "open", - } - } -} - /// One starting text to replace, in every document instantiated from a seed. pub struct Amendment { /// Stable identifier: each document's decision is kept under it. @@ -232,7 +225,8 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' }; let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| { u.versions.iter().find(|v| match a.version { - Version::InForce => v.status == "in_force", + // The starting text: in force, or since 2026-11-02 put forward (ADR 0011). + Version::InForce => v.status == "in_force" || v.seed_proposal, Version::Open(label) => v.status == "open" && v.label.as_deref() == Some(label), }) }); @@ -252,7 +246,8 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' } } -async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result> { +/// What the database says of a version holding `status` (`None`: it does not). +pub(crate) async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result> { #[derive(sqlx::FromRow)] struct Row { origin: String, @@ -327,35 +322,39 @@ async fn decide( if decided > 0 { return Ok(None); } - let target_id: Option = match a.version { - Version::InForce => { - sqlx::query_scalar::<_, Option>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") - .bind(doc_id) - .bind(a.unit) - .fetch_optional(&mut *tx) - .await? - .flatten() - } - Version::Open(label) => { - sqlx::query_scalar( - "SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id - WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = ? - ORDER BY p.rowid LIMIT 1", - ) + let in_force = || { + sqlx::query_scalar::<_, Option>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") .bind(doc_id) .bind(a.unit) - .bind(label) - .fetch_optional(&mut *tx) - .await? - } }; - let found = match &target_id { - Some(id) => facts(&mut tx, id, a.version.status()).await?, + let open = |label: Option<&'static str>| { + sqlx::query_scalar::<_, String>( + "SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id + WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' + AND (p.label = ?3 OR (?3 IS NULL AND p.seed_featured = 1)) + ORDER BY p.rowid LIMIT 1", + ) + .bind(doc_id) + .bind(a.unit) + .bind(label) + }; + // The version examined, and the status it holds. + let examined: Option<(String, &'static str)> = match a.version { + Version::InForce => match in_force().fetch_optional(&mut *tx).await?.flatten() { + Some(id) => Some((id, "in_force")), + // No text in force: the starting text may be a seed proposal (ADR 0011). + None => open(None).fetch_optional(&mut *tx).await?.map(|id| (id, "open")), + }, + Version::Open(label) => open(Some(label)).fetch_optional(&mut *tx).await?.map(|id| (id, "open")), + }; + let target_id = examined.as_ref().map(|(id, _)| id.clone()); + let found = match &examined { + Some((id, status)) => facts(&mut tx, id, status).await?, None => None, }; let mut v = verdict(found.as_ref(), a); let now = util::now(); - if let (Verdict::Apply, Some(pid)) = (v, &target_id) { + if let (Verdict::Apply, Some((pid, status))) = (v, &examined) { let src = seed.source_lang.as_str(); let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default(); let (title, body) = (pick(&target.title), pick(&target.body)); @@ -369,7 +368,7 @@ async fn decide( .bind(&body) .bind(&now) .bind(pid) - .bind(a.version.status()) + .bind(*status) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { diff --git a/crates/ld-server/src/seed_proposal_amend.rs b/crates/ld-server/src/seed_proposal_amend.rs new file mode 100644 index 0000000..96a0420 --- /dev/null +++ b/crates/ld-server/src/seed_proposal_amend.rs @@ -0,0 +1,571 @@ +//! Seed proposal amendments — a starting text in force made a seed proposal in +//! an existing base (ADR 0011). +//! +//! Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les +//! 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition +//! seed" » — then: « on pouvait l'amorcer avec une inertie basse, pour que le +//! nombre de votes initiaux soit assez petit pour commencer l'expérience en mode +//! prod ». New bases get I1, E1 and E2 from the seed as open proposals put +//! forward, at `seed::SEED_PROPOSAL_LEVEL`. An existing base — production holds +//! them in force, seeds a minima, in Monnaie libre and in the test group — gets +//! them through an amendment listed here, decided once per document and article. +//! +//! **The status**, only if nobody has touched the text in force: +//! +//! - it is the seed's own (origin `seed`), never voted into force, never +//! confirmed (`entered_at`, `confirmed_at` empty); +//! - nobody ever voted on it, on the server or in a ballot of the chain; +//! - no typo fix (applied, waiting or refused), no member translation, no +//! objection names it; its history is the seed's alone: its initial text, and +//! at most the seed amendment that replaced it (I1, the « Raison d'être »); +//! - its text is the seed proposal's of today (fingerprint). +//! +//! Applied, the same version stays — its text, translations, history and votes +//! (none) — and becomes open and marked; the article has no text in force any +//! more; its history gets a line (kind `seed_proposal`). +//! +//! **The level**, with the status only, and only if nobody has touched it: no +//! change of inertia proposed by a member that is open, applied or voted on, no +//! vote that changed it, and a level the seed gave the article before (I1: 3; +//! E1 and E2: 3, then 4 since 30/10). Applied, the article says so +//! (`inertia_changes`, « réglage de départ du texte »). Refused, it keeps its +//! level and the status still changes. +//! +//! Each decision is kept in `seed_proposal_amendments` (status and level: +//! applied, refused and why, or present) and in the logs. Where the document +//! publishes, the publication queue gets a row — and waits while no article is +//! in force (`publish`, ADR 0011). + +use crate::AppState; +use crate::seed::{self, SeedDoc}; +use crate::seed_amend::{self, fingerprint}; +use crate::util; +use ld_engine::inertia::InertiaLevel; +use sqlx::SqliteConnection; + +/// One starting text in force to put forward, in every document instantiated from a seed. +pub struct SeedProposalAmendment { + /// Stable identifier: each document's decision is kept under it. + pub id: &'static str, + /// Slug of the seed document. + pub doc: &'static str, + /// Code of the article. + pub unit: &'static str, + /// Fingerprint of the text (source language): the seed proposal of the seed + /// today, which must hash to it, and the text in force it expects. + pub text: &'static str, + /// The levels the article may still hold for its level to go low: those the seed + /// gave it before. + pub levels: &'static [u8], + /// Reason given to the publication queue, and so to the texts repository. + pub reason: &'static str, +} + +pub const AMENDMENTS: &[SeedProposalAmendment] = &[ + SeedProposalAmendment { + id: "2026-11-02-i1-proposition-seed", + doc: "acte-engagement-certification", + unit: "I1", + text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", + levels: &[3], + reason: "Proposition seed : I1 sort de la vigueur", + }, + SeedProposalAmendment { + id: "2026-11-02-e1-proposition-seed", + doc: "acte-engagement-certification", + unit: "E1", + text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd", + levels: &[3, 4], + reason: "Proposition seed : E1 sort de la vigueur", + }, + SeedProposalAmendment { + id: "2026-11-02-e2-proposition-seed", + doc: "acte-engagement-certification", + unit: "E2", + text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0", + levels: &[3, 4], + reason: "Proposition seed : E2 sort de la vigueur", + }, +]; + +/// What the database says of the version in force. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Facts { + /// As a seed amendment reads it. + pub text: seed_amend::Facts, + /// Lines of ballots of the chain naming it, whatever became of them. + pub ballots: i64, +} + +/// What the article holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Found { + /// A version in force. + InForce(Facts), + /// No text in force, and its seed proposal: born so, or already amended. + SeedProposal, + /// Neither. + Nothing, +} + +/// What the database says of the article's level. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LevelFacts { + /// `units.inertia` as written. + pub raw: String, + /// Changes of inertia proposed on it that are open or applied, or hold a vote. + pub inertia_proposals: i64, + /// Changes of its inertia that a vote brought into force. + pub voted_changes: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// Already so: nothing to do. + Present, + Refuse(&'static str), +} + +impl Verdict { + fn outcome(self) -> (&'static str, Option<&'static str>) { + match self { + Verdict::Apply => ("applied", None), + Verdict::Present => ("present", None), + Verdict::Refuse(why) => ("refused", Some(why)), + } + } +} + +/// The status, apart from the database: any trace of a person keeps the text in force. +pub fn verdict(found: &Found, a: &SeedProposalAmendment) -> Verdict { + let f = match found { + Found::Nothing => return Verdict::Refuse("no_text_in_force"), + Found::SeedProposal => return Verdict::Present, + Found::InForce(f) => f, + }; + let t = &f.text; + let kinds: Vec<&str> = t.revisions.iter().map(String::as_str).collect(); + let seed_history = matches!((kinds.as_slice(), t.revision), (["initial"], 1) | (["initial", "amendment"], 2)); + let refusal = if t.origin != "seed" { + "not_a_seed" + } else if t.entered { + "voted_into_force" + } else if t.confirmed { + "confirmed" + } else if t.votes > 0 { + "votes" + } else if f.ballots > 0 { + "ballots" + } else if t.typos > 0 || kinds.iter().any(|k| matches!(*k, "typo" | "revert")) { + "corrected" + } else if !seed_history { + "revised" + } else if t.member_translations > 0 { + "translation_edited" + } else if t.signals > 0 { + "signalled" + } else if t.fingerprint != a.text { + "text_differs" + } else { + return Verdict::Apply; + }; + Verdict::Refuse(refusal) +} + +/// The level, apart from the database: it goes low with the status only, and any +/// change of inertia a person proposed or voted keeps it. +pub fn level_verdict(status: Verdict, facts: Option<&LevelFacts>, a: &SeedProposalAmendment, to: u8) -> Verdict { + if let Verdict::Refuse(_) = status { + return Verdict::Refuse("not_a_seed_proposal"); + } + let Some(f) = facts else { return Verdict::Refuse("no_article") }; + let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") }; + if level.get() == to { + Verdict::Present + } else if f.voted_changes > 0 { + Verdict::Refuse("changed_by_vote") + } else if f.inertia_proposals > 0 { + Verdict::Refuse("inertia_proposal") + } else if !a.levels.contains(&level.get()) { + Verdict::Refuse("level_differs") + } else { + Verdict::Apply + } +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + pub amendment: &'static str, + pub group: String, + pub status: Verdict, + pub level: Verdict, +} + +/// Decides every amendment not yet decided, document by document. +pub async fn apply( + state: &AppState, + seeds: &[SeedDoc], + list: &[SeedProposalAmendment], +) -> anyhow::Result> { + let mut out = Vec::new(); + let to = seed::SEED_PROPOSAL_LEVEL; + for a in list { + if !seed_says(seeds, a) { + continue; + } + let docs: Vec<(String, String, Option)> = + sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug") + .bind(a.doc) + .fetch_all(&state.db) + .await?; + for (doc_id, group, publish_path) in docs { + let Some((status, level)) = decide(state, a, to, &doc_id, publish_path.is_some()).await? else { + continue; + }; + let (s, sr) = status.outcome(); + let (l, lr) = level.outcome(); + if matches!(status, Verdict::Refuse(_)) || matches!(level, Verdict::Refuse(_)) { + tracing::warn!( + amendment = a.id, %group, unit = a.unit, status = s, reason = sr, level = l, level_reason = lr, + "seed proposal amendment: refused where the article was touched, it keeps what it had" + ); + } else { + tracing::info!( + amendment = a.id, %group, unit = a.unit, status = s, level = l, to_level = to, + "seed proposal amendment" + ); + } + if (status == Verdict::Apply || level == Verdict::Apply) && publish_path.is_some() { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, group, status, level }); + } + } + Ok(out) +} + +/// The seed must put the article forward, with the text the amendment names and +/// no level of its own: a seed edited later never reaches a base by this way. +fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool { + let unit = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| { + let src = s.source_lang.as_str(); + let u = s.units.iter().find(|u| u.code == a.unit)?; + let v = u.versions.iter().find(|v| v.seed_proposal)?; + Some((u, fingerprint(v.title.get(src)?, v.body.get(src)?))) + }); + match unit { + Some((u, print)) if print == a.text && u.inertia.is_none() => true, + _ => { + tracing::error!( + amendment = a.id, + doc = a.doc, + unit = a.unit, + "seed proposal amendment: the seed does not put this text forward, nothing decided" + ); + false + } + } +} + +/// What the article holds, and the version examined: in force, or its seed proposal. +async fn found( + conn: &mut SqliteConnection, + unit_id: &str, + in_force: Option<&str>, +) -> anyhow::Result<(Found, Option)> { + if let Some(id) = in_force { + let Some(text) = seed_amend::facts(&mut *conn, id, "in_force").await? else { + return Ok((Found::Nothing, Some(id.to_owned()))); + }; + let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?") + .bind(id) + .fetch_one(&mut *conn) + .await?; + return Ok((Found::InForce(Facts { text, ballots }), Some(id.to_owned()))); + } + let featured: Option = sqlx::query_scalar( + "SELECT id FROM proposals WHERE unit_id = ? AND status = 'open' AND origin = 'seed' AND seed_featured = 1 + ORDER BY rowid LIMIT 1", + ) + .bind(unit_id) + .fetch_optional(&mut *conn) + .await?; + Ok(match featured { + Some(id) => (Found::SeedProposal, Some(id)), + None => (Found::Nothing, None), + }) +} + +async fn level_facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result { + let inertia_proposals: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM proposals p WHERE p.unit_id = ? AND p.kind = 'inertia' + AND (p.status IN ('open', 'applied') OR EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id))", + ) + .bind(unit_id) + .fetch_one(&mut *conn) + .await?; + let voted_changes: i64 = sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia') + + (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')", + ) + .bind(unit_id) + .fetch_one(&mut *conn) + .await?; + Ok(LevelFacts { raw, inertia_proposals, voted_changes }) +} + +/// One document, in one transaction: `None` when it was decided at an earlier start. +async fn decide( + state: &AppState, + a: &SeedProposalAmendment, + to: u8, + doc_id: &str, + publishes: bool, +) -> anyhow::Result> { + let mut tx = state.db.begin().await?; + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM seed_proposal_amendments WHERE amendment_id = ? AND doc_id = ?") + .bind(a.id) + .bind(doc_id) + .fetch_one(&mut *tx) + .await?; + if decided > 0 { + return Ok(None); + } + let unit: Option<(String, String, Option)> = + sqlx::query_as("SELECT id, inertia, in_force_id FROM units WHERE doc_id = ? AND code = ?") + .bind(doc_id) + .bind(a.unit) + .fetch_optional(&mut *tx) + .await?; + let ((status_found, examined), level_found) = match &unit { + Some((id, raw, in_force)) => { + (found(&mut tx, id, in_force.as_deref()).await?, Some(level_facts(&mut tx, id, raw.clone()).await?)) + } + None => ((Found::Nothing, None), None), + }; + let mut status = verdict(&status_found, a); + let mut level = level_verdict(status, level_found.as_ref(), a, to); + let now = util::now(); + let to_db = InertiaLevel::new(to).map(InertiaLevel::as_db).unwrap_or_default(); + if let (Verdict::Apply, Some((unit_id, _, Some(pid)))) = (status, &unit) { + // Written only over the version that was judged, still untouched and in force. + let revision = match &status_found { + Found::InForce(f) => f.text.revision, + _ => 0, + }; + let left = sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ? AND in_force_id = ?") + .bind(unit_id) + .bind(pid) + .execute(&mut *tx) + .await?; + let opened = sqlx::query( + "UPDATE proposals SET status = 'open', seed_featured = 1, left_at = ?, updated_at = ? + WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL + AND confirmed_at IS NULL AND revision = ?", + ) + .bind(&now) + .bind(&now) + .bind(pid) + .bind(revision) + .execute(&mut *tx) + .await?; + if left.rows_affected() == 1 && opened.rows_affected() == 1 { + // The history says it: the same text, at the same revision, now a seed proposal. + sqlx::query( + "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) + SELECT id, revision, 'seed_proposal', title, body, NULL, ? FROM proposals WHERE id = ?", + ) + .bind(&now) + .bind(pid) + .execute(&mut *tx) + .await?; + } else { + // Nothing is written: what was examined changed under us. + tx.rollback().await?; + tx = state.db.begin().await?; + status = Verdict::Refuse("changed"); + level = level_verdict(status, level_found.as_ref(), a, to); + } + } + if let (Verdict::Apply, Some((unit_id, raw, _))) = (level, &unit) { + let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?") + .bind(&to_db) + .bind(unit_id) + .bind(raw) + .execute(&mut *tx) + .await?; + if done.rows_affected() == 1 { + let from = InertiaLevel::parse(raw).map_or_else(|| raw.clone(), InertiaLevel::as_db); + sqlx::query( + "INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at) + VALUES (?, ?, ?, ?, 'amendment', ?, ?)", + ) + .bind(unit_id) + .bind(doc_id) + .bind(from) + .bind(&to_db) + .bind(a.id) + .bind(&now) + .execute(&mut *tx) + .await?; + } else { + level = Verdict::Refuse("changed"); + } + } + if publishes && (status == Verdict::Apply || level == Verdict::Apply) { + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(doc_id) + .bind(a.reason) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + let (outcome, reason) = status.outcome(); + let (level_outcome, level_reason) = level.outcome(); + sqlx::query( + "INSERT INTO seed_proposal_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, + from_level, to_level, level_outcome, level_reason, at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(doc_id) + .bind(a.unit) + .bind(examined) + .bind(outcome) + .bind(reason) + .bind(unit.as_ref().map(|(_, raw, _)| raw.clone())) + .bind(&to_db) + .bind(level_outcome) + .bind(level_reason) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some((status, level))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: SeedProposalAmendment = SeedProposalAmendment { + id: "test", + doc: "doc", + unit: "E1", + text: "seed", + levels: &[3, 4], + reason: "Proposition seed : E1 sort de la vigueur", + }; + + fn intact() -> Facts { + Facts { + text: seed_amend::Facts { + origin: "seed".into(), + revision: 1, + revisions: vec!["initial".into()], + fingerprint: "seed".into(), + ..seed_amend::Facts::default() + }, + ballots: 0, + } + } + + fn with(f: impl FnOnce(&mut seed_amend::Facts)) -> Found { + let mut facts = intact(); + f(&mut facts.text); + Found::InForce(facts) + } + + fn at(raw: &str) -> LevelFacts { + LevelFacts { raw: raw.into(), ..LevelFacts::default() } + } + + #[test] + fn an_untouched_text_in_force_is_put_forward() { + assert_eq!(verdict(&Found::InForce(intact()), &A), Verdict::Apply); + // I1: its initial text, then the seed amendment of 30/10. + let amended = with(|t| { + t.revision = 2; + t.revisions = vec!["initial".into(), "amendment".into()]; + }); + assert_eq!(verdict(&amended, &A), Verdict::Apply); + } + + #[test] + fn a_seed_proposal_already_there_is_left_as_it_is() { + assert_eq!(verdict(&Found::SeedProposal, &A), Verdict::Present); + } + + #[test] + fn any_trace_of_a_person_keeps_the_text_in_force() { + let cases = [ + (Found::Nothing, "no_text_in_force"), + (with(|t| t.origin = "member".into()), "not_a_seed"), + (with(|t| t.entered = true), "voted_into_force"), + (with(|t| t.confirmed = true), "confirmed"), + (with(|t| t.votes = 1), "votes"), + (Found::InForce(Facts { ballots: 1, ..intact() }), "ballots"), + (with(|t| t.typos = 1), "corrected"), + ( + with(|t| { + t.revision = 2; + t.revisions = vec!["initial".into(), "typo".into()]; + }), + "corrected", + ), + ( + with(|t| { + t.revision = 2; + t.revisions = vec!["initial".into(), "substantive".into()]; + }), + "revised", + ), + (with(|t| t.revision = 2), "revised"), + (with(|t| t.revisions = vec![]), "revised"), + (with(|t| t.member_translations = 1), "translation_edited"), + (with(|t| t.signals = 1), "signalled"), + (with(|t| t.fingerprint = "other".into()), "text_differs"), + ]; + for (found, reason) in cases { + assert_eq!(verdict(&found, &A), Verdict::Refuse(reason), "{found:?}"); + } + } + + #[test] + fn the_level_goes_low_with_the_status_where_nobody_touched_it() { + assert_eq!(level_verdict(Verdict::Apply, Some(&at("4")), &A, 2), Verdict::Apply); + assert_eq!(level_verdict(Verdict::Apply, Some(&at("3")), &A, 2), Verdict::Apply); + // The word of before, if an older image wrote it. + assert_eq!(level_verdict(Verdict::Apply, Some(&at("standard")), &A, 2), Verdict::Apply); + assert_eq!(level_verdict(Verdict::Present, Some(&at("2")), &A, 2), Verdict::Present); + assert_eq!(level_verdict(Verdict::Apply, Some(&at("1")), &A, 1), Verdict::Present, "level 1, if Yvv says so"); + } + + #[test] + fn a_change_of_inertia_a_person_proposed_or_voted_keeps_the_level() { + let cases = [ + (Verdict::Refuse("votes"), Some(at("4")), "not_a_seed_proposal"), + (Verdict::Apply, None, "no_article"), + (Verdict::Apply, Some(at("haute")), "level_unreadable"), + (Verdict::Apply, Some(LevelFacts { voted_changes: 1, ..at("4") }), "changed_by_vote"), + (Verdict::Apply, Some(LevelFacts { inertia_proposals: 1, ..at("4") }), "inertia_proposal"), + (Verdict::Apply, Some(at("5")), "level_differs"), + (Verdict::Apply, Some(at("1")), "level_differs"), + ]; + for (status, facts, reason) in cases { + assert_eq!(level_verdict(status, facts.as_ref(), &A, 2), Verdict::Refuse(reason), "{facts:?}"); + } + } + + #[test] + fn the_shipped_amendments_name_the_three_first_modules_and_their_levels_of_before() { + let units: Vec<&str> = AMENDMENTS.iter().map(|a| a.unit).collect(); + assert_eq!(units, ["I1", "E1", "E2"]); + assert!(AMENDMENTS.iter().all(|a| !a.levels.contains(&seed::SEED_PROPOSAL_LEVEL))); + assert!(InertiaLevel::new(seed::SEED_PROPOSAL_LEVEL).is_some_and(|l| l.get() <= 2), "a low level"); + } +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index d6a2c64..49d63ae 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -749,6 +749,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"); @@ -822,6 +823,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 }; @@ -1005,6 +1007,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"); diff --git a/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json b/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json new file mode 100644 index 0000000..171b38f --- /dev/null +++ b/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json @@ -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." + } + } + ] + } + ] +} diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs new file mode 100644 index 0000000..22bf64b --- /dev/null +++ b/crates/ld-server/tests/seed_proposals.rs @@ -0,0 +1,698 @@ +//! 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, +} + +impl Client { + fn of(state: &AppState) -> Self { + Self { router: app(state.clone()), cookie: None } + } + + fn other(&self) -> Self { + Self { router: self.router.clone(), cookie: None } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); + if let Some(c) = &self.cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .expect("request"); + let res = self.router.clone().oneshot(req).await.expect("response"); + let status = res.status(); + if let Some(set) = res.headers().get(header::SET_COOKIE) { + let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); + if raw.starts_with("ld_session=") { + self.cookie = Some(raw); + } + } + let bytes = res.into_body().collect().await.expect("body").to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + } + + async fn 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 = 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.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 { + 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, Option, String); + +async fn decisions(state: &AppState) -> Vec { + 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> { + 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, Option) = + 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 = 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)> = 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::>() + ); + + // 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 { + 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, Option)> { + 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::>(), ["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, C = 1 — one voice is not enough (B^W keeps R above 1). + let doc = a.doc("essai").await; + let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned(); + let v = a.vote(&i1, "for").await; + assert!(v["entered"].as_array().expect("entered").is_empty(), "one of two is 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)); + + // From 16 members on, B^W no longer lifts R above C = 1: one voice passes a text in + // the test group (ADR 0007), whatever the level: R = C + B^W at one vote cast. + 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); +} diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 171b38f..8178b8b 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -1,5 +1,5 @@ { - "_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).", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs, 2 « basse » par défaut ; Yvv, 2026-11-02, ADR 0011). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. 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, en vigueur sans vote (seed a minima ; 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). Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend), jamais en réinstanciant le document.", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -37,11 +37,11 @@ "code": "I1", "section": "introduction", "position": 1, - "inertia": 3, "versions": [ { "label": "1.0.0", - "status": "in_force", + "status": "open", + "seedProposal": true, "title": { "fr": "Raison d'être", "en": "Purpose", @@ -103,11 +103,11 @@ "code": "E1", "section": "fondamental", "position": 4, - "inertia": 4, "versions": [ { "label": "1.0.0", - "status": "in_force", + "status": "open", + "seedProposal": true, "title": { "fr": "Unicité du compte", "en": "Uniqueness of the account", @@ -125,11 +125,11 @@ "code": "E2", "section": "fondamental", "position": 5, - "inertia": 4, "versions": [ { "label": "1.0.0", - "status": "in_force", + "status": "open", + "seedProposal": true, "title": { "fr": "Réciprocité", "en": "Reciprocity", From 1027155091b7beea40cd8dbfed3bdda054da2eac Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 07:51:36 +0100 Subject: [PATCH 05/24] =?UTF-8?q?WIP=20accueil=20:=20la=20vo=C3=BBte=20abr?= =?UTF-8?q?ite=20les=20trois=20points=20(bo=C3=AEte=20=C3=A0=20outils=20en?= =?UTF-8?q?=20t=C3=AAte),=20titre=20centr=C3=A9=20en=20deux=20lignes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une clé i18n par ligne du titre (fr, en, es), jamais de
dans une chaîne. Co-Authored-By: Claude Opus 5.5 --- web/src/lib/i18n/en.ts | 3 +- web/src/lib/i18n/es.ts | 3 +- web/src/lib/i18n/fr.ts | 4 +- web/src/routes/+page.svelte | 155 +++++++++++++++++++++++++++--------- 4 files changed, 123 insertions(+), 42 deletions(-) diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 53f7543..50e861e 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -30,7 +30,8 @@ export const en: Messages = { 'nav.menu': 'Menu', 'nav.close': 'Close', - 'home.title': 'Making collective decisions possible', + 'home.title.line1': 'Making it possible', + 'home.title.line2': 'to decide together', 'home.leitmotiv.context': 'context and framing', 'home.leitmotiv.process': 'processes and methods', 'home.leitmotiv.tools': 'a toolbox', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index b5a606f..144df45 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -30,7 +30,8 @@ export const es: Messages = { 'nav.menu': 'Menú', 'nav.close': 'Cerrar', - 'home.title': 'Hacer posible la decisión colectiva', + 'home.title.line1': 'Hacer posible', + 'home.title.line2': 'la decisión colectiva', 'home.leitmotiv.context': 'contexto y encuadre', 'home.leitmotiv.process': 'procesos y modalidades', 'home.leitmotiv.tools': 'caja de herramientas', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index ed9159e..2a922d3 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -33,7 +33,9 @@ export const fr = { 'nav.menu': 'Menu', 'nav.close': 'Fermer', - 'home.title': 'Rendre possible la décision collective', + // The title on two lines, the break after « possible » (Yvv, 2026-11-02): one key per line. + 'home.title.line1': 'Rendre possible', + 'home.title.line2': 'la décision collective', 'home.leitmotiv.context': 'contexte et cadrage', 'home.leitmotiv.process': 'processus et modalités', 'home.leitmotiv.tools': 'boîte à outils', diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index f25707b..df73a6e 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,17 +1,28 @@
-

{t('home.title')}

-
    -
  • {t('home.leitmotiv.context')}
  • -
  • {t('home.leitmotiv.process')}
  • -
  • {t('home.leitmotiv.tools')}
  • -
+
+ +
    + {#each LEITMOTIV as key, i (key)} +
  • {t(key)}
  • + {/each} +
+
+

+ {t('home.title.line1')} + {t('home.title.line2')} +

{#if session.me} @@ -28,56 +39,122 @@ .home { max-width: 44rem; margin: 0 auto; - padding: clamp(3rem, 10vh, 6.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem; + padding: clamp(2rem, 7vh, 4.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem; display: grid; gap: clamp(2.4rem, 6vh, 3.6rem); } - .intro h1 { - font-family: var(--font-text); - font-weight: 500; - font-size: clamp(2.1rem, 1.4rem + 3vw, 3.4rem); - letter-spacing: -0.015em; - line-height: 1.08; - font-variation-settings: 'opsz' 72; - max-width: 15ch; - text-wrap: balance; + + /* The arch, drawn by the sign of the logo (stones in the ink of the mood, the keystone in + `--cle`), never tilted. Its box is the stone itself: 92 units wide (the base slab, x from + −46 to 46) and 59 high (y from −40, the top of the keystone, to 19, under the slab); the SVG's + own 3-unit margin spills out on every side. On a phone the arch reaches into the side padding, + 0.75rem short of the screen's edges, so that the opening keeps as much room as it can. */ + .vault { + --w: min(34rem, 100vw - 1.5rem); + container-type: inline-size; + position: relative; + width: var(--w); + margin-inline: calc(50% - var(--w) / 2); + aspect-ratio: 92 / 59; + color: var(--ink); } - /* The leitmotiv: three lines, each set on a small stone of the keystone's colour. */ + .sign { + position: absolute; + inset: calc(-300% / 59) calc(-300% / 92); + } + .sign :global(svg) { + display: block; + width: 100%; + height: 100%; + } + /* The light under the vault: the opening (x from −18.5 to 18.5, from the keystone down to the + slab), its top a half circle, washed with the keystone's colour. */ + .vault::before { + content: ''; + position: absolute; + inset: calc(2150% / 59) calc(2750% / 92) calc(340% / 59); + border-radius: 50% 50% 0 0 / 54.25% 54.25% 0 0; + background: radial-gradient( + ellipse 75% 62% at 50% 60%, + color-mix(in srgb, var(--cle) 12%, transparent), + transparent + ); + } + + /* The three points, sheltered: centred in the opening (x from −19 to 19, a block centred on + y = −1), one line each. The type follows the arch (cqi): the longest line of the three + languages, 10.8 em, holds in the 38 units of the opening with room to spare. */ .leitmotiv { - list-style: none; - margin: 1.4rem 0 0; + position: absolute; + inset-inline: calc(2700% / 92); + top: calc(3900% / 59); + translate: 0 -50%; + margin: 0; padding: 0; + list-style: none; display: grid; - gap: 0.2rem; + justify-items: center; + gap: 0.2em; font-family: var(--font-text); font-style: italic; - font-size: clamp(1.12rem, 1rem + 0.55vw, 1.4rem); - line-height: 1.45; + font-size: 3.55cqi; + line-height: 1.3; color: var(--ink-soft); + white-space: nowrap; + text-align: center; } - .leitmotiv li { - display: flex; - align-items: baseline; - gap: 0.7rem; + + .intro h1 { + margin-top: clamp(1.4rem, 4vh, 2.4rem); + font-family: var(--font-text); + font-weight: 500; + font-size: min(3.3rem, 8.6vw); + letter-spacing: -0.015em; + line-height: 1.1; + font-variation-settings: 'opsz' 72; + text-align: center; } - .leitmotiv li::before { - content: ''; - flex: none; - width: 0.62em; - height: 0.34em; - border-radius: 0.1em; - background: var(--cle); - opacity: 0.75; - translate: 0 -0.12em; + .line { + display: block; + text-wrap: balance; } + .enter { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.6rem 1.2rem; + display: grid; + justify-items: center; + gap: 0.8rem; + text-align: center; + } + .enter .btn { + min-height: 2.75rem; + padding-inline: 1.4rem; } .enter p { font-size: 0.9rem; max-width: 34ch; } + + /* The keystone sets the arch, then the three points come under it. */ + @media (prefers-reduced-motion: no-preference) { + .sign :global(path[fill^='var(--cle']) { + transform-box: fill-box; + animation: settle 0.7s var(--ease) 0.1s backwards; + } + .leitmotiv li { + animation: appear 0.6s var(--ease) backwards; + animation-delay: calc(0.55s + var(--i) * 0.12s); + } + } + @keyframes settle { + from { + transform: translateY(-40%); + opacity: 0; + } + } + @keyframes appear { + from { + transform: translateY(0.4em); + opacity: 0; + } + } From 563745204052c603bee8a982a99804e041cee963 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 03:42:20 +0100 Subject: [PATCH 06/24] =?UTF-8?q?WIP=20accueil=20:=20la=20bande=20des=20tr?= =?UTF-8?q?ois=20points=20sous=20la=20cl=C3=A9,=20centr=C3=A9e=20sur=20la?= =?UTF-8?q?=20naissance=20de=20l'arche=20;=20marges=20lat=C3=A9rales=20du?= =?UTF-8?q?=20dessin=20rogn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/routes/+page.svelte | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index df73a6e..990d05d 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -47,8 +47,9 @@ /* The arch, drawn by the sign of the logo (stones in the ink of the mood, the keystone in `--cle`), never tilted. Its box is the stone itself: 92 units wide (the base slab, x from −46 to 46) and 59 high (y from −40, the top of the keystone, to 19, under the slab); the SVG's - own 3-unit margin spills out on every side. On a phone the arch reaches into the side padding, - 0.75rem short of the screen's edges, so that the opening keeps as much room as it can. */ + own 3-unit margin spills out above and below (clipped on the sides, where it is empty). On a + phone the arch reaches into the side padding, 0.75rem short of the screen's edges, so that the + opening keeps as much room as it can. */ .vault { --w: min(34rem, 100vw - 1.5rem); container-type: inline-size; @@ -56,6 +57,7 @@ width: var(--w); margin-inline: calc(50% - var(--w) / 2); aspect-ratio: 92 / 59; + overflow-x: clip; color: var(--ink); } .sign { @@ -81,18 +83,18 @@ ); } - /* The three points, sheltered: centred in the opening (x from −19 to 19, a block centred on - y = −1), one line each. The type follows the arch (cqi): the longest line of the three - languages, 10.8 em, holds in the 38 units of the opening with room to spare. */ + /* The three points, sheltered: centred in the opening (x from −19 to 19; a band from y = −19, + under the keystone, to 19, centred on the springing line y = 0), one line each. The type + follows the arch (cqi): the longest line of the three languages, 10.8 em, holds in the + 38 units of the opening with room to spare. */ .leitmotiv { position: absolute; - inset-inline: calc(2700% / 92); - top: calc(3900% / 59); - translate: 0 -50%; + inset: calc(2100% / 59) calc(2700% / 92) 0; margin: 0; padding: 0; list-style: none; display: grid; + align-content: center; justify-items: center; gap: 0.2em; font-family: var(--font-text); From 723242e0a68aa6c3c8ba70aafcabe25d31a261b2 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 03:42:29 +0100 Subject: [PATCH 07/24] =?UTF-8?q?Tableau=20:=20rel=C3=A8ve=20d'orch=20le?= =?UTF-8?q?=2003/11=20=C3=A0=2003=20h=2038,=20=C3=A9tat=20des=20quatre=20c?= =?UTF-8?q?opies,=20ordre=20de=20reprise=20H,=20S,=20V,=20U=20;=20H=20rela?= =?UTF-8?q?nc=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 41 +++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 61741d7..2263f98 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -27,20 +27,35 @@ fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle ve - un vote simulé compte, puis s'efface à l'ouverture de la chaîne (acte « supprime ») ; - la publication est suspendue jusqu'au premier texte en vigueur. -Lots en cours, en copies de travail : -- **S** : I1, E1 et E2 deviennent des « Propositions seed », pas en vigueur, au cran 2 ; la - publication suspendue (ADR 0011, migration 0009). Puis le quorum : ADR 0013, migration 0011, - `PUT /units/{id}/quorum`, champs `quorum`, `yesNeeded` et `noNeeded`. -- **V** : le tampon entre la base des votes et les indexeurs ; « je vois ce que j'ai fait sans - attendre » ; contrôle a posteriori (unique, le dernier qui vaut) ; vote simulé partout tant que la - chaîne est fermée, qui compte. ADR 0012, migration 0010. -- **U** : le lecteur. Page courte ; ordonnancement par poignées en fin de section ; trois natures de - modules ; consoles sans doublon ; déplier ou panneau, une règle (note `UX-lecteur.md`) ; la ligne - oui / non et le réglage du quorum. Relancé le 02/11 à 07 h 26, après une coupure réseau. -- **H** : l'accueil. Les trois points sous la clé de voûte, « boîte à outils » en premier ; le titre - centré, sur deux lignes. Relancé le 02/11 à 07 h 26, après une coupure réseau. +**Relève d'orch le 03/11 à 03 h 38**, sur ordre d'orch racine (dev-63). La session d'avant était +bloquée par le quota depuis le 02/11 à 08 h 51, puis coupée par le redémarrage du poste à 02 h 56. +Les quatre agents sont morts, leurs copies sont intactes, `/tmp` est vidé (captures et bases de +test perdues). Budget : finir S, U, V et H, sans nouveau lot, **un sous-agent à la fois** (compte +partagé avec HCO, qui passe en premier). **Pas de mandat** : M6 attend le « déploie » d'Yvv tapé +dans cette session (le « Pousse. Déploie. » du 02/11 a été dit à une autre session). -Puis : fusion, boucle, relecture indépendante, répétition, acte M6. +Ordre de reprise : H, puis S, puis V, puis U. Chaque lot fusionne d'abord le chantier, qui contient +déjà les lots finis. Le contrat du quorum vient de S et sert V et U. + +Lots en cours, en copies de travail (`.claude/worktrees/agent-`) : +- **H** (`a6b179c…`, `lot/h-accueil`) : l'accueil. Les trois points sous la clé de voûte, « boîte + à outils » en premier ; le titre centré, sur deux lignes. WIP `1027155` + un réglage non commité. + **Relancé le 03/11 à 03 h 41.** +- **S** (`a0b2510…`, `lot/s-seed`) : I1, E1 et E2 deviennent des « Propositions seed », pas en + vigueur, au cran 2 ; la publication suspendue (ADR 0011, migration 0009). WIP `6228d39` + 14 + fichiers (tests en reprise). Phase 2 pas commencée : le quorum, ADR 0013, migration 0011, + `PUT /units/{id}/quorum`, champs `quorum`, `yesNeeded` et `noNeeded`. En attente de H. +- **V** (`a78bacc…`, `lot/v-tampon`) : le tampon entre la base des votes et les indexeurs ; « je + vois ce que j'ai fait sans attendre » ; contrôle a posteriori (unique, le dernier qui vaut) ; vote + simulé partout tant que la chaîne est fermée, qui compte, puis s'efface par un acte « supprime ». + ADR 0012 et moteur commités (`e7dda2f`, `ee37c3b`) ; serveur, migration 0010 et tests non + commités (16 fichiers). En attente de S. +- **U** (`adddf06…`, `lot/u-lecteur`) : le lecteur. Page courte ; ordonnancement par poignées en + fin de section ; trois natures de modules ; consoles sans doublon ; déplier ou panneau, une règle ; + la ligne oui / non et le réglage du quorum. Note `UX-lecteur.md` commitée (`d4531da`), 4 fichiers + commencés. En attente de V. + +Puis : boucle sur le chantier, relecture indépendante, répétition, dossier d'acte M6, PRÊT. Yvv relit les quatre textes en ligne. Au prochain tour, avec ses retours : - N1.1 : G se calcule sur p̃ rapporté à W ; From d76598b41b250183759c855d74fb5f9642502d16 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 03:42:43 +0100 Subject: [PATCH 08/24] Tableau : heure du blocage par le quota en heure de Paris (09 h 51, pas 08 h 51 UTC) Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 2263f98..2a7de14 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -28,7 +28,7 @@ fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle ve - la publication est suspendue jusqu'au premier texte en vigueur. **Relève d'orch le 03/11 à 03 h 38**, sur ordre d'orch racine (dev-63). La session d'avant était -bloquée par le quota depuis le 02/11 à 08 h 51, puis coupée par le redémarrage du poste à 02 h 56. +bloquée par le quota depuis le 02/11 à 09 h 51, puis coupée par le redémarrage du poste à 02 h 56. Les quatre agents sont morts, leurs copies sont intactes, `/tmp` est vidé (captures et bases de test perdues). Budget : finir S, U, V et H, sans nouveau lot, **un sous-agent à la fois** (compte partagé avec HCO, qui passe en premier). **Pas de mandat** : M6 attend le « déploie » d'Yvv tapé From 0d3dd416d25a038ca08a09961c8cead3bc0fdd8e Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 03:46:57 +0100 Subject: [PATCH 09/24] =?UTF-8?q?Tableau=20:=20Q9=20et=20Q10=20tranch?= =?UTF-8?q?=C3=A9es=20par=20Yvv=20le=2003/11=20(votants=20manquants=20=3D?= =?UTF-8?q?=20non=20;=20AGPL-3.0-or-later)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 2a7de14..ead1b9c 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -119,8 +119,8 @@ chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichag | Q6 | Ğ1Companion ne signe pas encore une transaction construite par une page : quelle extension pour le geste unique ? | **tranché** (01/11) : duniter-connect maintenant ; Ğ1Companion et trustinWallet sur la même interface ; Ğecko par lien et QR | | Q7 | Le compte qui reçoit les 0,1 Ğ1 : à qui, et que deviennent les sommes ? | **tranché** (01/11) : un compte « libreDecision » créé par Yvv dans Ğecko, les sommes au collectif ; attend son adresse publique | | Q8 | Le secret du vote tombe par recoupement : la chaîne montre qui vient de voter, et les totaux bougent aussitôt (relecture de `74f96be`) | **tranché** (01/11) : on ne dit rien ; totaux et son propre vote en direct, inchangés | -| Q9 | Sous le quorum, les votants qui manquent comptent-ils comme des « non » ? (Sinon, un « non » peut faire passer un texte en complétant le quorum.) | **défaut appliqué** : oui, ils comptent comme des « non » ; posé à Yvv le 02/11 | -| Q10 | Licence : AGPL-3.0 seulement, ou « ou toute version ultérieure » ? | **défaut appliqué** : AGPL-3.0-only (`76b8e9f`) ; posé à Yvv le 02/11 | +| Q9 | Sous le quorum, les votants qui manquent comptent-ils comme des « non » ? (Sinon, un « non » peut faire passer un texte en complétant le quorum.) | **tranché** (03/11, formulaire d'orch racine vers 03 h 45) : « Ils comptent comme non » ; au cran 2, quorum 12, 9 oui passent avec 3 non au plus | +| Q10 | Licence : AGPL-3.0 seulement, ou « ou toute version ultérieure » ? | **tranché** (03/11, même formulaire) : « Ou version ultérieure » → `AGPL-3.0-or-later` dans les manifestes (`Cargo.toml`, `web/package.json` et son verrou) et le README ; `LICENSE` inchangé ; confié au lot S | Défauts posés par orch, sans question (Yvv peut les corriger) : - Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote From c1275512e1f39580e09b1a9924bed4e064db198c Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 03:54:14 +0100 Subject: [PATCH 10/24] =?UTF-8?q?Accueil=20:=20sur=20t=C3=A9l=C3=A9phone,?= =?UTF-8?q?=20l'italique=20des=20trois=20points=20garde=20ses=20avances=20?= =?UTF-8?q?exactes=20(lettres=20et=20espaces=20r=C3=A9guliers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/routes/+page.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 990d05d..26d6b08 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -85,8 +85,10 @@ /* The three points, sheltered: centred in the opening (x from −19 to 19; a band from y = −19, under the keystone, to 19, centred on the springing line y = 0), one line each. The type - follows the arch (cqi): the longest line of the three languages, 10.8 em, holds in the - 38 units of the opening with room to spare. */ + follows the arch (cqi): the longest line of the three languages, 10.5 em (es), holds in the + 38 units of the opening with room to spare. On a phone the italic is small (12px at 360): + its advances are kept exact, not rounded to the pixel, or the letters drift apart + (« pr ocessus ») and the word spaces close up. */ .leitmotiv { position: absolute; inset: calc(2100% / 59) calc(2700% / 92) 0; @@ -104,6 +106,7 @@ color: var(--ink-soft); white-space: nowrap; text-align: center; + text-rendering: geometricPrecision; } .intro h1 { From 48529e5409b2ac0c9c938c3a3f4ecd1cad01ae48 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 03:58:08 +0100 Subject: [PATCH 11/24] =?UTF-8?q?e2e=20:=20l'accueil=20sous=20la=20vo?= =?UTF-8?q?=C3=BBte=20(C1=20au=20bureau,=20M1=20sur=20t=C3=A9l=C3=A9phone?= =?UTF-8?q?=20=C3=A0=20412=20puis=20360)=20:=20les=20trois=20points=20dans?= =?UTF-8?q?=20l'ouverture=20et=20centr=C3=A9s,=20le=20titre=20centr=C3=A9?= =?UTF-8?q?=20en=20deux=20lignes,=20rien=20d'inclin=C3=A9=20ni=20de=20d?= =?UTF-8?q?=C3=A9bordant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/helpers.ts | 73 +++++++++++++++++++++++++++++++++++++++++- web/e2e/mobile.spec.ts | 10 ++++-- web/e2e/reader.spec.ts | 7 ++-- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index ebf61fd..3d881a4 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -1,4 +1,4 @@ -import type { Browser, Page } from '@playwright/test'; +import { expect, type Browser, type Page } from '@playwright/test'; export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification'; export const TEST_DOC = '/g/essai/docs/acte-engagement-certification'; @@ -138,3 +138,74 @@ export async function chain(page: Page, docPath = DOC) { (await page.request.post(`${base}/control/${what}`)).json(), }; } + +/** + * The welcome (Yvv, 2026-11-02), in French: the keystone arch shelters the three points, the + * toolbox first, each centred under the keystone in the opening; then the title, centred, on + * two lines broken after « possible ». Nothing tilted, nothing past the edges of the screen. + * Measured once the arch has settled (its entrance done). + */ +export async function expectWelcome(page: Page) { + await expect(page.locator('.vault .leitmotiv li')).toHaveText(['boîte à outils', 'contexte et cadrage', 'processus et modalités']); + const h1 = page.getByRole('heading', { level: 1 }); + await expect(h1).toHaveText('Rendre possible la décision collective'); + await expect(h1.locator('.line')).toHaveText(['Rendre possible', 'la décision collective']); + const m = await page.evaluate(async () => { + const intro = document.querySelector('.intro')!; + await Promise.all(intro.getAnimations({ subtree: true }).map((a) => a.finished)); + const text = (el: Element) => { + const r = document.createRange(); + r.selectNodeContents(el); + return { box: r.getBoundingClientRect(), rects: r.getClientRects().length }; + }; + const v = document.querySelector('.vault')!.getBoundingClientRect(); + // The arch in its own units: 92 wide (x from −46), the top of the keystone at y = −40. + const u = v.width / 92; + const at = (x: number, y: number) => ({ x: v.left + (x + 46) * u, y: v.top + (y + 40) * u }); + const points = [...document.querySelectorAll('.leitmotiv li')].map((li) => { + const { box, rects } = text(li); + // Back into the arch's units, from its axis and its springing line (y = 0). + return { rects, left: (box.left - at(0, 0).x) / u, right: (box.right - at(0, 0).x) / u, top: (box.top - at(0, 0).y) / u, bottom: (box.bottom - at(0, 0).y) / u }; + }); + const h = document.querySelector('h1')!.getBoundingClientRect(); + const lines = [...document.querySelectorAll('h1 .line')].map((l) => ({ ...text(l), block: l.getBoundingClientRect() })); + const tilted = [document.querySelector('.vault svg')!, ...document.querySelectorAll('.vault svg path')] + .flatMap((n) => { + const out: string[] = []; + for (let e: Element | null = n; e && e !== document.body; e = e.parentElement) { + const cs = getComputedStyle(e); + const flat = cs.transform === 'none' || /^matrix\(1, 0, 0, 1, /.test(cs.transform); + if (!flat || (cs.rotate && cs.rotate !== 'none')) out.push(`${e.tagName} ${cs.transform} ${cs.rotate}`); + } + return out; + }); + return { + overflow: document.documentElement.scrollWidth - innerWidth, + axis: v.left + v.width / 2, + vaultBottom: v.bottom, + points, + title: { top: h.top, axis: h.left + h.width / 2 }, + lines: lines.map(({ box, rects, block }) => ({ rects, top: block.top, bottom: block.bottom, axis: box.left + box.width / 2 })), + tilted, + }; + }); + expect(m.overflow, 'nothing past the edge of the screen').toBeLessThanOrEqual(0); + expect(m.tilted, 'the arch is never tilted').toEqual([]); + // The three points: one line each, top to bottom, centred on the axis, under the keystone (its + // foot at y = −19), between the imposts (x = ±19) and above the slab (y = 15.6); a line that + // rises above the springing line stays clear of the voussoirs, closer in there (x = ±17). + for (const [i, p] of m.points.entries()) { + expect(p.rects, `point ${i}: one line`).toBe(1); + expect(Math.abs((p.left + p.right) / 2), `point ${i}: centred`).toBeLessThan(0.3); + expect(p.top, `point ${i}: under the keystone`).toBeGreaterThan(-19); + expect(p.bottom, `point ${i}: above the slab`).toBeLessThan(15.6); + expect(p.right, `point ${i}: in the opening`).toBeLessThan(p.top < 0 ? 17 : 19); + if (i > 0) expect(p.top, `point ${i}: under the one before`).toBeGreaterThan(m.points[i - 1]!.top); + } + // The title: under the arch, centred on its axis, two lines of one line each. + expect(m.title.top).toBeGreaterThanOrEqual(m.vaultBottom); + expect(Math.abs(m.title.axis - m.axis)).toBeLessThan(1); + expect(m.lines.map((l) => l.rects)).toEqual([1, 1]); + expect(m.lines[1]!.top).toBeGreaterThanOrEqual(m.lines[0]!.bottom - 1); + for (const l of m.lines) expect(Math.abs(l.axis - m.axis)).toBeLessThan(1); +} diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index 15ffc4f..6357a22 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Locator } from '@playwright/test'; -import { DOC, shot, signIn } from './helpers'; +import { DOC, expectWelcome, shot, signIn } from './helpers'; /** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */ function contrast(a: string, b: string): number { @@ -25,13 +25,19 @@ async function backdrop(el: Locator): Promise { }); } -test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => { +test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto('/'); const brand = page.locator('header.topbar a.brand'); await expect(brand).toHaveAccessibleName('libreDecision'); await expect(brand.locator('svg')).toBeVisible(); await expect(page.locator('a.root')).toContainText('Monnaie libre'); + // The welcome on this phone, then on a narrow one (360): the arch still shelters the three points. + await expectWelcome(page); + const size = page.viewportSize(); + await page.setViewportSize({ width: 360, height: 780 }); + await expectWelcome(page); + if (size) await page.setViewportSize(size); await page.goto(DOC); await page.getByRole('button', { name: 'Menu' }).click(); const drawer = page.getByRole('dialog', { name: 'Boîte à outils' }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 009571b..e4766c3 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -1,10 +1,9 @@ import { expect, test } from '@playwright/test'; -import { BOB, DOC, person, signIn, TEST_DOC, wallet } from './helpers'; +import { BOB, DOC, expectWelcome, person, signIn, TEST_DOC, wallet } from './helpers'; -test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => { +test('C1 — l’accueil sans session : la voûte abrite les trois points, le titre centré en deux lignes, la connexion, le menu complet', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective'); - await expect(page.locator('.leitmotiv li')).toHaveText(['contexte et cadrage', 'processus et modalités', 'boîte à outils']); + await expectWelcome(page); const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) { await expect(nav.locator('.tool', { hasText: item })).toBeVisible(); From d07883eca006d855379f7fc373dd853c28a3f19f Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:05:43 +0100 Subject: [PATCH 12/24] =?UTF-8?q?Tableau=20:=20lot=20H=20fusionn=C3=A9=20(?= =?UTF-8?q?a3c0a76),=20S=20relanc=C3=A9=20=C3=A0=2004=20h=2005=20;=20deux?= =?UTF-8?q?=20d=C3=A9fauts=20du=20menu=20et=20du=20sceau=20en=20liste=20d'?= =?UTF-8?q?attente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index ead1b9c..759f1d4 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -38,13 +38,15 @@ Ordre de reprise : H, puis S, puis V, puis U. Chaque lot fusionne d'abord le cha déjà les lots finis. Le contrat du quorum vient de S et sert V et U. Lots en cours, en copies de travail (`.claude/worktrees/agent-`) : -- **H** (`a6b179c…`, `lot/h-accueil`) : l'accueil. Les trois points sous la clé de voûte, « boîte - à outils » en premier ; le titre centré, sur deux lignes. WIP `1027155` + un réglage non commité. - **Relancé le 03/11 à 03 h 41.** +- **H** (`a6b179c…`, `lot/h-accueil`) : l'accueil. **Fini et fusionné** le 03/11 à 04 h 03 + (`a3c0a76`). Boucle verte dans sa copie : 237 tests Rust, 55 vitest, e2e 35 / 35. À faire voir + à Yvv : le titre anglais devient « Making it possible / to decide together » ; les trois points + font 12 px à 360 de large. - **S** (`a0b2510…`, `lot/s-seed`) : I1, E1 et E2 deviennent des « Propositions seed », pas en vigueur, au cran 2 ; la publication suspendue (ADR 0011, migration 0009). WIP `6228d39` + 14 - fichiers (tests en reprise). Phase 2 pas commencée : le quorum, ADR 0013, migration 0011, - `PUT /units/{id}/quorum`, champs `quorum`, `yesNeeded` et `noNeeded`. En attente de H. + fichiers (tests en reprise). Phase 2 : le quorum, ADR 0013, migration 0011, + `PUT /units/{id}/quorum`, champs `quorum`, `yesNeeded` et `noNeeded`. Phase 3 : la licence + `AGPL-3.0-or-later`. **Relancé le 03/11 à 04 h 05.** - **V** (`a78bacc…`, `lot/v-tampon`) : le tampon entre la base des votes et les indexeurs ; « je vois ce que j'ai fait sans attendre » ; contrôle a posteriori (unique, le dernier qui vaut) ; vote simulé partout tant que la chaîne est fermée, qui compte, puis s'efface par un acte « supprime ». @@ -92,7 +94,9 @@ Ce qu'Yvv doit fournir : | L12 | Objections « redit » et « contredit » : la plus récente des deux propositions n'est pas votable tant que son auteur n'a pas répondu (redit : modifier l'article visé ; contredit : reformuler) ; un message à l'auteur de l'objection dans trustinWallet : lever ou réitérer ; sans réponse sous 3 jours, l'objection est levée. | F | M4 | | L13 | Coquilles : celle d'un autre que l'auteur attend sa validation (message), puis passe seule après 3 jours ; l'auteur garde la main tant que le texte n'est pas en vigueur ; sur un texte en vigueur, seule une coquille validée le corrige. | F | M4 | -**Liste d'attente** (hors livraison, sauf mot d'Yvv) : l'inscription de chaque vote sur la +**Liste d'attente** (hors livraison, sauf mot d'Yvv) : au bureau, le bas du menu latéral (pastilles +d'ambiance, « Se connecter ») est rogné au bord droit ; sur téléphone avec session, le sceau fixe +chevauche la flèche d'une carte de groupe (vus par le lot H le 03/11, pas comparés à `5cf30e0`) ; l'inscription de chaque vote sur la chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichage dans Ğecko. ## Lots From e2445f6467ac68fcc7059ea93a92e5e0e71c7fc5 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:06:07 +0100 Subject: [PATCH 13/24] =?UTF-8?q?WIP=20lot=20S=20:=20floor=5Famend=20retir?= =?UTF-8?q?=C3=A9=20(le=20quorum=20le=20remplace),=20fixture=20de=20produc?= =?UTF-8?q?tion=20sous=20tests/fixtures/seed-production-2026-11-02/,=20rep?= =?UTF-8?q?rise=20des=20tests=20(api,=20inertia,=20ballots,=20objections,?= =?UTF-8?q?=20typos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../migrations/0009_seed_proposals.sql | 15 -- crates/ld-server/src/config.rs | 5 +- crates/ld-server/src/floor_amend.rs | 199 ------------------ crates/ld-server/src/lib.rs | 1 - crates/ld-server/src/publish.rs | 4 +- crates/ld-server/src/seed.rs | 6 +- crates/ld-server/src/typos.rs | 16 +- crates/ld-server/tests/api.rs | 89 +++++--- crates/ld-server/tests/ballots.rs | 58 ++--- .../acte-engagement-certification.json} | 0 crates/ld-server/tests/inertia.rs | 77 ++++--- crates/ld-server/tests/objections.rs | 18 +- crates/ld-server/tests/seed_proposals.rs | 2 +- crates/ld-server/tests/typos.rs | 7 +- 14 files changed, 185 insertions(+), 312 deletions(-) delete mode 100644 crates/ld-server/src/floor_amend.rs rename crates/ld-server/tests/fixtures/{seed-production-2026-11-02.json => seed-production-2026-11-02/acte-engagement-certification.json} (100%) diff --git a/crates/ld-server/migrations/0009_seed_proposals.sql b/crates/ld-server/migrations/0009_seed_proposals.sql index e7abc5a..e56b55c 100644 --- a/crates/ld-server/migrations/0009_seed_proposals.sql +++ b/crates/ld-server/migrations/0009_seed_proposals.sql @@ -34,18 +34,3 @@ CREATE TABLE seed_proposal_amendments ( at TEXT NOT NULL, PRIMARY KEY (amendment_id, doc_id) ); - --- A floor C changed in an existing document (src/floor_amend.rs), should Yvv --- lower it to start (question asked on 2026-11-02; none listed yet): decided --- once per document, only if the floor is still the one expected. Each decision --- is kept here: applied, refused (and why), or present. -CREATE TABLE floor_amendments ( - amendment_id TEXT NOT NULL, - doc_id TEXT NOT NULL REFERENCES documents(id), - from_floor REAL, -- the floor found - to_floor REAL NOT NULL, - outcome TEXT NOT NULL, -- applied | refused | present - reason TEXT, -- why it was refused - at TEXT NOT NULL, - PRIMARY KEY (amendment_id, doc_id) -); diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index 6915938..afac818 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -20,8 +20,9 @@ pub struct Publish { pub author_name: String, pub author_email: String, /// Publish a document even when none of its articles is in force - /// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default (orch, 2026-11-02, while Yvv - /// decides): the queue waits, and the first text that enters sends it (ADR 0011). + /// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default — Yvv, 2026-11-02: « Suspendre + /// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it + /// (ADR 0011). pub without_articles: bool, } diff --git a/crates/ld-server/src/floor_amend.rs b/crates/ld-server/src/floor_amend.rs deleted file mode 100644 index 4080ec8..0000000 --- a/crates/ld-server/src/floor_amend.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Floor amendments — the floor C of an existing document changed (ADR 0011). -//! -//! C is the minimal number of *for* votes below which nothing passes (ADR 0002): -//! 50 in Monnaie libre, whatever the level of inertia. A low level for the seed -//! proposals does not make a start with five people possible; whether C is -//! lowered to start is Yvv's question (orch, 2026-11-02). **None is listed**: -//! the floor stays. Should he lower it, one entry here does it, with the seed's -//! `voteFloor` of the group set to the same value (new bases read the seed): -//! -//! - decided once per document, at the first start that knows the amendment; -//! - only if the floor is still the one expected (`from`); already `to`: present; -//! - kept in `floor_amendments` and in the logs; where the document publishes, -//! the publication queue gets a row (`document.json` carries `voteFloor`). -//! -//! Every threshold of the document follows at the next pass of the sweeper, and -//! so does the reference participation (2C/W) while fewer than five proposals -//! are voted (ADR 0007). - -use crate::AppState; -use crate::seed::SeedDoc; -use crate::util; - -/// One floor to change, in the document a seed instantiated in a group. -pub struct FloorAmendment { - /// Stable identifier: the decision is kept under it. - pub id: &'static str, - /// Slug of the seed document. - pub doc: &'static str, - /// The group whose document it changes. - pub group: &'static str, - /// The floor it must still hold. - pub from: f64, - /// The floor it gets: the seed's today for the group, which must say so. - pub to: f64, - /// Reason given to the publication queue, and so to the texts repository. - pub reason: &'static str, -} - -/// None while Yvv has not decided (2026-11-02). -pub const AMENDMENTS: &[FloorAmendment] = &[]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Verdict { - Apply, - /// The floor is already there: nothing to do. - Present, - Refuse(&'static str), -} - -fn same(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-9 -} - -/// The rule, apart from the database. -pub fn verdict(found: Option, a: &FloorAmendment) -> Verdict { - match found { - None => Verdict::Refuse("no_document"), - Some(c) if same(c, a.to) => Verdict::Present, - Some(c) if !same(c, a.from) => Verdict::Refuse("floor_differs"), - Some(_) => Verdict::Apply, - } -} - -/// A decision taken at this start. -#[derive(Debug, Clone, PartialEq)] -pub struct Outcome { - pub amendment: &'static str, - pub verdict: Verdict, -} - -/// Decides every amendment not yet decided. -pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[FloorAmendment]) -> anyhow::Result> { - let mut out = Vec::new(); - for a in list { - if !seed_says(seeds, a) { - continue; - } - let doc: Option<(String, f64, Option)> = - sqlx::query_as("SELECT id, vote_floor, publish_path FROM documents WHERE slug = ? AND group_slug = ?") - .bind(a.doc) - .bind(a.group) - .fetch_optional(&state.db) - .await?; - let mut tx = state.db.begin().await?; - let doc_id = doc.as_ref().map(|(id, _, _)| id.clone()); - if let Some(id) = &doc_id { - let decided: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM floor_amendments WHERE amendment_id = ? AND doc_id = ?") - .bind(a.id) - .bind(id) - .fetch_one(&mut *tx) - .await?; - if decided > 0 { - continue; - } - } - let mut v = verdict(doc.as_ref().map(|(_, c, _)| *c), a); - let now = util::now(); - if let (Verdict::Apply, Some((id, c, publish_path))) = (v, &doc) { - // Written only over the floor that was judged. - let done = sqlx::query("UPDATE documents SET vote_floor = ? WHERE id = ? AND vote_floor = ?") - .bind(a.to) - .bind(id) - .bind(c) - .execute(&mut *tx) - .await?; - if done.rows_affected() != 1 { - v = Verdict::Refuse("changed"); - } else if publish_path.is_some() { - sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") - .bind(id) - .bind(a.reason) - .bind(&now) - .bind(&now) - .execute(&mut *tx) - .await?; - } - } - let Some(id) = doc_id else { - tracing::warn!(amendment = a.id, group = a.group, "floor amendment: no such document, nothing decided"); - continue; - }; - let (outcome, reason) = match v { - Verdict::Apply => ("applied", None), - Verdict::Present => ("present", None), - Verdict::Refuse(why) => ("refused", Some(why)), - }; - sqlx::query( - "INSERT INTO floor_amendments (amendment_id, doc_id, from_floor, to_floor, outcome, reason, at) - VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(a.id) - .bind(&id) - .bind(doc.as_ref().map(|(_, c, _)| *c)) - .bind(a.to) - .bind(outcome) - .bind(reason) - .bind(&now) - .execute(&mut *tx) - .await?; - tx.commit().await?; - match v { - Verdict::Refuse(why) => { - tracing::warn!(amendment = a.id, group = a.group, reason = why, "floor amendment refused: the floor stays") - } - _ => tracing::info!(amendment = a.id, group = a.group, from = a.from, to = a.to, outcome, "floor amendment"), - } - if v == Verdict::Apply { - state.publish_wake.notify_one(); - } - out.push(Outcome { amendment: a.id, verdict: v }); - } - Ok(out) -} - -/// The seed must give the group this floor: new bases read it there. -fn seed_says(seeds: &[SeedDoc], a: &FloorAmendment) -> bool { - let floor = seeds - .iter() - .find(|s| s.slug == a.doc) - .and_then(|s| s.instances.iter().find(|i| i.group == a.group)) - .map(|i| i.vote_floor); - if floor.is_some_and(|c| same(c, a.to)) { - return true; - } - tracing::error!( - amendment = a.id, - group = a.group, - "floor amendment: the seed does not give this floor, nothing decided" - ); - false -} - -#[cfg(test)] -mod tests { - use super::*; - - const A: FloorAmendment = FloorAmendment { - id: "test", - doc: "doc", - group: "monnaie-libre", - from: 50.0, - to: 10.0, - reason: "plancher C abaissé : 50 → 10", - }; - - #[test] - fn none_is_listed_while_yvv_decides() { - assert!(AMENDMENTS.is_empty()); - } - - #[test] - fn the_floor_changes_only_from_the_one_expected() { - assert_eq!(verdict(Some(50.0), &A), Verdict::Apply); - assert_eq!(verdict(Some(10.0), &A), Verdict::Present); - assert_eq!(verdict(Some(40.0), &A), Verdict::Refuse("floor_differs")); - assert_eq!(verdict(None, &A), Verdict::Refuse("no_document")); - } -} diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index dc5fbac..6425be2 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -17,7 +17,6 @@ pub mod deadlines; pub mod doc_title; pub mod docs; pub mod error; -pub mod floor_amend; pub mod indexer; pub mod inertia_amend; pub mod messages; diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index 6a620ec..eb36490 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -125,8 +125,8 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { /// Does the document wait for a text before it is published? Yes when none of its /// articles is in force — the seed proposals are not (ADR 0011) — unless the -/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default -/// is the orch's (2026-11-02), while Yvv decides: never a document without a text. +/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default is +/// Yvv's (2026-11-02, « Suspendre jusqu'au 1er texte »): never a document without a text. pub async fn waits_for_a_text(state: &AppState, doc_id: &str) -> anyhow::Result { if state.cfg.publish.as_ref().is_none_or(|p| p.without_articles) { return Ok(false); diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index b2ab103..c4ec844 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -26,8 +26,9 @@ use std::path::Path; /// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « on /// pouvait l'amorcer avec une inertie basse, pour que le nombre de votes initiaux soit -/// assez petit pour commencer l'expérience en mode prod » — level 2 (« basse »), or -/// level 1 (« très basse »): his answer awaited, 2 by default. The one place to change: +/// assez petit pour commencer l'expérience en mode prod » — level 2 (« basse »), +/// confirmed by Yvv the same day (level 1, « très basse », was the other choice). The +/// one place to change: /// the seed gives no level to an article it puts forward, and the seed proposal /// amendment (`seed_proposal_amend`) writes this one. pub const SEED_PROPOSAL_LEVEL: u8 = 2; @@ -133,7 +134,6 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> { module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?; // After the text amendments: a starting text replaced first, then put forward. seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?; - crate::floor_amend::apply(state, &seeds, crate::floor_amend::AMENDMENTS).await?; Ok(()) } diff --git a/crates/ld-server/src/typos.rs b/crates/ld-server/src/typos.rs index 1c811e7..c227e14 100644 --- a/crates/ld-server/src/typos.rs +++ b/crates/ld-server/src/typos.rs @@ -491,13 +491,25 @@ mod tests { let state = AppState::new(Config::for_tests(), db).await.expect("state"); seed::ensure(&state).await.expect("seed"); let now = "2026-10-31T00:00:00Z"; + // A seed proposal (ADR 0011) the vote has just put in force. let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as( "SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id - WHERE p.status = 'in_force' AND p.kind = 'text' LIMIT 1", + WHERE p.status = 'open' AND p.seed_featured = 1 AND p.kind = 'text' LIMIT 1", ) .fetch_one(&state.db) .await - .expect("a version in force"); + .expect("a seed proposal"); + sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ? WHERE id = ?") + .bind(now) + .bind(&id) + .execute(&state.db) + .await + .expect("in force"); + sqlx::query("UPDATE units SET in_force_id = ?1 WHERE id = (SELECT unit_id FROM proposals WHERE id = ?1)") + .bind(&id) + .execute(&state.db) + .await + .expect("unit"); sqlx::query( "INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)", ) diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 49d63ae..1fd036b 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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, @@ -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) } @@ -88,7 +110,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 +121,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 +135,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")); } @@ -155,7 +174,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; @@ -185,7 +204,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, _) = 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); @@ -343,7 +362,7 @@ 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, _) = Client::production().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; @@ -389,7 +408,8 @@ 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 +420,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.", @@ -741,7 +761,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(), @@ -815,7 +835,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(), @@ -906,12 +926,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); @@ -931,8 +957,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) @@ -953,10 +981,12 @@ fn outcomes(rows: &[(String, String, Option, 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] @@ -967,13 +997,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)]); } @@ -986,7 +1016,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"); } @@ -999,7 +1030,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(), diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index ceb714b..23f4d95 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -318,7 +318,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"); @@ -416,7 +416,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); @@ -442,7 +442,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, @@ -474,7 +474,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)]); @@ -500,7 +500,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. @@ -527,8 +527,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; @@ -609,7 +609,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. @@ -631,7 +631,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!( @@ -660,7 +660,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"]); @@ -693,7 +693,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; @@ -719,7 +719,7 @@ 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 e1 = version(&doc, "E1", "0"); let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; for (n, account) in keys.iter().enumerate() { @@ -732,7 +732,7 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() ); if n == 49 { // 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", @@ -785,7 +785,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", @@ -856,7 +856,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) }); @@ -875,7 +875,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(); @@ -910,7 +910,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; @@ -965,7 +965,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( @@ -992,7 +992,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()); @@ -1012,7 +1012,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; @@ -1035,7 +1035,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)); @@ -1066,7 +1066,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)) }); @@ -1099,7 +1099,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()); @@ -1124,7 +1124,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"); @@ -1183,7 +1183,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; @@ -1297,7 +1297,7 @@ async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_v assert!(doc["vote"]["chain"].is_null()); assert_eq!(doc["canVote"], false); 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", @@ -1308,7 +1308,7 @@ async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_v assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off"))); let essai = c.doc("essai").await; assert_eq!((essai["vote"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true))); - 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; } @@ -1353,9 +1353,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"), ( diff --git a/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json b/crates/ld-server/tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.json similarity index 100% rename from crates/ld-server/tests/fixtures/seed-production-2026-11-02.json rename to crates/ld-server/tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.json diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index 7ee777b..38d634d 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -1,7 +1,9 @@ //! 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}; @@ -90,6 +92,14 @@ 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 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 { levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect() } @@ -99,16 +109,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"]; @@ -138,9 +148,9 @@ async fn the_five_levels_and_the_scale_are_served() { } // 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"); assert_eq!(d["participation"]["source"], "reference"); @@ -160,25 +170,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. + assert_eq!( + (d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), + (Some(5), Some(u64::from(LOW))) + ); + // W(essai) = 2: both vote for, the article goes to level 5. a.vote(&p, "for").await; let v = b.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 +200,16 @@ 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(¶ms).expect("json"); + let judged = Scale { electorate: 2, floor: 1.0, participation: 0.5 }.params(level(LOW)); 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["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] @@ -358,9 +372,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 +411,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 +466,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 +493,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] diff --git a/crates/ld-server/tests/objections.rs b/crates/ld-server/tests/objections.rs index 082e005..a0d0e38 100644 --- a/crates/ld-server/tests/objections.rs +++ b/crates/ld-server/tests/objections.rs @@ -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, } 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::>().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| { diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index 22bf64b..453e5e5 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -203,7 +203,7 @@ const PREAMBULE: [(&str, &str, &str); 3] = [ /// 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.json"); + 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") } diff --git a/crates/ld-server/tests/typos.rs b/crates/ld-server/tests/typos.rs index 69b7776..8ba2b57 100644 --- a/crates/ld-server/tests/typos.rs +++ b/crates/ld-server/tests/typos.rs @@ -15,9 +15,14 @@ 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) } From aaa653e1a7820f7a6fd1d0c8b1c5f3c868850471 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:12:20 +0100 Subject: [PATCH 14/24] Lot S : tests serveur repris sans Propositions seed en vigueur (modules, overview), rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modules.rs : 0 article en vigueur et 35 propositions en cours dans une base neuve (I1, E1 et E2 sont des Propositions seed) ; l'ajout des modules à une base existante se rejoue sur le seed de production du 02/11 (textes en vigueur), comme inertia.rs. overview.rs : le vote porte sur la Proposition seed I1 ; 41 modules à voter, inchangé. Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/src/seed.rs | 9 ++++----- crates/ld-server/src/seed_amend.rs | 6 +++++- crates/ld-server/tests/api.rs | 7 +++++-- crates/ld-server/tests/modules.rs | 25 +++++++++++++++++------- crates/ld-server/tests/overview.rs | 7 ++++--- crates/ld-server/tests/seed_proposals.rs | 14 ++++++++++--- crates/ld-server/tests/typos.rs | 3 ++- 7 files changed, 49 insertions(+), 22 deletions(-) diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index c4ec844..ebf0609 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -198,7 +198,9 @@ fn check(seed: &SeedDoc) -> anyhow::Result<()> { anyhow::bail!("{}: a seed proposal is open, never in force", u.code); } match (u.puts_forward(), u.inertia.is_some()) { - (true, true) => anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code), + (true, true) => { + anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code) + } (false, false) => anyhow::bail!("{}: no level of inertia", u.code), _ => {} } @@ -273,10 +275,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .await?; // The mark, written apart: only where the seed puts the version forward. if v.seed_proposal && status == "open" { - sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?") - .bind(&pid) - .execute(&mut *tx) - .await?; + sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?").bind(&pid).execute(&mut *tx).await?; } if status == "in_force" { sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index 6bf0465..4ff8118 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -247,7 +247,11 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' } /// What the database says of a version holding `status` (`None`: it does not). -pub(crate) async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result> { +pub(crate) async fn facts( + conn: &mut SqliteConnection, + proposal_id: &str, + status: &str, +) -> anyhow::Result> { #[derive(sqlx::FromRow)] struct Row { origin: String, diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 1fd036b..9aaeaf4 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -409,7 +409,10 @@ async fn inertia_change_is_voted_at_the_level_of_the_article() { assert_eq!(again["id"], p.as_str()); let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await; 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))); + assert_eq!( + (d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), + (Some(5), Some(low)) + ); } #[tokio::test] @@ -843,7 +846,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, + without_articles: false, }); cfg }; diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index 8bf1fd3..77797cb 100644 --- a/crates/ld-server/tests/modules.rs +++ b/crates/ld-server/tests/modules.rs @@ -183,9 +183,10 @@ 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] @@ -250,7 +251,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 +261,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::() + 1)) + (Some(0), Some(before.sum::() + 1)) ); // A new article enters the section: at its end, and at the end of the order under vote. let created = chiara @@ -492,8 +494,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 +565,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, Option)> = 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 +630,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"); diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index 25701b7..7ab72c2 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -147,15 +147,16 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { 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; 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. diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index 453e5e5..a8fe523 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -140,7 +140,9 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() 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)); + 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}"); @@ -203,7 +205,8 @@ const PREAMBULE: [(&str, &str, &str); 3] = [ /// 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"); + 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") } @@ -426,7 +429,12 @@ async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once( }; assert_eq!( changes, - [line("essai", "E1", "4"), line("essai", "E2", "4"), line("essai", "I1", "3"), line("monnaie-libre", "E2", "4")] + [ + 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; diff --git a/crates/ld-server/tests/typos.rs b/crates/ld-server/tests/typos.rs index 8ba2b57..8524498 100644 --- a/crates/ld-server/tests/typos.rs +++ b/crates/ld-server/tests/typos.rs @@ -21,7 +21,8 @@ impl Client { async fn new() -> (Self, AppState) { let db = open_db("sqlite::memory:").await.expect("db"); 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.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) From 9a9c99b93b4e162491b2993ce8cbb13623af2e83 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:27:41 +0100 Subject: [PATCH 15/24] =?UTF-8?q?Lot=20S=20:=20=C2=AB=20Proposition=20seed?= =?UTF-8?q?=20=C2=BB=20dans=20l'interface=20(fr,=20en,=20es),=20le=20texte?= =?UTF-8?q?=20sans=20article=20en=20vigueur=20le=20dit=20;=20e2e=20repris?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interface au strict nécessaire (le lot U refera la présentation) : la marque « Proposition seed » là où une version est montrée (liste des propositions, Détails, panneau Propositions), seedProposal?: boolean dans types.ts ; au-dessus des ordres, « Aucun article n'est en vigueur » quand c'est le cas. e2e : C3 (aucun texte en vigueur, 33 lignes, trois Propositions seed au cran 2), C9 et C18 (« si tout passe »), C20, C23, C24 et M1 par la liste des propositions dans Monnaie libre ; dans le groupe d'essai, I1, E1 et E2 entrent d'abord par le vote (seedsInForce : deux voix puis leur retrait, le texte reste en vigueur) pour les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24, C28 à C31). Boucle e2e : 35 / 35. Co-Authored-By: Claude Opus 5.5 --- web/e2e/helpers.ts | 42 ++++++- web/e2e/mobile.spec.ts | 8 +- web/e2e/objections.spec.ts | 6 +- web/e2e/reader.spec.ts | 118 ++++++++++-------- web/e2e/vote.spec.ts | 34 +++-- web/src/lib/components/DeepList.svelte | 1 + web/src/lib/components/DocPanel.svelte | 2 +- web/src/lib/components/VersionDetails.svelte | 1 + web/src/lib/i18n/en.ts | 2 + web/src/lib/i18n/es.ts | 2 + web/src/lib/i18n/fr.ts | 2 + web/src/lib/types.ts | 2 + .../routes/g/[group]/docs/[doc]/+page.svelte | 4 +- 13 files changed, 147 insertions(+), 77 deletions(-) diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index 3d881a4..d793b5c 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -75,6 +75,11 @@ export async function signed(page: Page): Promise<{ address: string; dest: strin ); } +/** The line of an article in « Propositions en cours », by its title alone (an excerpt may name it). */ +export function deepRow(page: Page, title: string) { + return page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }); +} + /** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */ export async function shot(page: Page, name: string) { const dir = process.env.LD_SHOTS; @@ -86,13 +91,48 @@ const CSRF = { 'x-requested-with': 'libredecision' }; /** The document of the test group, as the server gives it. */ export async function testDoc(page: Page): Promise<{ id: string; - units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[]; + units: { + id: string; + code: string; + inForce: { id: string; title: string } | null; + proposals: { id: string; title: string; seedProposal?: boolean }[]; + }[]; }> { const g = await (await page.request.get('/api/groups/essai')).json(); const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); return { id: d.doc.id, units: d.units }; } +/** + * The three seed proposals (ADR 0011: I1, E1 and E2, not in force at the start) brought into + * force in the test group, as the vote does it: voices for until each enters, then those + * voices withdrawn. A version in force stays so (a permanent vote: only a rival displaces + * it), at zero votes, as the controls written before the seed proposals found it. Never in + * Monnaie libre, whose vote goes through the chain; nothing done twice. + */ +export async function seedsInForce(browser: Browser) { + const voices = [await person(browser, 'Amorce A'), await person(browser, 'Amorce B')]; + for (const code of ['I1', 'E1', 'E2']) { + const unit = (await testDoc(voices[0]!)).units.find((u) => u.code === code); + const seed = unit?.proposals.find((p) => p.seedProposal); + if (!unit || unit.inForce || !seed) continue; + const cast: Page[] = []; + for (const page of voices) { + const r = await page.request.post(`/api/proposals/${seed.id}/vote`, { data: { choice: 'for' }, headers: CSRF }); + if (!r.ok()) throw new Error(`vote ${r.status()} ${await r.text()}`); + cast.push(page); + if ((await r.json()).entered?.length) break; + } + for (const page of cast) { + const r = await page.request.post(`/api/proposals/${seed.id}/vote`, { data: { choice: null }, headers: CSRF }); + if (!r.ok()) throw new Error(`withdraw ${r.status()} ${await r.text()}`); + } + const now = (await testDoc(voices[0]!)).units.find((u) => u.code === code); + if (now?.inForce?.id !== seed.id) throw new Error(`${code} did not enter into force`); + } + for (const page of voices) await page.context().close(); +} + /** A new article proposed by the person of `page`, submitted to the vote; returns its id. */ export async function propose(page: Page, title: string, body: string): Promise { const doc = await testDoc(page); diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index 6357a22..5470d5d 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -79,10 +79,10 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti expect(mark.shadow).toContain('0px 0px 0px 2px'); await drawer.getByRole('button', { name: 'Fermer' }).first().click(); - // The vote unfolds under the article, on a phone too. - const first = page.locator('section.text article.unit').first(); - await first.getByRole('button', { name: 'Le vote' }).click(); - const fold = first.locator('section.fold'); + // The vote unfolds under the article, on a phone too (a seed proposal, ADR 0011: nothing is + // in force at the start, its vote unfolds in the list of proposals). + await page.locator('ul.deep button.row').first().click(); + const fold = page.locator('ul.deep section.fold'); await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible(); // The settings of the formula read on a phone: the table of the five levels holds in the sheet. await fold.getByRole('button', { name: 'Réglages de la formule' }).click(); diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts index 1b7f31c..45dd711 100644 --- a/web/e2e/objections.spec.ts +++ b/web/e2e/objections.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers'; +import { advanceClock, person, propose, seedsInForce, shots, TEST_DOC, testDoc } from './helpers'; // Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit » // holds back the later of two versions until it is lifted; the author answers, @@ -10,6 +10,10 @@ import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpe const DAY = 86_400; +// An objection names an article in force, a typo of C31 a text in force: the seed proposals +// I1, E1 and E2 (ADR 0011) are voted into force in the test group first. +test.beforeAll(async ({ browser }) => seedsInForce(browser)); + /** The menu's « Messages », with its number of unread messages. */ async function openBox(page: Page, unread: number) { const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index e4766c3..55ec01a 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -1,5 +1,9 @@ -import { expect, test } from '@playwright/test'; -import { BOB, DOC, expectWelcome, person, signIn, TEST_DOC, wallet } from './helpers'; +import { expect, test, type Page } from '@playwright/test'; +import { BOB, deepRow, DOC, expectWelcome, person, seedsInForce, signIn, TEST_DOC, testDoc, wallet } from './helpers'; + +// The seed proposals (ADR 0011) are not in force at the start: the controls that read a +// text in force find I1, E1 and E2 brought in by the vote in the test group. +test.beforeAll(async ({ browser }) => seedsInForce(browser)); test('C1 — l’accueil sans session : la voûte abrite les trois points, le titre centré en deux lignes, la connexion, le menu complet', async ({ page }) => { await page.goto('/'); @@ -33,38 +37,41 @@ test('C2 — l’accueil avec session : le choix du groupe, ceux de la même cl await expect(page).toHaveURL((u) => u.pathname === '/'); }); -test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => { +test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deux engagements sont des Propositions seed', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification"); - const units = page.locator('section.text article.unit'); - await expect(units).toHaveCount(3); - await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être"); - await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte'); - await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité'); - // The seed mark, with the real number of votes beside it (none yet on these three). - await expect(units.getByText('seed a minima · 0 vote')).toHaveCount(3); - await expect(units.getByTitle("Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote, comme tout texte.")).toHaveCount(3); + // Nothing in force (Yvv, 2026-11-02): said once, above the orders of the sections. + await expect(page.locator('section.text article.unit')).toHaveCount(0); + await expect(page.getByText("Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.")).toBeVisible(); await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); - // One line per article: E3 and E7 hold their two variants inside. - await expect(page.locator('ul.deep button.row')).toHaveCount(30); + // One line per article: the 33, E3 and E7 holding their two variants inside. + await expect(page.locator('ul.deep button.row')).toHaveCount(33); + // The three seed proposals, marked, at the low level they start at. + const seeds = page.locator('ul.deep button.row', { has: page.locator('.tag', { hasText: 'Proposition seed' }) }); + await expect(seeds.locator('.title')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']); + await expect(seeds.getByTitle("Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.")).toHaveCount(3); + for (const row of await seeds.all()) await expect(row).toContainText('0 pour · 0 contre'); + for (const row of await seeds.all()) await expect(row).toContainText('inertie 2 / 5 · basse'); }); test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => { + // In the test group, where the Raison d'être was voted into force (beforeAll). await signIn(page, 'Chiara'); - await page.goto(DOC); + await page.goto(TEST_DOC); const first = page.locator('section.text article.unit').first(); + await expect(first.locator('h2')).toHaveText("Raison d'être"); await first.hover(); const toggle = first.getByRole('button', { name: 'Le vote' }); await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); const fold = first.locator('section.fold'); - await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible(); - await expect(fold.getByText('Il manque 50 voix pour.')).toBeVisible(); + await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible(); + await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible(); await fold.getByText('La formule du seuil').click(); await expect(fold.getByText(/W — électorat/)).toBeVisible(); - // The level of the article sets M and G: the Raison d'être is at 3 / 5. - await expect(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible(); + // The level of the article sets M and G: the Raison d'être, a seed proposal, starts at 2 / 5. + await expect(fold.getByText('M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse')).toBeVisible(); await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible(); // No window opens: the vote unfolds in place, lives in the URL, and folds back. await expect(page.locator('aside.panel')).toHaveCount(0); @@ -76,18 +83,28 @@ test('C4 — les actions sont contextuelles, le vote se déplie sous l’article }); test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => { - const yvv = await person(browser, 'Yvv'); - const chiara = await person(browser, 'Chiara'); - for (const p of [yvv, chiara]) { - await p.goto(TEST_DOC); - await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click(); - await p.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); - await expect(p.getByText('Vote enregistré.')).toBeVisible(); + // Every dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1, one + // voice may suffice (ADR 0007); else the second one brings it in. + const title = 'Les deux garanties réciproques'; + const news = `« ${title} » est entré en vigueur.`; + const inForce = async (page: Page) => (await testDoc(page)).units.some((u) => u.inForce?.title === title); + let voices = 0; + let last: Page | undefined; + for (const name of ['Yvv', 'Chiara']) { + last = await person(browser, name); + await last.goto(TEST_DOC); + await last.locator('ul.deep button.row', { hasText: title }).click(); + await last.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); + await expect(last.getByText('Vote enregistré.')).toBeVisible(); + voices += 1; + if (await inForce(last)) break; } - await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible(); - await expect(chiara.locator('section.text article.unit')).toHaveCount(4); + if (!last) throw new Error('nobody voted'); + await expect(last.getByText(news)).toBeVisible(); + // With the three seed proposals voted in (beforeAll). + await expect(last.locator('section.text article.unit')).toHaveCount(4); // In force now: its vote stays unfolded, under the text. - await expect(chiara.locator('section.text section.fold')).toContainText('2 pour · 0 contre'); + await expect(last.locator('section.text section.fold')).toContainText(`${voices} pour · 0 contre`); }); test('C6 — une coquille se corrige sans vote', async ({ page }) => { @@ -147,8 +164,9 @@ test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => { await page.getByRole('radio', { name: 'Si tout passe' }).click(); await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible(); await expect(page.locator('section.text article.unit')).toHaveCount(33); + // Nothing in force in Monnaie libre, and nothing well rated yet (no « pour » there so far). await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click(); - await expect(page.locator('section.text article.unit')).toHaveCount(3); + await expect(page.locator('section.text article.unit')).toHaveCount(0); }); test('C10 — trustinWallet : la gestion des accès est réservée', async ({ browser }) => { @@ -228,9 +246,11 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2); }); -test('C18 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => { +test('C18 — la Raison d’être ouvre le texte, en trois langues', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); + // A seed proposal, not in force: it opens the text « if everything passes ». + await page.getByRole('radio', { name: 'Si tout passe' }).click(); const first = page.locator('section.text article.unit').first(); await expect(first.locator('h2')).toHaveText("Raison d'être"); // The first sentence in italics, then two paragraphs. @@ -294,20 +314,20 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 100 votants comme d'habitude.")).toBeVisible(); await page.keyboard.press('Escape'); - // From the vote of an article: Unicité du compte is at 4 / 5, its row is marked. - const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); - await e1.hover(); - await e1.getByRole('button', { name: 'Le vote' }).click(); - await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible(); + // From the vote of an article: Unicité du compte, a seed proposal, is at 2 / 5, its row is marked. + await deepRow(page, 'Unicité du compte').click(); + const e1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); + await expect(e1.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible(); await e1.getByRole('button', { name: 'Réglages de la formule' }).click(); await expect(panel.locator('tr.current')).toHaveCount(1); - await expect(panel.locator('tr.current th')).toContainText('4'); + await expect(panel.locator('tr.current th')).toContainText('2'); await expect(panel.locator('tr.current th')).toContainText('cet article'); await expect(required).toHaveText(['50', '50', '50', '50', '50']); await expect(panel.getByText('Voix pour : à 0 votant, la participation de cette version.')).toBeVisible(); await page.keyboard.press('Escape'); - // The five levels to choose from, in the test group; the change is voted at the article's level. + // The five levels to choose from, in the test group (Réciprocité voted into force there, + // beforeAll); the change is voted at the article's level. await page.goto(TEST_DOC); const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' }); await e2.hover(); @@ -316,25 +336,25 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio'); await expect(levels).toHaveCount(5); // The current level is reachable — one stop in the tab order — and never chosen. - await expect(levels.nth(3)).toHaveAttribute('aria-disabled', 'true'); - await expect(levels.nth(3)).toContainText('actuelle'); + await expect(levels.nth(1)).toHaveAttribute('aria-disabled', 'true'); + await expect(levels.nth(1)).toContainText('actuelle'); await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); - await expect(levels.nth(3)).toHaveAttribute('tabindex', '0'); - await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible(); + await expect(levels.nth(1)).toHaveAttribute('tabindex', '0'); + await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 2 / 5 · basse.")).toBeVisible(); // A click on it (Playwright refuses an aria-disabled target: the event is sent as is) chooses nothing. - await levels.nth(3).dispatchEvent('click'); + await levels.nth(1).dispatchEvent('click'); await expect(e2.locator('[role="radio"][aria-checked="true"]')).toHaveCount(0); // With the keyboard: the arrows go from level to level and choose it, wrapping round. - await levels.nth(3).focus(); - await page.keyboard.press('ArrowRight'); - await expect(levels.nth(4)).toBeFocused(); - await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); - await page.keyboard.press('ArrowRight'); + await levels.nth(1).focus(); + await page.keyboard.press('ArrowLeft'); await expect(levels.nth(0)).toBeFocused(); await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true'); - await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); await page.keyboard.press('ArrowLeft'); + await expect(levels.nth(4)).toBeFocused(); await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); + await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); + await page.keyboard.press('ArrowRight'); + await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true'); // Or with a click. await levels.nth(4).click(); await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); @@ -342,5 +362,5 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { // Its own vote unfolds, in the list of proposals. const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' }); await expect(change).toBeVisible(); - await expect(change.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible(); + await expect(change.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible(); }); diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index fd1354e..db7c70b 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -1,9 +1,12 @@ import { expect, test } from '@playwright/test'; -import { ALICE, chain, CHARLIE, DOC, person, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers'; +import { ALICE, chain, CHARLIE, deepRow, DOC, person, seedsInForce, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers'; // Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a // basket, and on the Ğ1 chain where the electorate is the web of trust. The chain -// here is simulated (e2e/chain-mock.mjs): never a real transaction. +// here is simulated (e2e/chain-mock.mjs): never a real transaction. In Monnaie libre +// nothing is in force at the start (the seed proposals, ADR 0011): their vote unfolds in +// the list of proposals; in the test group they were voted into force. +test.beforeAll(async ({ browser }) => seedsInForce(browser)); test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil', async ({ page, browser }) => { await signIn(page, 'Chiara'); @@ -116,19 +119,17 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); - // Under the article: her vote, from the chain, and the totals. - const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); - await e1.hover(); - await e1.getByRole('button', { name: 'Le vote' }).click(); + // Under the seed proposal: her vote, from the chain, and the totals. + const e1 = deepRow(page, 'Unicité du compte'); + await e1.click(); const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1'); await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible(); // The last one stands, in the order of the chain: a « contre » included first, but seen late // by the indexer, never overrides the withdrawal included after it. - const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' }); - await e2.hover(); - await e2.getByRole('button', { name: 'Le vote' }).click(); + const e2 = deepRow(page, 'Réciprocité'); + await e2.click(); const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' }); await control('hide'); await fold2.getByRole('button', { name: 'Contre', exact: true }).click(); @@ -147,18 +148,15 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible(); // One vote unfolded at a time (an accordion): back to the first, untouched. await expect(fold1).toHaveCount(0); - await e1.hover(); - await e1.getByRole('button', { name: 'Le vote' }).click(); + await e1.click(); await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible(); await expect(fold2).toHaveCount(0); // Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain. const other = await person(browser, 'Maëlle'); await other.goto(DOC); - const raison = other.locator('section.text article.unit').first(); - await raison.hover(); - await raison.getByRole('button', { name: 'Le vote' }).click(); - await raison.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); + await deepRow(other, "Raison d'être").click(); + await other.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible(); await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click(); @@ -192,10 +190,8 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; await page.goto(DOC); await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0); - const first = page.locator('section.text article.unit').first(); - await first.hover(); - await first.getByRole('button', { name: 'Le vote' }).click(); - const fold = first.locator('section.fold'); + await page.locator('ul.deep button.row').first().click(); + const fold = page.locator('ul.deep section.fold'); await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); // The totals and the threshold stay in sight. diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index 1e71a2e..107c39b 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -78,6 +78,7 @@ {/snippet} {#snippet marks(p: Proposal)} + {#if p.seedProposal}{t('doc.seedProposal')}{/if} {#if p.evaluation?.wellRated}{t('deep.wellrated')}{/if} {#if p.blocked}{t('deep.held')}{/if} {#if p.myVoteStale}{t('deep.stale')}{/if} diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index c9e412f..ab013f1 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -117,7 +117,7 @@ {v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} {nameOfVersion(v)} - {v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · {t('deep.held')}{/if} + {v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : v.seedProposal ? t('doc.seedProposal') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · {t('deep.held')}{/if} diff --git a/web/src/lib/components/VersionDetails.svelte b/web/src/lib/components/VersionDetails.svelte index 9da7aed..3457cd6 100644 --- a/web/src/lib/components/VersionDetails.svelte +++ b/web/src/lib/components/VersionDetails.svelte @@ -238,6 +238,7 @@

{p.kind === 'text' || p.kind === 'title' ? text.title : titleOf(p, unit)}

{#if p.seedMinimal}{t('doc.seed')} + {:else if p.seedProposal}{t('doc.seedProposal')} {:else if p.confirmedAt}{t('doc.confirmed')} {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} {#if p.blocked}{t('deep.held')}{/if} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 50e861e..00b8bc7 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -123,6 +123,8 @@ export const en: Messages = { 'doc.seed.long': 'Came into force without a vote, so as not to start from a blank page. It remains open for voting, like any text.', 'doc.seed.votes.one': 'seed a minima · {n} vote', 'doc.seed.votes.other': 'seed a minima · {n} votes', + 'doc.seedProposal': 'Seed proposal', + 'doc.seedProposal.long': 'A starting text put forward to start the vote. It is not in force: it comes into force through the vote, like any proposal.', 'doc.confirmed': 'confirmed by vote', 'doc.adopted': 'adopted on {date}', 'doc.action.vote': 'Voting', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 144df45..bb79f9d 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -123,6 +123,8 @@ export const es: Messages = { 'doc.seed.long': 'Entró en vigor sin votación, para no partir de una página en blanco. Sigue en votación, como todo texto.', 'doc.seed.votes.one': 'seed a minima · {n} voto', 'doc.seed.votes.other': 'seed a minima · {n} votos', + 'doc.seedProposal': 'Propuesta seed', + 'doc.seedProposal.long': 'Texto de partida destacado para lanzar la votación. No está en vigor: entra en vigor por la votación, como toda propuesta.', 'doc.confirmed': 'confirmado por la votación', 'doc.adopted': 'adoptado el {date}', 'doc.action.vote': 'Votación', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 2a922d3..46ff1e2 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -127,6 +127,8 @@ export const fr = { 'doc.seed.long': "Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote, comme tout texte.", 'doc.seed.votes.one': 'seed a minima · {n} vote', 'doc.seed.votes.other': 'seed a minima · {n} votes', + 'doc.seedProposal': 'Proposition seed', + 'doc.seedProposal.long': "Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.", 'doc.confirmed': 'confirmé par le vote', 'doc.adopted': 'adopté le {date}', 'doc.action.vote': 'Le vote', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index f700121..0e5701f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -107,6 +107,8 @@ export interface Proposal { enteredAt: string | null; confirmedAt: string | null; seedMinimal: boolean; + /** A seed proposal (ADR 0011): open, put forward by the seed to start the vote, not in force. */ + seedProposal?: boolean; tally: Tally; evaluation: Evaluation | null; votedAtInertia: Inertia; diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index 9481bc9..8600d1e 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -192,6 +192,8 @@

{#if doc.mode === 'inforce' || !doc.preview} + + {#if !doc.inForce.length}

{t('doc.empty')}

{/if} {#each doc.inForceBySection as s, i (`${i}:${s.section}`)} {#each s.units as u (u.id)} {#if u.inForce} @@ -224,8 +226,6 @@ {#snippet fold()}{@render foldOf(o, op)}{/snippet} {/if} - {:else} -

{t('doc.empty')}

{/each} {:else} {#each doc.preview as pu (pu.unitId)} From e49f7fb98fb098146942335e739b4ef629d18d75 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:30:08 +0100 Subject: [PATCH 16/24] =?UTF-8?q?Lot=20S,=20phase=201=20:=20les=20Proposit?= =?UTF-8?q?ions=20seed=20document=C3=A9es=20(ADR=200011,=20CHANTIER,=20CON?= =?UTF-8?q?TROLES,=20CLAUDE.md,=20=5Fsource=20du=20seed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0011 : aucun article en vigueur au départ ; I1, E1 et E2 Propositions seed au cran 2 (une constante), avec l'intention d'Yvv de remonter l'inertie par un vote une fois les textes passés, pour qu'elle ne passe pas avant eux ; la publication attend le premier texte ; l'amendement gardé d'une base existante, et ce qu'il décidera en production (les propositions d'inertie ouvertes I1 → 5 et E1 → 5 notées le 30/10 y garderaient leur cran : à trancher avant M6). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 16 ++-- docs/adr/0002-vote-permanent.md | 3 + docs/adr/0011-propositions-seed.md | 90 +++++++++++++++++++ docs/adr/README.md | 1 + docs/chantiers/acte-certification/CHANTIER.md | 46 +++++++--- .../chantiers/acte-certification/CONTROLES.md | 21 +++-- seeds/acte-engagement-certification.json | 2 +- 7 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 docs/adr/0011-propositions-seed.md diff --git a/CLAUDE.md b/CLAUDE.md index 81adca2..612772f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,15 +52,19 @@ crates/ # (les échéances de 3 jours) · clock.rs (horloge injectable) · adoption.rs # (balayage, verrou) · participation.rs (p̃, une fois par # jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs · - # seed_amend.rs / inertia_amend.rs (amendements gardés) · ballots.rs (vote groupé, + # seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ; + # le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé, # bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments) # · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une # section, le titre du document) · module_amend.rs (ajout gardé) · web.rs (sert web/build) tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) · # objections.rs · typos.rs (lot F, horloge avancée, migration 0008) · modules.rs (ordre, - # titre, ajout gardé) -seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ; - # inertie 3, E1/E2 à 4 ; modules : le titre à 4, l'ordre de chaque section à 2) + # titre, ajout gardé) · seed_proposals.rs (Propositions seed, publication suspendue) · + # fixtures/seed-production-2026-11-02/ (le seed que la production fait tourner : + # I1/E1/E2 en vigueur, pour rejouer les amendements sur une base comme elle) +seeds/ # acte-engagement-certification.json (trilingue, 33 articles, aucun en vigueur ; + # I1/E1/E2 Propositions seed au cran 2 (seed::SEED_PROPOSAL_LEVEL), inertie 3 ailleurs ; + # modules en vigueur sans vote : le titre à 4, l'ordre de chaque section à 2) # upcoming/.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage web/src/ lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée @@ -114,7 +118,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la est la personne ou le groupe, jamais le système. Le français du texte voté fait foi. - **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans `ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`. -- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; +- **Invariants** : au départ, aucun article n'est en vigueur : I1, E1 et E2 sont des Propositions + seed (ouvertes, mises en avant, au cran 2 ; seuls les modules sont des *seeds a minima*), et la + publication d'un document attend son premier texte en vigueur (ADR 0011) ; une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en diff --git a/docs/adr/0002-vote-permanent.md b/docs/adr/0002-vote-permanent.md index 399b0a1..59c24c9 100644 --- a/docs/adr/0002-vote-permanent.md +++ b/docs/adr/0002-vote-permanent.md @@ -3,6 +3,9 @@ > **Complétée par [0007](0007-inertie-cinq-crans.md)** (2026-10-30) : l'inertie a cinq crans, > calés sur la participation habituelle du groupe, et un changement d'inertie se vote au cran > de l'article — il n'est plus voté au cran au-dessus. +> **Complétée par [0011](0011-propositions-seed.md)** (2026-11-02) : aucun article n'est en +> vigueur au départ ; les textes de départ des articles sont des Propositions seed, seuls les +> modules restent des *seeds a minima*. **Contexte.** Chaque article est sous vote permanent ; « dès qu'une proposition atteint le score, elle remonte en vigueur ». La formule inertielle Ğ1 donne un seuil faible quand peu de gens diff --git a/docs/adr/0011-propositions-seed.md b/docs/adr/0011-propositions-seed.md new file mode 100644 index 0000000..10bdc82 --- /dev/null +++ b/docs/adr/0011-propositions-seed.md @@ -0,0 +1,90 @@ +# 0011 — Les Propositions seed : rien en vigueur au départ, trois textes mis en avant + +> Remplace, dans [0002](0002-vote-permanent.md), « les textes de départ sont des *seeds a +> minima* » pour les articles : seuls les modules (le titre, l'ordre de chaque section, ADR 0009) +> restent en vigueur sans vote. Décision d'Yvv du 2026-11-02. + +**Contexte.** Depuis le 2026-10-21, la Raison d'être (I1) et les deux engagements fondamentaux, +Unicité du compte (E1) et Réciprocité (E2), étaient en vigueur au départ, sans vote (*seeds a +minima*, marge −C). Yvv, 2026-11-02 : « pour le démarrage, conserve un statut particulier pour +les 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition seed" (intégré +par un ou deux clients pour amorcer le mouvement de vote). » Puis : « on pouvait l'amorcer avec +une inertie basse, pour que le nombre de votes initiaux soit assez petits pour commencer +l'expérience en mode prod. Et je mettrai, une fois les textes passés, une inertie forte pour +éviter que l'inertie passe avant les textes. » Au formulaire du même jour : le cran 2 +(« basse ») ; la publication « Suspendre jusqu'au 1er texte ». + +## Décision + +- **Une Proposition seed** est une proposition ouverte du seed, mise en avant pour lancer le + vote : I1, E1 et E2 de l'Acte d'engagement Certification, dans chaque groupe. Elle **n'est pas + en vigueur** : elle y entre comme toute proposition, par son seuil, la place étant vide. Sa + marque est écrite à part (`proposals.seed_featured`) ; l'API dit `seedProposal` tant que la + version est ouverte et n'est jamais entrée en vigueur (`docs::ProposalRow::seed_proposal`). + Entrée en vigueur, c'est un texte adopté comme un autre. L'interface la marque + « Proposition seed » (fr), « Seed proposal » (en), « Propuesta seed » (es). +- **Au départ, aucun article n'est en vigueur.** Le titre du document et l'ordre de chaque + section restent des *seeds a minima* (en vigueur sans vote, ADR 0009). Le lecteur le dit + (« Aucun article n'est en vigueur… ») ; les trois textes se lisent dans « Propositions en + cours » et dans l'aperçu « si tout passe ». +- **Le cran 2 pour les trois**, une seule constante : `seed::SEED_PROPOSAL_LEVEL`. Le seed ne + leur donne pas de cran (le chargement du seed le refuse), l'amendement écrit celui-là. + **L'intention d'Yvv** : remonter l'inertie une fois les textes passés. Une proposition de + changement d'inertie se vote au cran de l'article (ADR 0007) : faite avant l'entrée des textes, + elle pourrait passer avant eux et leur demander davantage ; faite après, elle protège des + textes déjà adoptés. Le serveur n'automatise rien : c'est un vote, après les textes. +- **La publication attend le premier texte.** Tant qu'aucun article d'un document n'est en + vigueur, sa file d'envoi attend (`publish::waits_for_a_text`) : ce n'est pas un échec (aucune + tentative comptée, aucune erreur), l'API dit `publication.state = "suspended"`. Le premier + texte qui entre ajoute une ligne à la file, et tout part avec lui. `LD_PUBLISH_WITHOUT_ARTICLES=1` + publie quand même (un document sans article). Le groupe d'essai ne publie jamais. + +## Une base existante + +Un document seedé n'est jamais réinstancié. Dans une base existante (la production : I1, E1 et +E2 en vigueur, *seeds a minima*, dans Monnaie libre et dans le groupe d'essai), trois +**amendements de Proposition seed** (`crates/ld-server/src/seed_proposal_amend.rs`, +`2026-11-02-i1-proposition-seed`, `-e1-`, `-e2-`) décident une fois par document et par article, +consignés dans `seed_proposal_amendments` et dans les journaux. + +**Le statut**, seulement si personne n'a touché le texte en vigueur : origine `seed`, jamais +entré par un vote ni confirmé ; aucun vote du serveur, aucune ligne de bulletin de la chaîne, +quel que soit son sort ; aucune coquille (appliquée, en attente ou refusée), aucune traduction +écrite par un membre, aucune objection qui le nomme ; son historique est celui du seed (le texte +initial, et pour I1 l'amendement de la Raison d'être du 30/10, révision 2) ; son texte est celui +de la Proposition seed d'aujourd'hui (empreinte). Appliqué : la même version, avec son texte, ses +traductions et son historique, devient ouverte et marquée ; l'article n'a plus de texte en +vigueur ; l'historique dit le changement (`seed_proposal`, sans diff). Refusé : le texte reste en +vigueur, *seed a minima*. + +**Le cran**, avec le statut seulement, et seulement si personne n'y a touché : aucune proposition +de changement d'inertie d'un membre ouverte, appliquée ou votée, aucun vote qui l'a changé, et un +cran que le seed lui a donné avant (I1 : 3 ; E1 et E2 : 3, puis 4 depuis le 30/10). Appliqué : +l'article le dit (`inertia_changes`, « réglage de départ du texte »). Refusé : il garde son cran, +le statut change quand même. + +**En production**, l'orch notait le 30/10 deux propositions d'inertie ouvertes à zéro vote dans +Monnaie libre, I1 → 5 et E1 → 5. Si elles sont encore ouvertes au démarrage, I1 et E1 y +deviennent des Propositions seed **à leur cran d'avant** (3 et 4 ; refus `inertia_proposal`), E2 +au cran 2. Pour que les trois démarrent au cran 2, leurs auteurs les retirent avant la mise en +ligne (une proposition retirée sans vote ne compte plus). À trancher par Yvv avant M6. + +Là où le document publie (Monnaie libre), chaque amendement appliqué ajoute une ligne à la file +(« Proposition seed : I1 sort de la vigueur »…), qui attend le premier texte. Les deux +amendements d'inertie du 30/10 (E1 et E2 de 3 à 4, ADR 0007) sont dépassés : décidés en +production, ils ne décident plus rien ailleurs (`superseded_by`). + +## Les données + +Migration `0009` (additive) : `proposals.seed_featured` ; la table `seed_proposal_amendments` +(statut et cran : appliqué, refusé et pourquoi, ou déjà là). Le seed : `"seedProposal": true` et +`"status": "open"` sur la version de I1, E1 et E2, sans `inertia`. + +## Coût + +- Personne ne lit de texte en vigueur avant le premier vote qui passe ; le dépôt des textes + garde la dernière publication jusque-là. +- Les comptes bougent : 0 article en vigueur, 35 propositions en cours (les 32 d'avant et les + trois Propositions seed) ; « Décisions » compte toujours 41 modules à voter. +- Les textes F1.2 et N1.1 du seed disent encore Unicité du compte et Réciprocité au cran 4 au + départ : orch les réécrira au prochain tour de textes, avec la relecture d'Yvv. diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e456e..651e77c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,3 +15,4 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan | [0008](0008-vote-par-transaction.md) | Le vote par transaction sur la chaîne Ğ1 : bulletin scellé, confirmation par l'indexeur, le dernier fait foi | 2026-10-30 | | [0009](0009-modules-ordre-et-titre.md) | Les modules : l'ordre des articles de chaque section (inertie 2), le titre du document (inertie 4) | 2026-10-31 | | [0010](0010-objections-et-coquilles-validees.md) | Objections « redit » / « contredit », coquilles validées, messagerie (remplace 0004, complète 0003) | 2026-10-31 | +| [0011](0011-propositions-seed.md) | Les Propositions seed : rien en vigueur au départ, trois textes mis en avant au cran 2, la publication attend le premier texte (complète 0002) | 2026-11-02 | diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index baffd48..3ddb115 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -29,11 +29,15 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres groupes sur accord dans trustinWallet. -4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ la - **Raison d'être** (texte d'Yvv du 2026-10-30, qui remplace le Préambule : « 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. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués - *seed a minima* avec leur nombre réel de votes (*seed a minima · 0 vote*, puis *· 1 vote*…). +4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata). **Au départ, aucun + article n'est en vigueur** (Yvv, 2026-11-02 ; ADR 0011), et le lecteur le dit : la **Raison + d'être** (texte d'Yvv du 2026-10-30, qui remplace le Préambule : « 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. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité) sont des + **Propositions seed** : ouvertes, mises en avant pour lancer le vote, marquées « Proposition + seed », au cran 2 ; elles entrent en vigueur comme toute proposition. Le titre du document et + l'ordre de chaque section, eux, sont en vigueur sans vote, marqués *seed a minima* avec leur + nombre réel de votes (*seed a minima · 0 vote*, puis *· 1 vote*…). **Le titre du document est un module** (inertie 4) : sous le titre, son état (en vigueur sans vote, ou adopté le…), « traduction à revoir » quand le titre lu est la traduction d'un titre remplacé, et les mêmes gestes : **Le vote** · **Propositions** · **Proposer un autre titre**. @@ -51,8 +55,8 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. **Détails** d'une version (coquilles, traductions, objections, historique) et **« Réglages de la formule »** (W, C, B, la participation habituelle du groupe et les cinq crans), atteints depuis l'en-tête du document et depuis chaque vote. -5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro - vote, inertie 3 / 5 — rangées par section (introduction, fondamental, technique, +5. **En dessous**, les propositions en cours — les 33 articles du texte source, à zéro vote : les + trois Propositions seed au cran 2, les 30 autres à l'inertie 3 / 5 — rangées par section (introduction, fondamental, technique, conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses @@ -97,9 +101,9 @@ G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃). | Cran | Nom | M_k | s_k | Au départ | |---|---|---|---|---| | 1 | très basse | 50 % | 60 % | | -| 2 | basse | 50 % | 67 % | l'ordre des articles de chaque section | +| 2 | basse | 50 % | 67 % | les trois Propositions seed (Raison d'être, Unicité du compte, Réciprocité), l'ordre des articles de chaque section | | 3 | moyenne | 55 % | 75 % | partout ailleurs | -| 4 | haute | 60 % | 83 % | Unicité du compte, Réciprocité, le titre du document | +| 4 | haute | 60 % | 83 % | le titre du document | | 5 | très haute | 66 % | 90 % | | **La participation habituelle p̃** = la médiane des votants des propositions du groupe votées @@ -122,6 +126,9 @@ données : 7224 / 120 votants, M 50 %, G 0,2, C 0 → 94 voix) et `crates/ld-eng (pour − requis) plus large que la version en place. La version déplacée redevient une proposition, avec ses votes. Un *seed a minima* a pour marge −C : la première proposition qui atteint son seuil le remplace. +- **Les Propositions seed** (ADR 0011) ne sont pas en vigueur : la place est vide, la première + version de l'article qui atteint son seuil y entre. Yvv remontera leur inertie une fois les + textes passés, par un vote : une inertie relevée avant eux leur demanderait davantage. - Retirer un article est une proposition comme une autre (« retirer cet article »). - Le balayage tourne à chaque vote et toutes les 5 minutes (la taille de la toile bouge). @@ -131,8 +138,10 @@ Chaque article porte son inertie, un cran de 1 à 5. La changer est une proposit elle-même au cran actuel de l'article** : l'inertie de l'inertie hérite de celle de l'article (Yvv, 2026-10-30 ; elle était votée au cran au-dessus, « inertie + », depuis le 2026-10-21). L'article dit le passé de son inertie : passée de tel cran à tel autre, par un vote ou par le -réglage de départ du texte (E1 et E2 relevés de 3 à 4 dans une base existante, seulement si -aucun vote n'a changé leur inertie : `crates/ld-server/src/inertia_amend.rs`). +réglage de départ du texte (E1 et E2 relevés de 3 à 4 dans une base existante le 30/10, +seulement si aucun vote n'a changé leur inertie : `crates/ld-server/src/inertia_amend.rs` ; puis +I1, E1 et E2 descendus au cran 2 des Propositions seed le 02/11, seulement si personne n'a touché +à leur cran : `crates/ld-server/src/seed_proposal_amend.rs`). ### Les modules : l'ordre des articles, le titre du document (ADR 0009) @@ -281,7 +290,10 @@ traductions marquées éditoriales) et `document.json`, commit et pousse sur Échec → nouvel essai avec délai croissant. La clé de publication est générée par le serveur ; sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture) du dépôt. Le groupe d'essai ne publie jamais. `document.json` dit aussi les modules : le titre -(son état, ses votes, ses traductions et leur fraîcheur) et l'ordre de chaque section. +(son état, ses votes, ses traductions et leur fraîcheur) et l'ordre de chaque section. **Tant +qu'aucun article d'un document n'est en vigueur, sa publication attend** (Yvv, 2026-11-02 : +« Suspendre jusqu'au 1er texte ») : ni échec ni tentative comptée, l'état dit « suspendue », et +le premier texte qui entre envoie tout (`LD_PUBLISH_WITHOUT_ARTICLES=1` publie quand même). ### Un texte de départ remplacé @@ -294,6 +306,15 @@ journaux quand l'article garde son texte) ; appliqué, l'amendement se lit dans l'article (« texte de départ remplacé », avec le diff) et le texte est republié (`crates/ld-server/src/seed_amend.rs`). +De même, un texte de départ en vigueur que le seed met désormais en avant comme **Proposition +seed** (I1, E1, E2 ; ADR 0011) le devient dans une base existante par un amendement gardé, une +seule fois par document et par article, et seulement si personne ne l'a touché (origine du seed, +jamais entré par un vote ni confirmé, aucun vote ni bulletin de la chaîne, aucune coquille, +traduction de membre ou objection, l'historique du seed, l'empreinte attendue) : la même version +redevient ouverte et marquée, l'historique le dit ; son cran descend à 2 avec lui, seulement si +personne n'y a touché non plus (aucun changement d'inertie proposé, voté ou appliqué). Décisions +consignées dans `seed_proposal_amendments` (`crates/ld-server/src/seed_proposal_amend.rs`). + ### Accès — trustinWallet Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter, @@ -331,6 +352,7 @@ sans toucher au texte de la communauté. Chiara et Yvv en sont membres. | Connexion | client OAuth2 de connect.monnaie-libre.fr (demande à poka) ; liens d'invitation en attendant | | Dépôt des textes | `git.open.us.org/yvv/decision-depot-textes-reference` pour l'instant (2026-10-21) ; puis un dépôt pour l'ouverture, par transfert, et le dépôt officiel pour les textes en vigueur | | Garde-fou du vote permanent | nombre minimal de votants « pour » (C) | +| Textes de départ (2026-11-02) | aucun article en vigueur : trois Propositions seed (I1, E1, E2) au cran 2, la publication suspendue jusqu'au premier texte (ADR 0011) | | Langues | fr, en, es ; le français est voté, les traductions sont éditoriales | | Chiara | testeuse et codeuse | | Assistant | à règles d'abord ; IA locale plus tard, sans dépendance d'abonnement | diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 4294bc8..ab67303 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -5,7 +5,10 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée. Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26, C28–C31) se font dans le **groupe -d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Les +d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Au départ, rien +n'y est en vigueur (ADR 0011) : les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24, +C28 à C31) y font d'abord entrer Raison d'être, Unicité du compte et Réciprocité par le vote (deux voix +au plus, puis leur retrait : le texte reste en vigueur, à zéro vote ; `seedsInForce`, `web/e2e/helpers.ts`). Les votes par la chaîne Ğ1 (C11, C17, C23, C25, C32, C33) ne se jouent, pour l'automate, que sur une **chaîne simulée** (`web/e2e/chain-mock.mjs` : un faux nœud, un faux indexeur, un faux portefeuille) ; pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. @@ -14,13 +17,13 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. |---|---|---|---| | C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints | | C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici | -| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » — le nombre réel de ses votes, qui change quand on vote —, sous le titre du document et à la fin de chaque section ses modules (C25, C26) ; en dessous, « Propositions en cours » (30 lignes, une par article) | -| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie | -| C5 | Entrée en vigueur | groupe d'essai : à deux, déplier le vote de « Les deux garanties réciproques », « Pour » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (2 pour · 0 contre) | +| C3 | Le texte au départ : les Propositions seed | Monnaie libre → Docs de référence → Acte d'engagement Certification | aucun article en vigueur (ADR 0011) : « Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil. », sous le titre du document, puis à la fin de chaque section ses modules (C25, C26) ; en dessous, « Propositions en cours » (33 lignes, une par article), dont Raison d'être, Unicité du compte et Réciprocité marquées « Proposition seed » (au survol : « Texte de départ mis en avant pour lancer le vote… »), « 0 pour · 0 contre », « inertie 2 / 5 · basse » | +| C4 | Gestes contextuels | groupe d'essai, où la Raison d'être est entrée en vigueur par le vote : la survoler → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : le seuil et les voix qui manquent ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie | +| C5 | Entrée en vigueur | groupe d'essai (Raison d'être, Unicité du compte et Réciprocité entrées en vigueur par le vote) : déplier le vote de « Les deux garanties réciproques », « Pour » ; une deuxième personne si cette voix ne suffit pas | à la voix qui suffit, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (autant de « pour » que de voix, 0 contre) | | C6 | Coquille sans vote | groupe d'essai, **en administratrice** (un texte du seed n'a pas d'auteur) : Unicité du compte → « Le vote » → « Coquilles, traductions, objections, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » (sans être administratrice, la correction attend : C31) | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît | | C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit | -| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 | +| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis aucun (rien en vigueur dans Monnaie libre, rien de bien coté sans « pour ») | | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | C11 | Une personne, une voix | avec un lien d'invitation et un portefeuille dont le compte n'est pas membre de la toile (Bob, chaîne simulée), voter « Pour » sur un article de Monnaie libre | le vote dit que la voix est celle de l'identité Ğ1 du portefeuille qui signe ; après signature : « Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1. », et sous l'article « Ton dernier vote n'a pas été compté… », 0 pour ; dans le groupe d'essai, la même personne vote directement, sans portefeuille | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | @@ -30,13 +33,13 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) | | C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant | | C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 41 modules à voter · dont 41 sans ton vote » (33 articles, le titre, l'ordre de 7 sections), puis « dont 40 » ; la ligne ouvre le lecteur | -| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » | +| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification → « Si tout passe », puis EN, puis ES | en tête du texte, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » | | C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version déplie son vote sous elle ; un nouveau clic sur la ligne la replie | -| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis Unicité du compte → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), C 50, B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois C : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; voix pour à 100 votants 80, 84, 88, 92, 95, « Voix pour : à 100 votants, comme d'habitude. » ; depuis le vote déplié : « Inertie · 4 / 5 · haute », la ligne du cran 4 marquée « cet article », 50 voix, « Voix pour : à 0 votant, la participation de cette version. » ; le sélecteur montre les cinq crans, 4 « actuelle », atteignable au clavier mais jamais choisi ; un seul arrêt de tabulation ; les flèches passent d'un cran à l'autre et le choisissent, en boucle ; le vote de la proposition se déplie : « inertie vers 5 / 5 · très haute », votée à « inertie 4 / 5 · haute » | +| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis, dans « Propositions en cours », Unicité du compte → son vote → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité (entrée en vigueur par le vote) → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), C 50, B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois C : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; voix pour à 100 votants 80, 84, 88, 92, 95, « Voix pour : à 100 votants, comme d'habitude. » ; depuis le vote déplié : « Inertie · 2 / 5 · basse », la ligne du cran 2 marquée « cet article », 50 voix, « Voix pour : à 0 votant, la participation de cette version. » ; le sélecteur montre les cinq crans, 2 « actuelle », atteignable au clavier mais jamais choisi ; un seul arrêt de tabulation ; les flèches passent d'un cran à l'autre et le choisissent, en boucle ; le vote de la proposition se déplie : « inertie vers 5 / 5 · très haute », votée à « inertie 2 / 5 · basse » | | C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « Seuil : … voix pour », « Il manque … », « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 | | C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour | -| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité, « Signer et envoyer (2) » ; puis sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous l'article « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre | -| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et le seuil restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là | +| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité (Propositions seed), « Signer et envoyer (2) » ; puis, dans « Propositions en cours », sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous la proposition « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre | +| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous la première proposition : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et le seuil restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là | | C25 | L'ordre des articles | groupe d'essai : en fin de « Conclusion », « Proposer un autre ordre » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (dès 16 membres, une voix suffit dans le groupe d'essai : ADR 0007) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par « Ordre des articles ·
» (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » ; le vote d'un ordre en vigueur déplié montre la liste entière ; la liste où l'on clique : l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie dans la liste des propositions, l'ordre proposé en clair ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, rien ne déborde, chaque ligne fait au moins 44 px | | C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre ; en anglais, l'ancien titre traduit, marqué « translation needs review » | | C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude. » | diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 8178b8b..1fc13e2 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -1,5 +1,5 @@ { - "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs, 2 « basse » par défaut ; Yvv, 2026-11-02, ADR 0011). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. 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, en vigueur sans vote (seed a minima ; 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). Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend), jamais en réinstanciant le document.", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. 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, en vigueur sans vote (seed a minima ; 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). Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend), jamais en réinstanciant le document.", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", From 37147d1f596b8e5c1f2afcd9718d6d54f33912bd Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 04:36:43 +0100 Subject: [PATCH 17/24] Lot S, phase 2 : le quorum dans le moteur (ADR 0013), vu rouge puis vert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le quorum Q (votants = pour + contre) remplace le C additif : T' = max(T, Q), la version passe si pour ≥ ⌈B^W + ratio(T')·T'⌉, ratio la courbe des cinq crans (M, G calé à la participation de référence, inchangé). La règle d'Yvv du 03/11 (« Ils comptent comme non ») est seule dans threshold::judged_voters. Pour l'écran : yes_needed (le total de pour qui passe avec les contre d'aujourd'hui) et no_needed (le total de contre qui tient les pour d'aujourd'hui, 0 si le quorum suffit) ; passe ⇔ pour ≥ yesNeeded ⇔ contre < noNeeded, éprouvé sur une grille. well_rated : la part tient sur ceux qui sont venus. Scale porte les quorums par cran, UnitState le quorum propre d'un article ; p0 reste 2·vote_floor/W. Vu rouge : sans la règle (T' = T), 9 tests échouent ; avec l'autre lecture d'un quorum (un minimum de votants, les absents non comptés), 8 échouent, dont « un contre n'aide jamais ». Chiffres de contrôle (W 5 707, p0 = 100/W) tenus : 9, 39, 46 au quorum ; 7, 8, 9, 9, 10 à dix votants. Le serveur suit au commit suivant. Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/compose.rs | 37 +++- crates/ld-engine/src/inertia.rs | 208 +++++++++++++------- crates/ld-engine/src/order.rs | 16 +- crates/ld-engine/src/threshold.rs | 316 +++++++++++++++++++++++------- 4 files changed, 419 insertions(+), 158 deletions(-) diff --git a/crates/ld-engine/src/compose.rs b/crates/ld-engine/src/compose.rs index 6f640e7..53cbb3e 100644 --- a/crates/ld-engine/src/compose.rs +++ b/crates/ld-engine/src/compose.rs @@ -19,7 +19,8 @@ //! - what the document would read if the well-rated, or all, proposals passed //! (`preview`); //! - which formula a proposal is judged by (`params_for`): the level of -//! inertia of its unit, on the scale of its group (`inertia::Scale`). +//! inertia of its unit and its quorum, on the scale of its document +//! (`inertia::Scale`). use crate::inertia::{InertiaLevel, Scale}; use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate}; @@ -40,6 +41,10 @@ pub struct UnitState { pub id: String, pub position: f64, pub inertia: InertiaLevel, + /// Its own quorum, set by the author of its first version before the first vote + /// (ADR 0013); `None`: the quorum of its level. + #[serde(default)] + pub quorum: Option, pub in_force: Option, } @@ -84,8 +89,11 @@ pub fn voting_level(unit: &UnitState, kind: ProposalKind) -> InertiaLevel { } } +/// The formula a proposal is judged by: the level it is voted at, and the quorum of its +/// unit — its own, or that level's (a change of inertia inherits both). pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams { - scale.params(voting_level(unit, kind)) + let level = voting_level(unit, kind); + scale.params(level, scale.quorum(level, unit.quorum)) } /// A proposal that changes nothing cannot enter into force. @@ -264,6 +272,7 @@ mod tests { id: id.into(), position, inertia: InertiaLevel::DEFAULT, + quorum: None, in_force: in_force.map(|b| VersionText { id: format!("{id}-v"), title: id.into(), @@ -286,8 +295,9 @@ mod tests { } } - /// Monnaie libre before any median: W 5745, C 50, the reference p0 = 2C/W. - const SCALE: Scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; + /// Monnaie libre before any median: W 5745, the reference p0 = 2 × 50 / W, the quorums + /// 12 · 12 · 50 · 50 · 50 (ADR 0013). + const SCALE: Scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] }; fn level(k: u8) -> InertiaLevel { InertiaLevel::new(k).expect("1 to 5") @@ -301,12 +311,18 @@ mod tests { } #[test] - fn the_floor_holds_then_releases() { + fn the_quorum_holds_then_releases() { + // Level 3, quorum 50: under it the voters missing count as against, 39 « pour » pass. let units = [unit("E3", 7.0, None)]; - let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)]; + let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 38, 0)]; assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty()); - let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)]; + let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 39, 0)]; assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]); + // Its own quorum, lower, set by its author: the same curve on fewer voters. + let mut own = unit("E3", 7.0, None); + own.quorum = Some(12); + let lower = [prop("p1", "E3", ProposalKind::Text, "connaître", 11, 0)]; + assert_eq!(adoptions(&[own], &lower, &SCALE).expect("valid"), vec!["p1".to_string()]); } #[test] @@ -339,7 +355,10 @@ mod tests { assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article"); assert_eq!(params_for(&u, kind, &SCALE), params_for(&u, ProposalKind::Text, &SCALE)); } - assert_eq!(params_for(&u, ProposalKind::Text, &SCALE).majority_pct, 60.0); + let p = params_for(&u, ProposalKind::Text, &SCALE); + assert_eq!((p.majority_pct, p.quorum), (60.0, 50), "the level, and so its quorum"); + u.quorum = Some(20); + assert_eq!(params_for(&u, ProposalKind::Inertia(level(1)), &SCALE).quorum, 20, "and the article's own"); } #[test] @@ -358,7 +377,7 @@ mod tests { // The seed a minima has no vote: the first rival that reaches its threshold wins. let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)]; assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]); - // A version in force by vote, margin 60 − 58 = 2: a rival at 55 (margin 55 − 54 = 1) waits. + // A version in force by vote, margin 60 − 47 = 13: a rival at 55 (margin 55 − 43 = 12) waits. if let Some(v) = units[0].in_force.as_mut() { v.tally = Tally { for_votes: 60, against: 0 }; } diff --git a/crates/ld-engine/src/inertia.rs b/crates/ld-engine/src/inertia.rs index 0025cb9..231dbfa 100644 --- a/crates/ld-engine/src/inertia.rs +++ b/crates/ld-engine/src/inertia.rs @@ -5,20 +5,25 @@ //! médiane aux votes du groupe » — the scale chosen is « calée sur la //! participation ». //! -//! The formula stays the Ğ1 one (`threshold`); only M and G depend on the -//! level k: +//! The formula stays the Ğ1 one (`threshold`); M, G and the quorum Q depend on +//! the level k: //! //! - M_k, the majority at full participation: 50, 50, 55, 60, 66 %; -//! - s_k, the share of *for* votes asked **beyond C** at the group's usual +//! - s_k, the share of *for* votes asked of the voters at the group's usual //! participation p̃: 60, 67, 75, 83, 90 %; //! - G_k follows from `s_k = M_k + (1 − M_k)(1 − p̃^G_k)`: -//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`. +//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`; +//! - Q_k, the quorum of the level, set by the document (Monnaie libre: 12, 12, +//! 50, 50, 50; the test group: 1 everywhere — ADR 0013); an article may hold its +//! own, set by its author before the first vote (`Scale::quorum`). //! //! p̃ is the median number of voters on the group's proposals, over W //! (`Participation`). Until the group has voted on enough proposals, a -//! reference `p0 = 2C/W` stands in. Both are kept within [1 %, 50 %]: ln(p̃) -//! stays negative and finite, so G is finite and positive, and the threshold -//! rises with the level and relaxes toward M as participation grows. +//! reference `p0 = 2Q₀/W` stands in, Q₀ the document's reference quorum (its +//! `vote_floor`, never an article's quorum: an author who lowers his would move +//! the curve of everyone). Both are kept within [1 %, 50 %]: ln(p̃) stays +//! negative and finite, so G is finite and positive, and the threshold rises +//! with the level and relaxes toward M as participation grows. use crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate}; use serde::de::Error as _; @@ -57,7 +62,7 @@ impl InertiaLevel { MAJORITY_PCT[self.index()] } - /// s_k, in percent: the share of *for* asked beyond C at the usual participation. + /// s_k, in percent: the share of *for* asked at the usual participation. pub fn share_pct(self) -> f64 { SHARE_PCT[self.index()] } @@ -115,7 +120,7 @@ impl<'de> Deserialize<'de> for InertiaLevel { pub enum ParticipationSource { /// The median of the group's voted proposals. Median, - /// Too few voted proposals yet: `p0 = 2C/W`. + /// Too few voted proposals yet: `p0 = 2Q₀/W`, Q₀ the document's reference quorum. Reference, } @@ -140,18 +145,19 @@ impl Participation { pub const LOWEST: f64 = 0.01; pub const HIGHEST: f64 = 0.5; - /// `2C/W`, before the bounds: the floor twice over, a participation the - /// group must reach anyway for C to leave room to the majority. - pub fn reference_ratio(electorate: u32, floor: f64) -> f64 { - 2.0 * floor.max(0.0) / f64::from(electorate.max(1)) + /// `2Q₀/W`, before the bounds: the reference quorum of the document twice + /// over (`documents.vote_floor`: 50 in Monnaie libre, 100 voters). It never + /// rests on an article's quorum, which its author may lower (ADR 0013). + pub fn reference_ratio(electorate: u32, reference_quorum: f64) -> f64 { + 2.0 * reference_quorum.max(0.0) / f64::from(electorate.max(1)) } /// p̃ from what was measured: the median when it rests on enough proposals, /// the reference otherwise; brought back within the bounds. - pub fn of(electorate: u32, floor: f64, median: Option, sample: u32) -> Self { + pub fn of(electorate: u32, reference_quorum: f64, median: Option, sample: u32) -> Self { let (raw, source) = match median { Some(m) if sample >= Self::MIN_SAMPLE => (m / f64::from(electorate.max(1)), ParticipationSource::Median), - _ => (Self::reference_ratio(electorate, floor), ParticipationSource::Reference), + _ => (Self::reference_ratio(electorate, reference_quorum), ParticipationSource::Reference), }; let ratio = bounded(raw); Self { ratio, source, sample, median, bounded: ratio != raw } @@ -190,35 +196,44 @@ pub fn gradient(level: InertiaLevel, participation: f64) -> f64 { (1.0 - (s - m) / (1.0 - m)).ln() / bounded(participation).ln() } -/// What the votes of a group are judged by: its electorate W, its floor C and -/// its usual participation p̃. +/// What the votes of a document are judged by: its electorate W, its usual +/// participation p̃ and the quorum of each level. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Scale { pub electorate: u32, - pub floor: f64, /// p̃. pub participation: f64, + /// Q_k, the quorum of each level, 1 to 5: the default of an article at that level. + pub quorums: [u32; 5], } impl Scale { - /// The letters of the formula for a level. - pub fn params(&self, level: InertiaLevel) -> FormulaParams { + /// The quorum an article is judged on: its own, set by its author before the first + /// vote, else the one of its level (Yvv, 2026-11-02: « C suit le cran, mais c'est une + /// proposition par défaut, variable modifiable par l'auteur initial du texte »). A + /// change of inertia inherits the level of its article, and so its quorum. + pub fn quorum(&self, level: InertiaLevel, own: Option) -> u32 { + own.unwrap_or(self.quorums[level.index()]) + } + + /// The letters of the formula for a level, on a quorum. + pub fn params(&self, level: InertiaLevel, quorum: u32) -> FormulaParams { FormulaParams { majority_pct: level.majority_pct(), gradient: gradient(level, self.participation), base: FormulaParams::DEFAULT_BASE, - constant: self.floor, + quorum, } } - pub fn evaluate(&self, tally: Tally, level: InertiaLevel) -> Result { - evaluate(tally, self.electorate, &self.params(level)) + pub fn evaluate(&self, tally: Tally, level: InertiaLevel, quorum: u32) -> Result { + evaluate(tally, self.electorate, &self.params(level, quorum)) } - /// ⌈R⌉ for a level at `cast` votes. - pub fn required(&self, cast: u32, level: InertiaLevel) -> Result { - crate::threshold::wot_threshold(self.electorate, cast, &self.params(level)) + /// ⌈R⌉ for a level and a quorum at `cast` votes. + pub fn required(&self, cast: u32, level: InertiaLevel, quorum: u32) -> Result { + crate::threshold::wot_threshold(self.electorate, cast, &self.params(level, quorum)) } } @@ -228,14 +243,22 @@ mod tests { use crate::threshold::required_ratio; const W: u32 = 7224; - const C: f64 = 50.0; + /// The reference quorum of Monnaie libre (`vote_floor`): p0 = 2 × 50 / W. + const Q0: f64 = 50.0; + /// The quorums of Monnaie libre by level (Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 »). + const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50]; - fn at_median(voters: u32) -> Scale { - Scale { electorate: W, floor: C, participation: f64::from(voters) / f64::from(W) } + fn level(k: u8) -> InertiaLevel { + InertiaLevel::new(k).expect("1 to 5") } + fn at_median(voters: u32) -> Scale { + Scale { electorate: W, participation: f64::from(voters) / f64::from(W), quorums: QUORUMS } + } + + /// ⌈R⌉ at `cast` votes, each level on its own quorum. fn row(scale: &Scale, cast: u32) -> Vec { - InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).expect("valid")).collect() + InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k, scale.quorum(*k, None)).expect("valid")).collect() } #[test] @@ -265,30 +288,48 @@ mod tests { assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5"); } + #[test] + fn the_control_figures_of_the_quorum() { + // W 5 707, before any median: p0 = 2 × 50 / W = 100 / W (orch, 2026-11-03). + let w = 5707; + let scale = Scale { electorate: w, participation: Participation::of(w, Q0, None, 0).ratio, quorums: QUORUMS }; + let yes = |k: u8, q: u32, against: u32| { + crate::threshold::yes_needed(against, w, &scale.params(level(k), q)).expect("valid") + }; + // At the quorum: level 2, Q 12 → 9 « pour »; level 3, Q 50 → 39; level 5, Q 50 → 46. + assert_eq!((yes(2, 12, 0), yes(3, 50, 0), yes(5, 50, 0)), (9, 39, 46)); + // 9 « pour » pass at level 2 with 3 « contre » at most (Yvv, 2026-11-03); with a 4th, + // 14 voters, 11 « pour » are asked (73,8 % of 14 is 10,2). + assert_eq!((yes(2, 12, 3), yes(2, 12, 4)), (9, 11)); + // Ten voters, no quorum in the way: 7, 8, 9, 9 and 10 « pour », level 1 to 5. + let ten: Vec = InertiaLevel::ALL.iter().map(|k| scale.required(10, *k, 1).expect("valid")).collect(); + assert_eq!(ten, [7, 8, 9, 9, 10]); + } + #[test] fn the_values_shown_to_yvv() { - // W 7224, C 50, a median of 120 voters (the smith vote of 2024). + // W 7224, quorums 12 · 12 · 50 · 50 · 50, a median of 120 voters (the smith vote of 2024). let scale = at_median(120); - assert_eq!(row(&scale, 120), [92, 97, 103, 109, 113]); - assert_eq!(row(&scale, 500), [306, 329, 362, 397, 432]); - assert_eq!(row(&scale, 2000), [1091, 1145, 1271, 1404, 1549]); + assert_eq!(row(&scale, 120), [72, 81, 90, 100, 108]); + assert_eq!(row(&scale, 500), [284, 310, 347, 386, 424]); + assert_eq!(row(&scale, 2000), [1068, 1123, 1252, 1389, 1537]); } #[test] fn a_median_of_360_voters() { let scale = at_median(360); - assert_eq!(row(&scale, 120), [95, 101, 106, 112, 116]); - assert_eq!(row(&scale, 360), [236, 258, 283, 308, 329]); - assert_eq!(row(&scale, 500), [316, 345, 381, 416, 449]); - assert_eq!(row(&scale, 2000), [1114, 1184, 1318, 1460, 1608]); + assert_eq!(row(&scale, 120), [76, 86, 96, 106, 113]); + assert_eq!(row(&scale, 360), [216, 242, 270, 299, 324]); + assert_eq!(row(&scale, 500), [296, 328, 367, 407, 443]); + assert_eq!(row(&scale, 2000), [1092, 1164, 1301, 1446, 1598]); } #[test] - fn at_the_usual_participation_the_share_beyond_c_is_s_k() { - for (w, c, voters) in [(7224, 50.0, 120), (7224, 50.0, 360), (5745, 50.0, 300), (40, 1.0, 9), (2, 1.0, 1)] { - let scale = Scale { electorate: w, floor: c, participation: f64::from(voters) / f64::from(w) }; + fn at_the_usual_participation_the_share_is_s_k() { + for (w, voters) in [(7224, 120), (7224, 360), (5745, 300), (40, 9), (2, 1)] { + let scale = Scale { electorate: w, participation: f64::from(voters) / f64::from(w), quorums: QUORUMS }; for k in InertiaLevel::ALL { - let ratio = required_ratio(w, voters, &scale.params(k)); + let ratio = required_ratio(w, voters, &scale.params(k, 0)); assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get()); } } @@ -296,26 +337,33 @@ mod tests { #[test] fn a_threshold_landing_on_a_whole_number_stays_on_it() { - // Level 5 at the median: 90 % of the 70 votes beyond C is exactly 63. - assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST), Ok(113)); - // Level 1: 60 % of 70 is exactly 42. - assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST), Ok(92)); + // Level 5 at the median: 90 % of 120 voters is exactly 108. + assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST, 50), Ok(108)); + // Level 1: 60 % of 120 is exactly 72. + assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST, 12), Ok(72)); } #[test] fn the_threshold_rises_with_the_level() { - for (w, c) in [(7224, 50.0), (5745, 50.0), (40, 1.0), (2, 1.0), (1, 1.0)] { + for (w, q0, quorums) in [ + (7224, Q0, QUORUMS), + (5745, Q0, QUORUMS), + (5745, Q0, [50; 5]), + (40, 1.0, [1; 5]), + (2, 1.0, [1; 5]), + (1, 1.0, [1; 5]), + ] { for voters in [1, 2, 9, 60, 120, 360, 3000] { let scale = Scale { electorate: w, - floor: c, - participation: Participation::of(w, c, Some(f64::from(voters)), 9).ratio, + participation: Participation::of(w, q0, Some(f64::from(voters)), 9).ratio, + quorums, }; for cast in (0..=w.min(4000)).step_by(7).chain([w]) { let r = row(&scale, cast); assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}"); let ratios: Vec = - InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k))).collect(); + InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k, 0))).collect(); if cast > 0 && cast < w { assert!( ratios.windows(2).all(|p| p[0] < p[1]), @@ -332,7 +380,7 @@ mod tests { for voters in [120, 360] { let scale = at_median(voters); for k in InertiaLevel::ALL { - let p = scale.params(k); + let p = scale.params(k, 0); let mut last = 1.0; for cast in (1..=W).step_by(13).chain([W]) { let ratio = required_ratio(W, cast, &p); @@ -346,26 +394,44 @@ mod tests { } #[test] - fn c_stays_a_floor_one_vote_never_passes_in_monnaie_libre() { + fn under_the_quorum_one_vote_never_passes_in_monnaie_libre() { for voters in [1, 72, 120, 360, 3612] { let scale = Scale { electorate: W, - floor: C, - participation: Participation::of(W, C, Some(f64::from(voters)), 9).ratio, + participation: Participation::of(W, Q0, Some(f64::from(voters)), 9).ratio, + quorums: QUORUMS, }; for k in InertiaLevel::ALL { - let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid"); + let q = scale.quorum(k, None); + let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, q).expect("valid"); assert!(!lone.reached, "k {}", k.get()); - assert_eq!(lone.required, 50); - for cast in 1..=50 { - assert!(scale.required(cast, k).expect("valid") >= 50, "nothing passes under C for"); + // Under the quorum, the voters missing count as against: the bar of Q voters. + let at_q = scale.required(q, k, q).expect("valid"); + assert!(at_q >= 2, "one voice is never enough"); + for cast in 0..=q { + assert_eq!(scale.required(cast, k, q).expect("valid"), at_q, "under the quorum, k {}", k.get()); } - assert!(!scale.evaluate(Tally { for_votes: 49, against: 0 }, k).expect("valid").reached); - assert!(scale.evaluate(Tally { for_votes: 50, against: 0 }, k).expect("valid").reached); + let short = Tally { for_votes: at_q - 1, against: 0 }; + assert!(!scale.evaluate(short, k, q).expect("valid").reached); + let enough = scale.evaluate(Tally { for_votes: at_q, against: 0 }, k, q).expect("valid"); + assert!(enough.reached); + assert_eq!(enough.yes_needed, at_q); } } } + #[test] + fn an_article_s_own_quorum_stands_for_its_level() { + let scale = at_median(120); + assert_eq!(scale.quorum(InertiaLevel::DEFAULT, None), 50); + assert_eq!(scale.quorum(level(2), None), 12); + assert_eq!(scale.quorum(InertiaLevel::DEFAULT, Some(20)), 20); + // The curve stays the level's: only the quorum moves. + let own = scale.params(InertiaLevel::DEFAULT, 20); + let default = scale.params(InertiaLevel::DEFAULT, 50); + assert_eq!((own.majority_pct, own.gradient, own.quorum), (default.majority_pct, default.gradient, 20)); + } + #[test] fn the_median() { assert_eq!(median(&[]), None); @@ -378,14 +444,14 @@ mod tests { #[test] fn the_reference_stands_in_until_five_proposals_are_voted_on() { // Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters. - assert!((Participation::reference_ratio(W, C) - 100.0 / 7224.0).abs() < 1e-15); - let early = Participation::of(W, C, Some(120.0), 4); + assert!((Participation::reference_ratio(W, Q0) - 100.0 / 7224.0).abs() < 1e-15); + let early = Participation::of(W, Q0, Some(120.0), 4); assert_eq!(early.source, ParticipationSource::Reference); assert!((early.voters(W) - 100.0).abs() < 1e-9); assert_eq!((early.sample, early.median, early.bounded), (4, Some(120.0), false)); - let none = Participation::of(W, C, None, 0); + let none = Participation::of(W, Q0, None, 0); assert_eq!(none.source, ParticipationSource::Reference); - let measured = Participation::of(W, C, Some(120.0), 5); + let measured = Participation::of(W, Q0, Some(120.0), 5); assert_eq!(measured.source, ParticipationSource::Median); assert!((measured.voters(W) - 120.0).abs() < 1e-9); assert!(!measured.bounded); @@ -394,7 +460,7 @@ mod tests { #[test] fn participation_is_kept_within_one_and_fifty_percent() { // Two voters on 7224: brought up to 1 %. - let low = Participation::of(W, C, Some(2.0), 9); + let low = Participation::of(W, Q0, Some(2.0), 9); assert_eq!((low.ratio, low.bounded), (0.01, true)); // A group where everyone votes: brought down to half. let all = Participation::of(12, 1.0, Some(12.0), 9); @@ -418,12 +484,14 @@ mod tests { } #[test] - fn the_test_group_needs_everyone() { - // W = 2, C = 1: both must vote for, at every level. - let scale = Scale { electorate: 2, floor: 1.0, participation: Participation::of(2, 1.0, None, 0).ratio }; + fn the_test_group_at_a_quorum_of_one() { + // W = 2, quorum 1 everywhere (Yvv, 2026-11-02): one « pour » alone passes, at every + // level; one « pour » and one « contre » never (half of all, under any M of the levels). + let scale = Scale { electorate: 2, participation: Participation::of(2, 1.0, None, 0).ratio, quorums: [1; 5] }; for k in InertiaLevel::ALL { - assert!(!scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid").reached); - assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k).expect("valid").reached); + assert!(scale.evaluate(Tally { for_votes: 1, against: 0 }, k, 1).expect("valid").reached); + assert!(!scale.evaluate(Tally { for_votes: 1, against: 1 }, k, 1).expect("valid").reached); + assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k, 1).expect("valid").reached); } } diff --git a/crates/ld-engine/src/order.rs b/crates/ld-engine/src/order.rs index 5616bfc..7775743 100644 --- a/crates/ld-engine/src/order.rs +++ b/crates/ld-engine/src/order.rs @@ -274,13 +274,15 @@ mod tests { use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions}; use crate::inertia::{InertiaLevel, Scale}; use crate::threshold::Tally; - // Monnaie libre before any median: W 5745, C 50; the order of a section at level 2. - let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; + // Monnaie libre before any median: W 5745, p0 = 100 / W; the order of a section at + // level 2, on the quorum of that level, 12 (ADR 0013). + let scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] }; let section = ids(&["T1", "E1", "E2"]); let module = |tally: Tally| UnitState { id: "ordre.fondamental".into(), position: 0.0, inertia: InertiaLevel::new(2).expect("level"), + quorum: None, in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(§ion), tally }), }; let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState { @@ -294,12 +296,14 @@ mod tests { blocked: false, }; let seed = [module(Tally::default())]; - // Never under C voices for; the order in force at zero votes is a seed a minima: margin −C. - assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty()); - assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]); + // Never under the bar of the quorum (9 « pour » of 12 voters); the order in force at zero + // votes is a seed a minima: the margin of zero votes, −9. + assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 8)], &scale).expect("valid").is_empty()); + assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 9)], &scale).expect("valid"), ["p"]); // An order that is the one in force changes nothing, whatever its votes. assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty()); - // Adopted by a vote, the order in force holds its margin: a weaker order waits, a stronger one passes. + // Adopted by a vote, the order in force holds its margin (70 − 48): a weaker order waits + // (60 − 42), a stronger one passes (90 − 61). let voted = [module(Tally { for_votes: 70, against: 0 })]; assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty()); assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]); diff --git a/crates/ld-engine/src/threshold.rs b/crates/ld-engine/src/threshold.rs index 52dfe97..0d87d5e 100644 --- a/crates/ld-engine/src/threshold.rs +++ b/crates/ld-engine/src/threshold.rs @@ -1,21 +1,24 @@ -//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula. +//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula, judged on a quorum. //! //! ```text -//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C) +//! T' = max(T, Q) +//! R = B^W + (M + (1 − M)(1 − (T'/W)^G)) · T' //! ``` //! -//! R is the minimum number of *for* votes; a proposal is adopted when -//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for + -//! against). At low participation the formula asks for near-unanimity, at full -//! participation the simple majority M suffices. C is the minimal number of -//! *for* votes below which nothing passes — the guard of a permanent vote -//! (Yvv, 2026-10-21: « Nombre minimal de votants »). +//! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`. +//! W is the size of the electorate, T the votes cast (for + against). At low +//! participation the formula asks for near-unanimity, at full participation the +//! simple majority M suffices. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02: +//! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir +//! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and +//! below it the voters missing count as against (`judged_voters`). It replaces the +//! floor C of *for* votes added in front of the formula (ADR 0002, 0007). //! -//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the -//! Python reference, with two deliberate guards — the participation ratio is -//! capped at 1, so a stale W can never lower the bar below M; and a count that -//! lands on a whole number stays on it (`settle`), so a rounding error never -//! adds a vote. Which M and G a text is judged by: `inertia` (five levels). +//! Parity with `maquette/frontend/app/engine/threshold.ts` at Q = 0 (the C = 0 of +//! the maquette), with two deliberate guards — the participation ratio is capped +//! at 1, so a stale W can never lower the bar below M; and a count that lands on a +//! whole number stays on it (`settle`), so a rounding error never adds a vote. +//! Which M, G and Q a text is judged by: `inertia` (five levels, their quorums). use serde::{Deserialize, Serialize}; @@ -28,8 +31,9 @@ pub struct FormulaParams { pub gradient: f64, /// B. pub base: f64, - /// C — minimal number of *for* votes. - pub constant: f64, + /// Q — the quorum: the voters (for + against) from which the share of the inertia + /// applies; below it, the voters missing count as against (`judged_voters`). + pub quorum: u32, } impl FormulaParams { @@ -42,14 +46,26 @@ pub enum ThresholdError { MajorityOutOfRange, } -/// The required ratio of *for* votes among the votes cast beyond C. -pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 { +/// T' — the voters a version is judged on. **The rule of the quorum, here alone.** +/// +/// Yvv, 2026-11-03, to « Sous le quorum, les votants qui manquent comptent-ils comme des +/// « non » ? » : « Ils comptent comme non ». Below the quorum Q, the voters who did not +/// come count as against: the share of the inertia is asked of max(T, Q) voters. At level +/// 2, quorum 12, 9 « pour » pass with 3 « contre » at most; Yvv's own example, 60 % and +/// Q = 10, takes 6 « pour ». So an « against » never helps a version pass — completing +/// the quorum with a « non » adds nothing the missing voters did not already count. +pub fn judged_voters(cast: u32, quorum: u32) -> u32 { + cast.max(quorum) +} + +/// The share of *for* votes asked of `voters` voters (T' when judging a version). +pub fn required_ratio(electorate: u32, voters: u32, params: &FormulaParams) -> f64 { let m = params.majority_pct / 100.0; - let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0); + let participation = (f64::from(voters) / f64::from(electorate.max(1))).min(1.0); m + (1.0 - m) * (1.0 - participation.powf(params.gradient)) } -/// ⌈R⌉ — the minimum number of *for* votes for adoption. +/// ⌈R⌉ — the minimum number of *for* votes for adoption, at `cast` votes cast. pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result { if electorate == 0 { return Err(ThresholdError::EmptyElectorate); @@ -57,20 +73,27 @@ pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Resu if !(0.0..=100.0).contains(¶ms.majority_pct) { return Err(ThresholdError::MajorityOutOfRange); } - let c = params.constant; + let voters = judged_voters(cast, params.quorum); let offset = params.base.powf(f64::from(electorate)); - let raw = if cast == 0 { - c + offset + let raw = if voters == 0 { + offset } else { - c + offset + settle(required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)) + offset + settle(required_ratio(electorate, voters, params) * f64::from(voters)) }; // `raw` is finite and non-negative for any valid input; saturate rather than wrap. Ok(clamp_ceil(raw)) } +/// Does a version with this tally pass? At least one vote cast, and its *for* reach +/// ⌈R⌉ on the voters it is judged on (`judged_voters`). +pub fn passes(tally: Tally, electorate: u32, params: &FormulaParams) -> Result { + let cast = tally.cast(); + Ok(cast > 0 && tally.for_votes >= wot_threshold(electorate, cast, params)?) +} + /// A count of votes that lands on a whole number stays on it: a rounding error /// of the logarithms (a billionth of a vote) must never raise a threshold by -/// one. At the usual participation, 90 % of the 70 votes beyond C is exactly 63. +/// one. At the usual participation, 90 % of 70 votes is exactly 63. fn settle(votes: f64) -> f64 { let whole = votes.round(); if (votes - whole).abs() < 1e-9 { whole } else { votes } @@ -107,32 +130,82 @@ impl Tally { #[serde(rename_all = "camelCase")] pub struct Evaluation { pub cast: u32, - /// ⌈R⌉ at the current participation. + /// Q, the quorum it is judged on. + pub quorum: u32, + /// ⌈R⌉ at the current participation (on T' = max(T, Q) voters). pub required: u32, - /// `for ≥ ⌈R⌉` with at least one vote cast. + /// `passes`: `for ≥ ⌈R⌉` with at least one vote cast. pub reached: bool, - /// *for* votes still missing (0 once reached). + /// *for* votes still missing with the *against* of now: `yes_needed − for` (0 once reached). pub missing: u32, - /// The majority the inertia asks for at this participation. + /// The share of *for* the inertia asks of T' voters. pub required_ratio: f64, - /// « Bien cotée » : it holds the majority its inertia asks for, it only - /// lacks voters (C ignored). Used by the "if the well-rated pass" preview. + /// « Bien cotée » : it holds the share its inertia asks of the T voters who came, + /// it only lacks voters (the quorum ignored). Used by the "if the well-rated pass" preview. pub well_rated: bool, + /// The total of *for* that passes with the *against* cast now. + pub yes_needed: u32, + /// The total of *against* that holds the *for* cast now in check: 0 when the quorum + /// already holds them (they do not pass even without any *against*). + pub no_needed: u32, +} + +/// The number of votes searched for never goes past this: the five levels always find +/// theirs well before (`yes_needed`, `no_needed`). +const SEARCH_LIMIT: u32 = u32::MAX / 4; + +/// The smallest `n` in `0..` for which `holds(n)`, `holds` being monotone (false, then +/// true); `u32::MAX` if it never holds up to `SEARCH_LIMIT`. +fn first(mut holds: impl FnMut(u32) -> Result) -> Result { + if holds(0)? { + return Ok(0); + } + let (mut lo, mut hi) = (0u32, 1u32); + while !holds(hi)? { + if hi >= SEARCH_LIMIT { + return Ok(u32::MAX); + } + lo = hi; + hi = hi.saturating_mul(2).min(SEARCH_LIMIT); + } + // holds(lo) is false, holds(hi) true. + while hi - lo > 1 { + let mid = lo + (hi - lo) / 2; + if holds(mid)? { hi = mid } else { lo = mid } + } + Ok(hi) +} + +/// The total of *for* that passes with `against` votes against. A *for* more never +/// hurts (`passes` is monotone): every total from this one on passes. +pub fn yes_needed(against: u32, electorate: u32, params: &FormulaParams) -> Result { + first(|y| passes(Tally { for_votes: y, against }, electorate, params)) +} + +/// The total of *against* that holds `for_votes` in check; 0 when they do not pass even +/// without any *against* (the quorum holds them). An *against* more never helps: every +/// total from this one on holds them. +pub fn no_needed(for_votes: u32, electorate: u32, params: &FormulaParams) -> Result { + first(|n| passes(Tally { for_votes, against: n }, electorate, params).map(|p| !p)) } pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result { let cast = tally.cast(); let required = wot_threshold(electorate, cast, params)?; - let without_floor = FormulaParams { constant: 0.0, ..*params }; - let required_without_floor = wot_threshold(electorate, cast, &without_floor)?; - let reached = cast > 0 && tally.for_votes >= required; + let reached = passes(tally, electorate, params)?; + let without_quorum = FormulaParams { quorum: 0, ..*params }; + let required_without_quorum = wot_threshold(electorate, cast, &without_quorum)?; + let yes = yes_needed(tally.against, electorate, params)?; Ok(Evaluation { cast, + quorum: params.quorum, required, reached, - missing: required.saturating_sub(tally.for_votes), - required_ratio: required_ratio(electorate, cast, params), - well_rated: cast > 0 && tally.for_votes >= required_without_floor, + missing: if reached { 0 } else { yes.saturating_sub(tally.for_votes) }, + required_ratio: required_ratio(electorate, judged_voters(cast, params.quorum), params), + well_rated: cast > 0 && tally.for_votes >= required_without_quorum, + yes_needed: yes, + no_needed: no_needed(tally.for_votes, electorate, params)?, }) } @@ -141,19 +214,19 @@ mod tests { use super::*; fn g1vote(m: f64, g: f64) -> FormulaParams { - FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 } + FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: 0 } } /// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2. - fn standard(constant: f64) -> FormulaParams { - FormulaParams { constant, ..g1vote(50.0, 0.2) } + fn standard(quorum: u32) -> FormulaParams { + FormulaParams { quorum, ..g1vote(50.0, 0.2) } } #[test] fn reference_case_of_the_smith_vote() { // Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94. - // The formula's own reference, letters given: the five levels (ADR 0007) - // choose M and G, the formula that turns them into a threshold is this one. + // The formula's own reference, letters given, without a quorum (Q = 0, the C = 0 + // of the maquette): the five levels choose M, G and Q (ADR 0007, 0013). let p = g1vote(50.0, 0.2); assert_eq!(wot_threshold(7224, 120, &p), Ok(94)); let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid"); @@ -163,7 +236,7 @@ mod tests { #[test] fn parity_with_the_typescript_reference() { - // Values computed by maquette/frontend/app/engine/threshold.ts. + // Values computed by maquette/frontend/app/engine/threshold.ts (C = 0). let p = g1vote(50.0, 0.2); assert_eq!(wot_threshold(7224, 0, &p), Ok(0)); assert_eq!(wot_threshold(7224, 1, &p), Ok(1)); @@ -174,42 +247,138 @@ mod tests { } #[test] - fn one_vote_passes_without_floor_and_never_with_it() { - // The trap of a permanent vote: a lone "for" meets the inertial bar. - let bare = standard(0.0); - assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached); - let guarded = standard(50.0); - let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid"); - assert!(!e.reached); - assert_eq!(e.required, 50); - assert_eq!(e.missing, 49); - assert!(e.well_rated, "unanimous, it only lacks voters"); + fn yvv_s_example_sixty_percent_and_a_quorum_of_ten() { + // Yvv, 2026-11-02: « Si c'est 60% et c=10, alors il faut 6 voix pour et ça passe. » + // A share of 60 % whatever the participation (G = 0), a large web (B^W vanishes). + let p = FormulaParams { majority_pct: 60.0, gradient: 0.0, base: 0.1, quorum: 10 }; + // G = 0: (T/W)^0 = 1, the share is M, 60 %. + assert!((required_ratio(7224, 10, &p) - 0.6).abs() < 1e-12); + for against in 0..=4 { + let t = Tally { for_votes: 6, against }; + assert_eq!(passes(t, 7224, &p), Ok(true), "6 pour, {against} contre"); + } + assert_eq!(passes(Tally { for_votes: 5, against: 0 }, 7224, &p), Ok(false), "5 of the 10 counted"); + assert_eq!(wot_threshold(7224, 6, &p), Ok(6)); + // Beyond the quorum, the share of all who came: 6 pour and 5 contre are 11 voters. + assert_eq!(passes(Tally { for_votes: 6, against: 5 }, 7224, &p), Ok(false)); + assert_eq!(passes(Tally { for_votes: 7, against: 4 }, 7224, &p), Ok(true)); } #[test] - fn floor_is_a_minimum_of_for_votes() { - let p = standard(50.0); - // 50 unanimous votes reach it, 49 do not. - assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached); - assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached); - // Beyond the floor the inertial majority applies to the rest. - let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid"); - assert!(!e.reached, "near-unanimity is asked of the votes beyond C"); + fn under_the_quorum_the_voters_missing_count_as_against() { + // Yvv, 2026-11-03: « Ils comptent comme non ». T' = max(T, Q). + assert_eq!( + (judged_voters(0, 12), judged_voters(9, 12), judged_voters(12, 12), judged_voters(30, 12)), + (12, 12, 12, 30) + ); + let p = standard(50); + // One « pour » alone is judged on 50 voters; so are 1 « pour » and 49 « contre ». + assert_eq!(wot_threshold(5745, 1, &p), wot_threshold(5745, 50, &p)); + assert!(!passes(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid")); + } + + #[test] + fn an_against_never_helps_a_version_pass() { + // Without the rule, an « against » that completes the quorum could make a text pass. + for (m, g, q, w) in [(50.0, 0.1, 12, 5707), (55.0, 0.15, 50, 5707), (66.0, 0.3, 50, 5745), (50.0, 0.6, 1, 2)] { + let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q }; + for y in 0..80 { + let mut passed = passes(Tally { for_votes: y, against: 0 }, w, &p).expect("valid"); + for n in 1..80 { + let now = passes(Tally { for_votes: y, against: n }, w, &p).expect("valid"); + assert!(passed || !now, "M {m} Q {q} W {w}: {y} pour, {n} contre passes, {} contre did not", n - 1); + passed = now; + } + } + } + } + + #[test] + fn one_vote_never_passes_with_a_quorum_of_two_or_more() { + // The invariant of Monnaie libre (quorum 2 at least): one voice alone never passes. + for q in [2, 12, 50] { + for g in [0.05, 0.1, 0.3, 1.77] { + let p = FormulaParams { majority_pct: 50.0, gradient: g, base: 0.1, quorum: q }; + let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid"); + assert!(!e.reached, "Q {q}, G {g}"); + assert!(e.well_rated, "unanimous, it only lacks voters"); + } + } + // Without a quorum, the trap of a permanent vote: a lone « pour » meets the inertial bar. + assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &standard(0)).expect("valid").reached); + } + + #[test] + fn yes_needed_and_no_needed_say_where_the_line_is() { + // At level 2 on the scale of Monnaie libre (G 0.10274, W 5707), quorum 12. + let p = FormulaParams { majority_pct: 50.0, gradient: 0.102_742, base: 0.1, quorum: 12 }; + let e = evaluate(Tally::default(), 5707, &p).expect("valid"); + assert_eq!((e.quorum, e.required, e.yes_needed, e.missing, e.no_needed), (12, 9, 9, 9, 0)); + // 9 « pour »: they pass with 3 « contre » at most; the 4th holds them. + let e = evaluate(Tally { for_votes: 9, against: 0 }, 5707, &p).expect("valid"); + assert!(e.reached); + assert_eq!((e.yes_needed, e.no_needed, e.missing), (9, 4, 0)); + // With a 4th « contre », 14 voters at least: 11 « pour » are asked (10,2 rounded up). + let e = evaluate(Tally { for_votes: 9, against: 4 }, 5707, &p).expect("valid"); + assert!(!e.reached); + assert_eq!(e.yes_needed, 11); + assert_eq!(e.missing, 2); + // 8 « pour »: the quorum already holds them, no « contre » is needed. + let e = evaluate(Tally { for_votes: 8, against: 0 }, 5707, &p).expect("valid"); + assert_eq!((e.reached, e.no_needed, e.missing), (false, 0, 1)); + } + + #[test] + fn passes_means_yes_needed_reached_means_no_needed_not_reached() { + // On a grid: levels (M, G as at the reference participation and beyond), quorums, + // electorates, « pour », « contre ». passes ⇔ for ≥ yesNeeded(against) ⇔ against < noNeeded(for). + let levels = [(50.0, 0.055), (50.0, 0.103), (55.0, 0.145), (60.0, 0.212), (66.0, 0.303), (66.0, 1.77)]; + for (m, g) in levels { + for q in [0, 1, 2, 12, 50] { + for w in [1, 2, 16, 40, 500, 5707] { + let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q }; + let yes: Vec = (0..70).map(|n| yes_needed(n, w, &p).expect("valid")).collect(); + for y in 0..70 { + let no = no_needed(y, w, &p).expect("valid"); + for n in 0..70 { + let t = Tally { for_votes: y, against: n }; + let ok = passes(t, w, &p).expect("valid"); + assert_eq!(ok, y >= yes[n as usize], "M {m} G {g} Q {q} W {w}: {y} pour {n} contre"); + assert_eq!(ok, n < no, "M {m} G {g} Q {q} W {w}: {y} pour {n} contre, noNeeded {no}"); + } + } + } + } + } + } + + #[test] + fn well_rated_holds_the_share_on_those_who_came() { + let p = standard(50); + // 30 « pour », 2 « contre »: the share holds on 32 voters, the quorum is not there. + let e = evaluate(Tally { for_votes: 30, against: 2 }, 5745, &p).expect("valid"); + assert!(!e.reached && e.well_rated); + // 10 « pour », 10 « contre »: the share does not hold. + assert!(!evaluate(Tally { for_votes: 10, against: 10 }, 5745, &p).expect("valid").well_rated); } #[test] fn no_vote_is_never_reached() { - let p = g1vote(50.0, 0.1); - let e = evaluate(Tally::default(), 3, &p).expect("valid"); - assert!(!e.reached); - assert!(!e.well_rated); + for q in [0, 1, 12] { + let p = FormulaParams { quorum: q, ..g1vote(50.0, 0.1) }; + let e = evaluate(Tally::default(), 3, &p).expect("valid"); + assert!(!e.reached); + assert!(!e.well_rated); + } } #[test] - fn small_group_needs_everyone() { - // Test group of two people, C = 1: both must vote for. - let p = standard(1.0); - assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); + fn the_test_group_passes_with_one_voice_at_a_quorum_of_one() { + // Two people, quorum 1 (Yvv, 2026-11-02: the test group stays at 1 everywhere): one + // « pour » of one voter holds any share; a « contre » beside it does not. + let p = standard(1); + assert!(evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); + assert!(!evaluate(Tally { for_votes: 1, against: 1 }, 2, &p).expect("valid").reached); assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached); } @@ -225,12 +394,12 @@ mod tests { #[test] fn a_whole_number_of_votes_never_gains_one_by_rounding() { - // 101 members, everyone votes, M 55 %: 1 + 55 % of 100 = 56. In floating - // point 0.55 × 100 is 55.000000000000007, which would ask for 57. - let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, constant: 1.0 }; - assert_eq!(wot_threshold(101, 101, &p), Ok(56)); + // 100 members, everyone votes, M 55 %: 55 % of 100 = 55. In floating point 0.55 × 100 + // is 55.000000000000007, which would ask for 56 (B^W = 10⁻¹⁰⁰ adds nothing). + let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, quorum: 1 }; + assert_eq!(wot_threshold(100, 100, &p), Ok(55)); // A genuine fraction still rounds up. - assert_eq!(wot_threshold(101, 100, &FormulaParams { constant: 0.0, ..p }), Ok(56)); + assert_eq!(wot_threshold(101, 99, &FormulaParams { quorum: 0, ..p }), Ok(55)); } #[test] @@ -239,5 +408,6 @@ mod tests { assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate)); let bad = g1vote(120.0, 0.2); assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange)); + assert_eq!(yes_needed(0, 0, &p), Err(ThresholdError::EmptyElectorate)); } } From 01b7841472ad824659babeb4409e584ae7378073 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 05:00:41 +0100 Subject: [PATCH 18/24] Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran, NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du cran), journaux quorum_changes et quorum_amendments ; - quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ; - route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum (l'auteur de la première version, un administrateur pour le seed), refusée dès qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ; - amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où il n'y en a pas, une fois par document, consigné, file de publication là où il publie ; - JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum, quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json publié : quorums ; adoptions.params : quorum (plus de constant) ; - seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai). - tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au cran 3, 43 au cran 4). Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/inertia.rs | 28 ++ crates/ld-server/migrations/0011_quorum.sql | 48 +++ crates/ld-server/src/adoption.rs | 11 +- crates/ld-server/src/docs.rs | 96 ++++- crates/ld-server/src/lib.rs | 3 + crates/ld-server/src/proposals.rs | 2 +- crates/ld-server/src/publish.rs | 2 +- crates/ld-server/src/quorum.rs | 176 +++++++++ crates/ld-server/src/quorum_amend.rs | 195 ++++++++++ crates/ld-server/src/seed.rs | 34 +- crates/ld-server/tests/api.rs | 40 +- crates/ld-server/tests/ballots.rs | 91 +++-- crates/ld-server/tests/inertia.rs | 52 ++- crates/ld-server/tests/modules.rs | 23 +- crates/ld-server/tests/overview.rs | 37 +- crates/ld-server/tests/quorum.rs | 404 ++++++++++++++++++++ crates/ld-server/tests/seed_proposals.rs | 11 +- crates/ld-server/tests/typos.rs | 4 + crates/trustin-access/src/lib.rs | 39 +- seeds/acte-engagement-certification.json | 4 +- 20 files changed, 1205 insertions(+), 95 deletions(-) create mode 100644 crates/ld-server/migrations/0011_quorum.sql create mode 100644 crates/ld-server/src/quorum.rs create mode 100644 crates/ld-server/src/quorum_amend.rs create mode 100644 crates/ld-server/tests/quorum.rs diff --git a/crates/ld-engine/src/inertia.rs b/crates/ld-engine/src/inertia.rs index 231dbfa..72d34fb 100644 --- a/crates/ld-engine/src/inertia.rs +++ b/crates/ld-engine/src/inertia.rs @@ -237,6 +237,14 @@ impl Scale { } } +/// The quorums an author may set (ADR 0013): from 2 — one voice never passes a text +/// alone — to W; from 1 in the test group, where one voice passes to try things out. A +/// tiny electorate never brings the lowest under 2. +pub fn quorum_bounds(electorate: u32, test_group: bool) -> (u32, u32) { + let lowest = if test_group { 1 } else { 2 }; + (lowest, electorate.max(lowest)) +} + #[cfg(test)] mod tests { use super::*; @@ -393,6 +401,26 @@ mod tests { } } + #[test] + fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() { + assert_eq!(quorum_bounds(5745, false), (2, 5745)); + assert_eq!(quorum_bounds(2, true), (1, 2)); + assert_eq!(quorum_bounds(1, true), (1, 1)); + assert_eq!(quorum_bounds(1, false), (2, 2), "never under two, even on a tiny electorate"); + // At the lowest bound outside the test group, one voice never passes, whatever the + // level, the electorate and the usual participation. + for w in [1, 2, 3, 10, 120, 5745, 7224] { + let (lowest, _) = quorum_bounds(w, false); + for p in [Participation::LOWEST, 0.1, Participation::HIGHEST] { + let scale = Scale { electorate: w, participation: p, quorums: [lowest; 5] }; + for k in InertiaLevel::ALL { + let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, lowest).expect("valid"); + assert!(!lone.reached, "W {w}, p̃ {p}, k {}", k.get()); + } + } + } + } + #[test] fn under_the_quorum_one_vote_never_passes_in_monnaie_libre() { for voters in [1, 72, 120, 360, 3612] { diff --git a/crates/ld-server/migrations/0011_quorum.sql b/crates/ld-server/migrations/0011_quorum.sql new file mode 100644 index 0000000..9d43b7b --- /dev/null +++ b/crates/ld-server/migrations/0011_quorum.sql @@ -0,0 +1,48 @@ +-- The quorum (2026-11-03, ADR 0013). Additive only: nothing is dropped, nothing +-- an older image needs is rewritten. (0010 is kept for lot V: the gap is meant.) +-- +-- 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 ». +-- The floor C of *for* votes put in front of the formula gives way to a quorum Q +-- by level: from Q voters on, the share of the inertia applies; below it, the +-- voters missing count as against (2026-11-03: « Ils comptent comme non »). +-- `documents.vote_floor` stays: the reference quorum Q₀, read for the reference +-- participation p0 = 2Q₀/W only (never an article's quorum), and as every +-- level's quorum while `quorums` is NULL. An older image ignores both columns. + +-- The quorum of each level, 1 to 5, as JSON `[Q1, Q2, Q3, Q4, Q5]`: the default +-- of an article at that level (Yvv: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, +-- 1 everywhere in the test group). NULL: a base the quorum amendment +-- (src/quorum_amend.rs) has not reached yet. +ALTER TABLE documents ADD COLUMN quorums TEXT; + +-- The quorum of an article, set by the author of its first version before the +-- first vote (src/quorum.rs; Yvv: « L'auteur, avant le 1er vote »). NULL: the +-- quorum of its level. A change of its inertia is judged on it too. +ALTER TABLE units ADD COLUMN quorum INTEGER; + +-- Every change of an article's own quorum: who, from what, to what. +CREATE TABLE quorum_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL REFERENCES units(id), + doc_id TEXT NOT NULL REFERENCES documents(id), + from_quorum INTEGER, -- NULL: the quorum of its level + to_quorum INTEGER, -- NULL: back to the quorum of its level + identity_id TEXT NOT NULL REFERENCES identities(id), + at TEXT NOT NULL +); +CREATE INDEX quorum_changes_unit ON quorum_changes(unit_id); + +-- Quorum amendments (src/quorum_amend.rs): the quorums by level of the seed, +-- written into an existing document that holds none, decided once per document. +-- Each decision is kept here: applied, refused (and why), or present. +CREATE TABLE quorum_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + from_quorums TEXT, -- what the document held: NULL, or its JSON + to_quorums TEXT NOT NULL, -- the seed's, JSON + outcome TEXT NOT NULL, -- applied | refused | present + reason TEXT, -- why it was refused + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs index f418f69..76629be 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -190,14 +190,15 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { Ok(entered) } -/// What an adoption keeps of the formula it was judged by. The four letters -/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`, -/// `constant`: read as they were written); the level and the scale join them. +/// What an adoption keeps of the formula it was judged by. The letters stay where +/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before +/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum +/// (ADR 0013), the level and the scale join them. fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value { let level = st.voting_level(unit, p); - let f = st.scale().params(level); + let f = st.params(unit, p); serde_json::json!({ - "majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant, + "majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum, "level": level, "share_pct": level.share_pct(), "participation": st.participation.ratio, "participation_source": st.participation.source, "participation_sample": st.participation.sample, "participation_median": st.participation.median, diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 707dcfa..349cb9d 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -15,7 +15,7 @@ use axum::extract::{Path, Query, State}; use ld_engine::ballot::Choice; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::inertia::{InertiaLevel, Participation, Scale}; -use ld_engine::threshold::{Evaluation, Tally}; +use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -196,8 +196,21 @@ pub struct DocRow { pub title: String, pub summary: String, pub source_lang: String, + /// The reference quorum Q₀ (C before 2026-11-03): read for the reference + /// participation p0 = 2Q₀/W only, and as every level's quorum while `quorums` is NULL. pub vote_floor: f64, pub publish_path: Option, + /// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum + /// amendment (`quorum_amend`). + pub quorums: Option, +} + +impl DocRow { + /// The quorum of each level, 1 to 5: the document's, or, in a base the quorum + /// amendment has not reached yet, its reference quorum at every level — the C of before. + pub fn quorums(&self) -> [u32; 5] { + self.quorums.as_deref().and_then(|raw| serde_json::from_str(raw).ok()).unwrap_or([whole(self.vote_floor); 5]) + } } #[derive(Clone, Debug, sqlx::FromRow)] @@ -208,6 +221,16 @@ pub struct UnitRow { pub position: f64, pub inertia: String, pub in_force_id: Option, + /// Its own quorum, set by the author of its first version before the first vote + /// (`quorum`); NULL: the quorum of its level. + pub quorum: Option, +} + +impl UnitRow { + /// Its own quorum, if its author set one. + pub fn own_quorum(&self) -> Option { + self.quorum.and_then(|q| u32::try_from(q).ok()) + } } #[derive(Clone, Debug, sqlx::FromRow)] @@ -294,7 +317,8 @@ pub struct DocState { pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult { sqlx::query_as::<_, DocRow>( - "SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?", + "SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, quorums + FROM documents WHERE id = ?", ) .bind(doc_id) .fetch_optional(&state.db) @@ -321,7 +345,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { let doc = load_doc(state, doc_id).await?; let group = load_group(state, &doc.group_slug).await?; let units = sqlx::query_as::<_, UnitRow>( - "SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id", + "SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id", ) .bind(doc_id) .fetch_all(&state.db) @@ -397,9 +421,25 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { } impl DocState { - /// What this document's votes are judged by: W, C and p̃. + /// What this document's votes are judged by: W, p̃ and the quorum of each level. pub fn scale(&self) -> Scale { - Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio } + Scale { electorate: self.electorate, participation: self.participation.ratio, quorums: self.doc.quorums() } + } + + /// The quorum an article is judged on, and its versions with it: its own, else its + /// level's (`Scale::quorum`). + pub fn quorum_of(&self, unit: &UnitRow) -> u32 { + self.scale().quorum(inertia_of(unit), unit.own_quorum()) + } + + /// The quorum of the article's level: what it is judged on without one of its own. + pub fn quorum_default(&self, unit: &UnitRow) -> u32 { + self.scale().quorum(inertia_of(unit), None) + } + + /// The letters a proposal is judged by: its level and its article's quorum. + pub fn params(&self, unit: &UnitRow, p: &ProposalRow) -> FormulaParams { + compose::params_for(&bare(unit), p.kind(), &self.scale()) } /// p̃ · W, as a whole number of voters. @@ -422,7 +462,7 @@ impl DocState { /// How far the threshold is, as shown: on `shown_tally`. pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { - self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok() + threshold::evaluate(self.shown_tally(&p.id), self.electorate, &self.params(unit, p)).ok() } /// Open to a vote: open or in force, and not held back by an objection. @@ -511,6 +551,7 @@ impl DocState { id: u.id.clone(), position: u.position, inertia: inertia_of(u), + quorum: u.own_quorum(), in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) { Some(order) => VersionText { id: p.id.clone(), @@ -563,13 +604,12 @@ impl DocState { } pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { - self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok() + threshold::evaluate(self.tally(&p.id), self.electorate, &self.params(unit, p)).ok() } /// The level a proposal is voted at — a change of inertia inherits its article's. pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel { - let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None }; - compose::voting_level(&us, p.kind()) + compose::voting_level(&bare(unit), p.kind()) } /// The usual participation, as the screen tells it. @@ -583,22 +623,36 @@ impl DocState { }) } - /// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes. - pub fn levels_json(&self, cast: u32) -> Vec { + /// The five levels on this scale: M, G, s_k, the quorum, and the *for* votes asked at + /// `cast` votes — on an article's own quorum if it has one (`own`), else each level's. + pub fn levels_json(&self, cast: u32, own: Option) -> Vec { let scale = self.scale(); InertiaLevel::ALL .iter() .map(|k| { - let params = scale.params(*k); + let quorum = scale.quorum(*k, own); + let params = scale.params(*k, quorum); json!({ "level": k, "majorityPct": params.majority_pct, "gradient": params.gradient, - "sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(), + "sharePct": k.share_pct(), "quorum": quorum, "cast": cast, + "required": scale.required(cast, *k, quorum).ok(), }) }) .collect() } } +/// A unit as the engine sees it, without its text: its level and its own quorum. +fn bare(unit: &UnitRow) -> UnitState { + UnitState { + id: unit.id.clone(), + position: unit.position, + inertia: inertia_of(unit), + quorum: unit.own_quorum(), + in_force: None, + } +} + /// A non-negative count, rounded to the nearest whole. fn whole(x: f64) -> u32 { if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 } @@ -705,6 +759,7 @@ pub async fn document( let pending = ballots::my_ballots(&state, &user.id, &id).await?; let changes = inertia_changes(&state, &id).await?; + let rights = crate::quorum::Rights::of(&state, &id).await?; let unit_json = |u: &UnitRow| { let of_unit: Vec<_> = visible .iter() @@ -723,11 +778,18 @@ pub async fn document( .as_ref() .and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned()); let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect(); - json!({ + let mut j = json!({ "id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u), "inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(), "inForce": in_force, "proposals": proposals, - }) + }); + // Its quorum (ADR 0013): its own or `null`, its level's, and who may set it. + if let (Some(o), serde_json::Value::Object(q)) = + (j.as_object_mut(), crate::quorum::unit_json(&st, &rights, &user, u)) + { + o.extend(q); + } + j }; let units: Vec<_> = st.articles().map(unit_json).collect(); // The modules (ADR 0009): the title of the document, the order of each section. @@ -757,7 +819,7 @@ pub async fn document( "doc": { "id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name, "title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary), - "sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, + "sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(), "electorate": st.electorate, "electorateSource": st.electorate_source, "formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE }, "participation": st.participation_json(), @@ -766,7 +828,7 @@ pub async fn document( "units": units, "modules": { "title": title, "orders": orders }, // The five levels, with the *for* votes asked at the usual participation. - "levels": st.levels_json(st.usual_voters()), + "levels": st.levels_json(st.usual_voters(), None), }))) } diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 6425be2..6a46f52 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -28,6 +28,8 @@ pub mod overview; pub mod participation; pub mod proposals; pub mod publish; +pub mod quorum; +pub mod quorum_amend; pub mod seed; pub mod seed_amend; pub mod seed_proposal_amend; @@ -161,6 +163,7 @@ pub fn api_router() -> Router { .route("/messages/{id}/read", post(messages::read)) .route("/dev/clock", post(deadlines::dev_clock)) .route("/units/{id}/inertia", post(proposals::propose_inertia)) + .route("/units/{id}/quorum", put(quorum::set)) .route("/units/{id}/order", post(order::propose)) .route("/units/{id}/title", post(doc_title::propose)) .route("/feedback", post(access::send_feedback)) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 75408ae..79bb0aa 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -622,7 +622,7 @@ pub async fn detail( "voteFloor": st.doc.vote_floor, "participation": st.participation_json(), // The five levels, with the *for* votes each would ask at this version's participation. - "levels": st.levels_json(st.shown_tally(&p.id).cast()), + "levels": st.levels_json(st.shown_tally(&p.id).cast(), unit.own_quorum()), }))) } diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index eb36490..6d69dac 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -392,7 +392,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result sqlx::Result> { + let ids: Vec = sqlx::query_scalar( + "SELECT p.unit_id FROM votes v + JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id + WHERE u.doc_id = ?1 + UNION + SELECT p.unit_id FROM ballot_lines l + JOIN ballots b ON b.id = l.ballot_id + JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id + WHERE u.doc_id = ?1 AND b.status IN ('pending', 'confirmed') + UNION + SELECT a.unit_id FROM adoptions a WHERE a.doc_id = ?1", + ) + .bind(doc_id) + .fetch_all(&mut *conn) + .await?; + Ok(ids.into_iter().collect()) +} + +/// Does a vote exist on this unit (`voted_units`)? +pub async fn a_vote_exists(state: &AppState, unit_id: &str) -> AppResult { + let doc_id = doc_of_unit(state, unit_id).await?; + let mut conn = state.db.acquire().await?; + Ok(voted_units(&mut conn, &doc_id).await?.contains(unit_id)) +} + +async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult { + sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?") + .bind(unit_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound) +} + +/// The author of each unit's first version (`None`: a text of the seed, without author). +/// A first version is a text — of an article or a module —, never a removal or a change +/// of inertia, whatever became of it since. +pub async fn first_authors(state: &AppState, doc_id: &str) -> AppResult>> { + let rows: Vec<(String, Option)> = sqlx::query_as( + "SELECT p.unit_id, p.author_id FROM proposals p JOIN units u ON u.id = p.unit_id + WHERE u.doc_id = ? AND p.kind NOT IN ('remove', 'inertia') ORDER BY p.created_at, p.rowid", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let mut out = HashMap::new(); + for (unit, author) in rows { + out.entry(unit).or_insert(author); + } + Ok(out) +} + +/// What a person may do with the quorums of a document, read once for its view. +pub struct Rights { + first_authors: HashMap>, + voted: HashSet, +} + +impl Rights { + pub async fn of(state: &AppState, doc_id: &str) -> AppResult { + let first_authors = first_authors(state, doc_id).await?; + let mut conn = state.db.acquire().await?; + let voted = voted_units(&mut conn, doc_id).await?; + Ok(Self { first_authors, voted }) + } + + fn first_author(&self, unit: &UnitRow) -> Option<&str> { + self.first_authors.get(&unit.id).and_then(|a| a.as_deref()) + } + + /// May `user` set the quorum of `unit` now (`trustin_access::sets_quorum`)? + pub fn sets(&self, user: &IdentityRow, unit: &UnitRow) -> bool { + trustin_access::sets_quorum(&user.to_identity(), self.first_author(unit), self.voted.contains(&unit.id)) + } +} + +/// The quorum of a unit, as its view says it: its own (`null`: its level's), its level's, +/// and whether `user` may set it. +pub fn unit_json(st: &DocState, rights: &Rights, user: &IdentityRow, unit: &UnitRow) -> serde_json::Value { + json!({ + "quorum": unit.own_quorum(), "quorumDefault": st.quorum_default(unit), "canSetQuorum": rights.sets(user, unit), + }) +} + +#[derive(Deserialize)] +pub struct QuorumBody { + /// A whole number of voters; `null`: back to the quorum of its level. + quorum: Option, +} + +/// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`. +pub async fn set( + State(state): State, + User(user): User, + Path(unit_id): Path, + Json(b): Json, +) -> AppResult> { + let doc_id = doc_of_unit(&state, &unit_id).await?; + let st = docs::load_state(&state, &doc_id).await?; + docs::require_open(&state, &user, &st.group).await?; + let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?; + let authors = first_authors(&state, &doc_id).await?; + let first = authors.get(&unit.id).and_then(|a| a.as_deref()); + let identity = user.to_identity(); + if !trustin_access::sets_quorum(&identity, first, false) { + return Err(AppError::Forbidden); + } + let (lowest, highest) = quorum_bounds(st.electorate, st.group.slug == trustin_access::TEST_GROUP); + let to = match b.quorum { + None => None, + Some(q) if q >= i64::from(lowest) && q <= i64::from(highest) => Some(q), + Some(_) => return Err(AppError::Invalid("quorum")), + }; + let mut tx = state.db.begin().await?; + // Asked within the writing transaction: a vote that arrives first wins. + let voted = voted_units(&mut tx, &doc_id).await?.contains(&unit.id); + if !trustin_access::sets_quorum(&identity, first, voted) { + return Err(AppError::Conflict("voted")); + } + if unit.quorum != to { + let now = util::now(); + sqlx::query("UPDATE units SET quorum = ? WHERE id = ?").bind(to).bind(&unit.id).execute(&mut *tx).await?; + sqlx::query( + "INSERT INTO quorum_changes (unit_id, doc_id, from_quorum, to_quorum, identity_id, at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&unit.id) + .bind(&doc_id) + .bind(unit.quorum) + .bind(to) + .bind(&user.id) + .bind(&now) + .execute(&mut *tx) + .await?; + tracing::info!(doc = %doc_id, unit = %unit.code, from = ?unit.quorum, to = ?to, by = %user.id, "quorum set"); + } + tx.commit().await?; + Ok(Json(json!({ "quorum": to, "quorumDefault": st.quorum_default(unit), "canSetQuorum": true }))) +} diff --git a/crates/ld-server/src/quorum_amend.rs b/crates/ld-server/src/quorum_amend.rs new file mode 100644 index 0000000..6b54292 --- /dev/null +++ b/crates/ld-server/src/quorum_amend.rs @@ -0,0 +1,195 @@ +//! Quorum amendments — the quorums by level a seed now gives a document, written into +//! a document born without them (ADR 0013). +//! +//! Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, the test group at 1 +//! everywhere. A new base gets them from the seed (`quorums` of each instance). An +//! existing base — production holds two instances of the certification act — reads +//! `vote_floor` at every level until then (the C of before: 50 and 1), and gets them +//! through an amendment listed here: written **only where the document holds none**; +//! nothing else is touched (no article's own quorum, no vote, no text). +//! +//! Like the other amendments of the seed, each document is decided once, at the first +//! start that knows the amendment, and the decision is kept in `quorum_amendments` +//! (applied, refused and why, or present) and in the logs. Applied where the document +//! publishes, the publication queue gets a row (`document.json` carries the quorums). + +use crate::AppState; +use crate::seed::{SeedDoc, quorums_json}; +use crate::util; + +/// The quorums by level to write into every document instantiated from a seed. +pub struct QuorumAmendment { + /// Stable identifier: each document's decision is kept under it. + pub id: &'static str, + /// Slug of the seed document. + pub doc: &'static str, + /// The quorums of each group's instance: the seed's today, which must say so. + pub quorums: &'static [(&'static str, [u32; 5])], + /// Reason given to the publication queue, and so to the commit of the texts. + pub reason: &'static str, +} + +pub const AMENDMENTS: &[QuorumAmendment] = &[QuorumAmendment { + id: "2026-11-03-quorums-par-cran", + doc: "acte-engagement-certification", + quorums: &[("monnaie-libre", [12, 12, 50, 50, 50]), ("essai", [1, 1, 1, 1, 1])], + reason: "quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013)", +}]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// Born with these quorums: nothing to do. + Present, + Refuse(&'static str), +} + +/// The rule, apart from the database: `found` is what the document holds. +pub fn verdict(found: Option<&str>, wanted: &[u32; 5]) -> Verdict { + match found { + None => Verdict::Apply, + Some(raw) if serde_json::from_str::<[u32; 5]>(raw).ok().as_ref() == Some(wanted) => Verdict::Present, + // Written otherwise: kept as it is. + Some(_) => Verdict::Refuse("quorums_set"), + } +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + pub amendment: &'static str, + pub group: String, + pub verdict: Verdict, +} + +/// Decides every amendment not yet decided, document by document. +pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[QuorumAmendment]) -> anyhow::Result> { + let mut out = Vec::new(); + for a in list { + let docs: Vec<(String, String, Option)> = + sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug") + .bind(a.doc) + .fetch_all(&state.db) + .await?; + for (doc_id, group, publish_path) in docs { + let Some(wanted) = seed_says(seeds, a, &group) else { continue }; + let Some(v) = decide(state, a, &wanted, &doc_id, publish_path.is_some()).await? else { continue }; + match v { + Verdict::Apply => tracing::info!(amendment = a.id, %group, quorums = ?wanted, "quorums written"), + Verdict::Present => tracing::info!(amendment = a.id, %group, "quorums: already there"), + Verdict::Refuse(why) => { + tracing::warn!(amendment = a.id, %group, reason = why, "quorums refused: the document keeps its own") + } + } + if v == Verdict::Apply && publish_path.is_some() { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, group, verdict: v }); + } + } + Ok(out) +} + +/// The seed must give the group's instance the quorums the amendment writes: a seed +/// edited later never reaches a base by this way, and a seed without them decides nothing. +fn seed_says(seeds: &[SeedDoc], a: &QuorumAmendment, group: &str) -> Option<[u32; 5]> { + let wanted = a.quorums.iter().find(|(g, _)| *g == group).map(|(_, q)| *q)?; + let declared = seeds + .iter() + .find(|s| s.slug == a.doc) + .and_then(|s| s.instances.iter().find(|i| i.group == group)) + .and_then(|i| i.quorums); + if declared == Some(wanted) { + Some(wanted) + } else { + tracing::warn!(amendment = a.id, doc = a.doc, %group, "quorum amendment: the seed does not give these quorums, nothing decided"); + None + } +} + +/// One document, in one transaction: `None` when it was decided at an earlier start. +async fn decide( + state: &AppState, + a: &QuorumAmendment, + wanted: &[u32; 5], + doc_id: &str, + publishes: bool, +) -> anyhow::Result> { + let mut tx = state.db.begin().await?; + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM quorum_amendments WHERE amendment_id = ? AND doc_id = ?") + .bind(a.id) + .bind(doc_id) + .fetch_one(&mut *tx) + .await?; + if decided > 0 { + return Ok(None); + } + let found: Option = + sqlx::query_scalar("SELECT quorums FROM documents WHERE id = ?").bind(doc_id).fetch_one(&mut *tx).await?; + let v = verdict(found.as_deref(), wanted); + let now = util::now(); + let to = quorums_json(wanted); + if v == Verdict::Apply { + sqlx::query("UPDATE documents SET quorums = ? WHERE id = ? AND quorums IS NULL") + .bind(&to) + .bind(doc_id) + .execute(&mut *tx) + .await?; + if publishes { + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(doc_id) + .bind(a.reason) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } + let (outcome, reason) = match v { + Verdict::Apply => ("applied", None), + Verdict::Present => ("present", None), + Verdict::Refuse(why) => ("refused", Some(why)), + }; + sqlx::query( + "INSERT INTO quorum_amendments (amendment_id, doc_id, from_quorums, to_quorums, outcome, reason, at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(doc_id) + .bind(&found) + .bind(&to) + .bind(outcome) + .bind(reason) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(v)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ML: [u32; 5] = [12, 12, 50, 50, 50]; + + #[test] + fn written_where_none_kept_where_other() { + assert_eq!(verdict(None, &ML), Verdict::Apply); + assert_eq!(verdict(Some("[12,12,50,50,50]"), &ML), Verdict::Present); + assert_eq!(verdict(Some("[12, 12, 50, 50, 50]"), &ML), Verdict::Present, "the same numbers"); + assert_eq!(verdict(Some("[3,3,3,3,3]"), &ML), Verdict::Refuse("quorums_set")); + assert_eq!(verdict(Some("not json"), &ML), Verdict::Refuse("quorums_set")); + } + + #[test] + fn the_amendment_says_what_the_seed_says() { + let seed: serde_json::Value = + serde_json::from_str(include_str!("../../../seeds/acte-engagement-certification.json")).expect("seed"); + for (group, quorums) in AMENDMENTS[0].quorums { + let inst = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == *group); + assert_eq!(inst.map(|i| i["quorums"].clone()), Some(serde_json::json!(quorums)), "{group}"); + } + } +} diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index ebf0609..89bb0b3 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -11,16 +11,18 @@ //! section, ADR 0009), a module amendment (`module_amend`), built from what the //! document holds; a starting text in force the seed now puts forward as a seed //! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an -//! article nobody has touched. +//! article nobody has touched; the quorums by level the seed now gives a document +//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none. use crate::AppState; use crate::inertia_amend; use crate::module_amend; use crate::modules; +use crate::quorum_amend; use crate::seed_amend; use crate::seed_proposal_amend; use crate::util::{self, LangMap}; -use ld_engine::inertia::InertiaLevel; +use ld_engine::inertia::{InertiaLevel, quorum_bounds}; use serde::Deserialize; use std::path::Path; @@ -63,8 +65,13 @@ pub struct SeedModule { #[serde(rename_all = "camelCase")] pub struct SeedInstance { pub group: String, + /// The reference quorum Q₀ (the C of before): p0 = 2Q₀/W while too few proposals are + /// voted on; every level's quorum where `quorums` is missing. pub vote_floor: f64, pub publish_path: Option, + /// The quorum of each level, 1 to 5 (ADR 0013): the default of an article at that level. + #[serde(default)] + pub quorums: Option<[u32; 5]>, } #[derive(Deserialize)] @@ -134,6 +141,7 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> { module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?; // After the text amendments: a starting text replaced first, then put forward. seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?; + quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?; Ok(()) } @@ -191,8 +199,15 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result { } /// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL` -/// alone; every other article says its level. +/// alone; every other article says its level. A quorum is never under the lowest an +/// author may set (`quorum_bounds`): 2, or 1 in the test group. fn check(seed: &SeedDoc) -> anyhow::Result<()> { + for inst in &seed.instances { + let (lowest, _) = quorum_bounds(1, inst.group == trustin_access::TEST_GROUP); + if inst.quorums.is_some_and(|q| q.iter().any(|q| *q < lowest)) { + anyhow::bail!("{}: a quorum under {lowest}", inst.group); + } + } for u in &seed.units { if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") { anyhow::bail!("{}: a seed proposal is open, never in force", u.code); @@ -229,6 +244,14 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .bind(&now) .execute(&mut *tx) .await?; + // The quorums by level, written apart: only where the seed gives them (ADR 0013). + if let Some(q) = &inst.quorums { + sqlx::query("UPDATE documents SET quorums = ? WHERE id = ?") + .bind(quorums_json(q)) + .bind(&doc_id) + .execute(&mut *tx) + .await?; + } for unit in &seed.units { let unit_id = util::id(); sqlx::query( @@ -319,3 +342,8 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a tx.commit().await?; Ok(()) } + +/// The quorums by level as `documents.quorums` holds them: `[12,12,50,50,50]`. +pub fn quorums_json(quorums: &[u32; 5]) -> String { + serde_json::to_string(quorums).unwrap_or_else(|_| "null".into()) +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 9aaeaf4..1a06bce 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -83,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") } @@ -150,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()); @@ -191,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()); @@ -204,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::production().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); @@ -315,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, @@ -362,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::production().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(); @@ -1157,7 +1178,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. diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index 23f4d95..b77b129 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -268,6 +268,22 @@ impl Client { } } +/// The quorum of an article set to 2, as its author — an administrator, for a text of the +/// seed — sets it before the first vote (ADR 0013). In the test group, where the quorum +/// of every level is 1 and one voice passes, one voice of two then no longer does. +async fn two_voices(state: &AppState, group: &str, code: &str) { + let n = sqlx::query( + "UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)", + ) + .bind(code) + .bind(group) + .execute(&state.db) + .await + .expect("quorum") + .rows_affected(); + assert_eq!(n, 1, "{group} {code}"); +} + fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } @@ -373,8 +389,11 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { .await .expect("votes"); assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}"); - // One voice never makes a text pass (C = 50). - assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50); + // One voice never makes a text pass: at level 2, the quorum 12, the 11 voters missing + // counted as against — 9 « pour » are asked (ADR 0013). + let e = &find(&doc, &i1)["evaluation"]; + assert_eq!((e["quorum"].as_u64(), e["required"].as_u64(), e["yesNeeded"].as_u64()), (Some(12), Some(9), Some(9))); + assert_eq!(e["reached"], false); // Someone else sees the totals, not the vote. let mut yvv = c.other(); yvv.login("Yvv").await; @@ -404,7 +423,9 @@ async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() { assert_eq!(s["status"], "confirmed"); assert_eq!(s["entered"], json!([])); let d = c.ok("GET", &format!("/api/proposals/{p}"), None).await; - assert_eq!(d["proposal"]["evaluation"]["required"], 50); + // Level 3, the quorum 50: 39 « pour » of 50 voters, the 49 missing counted as against. + assert_eq!(d["proposal"]["evaluation"]["quorum"], 50); + assert_eq!(d["proposal"]["evaluation"]["required"], 39); assert_eq!(d["proposal"]["evaluation"]["wellRated"], true); assert_eq!(d["proposal"]["myVote"], "for"); assert_eq!(d["proposal"]["myVoteChain"], true); @@ -711,7 +732,7 @@ async fn a_session_sees_its_own_ballots_only() { } #[tokio::test] -async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() { +async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; @@ -720,7 +741,11 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() let e5 = version(&doc, "E5", "0"); let reason = "Ces deux textes disent le contraire l'un de l'autre."; let e1 = version(&doc, "E1", "0"); - let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); + // Level 3, the quorum 50 (ADR 0013): 39 « pour » with no « contre ». + let needed = find(&doc, &e4)["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded"); + assert_eq!(needed, 39); + let needed = u8::try_from(needed).expect("n"); + let keys: Vec = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; for (n, account) in keys.iter().enumerate() { let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await; @@ -730,7 +755,7 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); - if n == 49 { + if n + 1 == keys.len() { // The last ballot is signed; before the chain confirms it, an objection holds // E5 back against the seed proposal E1, written before it (ADR 0010, 0011): no new ballot may hold it. let o = c @@ -756,11 +781,12 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() // A session holds at most 20 pending ballots: the background task confirms on the way. ballots::sweep(&state).await.expect("sweep"); } - if n == 48 { + if n + 2 == keys.len() { ballots::sweep(&state).await.expect("sweep"); let d = c.doc("monnaie-libre").await; - assert_eq!(tally(&d, &e4), (49, 0)); - assert!(find(&d, &e4)["enteredAt"].is_null(), "49 voices: below C = 50"); + assert_eq!(tally(&d, &e4), (38, 0)); + assert!(find(&d, &e4)["enteredAt"].is_null(), "38 voices: one short of the 39 the quorum 50 asks"); + assert_eq!(find(&d, &e4)["evaluation"]["missing"], 1); } } ballots::sweep(&state).await.expect("sweep"); @@ -771,7 +797,7 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str()); // A ballot sealed before the objection counts, as a vote already cast: kept, but // the version held back cannot enter into force. - assert_eq!(tally(&d, &e5), (50, 0)); + assert_eq!(tally(&d, &e5), (39, 0)); assert_eq!(find(&d, &e5)["votable"], false); assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); } @@ -1208,7 +1234,7 @@ async fn a_name_known_only_from_a_chain_vote_stays_free() { } #[tokio::test] -async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { +async fn voices_on_the_chain_bring_an_order_then_a_title_into_force() { // The modules (ADR 0009) are voted like articles: in a basket, in one transaction. let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; @@ -1245,8 +1271,16 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { .expect("title") .to_owned(); let doc = c.doc("monnaie-libre").await; - let keys: Vec = (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 = (1..=43u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); + let mut sent = Vec::new(); for (n, account) in keys.iter().enumerate() { let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await; let index = 20_000 + u32::try_from(n).expect("n"); @@ -1255,22 +1289,26 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); - last = b; + sent.push(b); if n % 10 == 9 { ballots::sweep(&state).await.expect("sweep"); } } ballots::sweep(&state).await.expect("sweep"); - let s = c.status(&last).await; - assert_eq!(s["status"], "confirmed"); - let mut entered: Vec<(&str, &str)> = s["entered"] - .as_array() - .expect("entered") - .iter() - .map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default())) - .collect(); - entered.sort_unstable(); - assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}"); + // What entered at each confirmation, in the order of the chain. + let mut entered: Vec<(usize, String, String)> = Vec::new(); + for (n, b) in sent.iter().enumerate() { + let s = c.status(b).await; + assert_eq!(s["status"], "confirmed", "{s}"); + for e in s["entered"].as_array().expect("entered") { + entered.push((n + 1, e["code"].as_str().expect("code").into(), e["kind"].as_str().expect("kind").into())); + } + } + assert_eq!( + entered, + [(9, "ordre.conclusion".into(), "order".into()), (43, "titre".into(), "title".into())], + "the ninth voice, then the forty-third" + ); let d = c.doc("monnaie-libre").await; let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p")); assert!(p2 < p1, "K2 now reads before K1"); @@ -1392,6 +1430,9 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() { a.login("Yvv").await; let mut b = a.other(); b.login("Chiara").await; + for code in ["E4", "E5", "E6"] { + two_voices(&state, "essai", code).await; + } let doc = a.doc("essai").await; let id = doc["doc"]["id"].as_str().expect("id").to_owned(); let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0")); @@ -1400,7 +1441,7 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() { ]); let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await; assert_eq!(v["count"], 3); - assert!(v["entered"].as_array().expect("e").is_empty(), "one voice never makes a text pass"); + assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two, on a quorum of 2"); // All or nothing: one line that does not hold, and no line is written. let (s, _) = b .call( diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index 38d634d..06b1ef1 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -9,7 +9,7 @@ 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; @@ -95,6 +95,9 @@ fn level(k: u8) -> InertiaLevel { /// 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") @@ -137,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")["proposals"][0]["id"].as_str().expect("e1").to_owned(); let d = c.get(&format!("/api/proposals/{e1}")).await; 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"); } @@ -180,9 +187,9 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled( (d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(5), Some(u64::from(LOW))) ); - // W(essai) = 2: both vote for, the article goes to level 5. - a.vote(&p, "for").await; - let v = b.vote(&p, "for").await; + // 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"); @@ -200,11 +207,12 @@ 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(¶ms).expect("json"); - let judged = Scale { electorate: 2, floor: 1.0, participation: 0.5 }.params(level(LOW)); + 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(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")) @@ -254,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; @@ -623,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 @@ -666,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!( diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index 77797cb..e853df6 100644 --- a/crates/ld-server/tests/modules.rs +++ b/crates/ld-server/tests/modules.rs @@ -117,6 +117,21 @@ fn codes(doc: &Value, ids: &Value) -> Vec { } /// 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; @@ -191,7 +206,8 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() { #[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(); @@ -241,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(); @@ -400,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"); diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index 7ab72c2..7ec6756 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -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,8 +158,11 @@ 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"); @@ -154,14 +176,15 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { 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", "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); } @@ -171,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))); diff --git a/crates/ld-server/tests/quorum.rs b/crates/ld-server/tests/quorum.rs new file mode 100644 index 0000000..dbbbf38 --- /dev/null +++ b/crates/ld-server/tests/quorum.rs @@ -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, +} + +impl Client { + fn of(state: &AppState) -> Self { + Self { router: app(state.clone()), cookie: None } + } + + fn other(&self) -> Self { + Self { router: self.router.clone(), cookie: None } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); + if let Some(c) = &self.cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .expect("request"); + let res = self.router.clone().oneshot(req).await.expect("response"); + let status = res.status(); + if let Some(set) = res.headers().get(header::SET_COOKIE) { + let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); + if raw.starts_with("ld_session=") { + self.cookie = Some(raw); + } + } + let bytes = res.into_body().collect().await.expect("body").to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + } + + async fn ok(&mut self, method: &str, uri: &str, body: Option) -> Value { + let (s, v) = self.call(method, uri, body).await; + assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}"); + v + } + + async fn get(&mut self, uri: &str) -> Value { + self.ok("GET", uri, None).await + } + + async fn login(&mut self, name: &str, admin: bool) { + self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; + } + + async fn vote(&mut self, proposal: &str, choice: Option<&str>) -> Value { + self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await + } + + async fn doc(&mut self, group: &str) -> Value { + let id = self.get(&format!("/api/groups/{group}")).await["docs"][0]["id"].as_str().expect("doc").to_owned(); + self.get(&format!("/api/docs/{id}")).await + } + + /// `PUT /units/{id}/quorum`. + async fn set(&mut self, unit: &str, quorum: Value) -> (StatusCode, Value) { + self.call("PUT", &format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": quorum }))).await + } +} + +fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { + doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +fn unit_by_id<'a>(doc: &'a Value, id: &str) -> &'a Value { + doc["units"].as_array().expect("units").iter().find(|u| u["id"] == id).expect("unit") +} + +fn numbers(levels: &Value, key: &str) -> Vec { + levels.as_array().expect("levels").iter().map(|l| l[key].as_u64().expect("number")).collect() +} + +/// (quorum, quorumDefault, canSetQuorum) of a unit. +fn quorum_of(u: &Value) -> (Option, Option, Option) { + (u["quorum"].as_u64(), u["quorumDefault"].as_u64(), u["canSetQuorum"].as_bool()) +} + +/// (quorum, cast, required, yesNeeded, noNeeded, missing, reached) of an evaluation. +fn figures(e: &Value) -> (u64, u64, u64, u64, u64, u64, bool) { + let n = |k: &str| e[k].as_u64().unwrap_or_else(|| panic!("{k}: {e}")); + (n("quorum"), n("cast"), n("required"), n("yesNeeded"), n("noNeeded"), n("missing"), e["reached"] == true) +} + +async fn fresh() -> AppState { + fresh_with(Config::for_tests()).await +} + +async fn fresh_with(cfg: Config) -> AppState { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(cfg, db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +#[tokio::test] +async fn each_level_has_its_quorum_and_every_evaluation_says_it() { + let state = fresh().await; + let mut c = Client::of(&state); + c.login("Chiara", false).await; + let doc = c.doc("monnaie-libre").await; + assert_eq!(doc["doc"]["quorums"], json!(QUORUMS)); + assert_eq!(numbers(&doc["levels"], "quorum"), QUORUMS); + // I1, a seed proposal at level 2: its level's quorum, 12. Nobody voted: judged on 12 + // voters, the 12 missing counted as against — 9 « pour » pass (the control figure). + let i1 = unit(&doc, "I1"); + assert_eq!(quorum_of(i1), (None, Some(12), Some(false)), "not the author, no administrator"); + assert_eq!(figures(&i1["proposals"][0]["evaluation"]), (12, 0, 9, 9, 0, 9, false)); + // E3 at level 3: the quorum 50, 39 « pour » at no vote. + let e3 = unit(&doc, "E3"); + assert_eq!(e3["inertia"], 3); + assert_eq!(quorum_of(e3), (None, Some(50), Some(false))); + assert_eq!(figures(&e3["proposals"][0]["evaluation"]), (50, 0, 39, 39, 0, 39, false)); + // The detail says the same, the five levels on their quorums. + let p = i1["proposals"][0]["id"].as_str().expect("i1"); + let d = c.get(&format!("/api/proposals/{p}")).await; + assert_eq!(figures(&d["proposal"]["evaluation"]), (12, 0, 9, 9, 0, 9, false)); + assert_eq!(numbers(&d["levels"], "quorum"), QUORUMS); + assert_eq!(numbers(&d["levels"], "required"), [8, 9, 39, 43, 46]); + + // The test group: 1 everywhere. + let essai = c.doc("essai").await; + assert_eq!(essai["doc"]["quorums"], json!([1, 1, 1, 1, 1])); + assert_eq!(quorum_of(unit(&essai, "E3")), (None, Some(1), Some(false))); + assert_eq!(unit(&essai, "I1")["proposals"][0]["evaluation"]["quorum"], 1); + + // What is published carries them (document.json). + let id = doc["doc"]["id"].as_str().expect("id"); + let st = ld_server::docs::load_state(&state, id).await.expect("state"); + let files = ld_server::publish::render(&state, &st).await.expect("render"); + let json: Value = serde_json::from_str(&files.iter().find(|(n, _)| n == "document.json").expect("document.json").1) + .expect("json"); + assert_eq!(json["quorums"], json!(QUORUMS)); +} + +#[tokio::test] +async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() { + let state = fresh().await; + let mut chiara = Client::of(&state); + chiara.login("Chiara", false).await; + let mut luna = chiara.other(); + luna.login("Luna", false).await; + let doc = chiara.doc("essai").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let created = chiara + .ok( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ "section": "fondamental", "title": "Nouveau", "body": "Un nouvel article.", "submit": true })), + ) + .await; + let (u, text) = + (created["unitId"].as_str().expect("unit").to_owned(), created["id"].as_str().expect("id").to_owned()); + // Its author may, another member may not; the test group: its level's quorum, 1. + assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (None, Some(1), Some(true))); + assert_eq!(quorum_of(unit_by_id(&luna.doc("essai").await, &u)), (None, Some(1), Some(false))); + let (s, v) = luna.set(&u, json!(2)).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::FORBIDDEN, Some("forbidden"))); + // From 1 in the test group to W (2 members). + for bad in [json!(0), json!(3), json!(-1), json!(1.5)] { + let (s, v) = chiara.set(&u, bad.clone()).await; + assert!(s == StatusCode::BAD_REQUEST || s == StatusCode::UNPROCESSABLE_ENTITY, "{bad}: {s} {v}"); + if s == StatusCode::BAD_REQUEST { + assert_eq!(v["error"], "quorum", "{bad}"); + } + } + let (s, v) = chiara.set(&u, json!(2)).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v, json!({ "quorum": 2, "quorumDefault": 1, "canSetQuorum": true })); + let doc = chiara.doc("essai").await; + let mine = unit_by_id(&doc, &u); + assert_eq!(quorum_of(mine), (Some(2), Some(1), Some(true))); + assert_eq!(mine["proposals"][0]["evaluation"]["quorum"], 2); + // A change of its inertia is judged on it too. + let inertia = chiara.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await; + let d = chiara.get(&format!("/api/proposals/{}", inertia["id"].as_str().expect("id"))).await; + assert_eq!(d["proposal"]["evaluation"]["quorum"], 2); + assert_eq!(numbers(&d["levels"], "quorum"), [2; 5], "the article's own quorum, at every level"); + // Back to its level's, then 2 again: each change is journaled. + let (s, v) = chiara.set(&u, Value::Null).await; + assert_eq!((s, &v), (StatusCode::OK, &json!({ "quorum": null, "quorumDefault": 1, "canSetQuorum": true }))); + chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await; + let journal: Vec<(Option, Option, String)> = sqlx::query_as( + "SELECT q.from_quorum, q.to_quorum, i.display_name FROM quorum_changes q + JOIN identities i ON i.id = q.identity_id WHERE q.unit_id = ? ORDER BY q.id", + ) + .bind(&u) + .fetch_all(&state.db) + .await + .expect("journal"); + let chiara_name = "Chiara".to_owned(); + assert_eq!( + journal, + [(None, Some(2), chiara_name.clone()), (Some(2), None, chiara_name.clone()), (None, Some(2), chiara_name)] + ); + + // The first vote: 1 « pour » of 2 voters on a quorum of 2 does not pass, and the + // quorum is held from then on. + let v = luna.vote(&text, Some("for")).await; + assert!(v["entered"].as_array().expect("entered").is_empty(), "{v}"); + assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(false))); + let (s, v) = chiara.set(&u, json!(1)).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted"))); + // A vote on its change of inertia counts as well: it is the same article. + assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query")); +} + +/// 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, Option); + +async fn decisions(state: &AppState) -> Vec { + sqlx::query_as( + "SELECT d.group_slug, q.from_quorums, q.to_quorums, q.outcome, q.reason + FROM quorum_amendments q JOIN documents d ON d.id = q.doc_id ORDER BY d.group_slug", + ) + .fetch_all(&state.db) + .await + .expect("decisions") +} + +async fn stored(state: &AppState, group: &str) -> Option { + sqlx::query_scalar("SELECT quorums FROM documents WHERE group_slug = ?") + .bind(group) + .fetch_one(&state.db) + .await + .expect("quorums") +} + +fn row(group: &str, from: Option<&str>, to: &str, outcome: &str, reason: Option<&str>) -> Decision { + (group.into(), from.map(Into::into), to.into(), outcome.into(), reason.map(Into::into)) +} + +const ML: &str = "[12,12,50,50,50]"; +const ESSAI: &str = "[1,1,1,1,1]"; + +#[tokio::test] +async fn the_quorum_amendment_writes_the_quorums_by_level_once() { + // Production as it runs: no quorum written; every level reads the C of before. + let born = fresh_with(production_config()).await; + assert_eq!((stored(&born, "monnaie-libre").await, stored(&born, "essai").await), (None, None)); + assert!(decisions(&born).await.is_empty(), "a seed without quorums decides nothing"); + let mut c = Client::of(&born); + c.login("Chiara", false).await; + assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50])); + assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1])); + + // Started with today's seed: written, where none was. + let state = restart(&born, Config::for_tests()).await; + let first = decisions(&state).await; + assert_eq!( + first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::>(), + [row("essai", None, ESSAI, "applied", None), row("monnaie-libre", None, ML, "applied", None)] + ); + assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some(ML)); + assert_eq!(stored(&state, "essai").await.as_deref(), Some(ESSAI)); + let mut c = Client::of(&state); + c.login("Chiara", false).await; + assert_eq!(unit(&c.doc("monnaie-libre").await, "I1")["proposals"][0]["evaluation"]["quorum"], 12); + // Republished where the document publishes; never from the test group. + let queued: Vec = sqlx::query_scalar( + "SELECT d.group_slug FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'quorums%'", + ) + .fetch_all(&state.db) + .await + .expect("outbox"); + assert_eq!(queued, ["monnaie-libre"]); + // Once only. + let again = restart(&state, Config::for_tests()).await; + assert_eq!(decisions(&again).await, first); + + // A new base is born with them: present. + let new = fresh().await; + let present: Vec = decisions(&new).await.into_iter().map(|(_, _, _, o, _)| o).collect(); + assert_eq!(present, ["present", "present"]); +} + +#[tokio::test] +async fn quorums_written_otherwise_are_kept_and_the_refusal_is_kept() { + let born = fresh_with(production_config()).await; + sqlx::query("UPDATE documents SET quorums = '[3,3,3,3,3]' WHERE group_slug = 'monnaie-libre'") + .execute(&born.db) + .await + .expect("quorums"); + let state = restart(&born, Config::for_tests()).await; + let got = decisions(&state).await; + assert_eq!( + got.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::>(), + [ + row("essai", None, ESSAI, "applied", None), + row("monnaie-libre", Some("[3,3,3,3,3]"), ML, "refused", Some("quorums_set")), + ] + ); + assert_eq!(stored(&state, "monnaie-libre").await.as_deref(), Some("[3,3,3,3,3]")); +} diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index a8fe523..281d4cf 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -665,11 +665,14 @@ async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() { a.login("Yvv", false).await; let mut b = a.other(); b.login("Chiara", false).await; - // Two members: W = 2, C = 1 — one voice is not enough (B^W keeps R above 1). + // 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 of two is not enough"); + 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; @@ -687,8 +690,8 @@ async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() { .expect("adoption"); assert_eq!(adopted, ("text".to_owned(), 2, 2)); - // From 16 members on, B^W no longer lifts R above C = 1: one voice passes a text in - // the test group (ADR 0007), whatever the level: R = C + B^W at one vote cast. + // 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; diff --git a/crates/ld-server/tests/typos.rs b/crates/ld-server/tests/typos.rs index 8524498..2cbbb1d 100644 --- a/crates/ld-server/tests/typos.rs +++ b/crates/ld-server/tests/typos.rs @@ -127,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; diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 5c54455..b183965 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -23,7 +23,10 @@ //! administrator; it is reiterated by the person who raised it only — //! `lifts_objection` / `reiterates_objection`; //! - each person reads their own messages only — `reads_message` -//! (2026-10-31, lot F, ADR 0010). +//! (2026-10-31, lot F, ADR 0010); +//! - the quorum of an article is set by the author of its first version — for a +//! text of the seed, by an administrator — and only before the first vote — +//! `sets_quorum` (2026-11-03, ADR 0013). use serde::{Deserialize, Serialize}; @@ -184,6 +187,24 @@ pub fn reads_message(identity: &Identity, recipient: &str) -> bool { identity.id == recipient } +/// Who sets the quorum of an article (Yvv, 2026-11-02: « L'auteur, avant le 1er vote », +/// ADR 0013): the author of its first version; for an article without author (a text of +/// the seed), an administrator of trustinWallet — never one for an author. Once a vote +/// exists on the article (`voted`, the server's `quorum::a_vote_exists`), nobody: the +/// votes cast were judged on it. +pub fn sets_quorum(identity: &Identity, first_author: Option<&str>, voted: bool) -> bool { + if voted { + return false; + } + match first_author { + Some(author) => identity.id == author, + None => identity.admin, + } +} + +/// The test group: hidden, the quorum 1 at every level — the one place one voice passes. +pub const TEST_GROUP: &str = "essai"; + /// The groups a fresh deployment starts with. Ordered as displayed. pub fn default_groups() -> Vec { let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group { @@ -204,7 +225,7 @@ pub fn default_groups() -> Vec { GroupPolicy::Managed, false, ), - g("essai", "Groupe d'essai", None, GroupPolicy::Managed, true), + g(TEST_GROUP, "Groupe d'essai", None, GroupPolicy::Managed, true), ] } @@ -308,6 +329,20 @@ mod tests { assert!(!lifts_objection(&other, "luna") && !reiterates_objection(&other, "luna")); } + #[test] + fn the_author_sets_the_quorum_before_the_first_vote_an_administrator_for_the_seed() { + let chiara = Identity { id: "chiara".into(), ..who(IdentityKind::Invite, false, false) }; + let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; + let other = Identity { id: "luna".into(), ..who(IdentityKind::G1, true, false) }; + assert!(sets_quorum(&chiara, Some("chiara"), false)); + assert!(!sets_quorum(&chiara, Some("chiara"), true), "after the first vote, nobody"); + assert!(!sets_quorum(&other, Some("chiara"), false)); + assert!(!sets_quorum(&admin, Some("chiara"), false), "an administrator never sets it for an author"); + assert!(sets_quorum(&admin, None, false), "a text of the seed: an administrator"); + assert!(!sets_quorum(&admin, None, true)); + assert!(!sets_quorum(&other, None, false)); + } + #[test] fn a_message_is_read_by_its_recipient_only() { let admin = Identity { id: "yvv".into(), ..who(IdentityKind::G1, true, true) }; diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 1fc13e2..3086ebe 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -1,5 +1,5 @@ { - "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. 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, en vigueur sans vote (seed a minima ; 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). Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend), jamais en réinstanciant le document.", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. 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, en vigueur sans vote (seed a minima ; 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). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document.", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -16,11 +16,13 @@ { "group": "monnaie-libre", "voteFloor": 50, + "quorums": [12, 12, 50, 50, 50], "publishPath": "monnaie-libre/acte-engagement-certification" }, { "group": "essai", "voteFloor": 1, + "quorums": [1, 1, 1, 1, 1], "publishPath": null } ], From f3d79eb38833311eca8ede9703e7d0d9430e92e7 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 05:05:03 +0100 Subject: [PATCH 19/24] =?UTF-8?q?Actes=20M2=20et=20M3a=20caducs,=20emport?= =?UTF-8?q?=C3=A9s=20par=20M4=20;=20tableau=20:=20sessions=20(orch=20dev-6?= =?UTF-8?q?3=20sous=20mandat,=20int=C3=A9grateur=20libredecision-d4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation seule. Co-Authored-By: Claude Opus 5.5 --- actes/2026-10-30-m3-inertie-cinq-crans.md | 3 ++- actes/2026-10-31-m2-vote.md | 3 ++- docs/chantiers/acte-certification/tableau.md | 25 +++++++++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/actes/2026-10-30-m3-inertie-cinq-crans.md b/actes/2026-10-30-m3-inertie-cinq-crans.md index 4b05cc4..6a32ebd 100644 --- a/actes/2026-10-30-m3-inertie-cinq-crans.md +++ b/actes/2026-10-30-m3-inertie-cinq-crans.md @@ -1,7 +1,8 @@ # Acte — Mise en ligne M3a (inertie à cinq crans, échelle calée sur la participation, réglages) Classe : déploie Projet : libreDecision Date : 2026-10-30 -**État : FIGÉ (2026-10-30, 20 h 20)** sur `71819f9` — relectures « a576b68 : ok » et « 71819f9 : ok » inscrites +**État : CADUC, emporté par M4** (constaté le 03/11/2026) : jamais exécuté ; `71819f9` est un ancêtre de `4d4adcc` (M4, en ligne depuis le 01/11 à 23 h 06) et de `3f10a0e` (M5, en ligne depuis le 02/11 à 03 h 04), son contenu est donc en production. Ne pas exécuter. +État d'avant : **FIGÉ (2026-10-30, 20 h 20)** sur `71819f9` — relectures « a576b68 : ok » et « 71819f9 : ok » inscrites ## Intention diff --git a/actes/2026-10-31-m2-vote.md b/actes/2026-10-31-m2-vote.md index 5108f02..fca99ef 100644 --- a/actes/2026-10-31-m2-vote.md +++ b/actes/2026-10-31-m2-vote.md @@ -1,7 +1,8 @@ # Acte — Mise en ligne M2 + M3a (le vote déplié et groupé, l'inertie à cinq crans) Classe : déploie Projet : libreDecision Date : 2026-10-31 -**État : FIGÉ (2026-10-31, 01 h 30)** sur `74f96be` — relectures « a576b68 : ok », « 71819f9 : ok », +**État : CADUC, emporté par M4** (constaté le 03/11/2026) : jamais exécuté ; `74f96be` est un ancêtre de `4d4adcc` (M4, en ligne depuis le 01/11 à 23 h 06) et de `3f10a0e` (M5, en ligne depuis le 02/11 à 03 h 04), son contenu est donc en production. Ne pas exécuter. +État d'avant : **FIGÉ (2026-10-31, 01 h 30)** sur `74f96be` — relectures « a576b68 : ok », « 71819f9 : ok », « 74f96be : ok » inscrites ## Intention diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 759f1d4..738e898 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -1,8 +1,20 @@ # Tableau — revue d'Yvv du 2026-10-30 -> Tenu par orch (seul rédacteur). État courant seulement ; l'historique est dans git. +> Tenu par l'intégrateur libredecision-d4, par délégation d'orch (dev-63, depuis le 03/11 à 05 h 10). +> État courant seulement ; l'historique est dans git. > Source : le message « REVIEW DECISION » d'Yvv, 2026-10-30 vers 08 h. +## Sessions + +| Rôle | Session | Ce qu'elle fait | +|---|---|---| +| orch | dev-63 (`e66db8ca`), ouverte dans /DEV | orchestre, parle à Yvv, pousse et met en ligne sous mandat (« déploie » et « pousse », du 03/11 au 08/11) | +| intégrateur | libredecision-d4, ouverte dans le dépôt | mène S, V puis U (un sous-agent à la fois), relit, fusionne, tient ce tableau, prépare les dossiers d'acte ; ne pousse et ne déploie rien | + +Vers orch : « ACTE pousse » (dépôt, branche, sha exact) ; « PRÊT M6 » (dossier figé, sha, +relecture indépendante inscrite dans `relectures.json`) ; QUESTION pour Yvv, avec sa réponse par +défaut (jamais de formulaire chez l'intégrateur). + ## REPRISE Orch seul sur le dépôt (sous-agents en copies de travail). **M1 (L1–L8) en ligne** depuis le @@ -27,12 +39,13 @@ fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle ve - un vote simulé compte, puis s'efface à l'ouverture de la chaîne (acte « supprime ») ; - la publication est suspendue jusqu'au premier texte en vigueur. -**Relève d'orch le 03/11 à 03 h 38**, sur ordre d'orch racine (dev-63). La session d'avant était +**Relève le 03/11 à 03 h 38**, sur ordre d'orch racine (dev-63) ; à 05 h 10, dev-63 devient orch de +libreDecision avec le mandat (Yvv, 04 h 58), et cette session devient intégratrice. La session d'avant était bloquée par le quota depuis le 02/11 à 09 h 51, puis coupée par le redémarrage du poste à 02 h 56. Les quatre agents sont morts, leurs copies sont intactes, `/tmp` est vidé (captures et bases de test perdues). Budget : finir S, U, V et H, sans nouveau lot, **un sous-agent à la fois** (compte -partagé avec HCO, qui passe en premier). **Pas de mandat** : M6 attend le « déploie » d'Yvv tapé -dans cette session (le « Pousse. Déploie. » du 02/11 a été dit à une autre session). +partagé avec HCO, qui passe en premier). M6 : l'intégrateur fige le dossier et fait relire, +orch l'exécute sous son mandat. Ordre de reprise : H, puis S, puis V, puis U. Chaque lot fusionne d'abord le chantier, qui contient déjà les lots finis. Le contrat du quorum vient de S et sert V et U. @@ -140,8 +153,8 @@ Défauts posés par orch, sans question (Yvv peut les corriger) : ## Actes - M1 : **exécuté** le 30/10/2026, 15 h 29 – 15 h 52 (`actes/2026-10-30-m1-mise-en-ligne.md`). -- M3a (L10) : **figé** sur `71819f9` (`actes/2026-10-30-m3-inertie-cinq-crans.md`), attend « déploie ». -- M2 + M3a (L9 et L10) : **figé** sur `74f96be` (`actes/2026-10-31-m2-vote.md`), le vote par la chaîne éteint ; remplace M3a s'il est armé. +- M3a (L10) : **caduc, emporté par M4** (`actes/2026-10-30-m3-inertie-cinq-crans.md`) : jamais exécuté, `71819f9` est en production depuis M4. +- M2 + M3a (L9 et L10) : **caduc, emporté par M4** (`actes/2026-10-31-m2-vote.md`) : jamais exécuté, `74f96be` est en production depuis M4. - M4 (L9 à L13) : **exécuté** le 01/11/2026, 22 h 52 – 23 h 40 (`actes/2026-10-31-m4-vote-modules-objections.md`) ; M2 et M3a n'ont plus d'objet. - M5 (lot G, articles des règles, licence) : **exécuté** le 02/11/2026, 02 h 53 – 03 h 10 (`actes/2026-11-02-m5-retenue-textes-regles.md`). From 8d1b07d8deeae61985d98685efb5600076016ab0 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 05:08:16 +0100 Subject: [PATCH 20/24] =?UTF-8?q?Tableau=20:=20l'ordre=20de=20r=C3=B4le=20?= =?UTF-8?q?de=20dev-63=20est=20parti=20=C3=A0=2005=20h=2002,=20pas=2005=20?= =?UTF-8?q?h=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/chantiers/acte-certification/tableau.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 738e898..791fe38 100644 --- a/docs/chantiers/acte-certification/tableau.md +++ b/docs/chantiers/acte-certification/tableau.md @@ -1,6 +1,6 @@ # Tableau — revue d'Yvv du 2026-10-30 -> Tenu par l'intégrateur libredecision-d4, par délégation d'orch (dev-63, depuis le 03/11 à 05 h 10). +> Tenu par l'intégrateur libredecision-d4, par délégation d'orch (dev-63, depuis le 03/11 à 05 h 02). > État courant seulement ; l'historique est dans git. > Source : le message « REVIEW DECISION » d'Yvv, 2026-10-30 vers 08 h. @@ -39,7 +39,7 @@ fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle ve - un vote simulé compte, puis s'efface à l'ouverture de la chaîne (acte « supprime ») ; - la publication est suspendue jusqu'au premier texte en vigueur. -**Relève le 03/11 à 03 h 38**, sur ordre d'orch racine (dev-63) ; à 05 h 10, dev-63 devient orch de +**Relève le 03/11 à 03 h 38**, sur ordre d'orch racine (dev-63) ; à 05 h 02, dev-63 devient orch de libreDecision avec le mandat (Yvv, 04 h 58), et cette session devient intégratrice. La session d'avant était bloquée par le quota depuis le 02/11 à 09 h 51, puis coupée par le redémarrage du poste à 02 h 56. Les quatre agents sont morts, leurs copies sont intactes, `/tmp` est vidé (captures et bases de From 17addf8d9f7913c5080f7a4e02d1a9840f551498 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 05:15:18 +0100 Subject: [PATCH 21/24] Lot S, phase 2 : le quorum dans l'interface (fr, en, es), e2e 35 / 35 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts : evaluation.quorum / yesNeeded / noNeeded, levels[].quorum, l'unité quorum / quorumDefault / canSetQuorum, doc.quorums (optionnels) ; publication « suspended » (ADR 0011) ; - Réglages de la formule : la colonne « Quorum » des cinq crans et sa note, la participation de référence « deux fois 50, le quorum de référence » ; plus de ligne C ; sur téléphone, les six colonnes tiennent dans la feuille ; - La formule du seuil : T' = max(T, Q), R = B^W + ratio(T')·T', la ligne Q (le quorum de la version) au lieu de C ; - textes : deep.note, formula.reading, settings.* parlent du quorum, plus de C (test vu rouge puis vert, tests/unit/inertia.test.ts) ; - e2e : Seuil 9 (cran 2) et 39 (cran 3) dans Monnaie libre, les quorums 12 · 12 · 50 · 50 · 50, voix pour à 100 votants 60 · 67 · 75 · 83 · 90, à 0 votant 8 · 9 · 39 · 43 · 46. Co-Authored-By: Claude Opus 5.5 --- web/e2e/mobile.spec.ts | 2 +- web/e2e/modules.spec.ts | 2 +- web/e2e/reader.spec.ts | 22 ++++++++++------- web/e2e/vote.spec.ts | 3 ++- web/src/lib/components/Formula.svelte | 14 +++++++---- web/src/lib/components/FormulaSettings.svelte | 24 +++++++++++++++---- web/src/lib/i18n/en.ts | 18 +++++++------- web/src/lib/i18n/es.ts | 18 +++++++------- web/src/lib/i18n/fr.ts | 18 +++++++------- web/src/lib/types.ts | 24 ++++++++++++++++--- web/tests/unit/inertia.test.ts | 14 +++++++++++ 11 files changed, 112 insertions(+), 47 deletions(-) diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index 5470d5d..0d914fd 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -83,7 +83,7 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti // in force at the start, its vote unfolds in the list of proposals). await page.locator('ul.deep button.row').first().click(); const fold = page.locator('ul.deep section.fold'); - await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible(); + await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible(); // The settings of the formula read on a phone: the table of the five levels holds in the sheet. await fold.getByRole('button', { name: 'Réglages de la formule' }).click(); const panel = page.locator('aside.panel'); diff --git a/web/e2e/modules.spec.ts b/web/e2e/modules.spec.ts index eb15fd6..cab972a 100644 --- a/web/e2e/modules.spec.ts +++ b/web/e2e/modules.spec.ts @@ -190,7 +190,7 @@ test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que await expect(panel.getByText(/^3 votants sur 4 \(62,5\s%\) : la médiane des 12 propositions votées depuis un an\.$/)).toBeVisible(); // Then what the group asks, the group as subject. await expect(panel.getByText(/^Ramenée entre 5\s% et 50\s% de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient \(50\s%\)\.$/)).toBeVisible(); - await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude.")).toBeVisible(); + await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude.")).toBeVisible(); await shot(page, 'c27-participation-bornee'); }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 55ec01a..7f0d737 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -283,7 +283,8 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page // A version unfolds its vote below it, as any proposal. await versions.nth(1).click(); await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true'); - await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible(); + // Level 3, the quorum 50 (ADR 0013): 39 « pour » at no vote, the voters missing counted as « non ». + await expect(page.locator('section.fold').getByText('Seuil : 39 voix pour')).toBeVisible(); await expect(page).toHaveURL(/vote=/); await e3.click(); await expect(e3).toHaveAttribute('aria-expanded', 'false'); @@ -299,19 +300,22 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { const panel = page.locator('aside.panel'); await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible(); await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible(); - await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible(); - // No vote yet: the reference, twice C. - await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible(); + // No vote yet: the reference, twice the reference quorum (ADR 0013). + await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois 50, le quorum de référence : la participation de référence/)).toBeVisible(); const rows = panel.locator('table tbody tr'); await expect(rows).toHaveCount(5); await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]); await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']); await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']); - // At 100 voters: C plus each level's share of the 50 votes beyond C. + // The quorum of each level (Yvv: « 12 · 12 · 50 · 50 · 50 »). + const quorums = rows.locator('td:nth-child(5)'); + await expect(quorums).toHaveText(['12', '12', '50', '50', '50']); + // At 100 voters, above every quorum: each level's share of the 100. const required = panel.locator('table tbody td.required'); - await expect(required).toHaveText(['80', '84', '88', '92', '95']); + await expect(required).toHaveText(['60', '67', '75', '83', '90']); await expect(panel.getByText("Voix pour : à 100 votants, comme d'habitude.")).toBeVisible(); - await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 100 votants comme d'habitude.")).toBeVisible(); + await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude.")).toBeVisible(); + await expect(panel.getByText(/^Quorum : en dessous, les votants qui manquent comptent comme « non »\./)).toBeVisible(); await page.keyboard.press('Escape'); // From the vote of an article: Unicité du compte, a seed proposal, is at 2 / 5, its row is marked. @@ -322,7 +326,9 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await expect(panel.locator('tr.current')).toHaveCount(1); await expect(panel.locator('tr.current th')).toContainText('2'); await expect(panel.locator('tr.current th')).toContainText('cet article'); - await expect(required).toHaveText(['50', '50', '50', '50', '50']); + // No vote: each level on its quorum, the voters missing counted as « non ». + await expect(quorums).toHaveText(['12', '12', '50', '50', '50']); + await expect(required).toHaveText(['8', '9', '39', '43', '46']); await expect(panel.getByText('Voix pour : à 0 votant, la participation de cette version.')).toBeVisible(); await page.keyboard.press('Escape'); diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index db7c70b..5b66089 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -195,7 +195,8 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); // The totals and the threshold stay in sight. - await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible(); + // Raison d'être, level 2, the quorum 12 (ADR 0013). + await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible(); await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded(); await shot(page, '6-vote-ferme'); // The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ». diff --git a/web/src/lib/components/Formula.svelte b/web/src/lib/components/Formula.svelte index 321d5b0..0e51fdc 100644 --- a/web/src/lib/components/Formula.svelte +++ b/web/src/lib/components/Formula.svelte @@ -33,12 +33,18 @@
{t('formula.title')}

{t('formula.intro')}

+ - R=C+BW+ + T′=max(T,Q) + + + + + R=BW+ (M+(1−M)(1− - (TW)G)) - ·max(0,T−C) + (T′W)G)) + ·T′
    @@ -49,7 +55,7 @@
  • {t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}
  • {/if}
  • {t('formula.B', { n: i18n.number(base) })}
  • -
  • {t('formula.C', { n: i18n.number(floor) })}
  • +
  • {t('formula.Q', { n: i18n.number(evaluation?.quorum ?? p?.quorum ?? floor) })}
  • {#if evaluation}
  • {t('formula.R', { n: evaluation.required })}
  • {/if}

{t('formula.reading')}

diff --git a/web/src/lib/components/FormulaSettings.svelte b/web/src/lib/components/FormulaSettings.svelte index ddcd9bc..dce07a8 100644 --- a/web/src/lib/components/FormulaSettings.svelte +++ b/web/src/lib/components/FormulaSettings.svelte @@ -1,8 +1,9 @@