diff --git a/CLAUDE.md b/CLAUDE.md index 81adca2..388636c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,16 +33,20 @@ développer. ``` crates/ - ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane, + ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle sur un quorum, judged_voters, + # yes_needed / no_needed), inertia (cinq crans, leurs quorums, bornes, médiane, # référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité, # longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier # fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection # (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction # (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre - # d'une section : qui en est, complété à la fin, places), pending (modules à voter) + # d'une section : qui en est, complété à la fin, places), pending (modules à voter), + # buffer (le tampon des votes, ADR 0012 : réconciliation, poids des états, + # SIMULATED_ADOPTS) trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou # chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message, - # groupes par défaut + # sets_quorum, casts_simulated (le vote simulé, chaîne fermée), groupes par défaut + # (TEST_GROUP) ld-server/ migrations/ # schéma SQLite — additif seulement src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations, @@ -52,15 +56,25 @@ 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é, - # bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments) + # 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, le tampon : intention, preuve, réconciliation ; le vote + # simulé, « ton vote ») · simulation.rs (l'acte « supprime » de la fin de la simulation : + # `ld-server discard-simulation [--apply]`, ADR 0012) · 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) + # section, le titre du document) · module_amend.rs (ajout gardé) · quorum.rs (le quorum + # d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) · + # quorum_amend.rs (les quorums par cran, amendement 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) · + # quorum.rs (quorums par cran, la route, « un vote existe », l'amendement) · + # 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 ; + # quorums par cran : 12 · 12 · 50 · 50 · 50 Monnaie libre, 1 partout essai) # 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 @@ -78,7 +92,7 @@ web/src/ # · g/[group]/decisions · g/[group]/mandats · # g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ; # /groupes, /decisions… redirigent -web/e2e/ # Playwright C1–C33 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et +web/e2e/ # Playwright C1–C34 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et # chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml @@ -100,7 +114,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace -cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 34 contrôles (35 tests, C25 en deux) +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 35 contrôles (36 tests, C25 en deux) LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud @@ -114,7 +128,12 @@ 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) ; un quorum par cran + (12 · 12 · 50 · 50 · 50 dans Monnaie libre), que l'auteur d'un article change avant le premier + vote : sous le quorum, les votants qui manquent comptent comme « non », et une seule voix ne fait + jamais passer un texte (quorum ≥ 2), sauf dans le groupe d'essai (quorum 1 ; ADR 0013) ; 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/Cargo.toml b/Cargo.toml index fcce766..db7e42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2024" rust-version = "1.90" repository = "https://git.open.us.org/LPK/decision" publish = false -license = "AGPL-3.0-only" +license = "AGPL-3.0-or-later" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/HANDOFF.md b/HANDOFF.md index 32ccab4..c515aa6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,8 +1,27 @@ # HANDOFF — libreDecision -> Dernière mise à jour : 2026-11-02 à 03 h 10, session orch du chantier 1 (revue d'Yvv du 30/10). +> Dernière mise à jour : 2026-11-03 à 07 h 06, intégrateur libredecision-d4 (orch : dev-63, session +> racine /DEV, mandat « déploie » et « pousse » du 03/11 au 08/11). État vivant : le tableau +> `docs/chantiers/acte-certification/tableau.md`. -## Où on en est +## Où on en est (03/11) + +- **M6 en ligne** depuis le 03/11 à 07 h 03 (`94ddb17`, pipeline 37 ; + `actes/2026-11-03-m6-propositions-seed-quorum-tampon.md`) : + - l'accueil sous la voûte (lot H) ; + - les Propositions seed : I1, E1, E2 ouvertes, au cran 2, aucun article en vigueur (ADR 0011) ; + - le quorum par cran, 12 · 12 · 50 · 50 · 50 en Monnaie libre, les votants manquants comptent + « non » (ADR 0013) ; la licence `AGPL-3.0-or-later` (lot S) ; + - le tampon des votes et le **vote simulé ouvert dans Monnaie libre** (pas de `LD_VOTE_ACCOUNT`), + marqué « simulé », qui compte puis s'efface par l'acte « supprime » à l'ouverture de la chaîne + (`ld-server discard-simulation`, serveur arrêté) ; la publication **pas avant des votes prouvés** + (lot V, ADR 0012). Le dépôt des textes reste à `9e0f45a`. +- **À dire à Yvv** : pas de lien d'équipe vers Monnaie libre pendant la simulation (chaque ouverture + est une voix simulée de plus). +- **En cours** : lot T (F1.1, F1.2, N1.1 réécrits sur le quorum, fichier de relecture pour Yvv), puis + lot U (le lecteur, M7). Liste d'attente de la relecture de M6 : au tableau. + +## Avant le 03/11 **Revue d'Yvv du 2026-10-30** : treize points (L1 à L13), suivis dans `docs/chantiers/acte-certification/tableau.md` (lots, questions, actes ; source d'état). diff --git a/README.md b/README.md index 3065c6c..616e47e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ maquette/ # la v2 Nuxt + FastAPI, telle quelle ## Licence -Logiciel libre sous **GNU Affero General Public License, version 3** (`AGPL-3.0-only`, choix d'Yvv -du 2026-11-02) : quiconque fait tourner une version modifiée en ligne doit en publier le code. +Logiciel libre sous **GNU Affero General Public License, version 3 ou toute version ultérieure** +(`AGPL-3.0-or-later`, choix d'Yvv du 2026-11-03) : quiconque fait tourner une version modifiée en +ligne doit en publier le code. Le texte est dans `LICENSE`. 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/actes/2026-11-03-m6-propositions-seed-quorum-tampon.md b/actes/2026-11-03-m6-propositions-seed-quorum-tampon.md new file mode 100644 index 0000000..2af3ddb --- /dev/null +++ b/actes/2026-11-03-m6-propositions-seed-quorum-tampon.md @@ -0,0 +1,249 @@ +# Acte — Mise en ligne M6 (Propositions seed, quorum par cran, tampon des votes et vote simulé, accueil sous la voûte) +Classe : déploie +Projet : libreDecision Date : 2026-11-03 +**État : EXÉCUTÉ (03/11, 06h45-07h04, par orch dev-63 sous mandat ; pipeline 37)** — figé le 03/11/2026 à 06 h 22 sur `94ddb17` (`94ddb175643c8d523a083bbfee3882f22b82f628`) — préparé par +l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse », du +03/11 au 08/11). Relecture indépendante : « 94ddb17 : ok », inscrite le 03/11 à 06 h 42 (prémisse 4). + +## Intention + +Mettre en ligne la revue d'Yvv du 02/11 et ses décisions du 02/11 et du 03/11 (registre) : +- **H** : l'accueil sous la voûte, les trois points sous l'arche, le titre centré en deux lignes + (`a3c0a76`) ; +- **S** : les Propositions seed (I1, E1, E2 ouvertes, au cran 2, ADR 0011), la publication suspendue sans + texte en vigueur, le quorum par cran (ADR 0013, `PUT /units/{id}/quorum`), la licence + `AGPL-3.0-or-later` (`349c8ed`) ; +- **V** : le tampon des votes (ADR 0012), le vote simulé partout tant que la chaîne est fermée, marqué + « simulé », qui compte puis s'efface par l'acte « supprime » ; la publication « pas avant des votes + prouvés » (`94ddb17`). + +Hors de M6 : T (les articles du seed qui décrivent encore l'ancien plancher C : F1.1, F1.2, N1.1), puis U +(le lecteur) en M7. Yvv, à 06 h 07 : « J'ai retiré les propositions. tu peux livrer Decision. » + +## Décisions d'Yvv que l'acte applique + +- Propositions seed au cran 2 ; aucun article en vigueur au départ (02/11). +- Quorum par cran 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 à l'essai ; l'auteur le change avant le + premier vote (02/11) ; sous le quorum, les votants manquants comptent comme « non » (03/11). +- Un vote simulé compte, puis s'efface à l'ouverture de la chaîne par un acte « supprime » (02/11). +- Q11 (03/11, « Pas avant des votes prouvés ») : un texte entré en vigueur par des votes simulés est en + vigueur sur le site, mais ne se publie pas. Règle stricte, confirmée par orch le 03/11 à 06 h 11 : rien + ne part tant qu'une entrée simulée ne tient pas sur les seuls votes prouvés. +- Q12 (03/11, « Oui, retire-les ») : les propositions d'inertie I1 → 5 et E1 → 5 retirées avant le + démarrage du nouveau serveur. **Fait par Yvv à l'écran, annoncé à 06 h 07** ; vu sur la copie de + 06 h 11 (prémisse 2). + +## Ce qui part + +Version `94ddb17`, qui contient la version en ligne `3f10a0e` : 51 commits, 84 fichiers. Hors du code +(vérifié le 03/11 à 06 h 22) : rien dans `docker/`, `.woodpecker/`, `.env` ou `data/`, aucun fichier +sensible. Le seed JSON change (bases neuves seulement : un document seedé n'est jamais réinstancié). + +## Ce que l'acte écrit dans la base de production (donnée runtime) + +- **Migrations additives** `0009` (Propositions seed : `proposals.seed_featured`, la table + `seed_proposal_amendments`), `0010` (tampon : `ballots.mode` défaut `'chain'`, `ballot_lines.outcome`, + l'index `ballots_doc` ; aucune ligne réécrite), `0011` (quorums : `documents.quorums`, `units.quorum`, + la table `quorum_amendments`). +- **Amendement Propositions seed** (`2026-11-02-i1/e1/e2-proposition-seed`), par document et par article, + seulement si personne n'y a touché : la version passe d'en vigueur à ouverte et marquée ; + `units.in_force_id` à NULL ; une ligne `revisions` de type `seed_proposal` ; le cran à 2 avec une ligne + `inertia_changes`, si personne n'a touché au cran ; une ligne `seed_proposal_amendments` ; dans Monnaie + libre, une ligne `outbox`, qui attend. +- **Amendement quorums** (`2026-11-03-quorums-par-cran`), une fois par document : `documents.quorums` + là où il est NULL ; une ligne `quorum_amendments` ; une ligne `outbox` dans Monnaie libre. +- **Le tampon** : la première mise en route n'en écrit rien. +- **La publication** : suspendue (aucun article en vigueur, puis « pas avant des votes prouvés »). Le + dépôt des textes garde son dernier état. +- **Aucun vote, aucune identité, aucune autre proposition n'est touché.** + +**Répétition faite le 03/11 de 06 h 21 à 06 h 22**, avec le serveur de `94ddb17` (binaire de la boucle), +sur une copie de la copie de production de 06 h 11 min 12 (santé `3f10a0e8`), publication vers un dépôt nu +local (`m6/repetition.sh`, scratchpad de l'intégrateur ; journal `m6/repetition.log`). + +| | Avant | Après | +|---|---|---| +| Migrations | 1–8 | 1–11 | +| Propositions | inertie retirées 3 · ordres en vigueur 16 · textes en vigueur 6 · textes ouverts 64 · titres en vigueur 2 | inertie retirées 3 · ordres en vigueur 16 · textes ouverts 70 · titres en vigueur 2 | +| Unités en vigueur | essai 12 · Monnaie libre 12 | essai 9 · Monnaie libre 9 | +| I1, E1, E2 (deux groupes) | en vigueur, crans 3, 4, 4 | ouvertes, Propositions seed, cran 2, 2, 2 | +| Révisions | 101 | 107 | +| Traductions | 144 | 144 | +| `inertia_changes` | 4 | 10 | +| `seed_proposal_amendments` | absente | 6 lignes, statut et cran « applied » | +| `quorum_amendments` / `documents.quorums` | absentes | 2 lignes « applied » ; Monnaie libre `[12,12,50,50,50]`, essai `[1,1,1,1,1]` | +| File de publication (Monnaie libre) | 0 en attente, 13 faites | 4 en attente, 13 faites | +| Commits poussés vers le dépôt des textes (local) | — | 0 (« publication suspended … waiting=4 ») | +| Votes, bulletins, signalements, identités | 0, 0, 0, 1 | 0, 0, 0, 1 | + +`integrity_check` ok, `foreign_key_check` vide, avant et après. Journal : six « seed proposal amendment … +status=applied level=applied to_level=2 », deux « quorums written », « publication suspended », aucune +ligne ERROR ni WARN. **Un second démarrage ne rejoue rien** (mêmes comptes, la file reste à 4, 0 commit). + +## Effet attendu + +- Les deux groupes : I1, E1 et E2 sont des « Propositions seed », ouvertes, au cran 2 ; aucun article n'est + en vigueur ; les modules (titre du document au cran 4, ordre de chaque section au cran 2) restent en + vigueur. +- Le seuil se lit en quorum : au cran 2, 9 « pour » passent avec 3 « contre » au plus. +- **Le vote simulé s'ouvre dans Monnaie libre dès la mise en ligne** (pas de `LD_VOTE_ACCOUNT`) : toute + personne qui ouvre le groupe y vote, une personne invitée comprise ; le vote compte aussitôt, marqué + « simulé » sous l'article ; 9 « pour » simulés font entrer I1, E1 ou E2 **sur le site**, sans + publication. Effet de « simule le vote à tout endroit » et « il compte, puis s'efface » ; orch le porte à + son rapport (03/11, 06 h 11). À l'ouverture de la chaîne, l'acte « supprime » (`ld-server + discard-simulation`, à blanc, puis `--apply`) écartera ces votes et réexaminera ce qu'ils ont fait entrer. + **Cet acte se jouera serveur arrêté** (relecture : `give_back` écrit sans garde, à côté du serveur). +- **Un lien d'équipe multiplie les voix simulées** (relecture, vu : un lien vers Monnaie libre ouvert + 9 fois sous 9 noms donne 9 « pour », et I1 entre sur le site). En production le 03/11 à 06 h 11 : + aucun lien d'équipe ; 2 invitations vers Monnaie libre, dont 1 pas encore ouverte (expire le 05/11). + **À dire à Yvv** : ne pas créer de lien d'équipe vers Monnaie libre pendant la simulation, ou le + borner à quelques ouvertures. +- Après un vote simulé, le message bref dit encore « Vote enregistré. » et le panier « N votes + enregistrés. » ; sous l'article, c'est bien « simulé ». Repris par U (M7). +- L'accueil sous la voûte. + +Irréversibilité : **partielle**. Le code se remet en arrière ; les données changées restent. + +## Avant — prémisses vivantes + +1. La production sert `3f10a0e` : `curl -s https://decision.librodrome.org/api/health` → + `"build":"3f10a0e8"` ; `git fetch prod && git rev-parse --short prod/main` → `3f10a0e`. +2. Sur une copie fraîche de la base (commande ci-dessous) : aucune proposition d'inertie `open` ; 0 vote, + 0 bulletin ; I1, E1, E2 en vigueur, aux crans 3, 4, 4, dans les deux groupes. **Lu sur la copie de + 06 h 11 min 12** : 3 propositions d'inertie, toutes `withdrawn` (I1 → 5, E1 → 5, et une ancienne sur + l'ordre de l'introduction) ; 0 vote, 0 bulletin, 0 signalement, 1 identité ; I1, E1, E2 en vigueur, + crans 3, 4, 4, dans les deux groupes. À relire sur la seconde copie, juste avant la poussée. Si un de + ces articles a bougé, l'amendement le refuse avec sa raison au journal, et l'acte reste valable. +3. Sur la même copie : 0 vote, donc aucune proposition n'entre au premier balayage. +4. La relecture indépendante de `94ddb17`, inscrite « ok » dans `~/harness/etat/relectures.json` ; aucune + autre relecture de ce code en cours. **Faite** : « 94ddb17 : ok », sous-agent neuf, rendue et inscrite le + 03/11 à 06 h 42 (transcript `~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-ab9f269c254b56f31.jsonl`). + Aucun bloquant. Elle a rejoué la mise en route sur une base faite par `3f10a0e` (écritures exactement + celles annoncées, second démarrage sans rejeu, publication suspendue), sondé la formule sur 25 millions + de cas (une voix seule ne passe jamais, un « contre » n'aide jamais, `yesNeeded` / `noNeeded` exacts), + la route du quorum (bornes, CSRF, auteur, 409 après un vote), les routes de développement (404 sans + `LD_DEV_LOGIN`), la simulation et l'acte « supprime » (à blanc : la base inchangée). Ses constats « à + reprendre » sont traités dans ce dossier (le retour, le lien d'équipe, l'acte « supprime ») ou + renvoyés à U (« Vote enregistré. »). +5. `git diff 3f10a0e 94ddb17 -- docker/ .woodpecker/ .env data/` → vide (vérifié à 06 h 22). +6. Boucle verte sur `94ddb17` le 03/11 de 06 h 16 à 06 h 21 : fmt, clippy `-D warnings`, 286 tests Rust, + svelte-check 0 erreur 0 avertissement, 56 vitest, build, e2e 36 / 36. Image de production construite + le 03/11 à 06 h 25 (`docker build -f docker/app.Dockerfile --target production`, sortie 0, les tests + rejoués dedans : 56 vitest, tests Rust tous « ok ») ; image de contrôle retirée à 06 h 26. + +## Copie de la base (prise par orch, lecture seule, aucune écriture côté production) + +```sh +set -euo pipefail +d=/tmp/ld-m6-copie; mkdir -m 700 -p "$d" +for f in libredecision.db libredecision.db-wal libredecision.db-shm; do + ssh sonic "docker exec lpk-libredecision-app sh -c 'test -f /data/$f && cat /data/$f'" > "$d/$f" ` (scratchpad de l'intégrateur), en `sqlite3 -readonly`. + +## Commande exacte + +Selon la règle du canon du 02/11, un dossier « déploie » ne liste que la poussée qui met en ligne : + +```sh +cd /home/yvv/Documents/PROD/DEV/libreDecision +git push prod 94ddb175643c8d523a083bbfee3882f22b82f628:main +``` + +(remote `prod` = `gitea@git.open.us.org:EHV/decision.git` ; Woodpecker `.woodpecker/app.yml`, puis +`maquette.yml`.) + +## Vérifications après + +- [x] Santé vue de l'extérieur → `"build":"94ddb175"`. +- [x] Journaux : `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "amendment|quorums written|publication|ERROR|WARN"'` + → six « seed proposal amendment … status=applied level=applied », deux « quorums written », + « publication suspended », aucune erreur. +- [x] Redémarrage à froid (`docker compose restart` dans `/opt/libredecision-app`), santé vue de + l'extérieur, journaux : aucun amendement rejoué. +- [x] Sur une copie fraîche : les comptes « Après » de la répétition, à la ligne près ; `integrity_check` + ok, `foreign_key_check` vide ; copie retirée ensuite. +- [x] Le dépôt des textes n'a reçu aucun commit : `git ls-remote gitea@git.open.us.org:yvv/decision-depot-textes-reference.git main` + → le même sha qu'avant la poussée (le relever avant). +- [ ] À l'écran (Yvv, à son retour) : l'accueil sous la voûte ; l'Acte d'engagement : les Propositions + seed, aucun article en vigueur ; voter dans Monnaie libre affiche « simulé ». + +## Retour + +**Avant tout retour vers `3f10a0e`, couper la publication.** `94ddb17` met 4 lignes en file au premier +démarrage (`seed_proposal_amend.rs:419`, `quorum_amend.rs:140`) ; `3f10a0e` ne connaît pas l'attente du +premier texte et publierait aussitôt un document **sans aucun article** (vu par la relecture : commit de +`fr.md`, `en.md`, `es.md` et `document.json` vides, 118 lignes retirées). Le compose prend +`${LD_PUBLISH_REMOTE:-…}` : une valeur vide reprend le défaut, il faut donc retirer la ligne. + +```sh +ssh sonic +cd /opt/libredecision-app +cp docker-compose.yml docker-compose.yml.m6-avant-retour +sed -i '/LD_PUBLISH_REMOTE/d' docker-compose.yml +docker tag libredecision-app:previous libredecision-app:latest +docker compose up -d --force-recreate +``` + +- **Répété le 03/11 à 06 h 45** : le serveur de `3f10a0e`, sans `LD_PUBLISH_REMOTE`, sur la base issue de + la répétition M6 : démarrage sans erreur, aucune publication (dépôt nu local : 0 commit), la file reste + à 4 en attente. Le serveur tolère les migrations qu'il ne connaît pas (`set_ignore_missing(true)`, + `lib.rs:100`). La prochaine mise en ligne réécrit `docker-compose.yml` depuis le dépôt (`app.yml`). +- **Les données** : pas de retour automatique. `3f10a0e` montrerait I1, E1, E2 comme propositions + ouvertes sans la marque, et aucun article en vigueur ; les votes simulés éventuels y compteraient comme + des votes du serveur. Un retour du code au-delà de quelques minutes demande un acte de données à part. +- **Risque connu, le retour automatique du pipeline** : si le nouveau conteneur échoue après avoir + démarré, `.woodpecker/app.yml` relance `previous` (`3f10a0e`) **avec** la publication, qui partirait + vide. La répétition et l'image ne montrent aucun échec de démarrage ; si le pipeline échoue quand même, + regarder aussitôt le dépôt des textes (`git ls-remote … main`) et, s'il a reçu le document vide, couper + la publication comme ci-dessus, puis republier le dernier état par-dessus (acte à part). +- **La publication** : en marche avant, rien ne part (suspendue). + +## Exécution + +Acte exécuté le 03/11/2026, 06h45-07h04, par orch (session dev-63, e66db8ca), sous mandat +« déploie » (inscrit du 03/11 au 08/11) ; consignes d'Yvv du 03/11 à 06h00 (« livre et déploie tout +ce que tu peux ») et à 06h07 (« tu peux livrer Decision »). + +- Dossier relu en entier à 06h45 ; relecture « 94ddb17 : ok » vue inscrite ; aucune autre relecture + de ce code en cours (libredecision-d4, PRÊT M6). +- Prémisse 1, 06h45:51 : santé `{"build":"3f10a0e8","ok":true}`, `prod/main` = `3f10a0e`. +- Seconde copie, 06h46:32 (`/tmp/ld-m6-copie2`, 700/600), santé `3f10a0e8` : `integrity_check` ok, + `foreign_key_check` vide. Prémisses 2 et 3 : la colonne « Avant » à la ligne près ; 3 propositions + d'inertie, toutes `withdrawn` ; 0 vote, 0 bulletin, 0 signalement, 1 identité ; I1, E1, E2 en vigueur, + crans 3, 4, 4, dans les deux groupes ; file 0 en attente, 13 faites. +- Dépôt des textes avant : `main` = `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3`. Pipelines : 36 + `success` en tête, rien en cours. +- Poussée `git push prod 94ddb175643c8d523a083bbfee3882f22b82f628:main` à 06h46:5x, passée du premier + coup (`3f10a0e..94ddb17`). +- Pipeline n° 37 : `running` à 06h47:06 ; `app` vert (healthcheck `success`, `retablir` sauté) ; + `maquette` verte ; `success` à 07h02:59. L'étape `sbom-publish` échoue dans les deux workflows, comme + au pipeline 36 : écart ancien, sans effet sur le déploiement. +- Après : + - santé `{"build":"94ddb175","ok":true}` ; + - journaux : six « seed proposal amendment … status="applied" level="applied" to_level=2 », deux + « quorums written » (essai `[1, 1, 1, 1, 1]`, Monnaie libre `[12, 12, 50, 50, 50]`), + « publication suspended … waiting=4 », aucune ligne ERROR ni WARN ; + - redémarrage à froid (`docker compose restart` dans `/opt/libredecision-app`) à 07h03:07 : + « listening … build=94ddb175 », « publication suspended … waiting=4 », aucun amendement rejoué ; + santé `94ddb175` ; + - copie fraîche, 07h03:31 (`/tmp/ld-m6-apres`) : la colonne « Après » de la répétition à la ligne + près. Migrations 1–11 ; inertie retirées 3, ordres en vigueur 16, textes ouverts 70, titres en + vigueur 2 ; unités en vigueur 9 et 9 ; I1, E1, E2 ouvertes au cran 2 dans les deux groupes ; + révisions 107 ; traductions 144 ; `inertia_changes` 10 ; 6 `seed_proposal_amendments` « applied » ; + 2 `quorum_amendments` « applied » ; file 4 en attente, 13 faites ; 0, 0, 0, 1 ; `integrity_check` + ok, `foreign_key_check` vide ; + - dépôt des textes : `main` = `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3`, inchangé : aucun commit. +- `main` local avancé sur `94ddb17` (`git fetch . 94ddb17…:main`, depuis `fa8e8dc`). +- Les trois copies de la base (`/tmp/ld-m6-copie` de 06h11, `/tmp/ld-m6-copie2`, `/tmp/ld-m6-apres`) + retirées à 07h03:43. Les copies de répétition de l'intégrateur sont à retirer par lui. +- Reste à l'écran, pour Yvv à son retour : l'accueil sous la voûte ; l'Acte d'engagement (les + Propositions seed, aucun article en vigueur) ; un vote dans Monnaie libre affiche « simulé ». +- Image de retour : `libredecision-app:previous` (`3f10a0e`), avec la coupure de la publication + d'abord (§ Retour). diff --git a/crates/ld-engine/src/buffer.rs b/crates/ld-engine/src/buffer.rs new file mode 100644 index 0000000..a974e8f --- /dev/null +++ b/crates/ld-engine/src/buffer.rs @@ -0,0 +1,387 @@ +//! The buffer between the votes and the chain (ADR 0012). +//! +//! Yvv, 2026-11-02: « il faut prévoir le "tampon" entre la base des votes et la +//! mise à jour par les indexeurs de la chaîne. Il faut prévoir le bon process +//! pour une ux moderne "je vois ce que j'ai fait sans attendre", avec un +//! contrôle a posteriori de l'intégrité du vote (bien unique, bien le dernier +//! qui vaut). » +//! +//! A vote goes through three times: +//! - **the intention**, the gesture itself (a sealed ballot, `ballot`): seen at +//! once by the person and in the totals shown, never counted for adoption; +//! - **the proof**, the chain read by the indexer (or, while the chain is +//! closed, the simulated confirmer); +//! - **the reconciliation**, after each proof: one voice per identity and per +//! version, the last in the order of the chain, whatever the order the proofs +//! were read in (`reconcile`). What the chain never confirms in time, refuses, +//! or what a more recent vote of the same identity overtook, is set aside — and +//! a *for* proven while an objection holds its version back. +//! +//! What each stage of a vote weighs, in the totals shown and in adoption, is +//! `Rules`. Pure: the facts come from the caller. + +use crate::ballot::{ChainPosition, Choice}; +use crate::objection; +use crate::threshold::Tally; +use serde::Serialize; + +/// Whether a simulated vote counts for adoption. Yvv, 2026-11-02: « Il compte, +/// puis s'efface » — like a proven vote, marked as simulated, until an act +/// « supprime » sets every simulated vote aside and examines again, with the +/// proven votes only, what they brought into force (`ld_server::simulation`). +/// `false` would turn it to « shown, never counted for adoption »; the totals +/// shown would not change. +pub const SIMULATED_ADOPTS: bool = true; + +/// Where a vote stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Stage { + /// Proven by the chain, or written by the server in a managed group. + Recorded, + /// An intention still waiting for its proof, within its window. + Pending, + /// A vote of the simulation (cast while the chain was closed), until the act + /// that sets them aside. + Simulated, +} + +impl Stage { + pub fn as_str(self) -> &'static str { + match self { + Self::Recorded => "recorded", + Self::Pending => "pending", + Self::Simulated => "simulated", + } + } + + /// The stage of a vote that counts (`votes.source`): `simulated`, or recorded + /// (`server`, `chain`). An intention is never in `votes`. + pub fn of_source(source: &str) -> Self { + if source == "simulated" { Self::Simulated } else { Self::Recorded } + } +} + +/// What the stages weigh. Opening the chain changes nothing by itself: the +/// simulated votes weigh until the act that sets them aside (Yvv, 2026-11-02). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rules { + /// See `SIMULATED_ADOPTS`. + pub simulated_adopts: bool, +} + +impl Default for Rules { + fn default() -> Self { + Self { simulated_adopts: SIMULATED_ADOPTS } + } +} + +impl Rules { + /// Does a vote at this stage weigh in the totals shown? Every stage: the + /// intention at once, the simulated vote with its mark. + pub fn shown(&self, stage: Stage) -> bool { + match stage { + Stage::Recorded | Stage::Pending | Stage::Simulated => true, + } + } + + /// Does a vote at this stage weigh in adoption (and in the usual participation)? + /// Never an intention without proof. + pub fn adopts(&self, stage: Stage) -> bool { + match stage { + Stage::Recorded => true, + Stage::Pending => false, + Stage::Simulated => self.simulated_adopts, + } + } +} + +/// A proven line of one identity on one version: where its proof sits on the +/// chain, its choice, and whether an objection held the version back when the +/// proof arrived. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Proven { + pub position: ChainPosition, + pub choice: Choice, + pub held: bool, +} + +/// What became of a proven line. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Outcome { + /// The voice of the identity on this version (a withdrawal too: then no voice). + Counted, + /// A more recent proven line of the same identity decided. + Superseded, + /// A *for* proven while an objection held the version back: never counts. + Objection, +} + +impl Outcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Counted => "counted", + Self::Superseded => "superseded", + Self::Objection => "objection", + } + } + + pub fn parse(raw: &str) -> Option { + [Self::Counted, Self::Superseded, Self::Objection].into_iter().find(|o| o.as_str() == raw) + } +} + +/// A *for* whose proof arrives while an objection holds its version back is set +/// aside (ADR 0012, revising 0010): against and a withdrawal still count (Yvv, +/// 2026-11-01: « Retirer ou "contre" »). +pub fn discarded_by_objection(choice: Choice, held: bool) -> bool { + !objection::may_cast(held, choice) +} + +/// The reconciliation of one identity on one version. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Reconciled { + /// One per line, in the order given. + pub outcomes: Vec, + /// The line that stands, if any: `None` when every line was set aside (the + /// vote of before, if any, is left as it is). + pub standing: Option, +} + +/// One voice per identity and per version, the last in the order of the chain: +/// the block, then the rank of the event — whatever the order the lines come in. +/// A *for* proven while held is set aside first and never takes a place in the +/// order. Two lines at the same place (never on the chain): the later given. +pub fn reconcile(lines: &[Proven]) -> Reconciled { + let mut outcomes = vec![Outcome::Superseded; lines.len()]; + let mut standing: Option = None; + for (i, l) in lines.iter().enumerate() { + if discarded_by_objection(l.choice, l.held) { + outcomes[i] = Outcome::Objection; + continue; + } + if standing.is_none_or(|j| lines[j].position <= l.position) { + standing = Some(i); + } + } + if let Some(i) = standing { + outcomes[i] = Outcome::Counted; + } + Reconciled { outcomes, standing } +} + +/// A change of the totals shown, for and against. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Shift { + pub for_votes: i64, + pub against: i64, +} + +impl Shift { + /// The totals, shifted; never below zero. + pub fn apply(self, t: Tally) -> Tally { + let add = |n: u32, d: i64| u32::try_from((i64::from(n) + d).clamp(0, i64::from(u32::MAX))).unwrap_or(n); + Tally { for_votes: add(t.for_votes, self.for_votes), against: add(t.against, self.against) } + } +} + +/// What an intention weighs in the totals shown. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Weighed { + /// What it changes in the totals shown. + pub shift: Shift, + /// The part of the totals shown it adds that still waits for its proof. + pub pending: Tally, +} + +/// The latest intention of a session on a version, still without proof, takes +/// the place — in the totals shown only — of the vote this session sees as its +/// own: +/// - `seen`: that vote, if it counts in the totals shown now (*for* or *against*); +/// - `intent`: the choice of the intention (`None`: a withdrawal); +/// - `current`: it votes the revision in place (otherwise it would be « to +/// reconfirm »: it adds nothing, but still takes the vote of before away); +/// - `held`: an objection holds the version back — a *for* then weighs nothing +/// at all (it would be set aside), the vote of before stays. +pub fn weigh_intent(seen: Option, intent: Choice, current: bool, held: bool) -> Weighed { + if discarded_by_objection(intent, held) { + return Weighed::default(); + } + let one = |c: Option| match c { + Some(Choice::For) => Tally { for_votes: 1, against: 0 }, + Some(Choice::Against) => Tally { for_votes: 0, against: 1 }, + Some(Choice::None) | None => Tally::default(), + }; + let new = one(current.then_some(intent)); + let old = one(seen); + Weighed { + shift: Shift { + for_votes: i64::from(new.for_votes) - i64::from(old.for_votes), + against: i64::from(new.against) - i64::from(old.against), + }, + pending: new, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FOR: Choice = Choice::For; + const AGAINST: Choice = Choice::Against; + const NONE: Choice = Choice::None; + + fn at(block: u32, event: u32) -> ChainPosition { + ChainPosition { block, event } + } + + fn line(block: u32, event: u32, choice: Choice) -> Proven { + Proven { position: at(block, event), choice, held: false } + } + + /// The outcome of each line, keyed by its position: comparable across orders. + fn by_position(lines: &[Proven]) -> (Vec<(ChainPosition, Outcome)>, Option) { + let r = reconcile(lines); + let mut out: Vec<(ChainPosition, Outcome)> = + lines.iter().zip(&r.outcomes).map(|(l, o)| (l.position, *o)).collect(); + out.sort_by_key(|(p, _)| *p); + (out, r.standing.map(|i| lines[i].position)) + } + + #[test] + fn the_last_in_the_order_of_the_chain_stands_whatever_the_order_of_reading() { + let a = line(200, 2, FOR); + let b = line(200, 4, AGAINST); + let c = line(300, 1, NONE); + let expected = ( + vec![(a.position, Outcome::Superseded), (b.position, Outcome::Superseded), (c.position, Outcome::Counted)], + Some(c.position), + ); + // Every order the indexer may read them in gives the same voice. + for order in [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]] { + assert_eq!(by_position(&order), expected, "{order:?}"); + } + // The withdrawal stands: no voice. + let r = reconcile(&[a, b, c]); + assert_eq!(r.standing.map(|i| [a, b, c][i].choice), Some(NONE)); + } + + #[test] + fn one_voice_per_identity_never_two() { + for lines in [ + vec![], + vec![line(5, 1, FOR)], + vec![line(5, 1, FOR), line(5, 2, FOR), line(4, 9, AGAINST)], + vec![Proven { held: true, ..line(9, 1, FOR) }, line(8, 1, AGAINST), line(7, 1, FOR)], + ] { + let r = reconcile(&lines); + assert_eq!(r.outcomes.len(), lines.len()); + let counted = r.outcomes.iter().filter(|o| **o == Outcome::Counted).count(); + assert_eq!(counted, usize::from(r.standing.is_some()), "{lines:?}"); + assert!(counted <= 1); + } + assert_eq!(reconcile(&[]).standing, None); + } + + #[test] + fn a_for_proven_while_held_is_set_aside_and_the_vote_of_before_stays() { + let before = line(100, 1, AGAINST); + let held_for = Proven { held: true, ..line(200, 1, FOR) }; + for lines in [[before, held_for], [held_for, before]] { + let (outcomes, standing) = by_position(&lines); + assert_eq!(outcomes, vec![(before.position, Outcome::Counted), (held_for.position, Outcome::Objection)]); + assert_eq!(standing, Some(before.position), "the *for* takes no place in the order"); + } + // Alone, it leaves nothing standing: the vote of before, if any, is left as it is. + let r = reconcile(&[held_for]); + assert_eq!((r.outcomes, r.standing), (vec![Outcome::Objection], None)); + // Against and a withdrawal, proven while held, count. + for choice in [AGAINST, NONE] { + let held = Proven { held: true, ..line(300, 1, choice) }; + let r = reconcile(&[before, held_for, held]); + assert_eq!(r.standing, Some(2), "{choice:?}"); + assert_eq!(r.outcomes, vec![Outcome::Superseded, Outcome::Objection, Outcome::Counted]); + } + assert!(discarded_by_objection(FOR, true)); + assert!(!discarded_by_objection(FOR, false)); + assert!(!discarded_by_objection(AGAINST, true) && !discarded_by_objection(NONE, true)); + } + + #[test] + fn two_lines_at_the_same_place_the_later_given_stands() { + let r = reconcile(&[line(0, 3, FOR), line(0, 3, AGAINST)]); + assert_eq!(r.standing, Some(1)); + } + + #[test] + fn a_simulated_vote_at_block_zero_comes_before_any_vote_of_the_chain() { + // The simulation proves at block 0, in the order votes arrive (ADR 0012). + let simulated = line(0, 41, FOR); + let chain = line(1, 0, AGAINST); + assert_eq!(by_position(&[chain, simulated]).1, Some(chain.position)); + } + + #[test] + fn what_each_stage_weighs() { + let rules = Rules::default(); + assert_eq!(rules.simulated_adopts, SIMULATED_ADOPTS); + for stage in [Stage::Recorded, Stage::Pending, Stage::Simulated] { + assert!(rules.shown(stage), "{stage:?}: everything is seen, at once"); + } + assert!(rules.adopts(Stage::Recorded)); + assert!(!rules.adopts(Stage::Pending), "never an intention without proof"); + // Yvv, 2026-11-02: « Il compte, puis s'efface » — like a proven vote, until the act. + assert!(SIMULATED_ADOPTS && rules.adopts(Stage::Simulated)); + // The other way, kept at hand: shown, never counted for adoption. + let shown_only = Rules { simulated_adopts: false }; + assert!(shown_only.shown(Stage::Simulated) && !shown_only.adopts(Stage::Simulated)); + assert!(shown_only.adopts(Stage::Recorded) && !shown_only.adopts(Stage::Pending)); + assert_eq!(Stage::of_source("simulated"), Stage::Simulated); + assert_eq!(Stage::of_source("chain"), Stage::Recorded); + assert_eq!(Stage::of_source("server"), Stage::Recorded); + for o in [Outcome::Counted, Outcome::Superseded, Outcome::Objection] { + assert_eq!(Outcome::parse(o.as_str()), Some(o)); + } + assert_eq!(Outcome::parse("maybe"), None); + } + + fn shift(for_votes: i64, against: i64) -> Shift { + Shift { for_votes, against } + } + + fn tally(for_votes: u32, against: u32) -> Tally { + Tally { for_votes, against } + } + + #[test] + fn an_intention_takes_the_place_of_the_vote_one_sees_as_one_s_own() { + let w = |seen, intent, current, held| weigh_intent(seen, intent, current, held); + // A first vote. + assert_eq!(w(None, FOR, true, false), Weighed { shift: shift(1, 0), pending: tally(1, 0) }); + // From *for* to *against*: one *for* less, one *against* more, that one waiting. + assert_eq!(w(Some(FOR), AGAINST, true, false), Weighed { shift: shift(-1, 1), pending: tally(0, 1) }); + // The same choice again: nothing moves, the voice now waits for its proof. + assert_eq!(w(Some(FOR), FOR, true, false), Weighed { shift: shift(0, 0), pending: tally(1, 0) }); + // A withdrawal takes the vote away at once. + assert_eq!(w(Some(AGAINST), NONE, true, false), Weighed { shift: shift(0, -1), pending: tally(0, 0) }); + assert_eq!(w(None, NONE, true, false), Weighed::default()); + // On a revision gone by: it adds nothing, it still takes the vote of before away. + assert_eq!(w(Some(FOR), AGAINST, false, false), Weighed { shift: shift(-1, 0), pending: tally(0, 0) }); + // Held back by an objection: a *for* weighs nothing (it would be set aside)... + assert_eq!(w(Some(AGAINST), FOR, true, true), Weighed::default()); + assert_eq!(w(None, FOR, true, true), Weighed::default()); + // ...against and a withdrawal do. + assert_eq!(w(Some(FOR), AGAINST, true, true), Weighed { shift: shift(-1, 1), pending: tally(0, 1) }); + assert_eq!(w(Some(FOR), NONE, true, true), Weighed { shift: shift(-1, 0), pending: tally(0, 0) }); + // A « seen » withdrawal is no vote. + assert_eq!(w(Some(NONE), FOR, true, false), w(None, FOR, true, false)); + } + + #[test] + fn a_shift_never_takes_the_totals_below_zero() { + assert_eq!(shift(-1, 2).apply(tally(3, 0)), tally(2, 2)); + assert_eq!(shift(-1, -1).apply(tally(0, 0)), tally(0, 0)); + assert_eq!(shift(1, 0).apply(tally(u32::MAX, 0)), tally(u32::MAX, 0)); + } +} 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..72d34fb 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,52 +196,77 @@ 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)) } } +/// 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::*; 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 +296,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 +345,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 +388,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 +402,64 @@ mod tests { } #[test] - fn c_stays_a_floor_one_vote_never_passes_in_monnaie_libre() { + 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] { 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 +472,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 +488,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 +512,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/lib.rs b/crates/ld-engine/src/lib.rs index e36681d..5bb71ac 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod address; pub mod assist; pub mod ballot; +pub mod buffer; pub mod compose; pub mod correction; pub mod edit; 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)); } } 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..e56b55c --- /dev/null +++ b/crates/ld-server/migrations/0009_seed_proposals.sql @@ -0,0 +1,36 @@ +-- 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) +); diff --git a/crates/ld-server/migrations/0010_vote_buffer.sql b/crates/ld-server/migrations/0010_vote_buffer.sql new file mode 100644 index 0000000..ce413a6 --- /dev/null +++ b/crates/ld-server/migrations/0010_vote_buffer.sql @@ -0,0 +1,19 @@ +-- The buffer of the votes (2026-11-02, lot V, ADR 0012). Additive only: no +-- existing row is rewritten; an older image (rollback) reads the same tables. +-- +-- A ballot is the intention of a vote, seen at once; its proof comes from the +-- chain (mode 'chain'), or, while the chain is closed, from the simulated +-- confirmer (mode 'simulated': no account, no amount, the voice of the session). +-- After each proof, the reconciliation keeps one voice per identity and per +-- version, the last in the order of the chain, and tells each proven line what +-- became of it. + +ALTER TABLE ballots ADD COLUMN mode TEXT NOT NULL DEFAULT 'chain'; -- chain | simulated +ALTER TABLE ballot_lines ADD COLUMN outcome TEXT; -- counted | superseded | objection (NULL: not proven yet, or proven before 0010) + +-- The intentions of a document, read with every view of it. +CREATE INDEX ballots_doc ON ballots(doc_id, status); + +-- `votes.source` takes one more value, 'simulated'; `messages.topic` one more, +-- 'ballot' (kinds ballot_expired | ballot_refused | ballot_objection): text +-- columns, nothing to change. 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..65d7c1f 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -190,17 +190,22 @@ 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 — and, for the act that ends the +/// simulation (ADR 0012, `simulation::discard`), the simulated share of the votes it +/// entered with, the version it displaced and the level of inertia of before. 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); + let simulated = st.simulated_counted(&p.id); 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, + "simulated": simulated, "displaced": unit.in_force_id, "from_inertia": docs::inertia_of(unit), }) } diff --git a/crates/ld-server/src/ballots.rs b/crates/ld-server/src/ballots.rs index 818dafd..500accc 100644 --- a/crates/ld-server/src/ballots.rs +++ b/crates/ld-server/src/ballots.rs @@ -21,6 +21,21 @@ //! Ğecko code). A ballot is read one last time at its end (24 hours), then let //! go; the adoption pass runs after each confirmation. Ballots let go are purged //! after a while (a day for those the wallet never signed, a week for the others). +//! +//! **The buffer** (ADR 0012, Yvv 2026-11-02: « je vois ce que j'ai fait sans +//! attendre », with a control « a posteriori » of the integrity of the vote): a +//! pending ballot is an **intention**, seen at once by the person (« ton vote ») +//! and in the totals shown, never counted for adoption; the chain brings its +//! **proof**; the **reconciliation** (`ld_engine::buffer::reconcile`) keeps one +//! voice per identity and per version, the last in the order of the chain, and +//! sets aside what is never proven in time, refused, overtaken — or a *for* +//! proven while an objection holds its version back. What is set aside is told +//! in the box « Messages ». +//! +//! **The simulation**: while the chain is closed (no `LD_VOTE_ACCOUNT`), a group +//! of the chain votes anyway, without a wallet — through the same buffer: a +//! sealed ballot, simulated, proven at once by the simulated confirmer, for the +//! identity of the session. use crate::AppState; use crate::adoption::{self, Entered}; @@ -28,6 +43,7 @@ use crate::auth::{IdentityRow, User}; use crate::docs::{self, DocState}; use crate::error::{AppError, AppResult}; use crate::indexer::{self, Found, Reading}; +use crate::messages::{self, Topic}; use crate::util; use axum::Json; use axum::extract::{Path, State}; @@ -35,10 +51,13 @@ use axum::http::{HeaderValue, header}; use axum::response::{IntoResponse, Response}; use ld_engine::address; use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict}; +use ld_engine::buffer::{self, Outcome, Proven, Stage}; use rand::RngCore; use serde::Deserialize; use serde_json::json; +use sqlx::SqliteConnection; use std::collections::{HashMap, HashSet}; +use std::sync::atomic::Ordering; use std::time::Duration; use tokio::sync::Mutex; use trustin_access::VoteWay; @@ -167,7 +186,62 @@ pub(crate) async fn write_server_votes(state: &AppState, identity: &str, casts: Ok(()) } -/// `POST /api/docs/{id}/votes` — a grouped vote in a managed group: one write. +/// How a vote of this session is written by the server in a group now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServerWay { + /// A managed group: the server is the authority, the vote is recorded at once. + Direct, + /// A group of the chain while the chain is closed: a simulated ballot, proven + /// by the simulated confirmer (ADR 0012). + Simulated, +} + +/// Where the vote goes through the chain and the chain is open, never on the +/// server (`409 chain_only`): a ballot to sign. +pub(crate) fn server_way(state: &AppState, group: &docs::GroupRow) -> AppResult { + match trustin_access::vote_way(&group.to_group()) { + VoteWay::Server => Ok(ServerWay::Direct), + VoteWay::Chain if state.chain().is_some() => Err(AppError::Conflict("chain_only")), + VoteWay::Chain => Ok(ServerWay::Simulated), + } +} + +/// May this session write its vote on the server, this way? trustinWallet's rules: +/// `votes_in` (a managed group), `casts_simulated` (the simulation). +pub(crate) async fn may_write( + state: &AppState, + user: &IdentityRow, + group: &docs::GroupRow, + way: ServerWay, +) -> AppResult { + let granted = docs::granted(state, &group.slug, &user.id).await?; + let (id, g) = (user.to_identity(), group.to_group()); + Ok(match way { + ServerWay::Direct => trustin_access::votes_in(&id, &g, granted), + ServerWay::Simulated => trustin_access::casts_simulated(&id, &g, granted, state.chain().is_some()), + }) +} + +/// Writes the votes of a session on the server — directly, or in simulation — and +/// runs the adoption pass. Returns what entered into force. +pub(crate) async fn write( + state: &AppState, + user: &IdentityRow, + st: &DocState, + way: ServerWay, + casts: &[Cast], +) -> AppResult> { + match way { + ServerWay::Direct => { + write_server_votes(state, &user.id, casts).await?; + adoption::run(state, &st.doc.id).await + } + ServerWay::Simulated => simulate(state, &user.id, st, casts).await, + } +} + +/// `POST /api/docs/{id}/votes` — a grouped vote written by the server: one write +/// in a managed group, one simulated ballot while the chain is closed. pub async fn vote_many( State(state): State, User(user): User, @@ -176,16 +250,87 @@ pub async fn vote_many( ) -> AppResult> { let st = docs::load_state(&state, &doc_id).await?; docs::require_open(&state, &user, &st.group).await?; - if trustin_access::vote_way(&st.group.to_group()) == VoteWay::Chain { - return Err(AppError::Conflict("chain_only")); - } - if !docs::may_vote(&state, &user, &st.group).await? { + let way = server_way(&state, &st.group)?; + if !may_write(&state, &user, &st.group, way).await? { return Err(AppError::Forbidden); } let casts = casts(&st, &b.lines)?; - write_server_votes(&state, &user.id, &casts).await?; - let entered = adoption::run(&state, &st.doc.id).await?; - Ok(Json(json!({ "count": casts.len(), "entered": entered }))) + let entered = write(&state, &user, &st, way, &casts).await?; + Ok(Json(json!({ "count": casts.len(), "entered": entered, "simulated": way == ServerWay::Simulated }))) +} + +/// The simulated confirmer (ADR 0012): while the chain is closed, a vote goes +/// through the same buffer as a vote of the chain — a sealed ballot, simulated +/// (no account, no amount, no wallet), proven at once for the identity of the +/// session, at block 0, then reconciled. Returns what entered into force. +pub(crate) async fn simulate( + state: &AppState, + requester: &str, + st: &DocState, + casts: &[Cast], +) -> AppResult> { + let lines: Vec = casts + .iter() + .map(|c| ballot::Line { + proposal_id: c.proposal_id.clone(), + revision: u32::try_from(c.revision).unwrap_or(u32::MAX), + choice: c.choice, + }) + .collect(); + let salt = salt(); + let sealed = ballot::seal(&st.doc.id, None, &lines, &salt).map_err(|_| AppError::Invalid("lines"))?; + let id = util::id(); + let mut tx = state.db.begin().await?; + sqlx::query( + "INSERT INTO ballots (id, doc_id, requested_by, author, salt, digest, remark_hash, account, amount, status, + created_at, expires_at, mode) + VALUES (?, ?, ?, NULL, ?, ?, ?, '', 0, 'pending', ?, ?, 'simulated')", + ) + .bind(&id) + .bind(&st.doc.id) + .bind(requester) + .bind(&salt) + .bind(&sealed.digest) + .bind(&sealed.remark_hash) + .bind(util::now()) + .bind(util::in_seconds(EXPIRES_HOURS * 3600)) + .execute(&mut *tx) + .await?; + for c in casts { + sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES (?, ?, ?, ?)") + .bind(&id) + .bind(&c.proposal_id) + .bind(c.revision) + .bind(c.choice.as_str()) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + let row = load(state, &id).await?; + settle_simulated(state, &row).await +} + +/// Proves a simulated ballot: the voice of the session that asked for it. +pub async fn settle_simulated(state: &AppState, row: &BallotRow) -> AppResult> { + settle(state, row, Proof { voter: row.requested_by.clone(), position: None, found: None }).await +} + +#[derive(Deserialize)] +pub struct DevChainBody { + open: bool, +} + +/// `POST /api/dev/vote-chain` — tests only, with the dev login: closes (or opens +/// again) the chain of this server, to try the simulation (ADR 0012). +pub async fn dev_chain( + State(state): State, + Json(b): Json, +) -> AppResult> { + if !state.cfg.dev_login { + return Err(AppError::NotFound); + } + state.chain_closed.store(!b.open, Ordering::Relaxed); + Ok(Json(json!({ "open": state.chain().is_some() }))) } // --------------------------------------------------------------------------- @@ -210,15 +355,34 @@ pub struct BallotRow { pub created_at: String, pub expires_at: String, pub checked_at: Option, + /// `chain`, or `simulated` (ADR 0012); read as `chain` when absent. + #[sqlx(default)] + pub mode: String, + #[sqlx(default)] + pub settled_at: Option, } const BALLOT_SELECT: &str = "SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status, - reason, voter_id, block, entered, created_at, expires_at, checked_at FROM ballots"; + reason, voter_id, block, entered, created_at, expires_at, checked_at, mode, settled_at FROM ballots"; impl BallotRow { pub fn comment(&self) -> String { format!("{}{}", ballot::PREFIX, self.digest) } + + /// A ballot of the simulation, proven by the simulated confirmer. + pub fn simulated(&self) -> bool { + self.mode == "simulated" + } + + pub fn mode(&self) -> &'static str { + if self.simulated() { "simulated" } else { "chain" } + } + + /// Where it sits on the chain: never for a simulated ballot (block 0 orders it only). + pub fn block_shown(&self) -> Option { + self.block.filter(|_| !self.simulated()) + } } fn salt() -> String { @@ -244,7 +408,7 @@ pub async fn create( if !trustin_access::asks_ballot(&user.to_identity(), &group, granted) { return Err(AppError::Forbidden); } - let chain = state.cfg.vote.as_ref().ok_or(AppError::Conflict("chain_vote_off"))?; + let chain = state.chain().ok_or(AppError::Conflict("chain_vote_off"))?; let casts = casts(&st, &b.lines)?; let author = match b.author.as_deref().map(str::trim).filter(|a| !a.is_empty()) { Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?), @@ -252,7 +416,7 @@ pub async fn create( }; // Pending, or let go within the hour: letting a ballot go does not free its place at once. let held: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ballots WHERE requested_by = ? + "SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND mode = 'chain' AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))", ) .bind(&user.id) @@ -329,7 +493,9 @@ pub async fn status( if row.requested_by != user.id { return Err(AppError::NotFound); } - if row.status == "pending" && row.checked_at.as_ref().is_none_or(|c| *c < util::in_seconds(-RECHECK_SECS)) { + if row.status == "pending" + && (row.simulated() || row.checked_at.as_ref().is_none_or(|c| *c < util::in_seconds(-RECHECK_SECS))) + { check(&state, &row).await?; row = load(&state, &id).await?; } @@ -386,9 +552,10 @@ pub async fn mine(State(state): State, User(user): User) -> AppResult< lines: i64, created_at: String, settled_at: Option, + mode: String, } let rows = sqlx::query_as::<_, Row>(&format!( - "SELECT b.id, b.doc_id, d.group_slug, d.slug, d.title, b.digest, b.status, b.reason, b.block, + "SELECT b.id, b.doc_id, d.group_slug, d.slug, d.title, b.digest, b.status, b.reason, b.block, b.mode, i.display_name AS voter, (SELECT COUNT(*) FROM ballot_lines l WHERE l.ballot_id = b.id) AS lines, b.created_at, b.settled_at FROM ballots b JOIN documents d ON d.id = b.doc_id LEFT JOIN identities i ON i.id = b.voter_id @@ -404,7 +571,9 @@ pub async fn mine(State(state): State, User(user): User) -> AppResult< json!({ "id": r.id, "doc": { "id": r.doc_id, "group": r.group_slug, "slug": r.slug, "title": title_json(&r.title) }, "comment": format!("{}{}", ballot::PREFIX, r.digest), "status": r.status, "reason": r.reason, - "block": r.block, "voter": r.voter, "lines": r.lines, "createdAt": r.created_at, "settledAt": r.settled_at, + "block": r.block.filter(|_| r.mode != "simulated"), "voter": r.voter, "lines": r.lines, + "createdAt": r.created_at, "settledAt": r.settled_at, + "mode": if r.mode == "simulated" { "simulated" } else { "chain" }, }) }) .collect(); @@ -495,7 +664,8 @@ pub async fn receipt(State(state): State, User(user): User, Path(id): "amount": row.amount, "status": row.status, "reason": row.reason, - "block": row.block, + "mode": row.mode(), + "block": row.block_shown(), "event": extra.event_id, "signer": extra.signer, "voter": voter, @@ -527,14 +697,17 @@ async fn view(state: &AppState, row: &BallotRow) -> AppResult row.entered.as_deref().and_then(|e| serde_json::from_str(e).ok()).unwrap_or_else(|| json!([])); Ok(json!({ "id": row.id, "status": row.status, "reason": row.reason, "comment": row.comment(), - "account": row.account, "amount": row.amount, "author": row.author, "block": row.block, - "voter": voter, "entered": entered, "expiresAt": row.expires_at, + "account": row.account, "amount": row.amount, "author": row.author, "block": row.block_shown(), + "voter": voter, "entered": entered, "expiresAt": row.expires_at, "mode": row.mode(), })) } /// Reads the indexer for one pending ballot and settles it if the chain has spoken /// — in a final block. At its end, a ballot is read one last time, then let go. pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> { + if row.simulated() { + return settle_simulated(state, row).await.map(|_| ()); + } let now = util::now(); let due = row.expires_at <= now; let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS); @@ -576,7 +749,12 @@ pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> { match verdict { // The right to vote is checked now, at confirmation, by trustinWallet's rule. Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => { - confirm(state, row, found, identity.index).await + let proof = Proof { + voter: format!("idty:{}", identity.index), + position: Some(found.comment.position), + found: Some(found), + }; + settle(state, row, proof).await.map(|_| ()) } Verdict::Paid { .. } => refuse(state, row, found, "not_member").await, other => refuse(state, row, found, other.reason().unwrap_or("refused")).await, @@ -599,153 +777,257 @@ async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str) .await?; if done.rows_affected() == 1 { tracing::info!(ballot = %row.id, reason, "ballot refused"); + tell(state, &row.id, &row.requested_by, &row.doc_id, "ballot_refused").await?; } Ok(()) } -/// The ballot counts: written for the identity of the signer, line by line, -/// where no more recent ballot of that identity decided already. A ballot sealed -/// before an objection counts as it was signed — a *for* too, as a vote already -/// cast —, and the version held back still cannot enter (ADR 0010). -async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> { - let voter = format!("idty:{index}"); - let position = found.comment.position; +/// Tells the session that asked for a ballot what became of it (ADR 0012): a +/// message of its box, without gesture — one votes again in the text. +async fn tell(state: &AppState, ballot_id: &str, requester: &str, doc_id: &str, kind: &str) -> AppResult<()> { + messages::notify(state, requester, doc_id, Topic::Ballot, ballot_id, kind, 1, None).await +} + +/// The proof of a ballot: whose voice it is, and where it sits on the chain. +pub struct Proof<'a> { + /// `idty:` of the signer; for a simulated ballot, the identity of the + /// session that asked (there is no wallet). + pub voter: String, + /// `None`: a simulated ballot — block 0, at the next rank of the simulation. + pub position: Option, + /// What the chain said of the comment (account, name, event); none in simulation. + pub found: Option<&'a Found>, +} + +/// The ballot is proven: confirmed for its voter, then each of its versions is +/// reconciled (`reconcile`). A *for* proven while an objection holds its +/// version back is set aside, and told (ADR 0012, revising 0010: it no longer +/// counts « as it was signed »). Proving twice changes nothing. Returns what +/// entered into force. +async fn settle(state: &AppState, row: &BallotRow, proof: Proof<'_>) -> AppResult> { let now = util::now(); - { + // The versions an objection holds back now (read before the transaction: one writer). + let held = docs::load_state(state, &row.doc_id).await?.blocked; + let mut set_aside = false; + let position = { let _one = CONFIRM.lock().await; let mut tx = state.db.begin().await?; - // The identity of the signer, as the chain has it now: a member (just - // checked), with its name and its account. Undone with the rest if the - // ballot was settled in the meantime. - let admin = state.cfg.admin_g1_ids.contains(&voter); - sqlx::query( - "INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at) - VALUES (?, 'g1', ?, ?, 1, ?, ?) - ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address, - g1_member = 1, admin = MAX(identities.admin, excluded.admin)", - ) - .bind(&voter) - .bind(found.name.clone().unwrap_or_else(|| voter.clone())) - .bind(&found.signer) - .bind(admin) - .bind(&now) - .execute(&mut *tx) - .await?; + if let Some(found) = proof.found { + // The identity of the signer, as the chain has it now: a member (just + // checked), with its name and its account. Undone with the rest if the + // ballot was settled in the meantime. + let admin = state.cfg.admin_g1_ids.contains(&proof.voter); + sqlx::query( + "INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at) + VALUES (?, 'g1', ?, ?, 1, ?, ?) + ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address, + g1_member = 1, admin = MAX(identities.admin, excluded.admin)", + ) + .bind(&proof.voter) + .bind(found.name.clone().unwrap_or_else(|| proof.voter.clone())) + .bind(&found.signer) + .bind(admin) + .bind(&now) + .execute(&mut *tx) + .await?; + } + let position = match proof.position { + Some(p) => p, + None => { + let last: i64 = sqlx::query_scalar( + "SELECT IFNULL(MAX(event_index), 0) FROM ballots WHERE mode = 'simulated' AND status = 'confirmed'", + ) + .fetch_one(&mut *tx) + .await?; + ChainPosition { block: 0, event: u32::try_from(last.saturating_add(1)).unwrap_or(u32::MAX) } + } + }; let won = sqlx::query( "UPDATE ballots SET status = 'confirmed', voter_id = ?, signer = ?, block = ?, event_index = ?, event_id = ?, settled_at = ? WHERE id = ? AND status = 'pending'", ) - .bind(&voter) - .bind(&found.signer) + .bind(&proof.voter) + .bind(proof.found.map(|f| &f.signer)) .bind(i64::from(position.block)) .bind(i64::from(position.event)) - .bind(&found.event_id) + .bind(proof.found.map(|f| &f.event_id)) .bind(&now) .bind(&row.id) .execute(&mut *tx) .await?; if won.rows_affected() != 1 { - // Settled in the meantime: confirming twice changes nothing. + // Settled in the meantime: proving twice changes nothing. tx.rollback().await?; - return Ok(()); + return Ok(Vec::new()); } - let lines: Vec<(String, i64, String)> = - sqlx::query_as("SELECT proposal_id, revision, choice FROM ballot_lines WHERE ballot_id = ?") + let lines: Vec<(String, String)> = + sqlx::query_as("SELECT proposal_id, choice FROM ballot_lines WHERE ballot_id = ?") .bind(&row.id) .fetch_all(&mut *tx) .await?; - for (proposal_id, revision, choice) in lines { - let others: Vec<(i64, i64)> = sqlx::query_as( - "SELECT b.block, b.event_index FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id - WHERE b.status = 'confirmed' AND b.voter_id = ? AND l.proposal_id = ? AND b.id <> ? - AND b.block IS NOT NULL AND b.event_index IS NOT NULL", - ) - .bind(&voter) - .bind(&proposal_id) - .bind(&row.id) - .fetch_all(&mut *tx) - .await?; - let others: Vec = others - .into_iter() - .filter_map(|(b, e)| { - Some(ChainPosition { block: u32::try_from(b).ok()?, event: u32::try_from(e).ok()? }) - }) - .collect(); - if !ballot::decides(position, &others) { + for (proposal_id, choice) in &lines { + let Some(choice) = Choice::parse(choice) else { + tracing::warn!(ballot = %row.id, %choice, "unknown choice in a ballot line"); continue; - } - match Choice::parse(&choice) { - Some(Choice::None) => { - sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?") - .bind(&proposal_id) - .bind(&voter) - .execute(&mut *tx) - .await?; - } - Some(c) => { - sqlx::query( - "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id) - VALUES (?, ?, ?, ?, ?, 'chain', ?, ?, ?) - ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice, - revision = excluded.revision, cast_at = excluded.cast_at, source = 'chain', - block = excluded.block, event_index = excluded.event_index, ballot_id = excluded.ballot_id", - ) - .bind(&proposal_id) - .bind(&voter) - .bind(c.as_str()) - .bind(revision) - .bind(&now) - .bind(i64::from(position.block)) - .bind(i64::from(position.event)) + }; + if buffer::discarded_by_objection(choice, held.contains(proposal_id)) { + sqlx::query("UPDATE ballot_lines SET outcome = ? WHERE ballot_id = ? AND proposal_id = ?") + .bind(Outcome::Objection.as_str()) .bind(&row.id) + .bind(proposal_id) .execute(&mut *tx) .await?; - } - None => tracing::warn!(ballot = %row.id, %choice, "unknown choice in a ballot line"), + set_aside = true; } } + for (proposal_id, _) in &lines { + reconcile(&mut tx, &proof.voter, proposal_id, &now).await?; + } tx.commit().await?; - } - tracing::info!(ballot = %row.id, block = position.block, event = position.event, "ballot confirmed"); + position + }; + tracing::info!(ballot = %row.id, mode = row.mode(), block = position.block, event = position.event, "ballot proven"); let entered: Vec = adoption::run(state, &row.doc_id).await?; sqlx::query("UPDATE ballots SET entered = ? WHERE id = ?") .bind(serde_json::to_string(&entered).unwrap_or_else(|_| "[]".into())) .bind(&row.id) .execute(&state.db) .await?; + if set_aside { + tell(state, &row.id, &row.requested_by, &row.doc_id, "ballot_objection").await?; + } + Ok(entered) +} + +/// The reconciliation of one identity on one version (ADR 0012): every proven +/// line of it, read again; one voice, the last in the order of the chain +/// (`ld_engine::buffer::reconcile`), whatever the order the proofs came in. Each +/// line is told what became of it; the voice that stands is written in `votes` +/// — or, every line set aside, the vote of before is left as it is. +pub async fn reconcile(conn: &mut SqliteConnection, voter: &str, proposal_id: &str, now: &str) -> AppResult<()> { + #[derive(sqlx::FromRow)] + struct Row { + ballot_id: String, + mode: String, + block: Option, + event_index: Option, + revision: i64, + choice: String, + outcome: Option, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT b.id AS ballot_id, b.mode, b.block, b.event_index, l.revision, l.choice, l.outcome + FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id + WHERE b.status = 'confirmed' AND b.voter_id = ? AND l.proposal_id = ? + ORDER BY b.block, b.event_index, b.created_at, b.rowid", + ) + .bind(voter) + .bind(proposal_id) + .fetch_all(&mut *conn) + .await?; + let mut kept: Vec<(&Row, Proven)> = Vec::with_capacity(rows.len()); + for r in &rows { + let position = r + .block + .zip(r.event_index) + .and_then(|(b, e)| Some(ChainPosition { block: u32::try_from(b).ok()?, event: u32::try_from(e).ok()? })); + let (Some(position), Some(choice)) = (position, Choice::parse(&r.choice)) else { + tracing::warn!(ballot = %r.ballot_id, "a proven line that does not read back"); + continue; + }; + let held = r.outcome.as_deref() == Some(Outcome::Objection.as_str()); + kept.push((r, Proven { position, choice, held })); + } + let lines: Vec = kept.iter().map(|(_, p)| *p).collect(); + let done = buffer::reconcile(&lines); + for ((r, _), outcome) in kept.iter().zip(&done.outcomes) { + if r.outcome.as_deref() != Some(outcome.as_str()) { + sqlx::query("UPDATE ballot_lines SET outcome = ? WHERE ballot_id = ? AND proposal_id = ?") + .bind(outcome.as_str()) + .bind(&r.ballot_id) + .bind(proposal_id) + .execute(&mut *conn) + .await?; + } + } + let Some((r, line)) = done.standing.and_then(|i| kept.get(i)) else { return Ok(()) }; + match line.choice { + Choice::None => { + sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?") + .bind(proposal_id) + .bind(voter) + .execute(&mut *conn) + .await?; + } + c => { + // Only when it changes: the same ballot standing again keeps its row as it is. + sqlx::query( + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice, + revision = excluded.revision, cast_at = excluded.cast_at, source = excluded.source, + block = excluded.block, event_index = excluded.event_index, ballot_id = excluded.ballot_id + WHERE votes.ballot_id IS NOT excluded.ballot_id", + ) + .bind(proposal_id) + .bind(voter) + .bind(c.as_str()) + .bind(r.revision) + .bind(now) + .bind(if r.mode == "simulated" { "simulated" } else { "chain" }) + .bind(i64::from(line.position.block)) + .bind(i64::from(line.position.event)) + .bind(&r.ballot_id) + .execute(&mut *conn) + .await?; + } + } Ok(()) } /// Lets go of one pending ballot not seen on the chain in time. async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> { - sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'") + let done = sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'") .bind(util::now()) .bind(&row.id) .execute(&state.db) .await?; + if done.rows_affected() == 1 { + tell(state, &row.id, &row.requested_by, &row.doc_id, "ballot_expired").await?; + } Ok(()) } /// Lets go of the pending ballots past their end and the grace after it: those /// the indexer could not be read for in time. pub async fn expire(state: &AppState) -> AppResult { - let done = sqlx::query( - "UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?", + let gone: Vec<(String, String, String)> = sqlx::query_as( + "UPDATE ballots SET status = 'expired', settled_at = ? + WHERE status = 'pending' AND mode = 'chain' AND expires_at <= ? + RETURNING id, requested_by, doc_id", ) .bind(util::now()) .bind(util::in_seconds(-GRACE_SECS)) - .execute(&state.db) + .fetch_all(&state.db) .await?; - Ok(done.rows_affected()) + for (id, requester, doc_id) in &gone { + tell(state, id, requester, doc_id, "ballot_expired").await?; + } + Ok(u64::try_from(gone.len()).unwrap_or(u64::MAX)) } -/// Purges the ballots let go long enough ago, lines and all. A ballot let go -/// never counted: nothing of a vote is lost. +/// Purges the ballots let go long enough ago, lines and all, and the messages +/// that told of them. A ballot let go never counted: nothing of a vote is lost. pub async fn purge(state: &AppState) -> AppResult { const WHICH: &str = "SELECT id FROM ballots WHERE status = 'expired' AND settled_at IS NOT NULL AND ((reason = 'dropped' AND settled_at < ?) OR settled_at < ?)"; let (dropped, expired) = (util::in_seconds(-PURGE_DROPPED_SECS), util::in_seconds(-PURGE_EXPIRED_SECS)); let mut tx = state.db.begin().await?; + sqlx::query(&format!("DELETE FROM messages WHERE topic = 'ballot' AND subject_id IN ({WHICH})")) + .bind(&dropped) + .bind(&expired) + .execute(&mut *tx) + .await?; sqlx::query(&format!("DELETE FROM ballot_lines WHERE ballot_id IN ({WHICH})")) .bind(&dropped) .bind(&expired) @@ -766,8 +1048,19 @@ pub async fn purge(state: &AppState) -> AppResult { pub async fn sweep(state: &AppState) -> AppResult { expire(state).await?; purge(state).await?; + // The simulated confirmer: a simulated ballot is proven in its own request; one + // left waiting (a stop in between) is proven here. + let simulated = + sqlx::query_as::<_, BallotRow>(&format!("{BALLOT_SELECT} WHERE status = 'pending' AND mode = 'simulated'")) + .fetch_all(&state.db) + .await?; + for row in &simulated { + if let Err(e) = settle_simulated(state, row).await { + tracing::warn!(ballot = %row.id, error = %e, "simulated ballot"); + } + } let rows = sqlx::query_as::<_, BallotRow>(&format!( - "{BALLOT_SELECT} WHERE status = 'pending' + "{BALLOT_SELECT} WHERE status = 'pending' AND mode = 'chain' ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}" )) .fetch_all(&state.db) @@ -823,8 +1116,9 @@ pub async fn my_votes(state: &AppState, me: &str, doc_id: &str) -> AppResult, pub choice: String, + /// The revision the line votes. + #[serde(skip)] + pub revision: i64, } /// Per version: the most recent pending ballot of `me` asked after its last -/// confirmed one; else its most recent ballot if that one was refused or let go -/// (a confirmed one shows as its vote). A ballot the wallet never signed -/// (dropped) says nothing; one asked before a confirmed vote says nothing either. +/// confirmed one; else its most recent ballot if that one was refused or let go, +/// or proven with its line set aside by an objection (a confirmed one shows as its +/// vote). A ballot the wallet never signed (dropped) says nothing; one asked +/// before a confirmed vote says nothing either. pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult> { #[derive(sqlx::FromRow)] struct Row { @@ -846,9 +1144,11 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult, choice: String, + revision: i64, + outcome: Option, } let rows = sqlx::query_as::<_, Row>( - "SELECT l.proposal_id, b.id, b.status, b.reason, l.choice FROM ballots b + "SELECT l.proposal_id, b.id, b.status, b.reason, l.choice, l.revision, l.outcome FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id WHERE b.doc_id = ? AND b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped' ORDER BY b.created_at, b.rowid", @@ -860,7 +1160,14 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult = HashMap::new(); let mut pending: HashMap = HashMap::new(); for r in rows { - let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice }; + let set_aside = r.status == "confirmed" && r.outcome.as_deref() == Some(Outcome::Objection.as_str()); + let b = MyBallot { + id: r.id, + status: if set_aside { "refused".into() } else { r.status }, + reason: if set_aside { Some(Outcome::Objection.as_str().into()) } else { r.reason }, + choice: r.choice, + revision: r.revision, + }; match b.status.as_str() { // A confirmed vote: what was asked before it says nothing more. "confirmed" => { @@ -871,6 +1178,9 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult { + if set_aside { + pending.remove(&r.proposal_id); + } latest.insert(r.proposal_id, b); } } @@ -879,29 +1189,140 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult AppResult> { + #[derive(sqlx::FromRow)] + struct Row { + requested_by: String, + proposal_id: String, + revision: i64, + choice: String, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT b.requested_by, l.proposal_id, l.revision, l.choice FROM ballots b + JOIN ballot_lines l ON l.ballot_id = b.id + WHERE b.doc_id = ? AND b.status = 'pending' AND b.mode = 'chain' + AND NOT EXISTS (SELECT 1 FROM ballots c JOIN ballot_lines cl ON cl.ballot_id = c.id + WHERE c.requested_by = b.requested_by AND cl.proposal_id = l.proposal_id + AND c.status = 'confirmed' + AND (c.created_at > b.created_at OR (c.created_at = b.created_at AND c.rowid > b.rowid))) + ORDER BY b.created_at, b.rowid", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let mut latest: HashMap<(String, String), Intent> = HashMap::new(); + for r in rows { + let Some(choice) = Choice::parse(&r.choice) else { continue }; + let key = (r.requested_by.clone(), r.proposal_id.clone()); + latest.insert( + key, + Intent { requester: r.requested_by, proposal_id: r.proposal_id, revision: r.revision, choice }, + ); + } + Ok(latest.into_values().collect()) +} + +/// « Ton vote » as the person sees it now (ADR 0012): its latest gesture — an +/// intention still waiting for the chain — or the vote that counts for it. +pub struct Seen { + pub choice: Option, + /// `recorded`, `pending`, `simulated`, or none. + pub state: Option<&'static str>, + /// It votes a revision gone by: « to reconfirm ». + pub stale: bool, + /// Proven on the chain. + pub chain: bool, +} + +pub fn seen(revision: i64, vote: Option<&MyVote>, ballot: Option<&MyBallot>) -> Seen { + if let Some(b) = ballot.filter(|b| b.status == "pending") { + let choice = (b.choice != Choice::None.as_str()).then(|| b.choice.clone()); + return Seen { + stale: choice.is_some() && b.revision != revision, + choice, + state: Some(Stage::Pending.as_str()), + chain: false, + }; + } + match vote { + Some(v) => Seen { + choice: Some(v.choice.clone()), + state: Some(Stage::of_source(&v.source).as_str()), + stale: v.revision != revision, + chain: v.source == "chain", + }, + None => Seen { choice: None, state: None, stale: false, chain: false }, + } +} + +/// A ballot, for a message of the box of the session that asked for it (ADR +/// 0012): what became of it, line by line. No gesture: one votes again in the text. +pub async fn for_message(state: &AppState, viewer: &IdentityRow, id: &str) -> AppResult> { + #[derive(sqlx::FromRow)] + struct LineRow { + proposal_id: String, + unit_id: String, + code: String, + title: String, + choice: String, + outcome: Option, + } + let row = sqlx::query_as::<_, BallotRow>(&format!("{BALLOT_SELECT} WHERE id = ?")) + .bind(id) + .fetch_optional(&state.db) + .await?; + let Some(row) = row.filter(|r| r.requested_by == viewer.id) else { return Ok(None) }; + let lines = sqlx::query_as::<_, LineRow>( + "SELECT l.proposal_id, p.unit_id, u.code, p.title, l.choice, l.outcome FROM ballot_lines l + JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id + WHERE l.ballot_id = ? ORDER BY u.position, u.id", + ) + .bind(id) + .fetch_all(&state.db) + .await?; + Ok(Some(json!({ + "id": row.id, "status": row.status, "reason": row.reason, "mode": row.mode(), + "createdAt": row.created_at, "settledAt": row.settled_at, + "lines": lines.iter().map(|l| json!({ + "proposalId": l.proposal_id, "unitId": l.unit_id, "code": l.code, "title": l.title, + "choice": l.choice, "outcome": l.outcome, + })).collect::>(), + }))) +} + /// How this session votes in a group, and whether it may now. pub async fn access(state: &AppState, user: &IdentityRow, group: &docs::GroupRow) -> AppResult { let g = group.to_group(); let granted = docs::granted(state, &g.slug, &user.id).await?; let id = user.to_identity(); let way = trustin_access::vote_way(&g); - let (open, reason) = match way { - VoteWay::Server => { - let open = trustin_access::votes_in(&id, &g, granted); - (open, (!open).then_some("members_only")) - } - VoteWay::Chain => match (&state.cfg.vote, trustin_access::asks_ballot(&id, &g, granted)) { - (None, _) => (false, Some("chain_off")), - (Some(_), false) => (false, Some("members_only")), - (Some(_), true) => (true, None), - }, + // While the chain is closed, a group of the chain votes in simulation: the vote + // leaves the page by a write of the server, marked as simulated (ADR 0012). + let simulated = way == VoteWay::Chain && state.chain().is_none(); + let open = match way { + VoteWay::Server => trustin_access::votes_in(&id, &g, granted), + VoteWay::Chain if simulated => trustin_access::casts_simulated(&id, &g, granted, false), + VoteWay::Chain => trustin_access::asks_ballot(&id, &g, granted), }; - let chain = match (way, &state.cfg.vote) { + let reason = (!open).then_some("members_only"); + let chain = match (way, state.chain()) { (VoteWay::Chain, Some(c)) => json!({ "account": c.account, "amount": c.amount, "nodes": c.nodes, "test": c.test_node.is_some(), "testNode": c.test_node, "expiresHours": EXPIRES_HOURS, }), _ => serde_json::Value::Null, }; - Ok(json!({ "way": way, "open": open, "reason": reason, "chain": chain })) + let leaves = if simulated { VoteWay::Server } else { way }; + Ok(json!({ "way": leaves, "open": open, "simulated": simulated, "reason": reason, "chain": chain })) } diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index cfa1cf5..e0a47e7 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -19,6 +19,17 @@ 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 — 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, + /// Publish a document even while a text, an order, a title or a level of inertia + /// in effect entered by simulated votes does not hold on the proven votes alone + /// (`LD_PUBLISH_SIMULATED`). Off — Yvv, 2026-11-03: « Pas avant des votes + /// prouvés »: in force on the site, never published before the proven votes carry + /// it (ADR 0012, `publish::waits_for_proven_votes`). + pub simulated: bool, } /// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how @@ -73,6 +84,10 @@ pub struct Config { pub build: String, /// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`). pub vote: Option, + /// Whether a simulated vote counts for adoption (ADR 0012): + /// `ld_engine::buffer::SIMULATED_ADOPTS` — Yvv, 2026-11-02: « Il compte, puis + /// s'efface »: it counts until the act « supprime » sets it aside. + pub simulated_adopts: bool, } fn var(name: &str) -> Option { @@ -110,6 +125,8 @@ 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"), + simulated: flag("LD_PUBLISH_SIMULATED"), }); let dev_login = flag("LD_DEV_LOGIN"); let vote = var("LD_VOTE_ACCOUNT").and_then(|account| { @@ -152,6 +169,7 @@ impl Config { wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745), build: var("LD_BUILD").unwrap_or_else(|| "dev".into()), vote, + simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS, data_dir, } } @@ -183,6 +201,7 @@ impl Config { wot_fallback: 5745, build: "test".into(), vote: None, + simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS, } } } diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index af2d971..23327be 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -13,9 +13,10 @@ use crate::util::{self, LangMap}; use axum::Json; use axum::extract::{Path, Query, State}; use ld_engine::ballot::Choice; +use ld_engine::buffer::{self, Rules, Stage}; 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 +197,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 +222,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)] @@ -232,15 +256,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, @@ -265,7 +303,21 @@ pub struct DocState { /// The units that are modules (ADR 0009), by unit id; any other is an article. pub module_kinds: HashMap, pub proposals: Vec, + /// What adoption counts (ADR 0012): recorded votes, and simulated ones by + /// default — never an intention without proof. pub tallies: HashMap, + /// What is recorded and shown: recorded votes and simulated ones, without the + /// intentions (what a published text may say). + pub recorded: HashMap, + /// The totals shown: `recorded`, each intention in place of the vote its + /// session sees as its own (`ld_engine::buffer::weigh_intent`). + pub shown: HashMap, + /// The part of `shown` still waiting for its proof. + pub pending: HashMap, + /// The part of `shown` that comes from the simulation. + pub simulated: HashMap, + /// What each stage of a vote weighs. + pub rules: Rules, /// Versions held back by a standing objection: against or a withdrawal only, never /// a new *for* (`admits`), and never entering into force. pub blocked: HashSet, @@ -280,7 +332,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) @@ -307,7 +360,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) @@ -333,30 +386,70 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { struct TallyRow { proposal_id: String, choice: String, + source: String, n: i64, } let rows = sqlx::query_as::<_, TallyRow>( - "SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id + "SELECT v.proposal_id, v.choice, v.source, COUNT(*) AS n 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 = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice", + WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice, v.source", ) .bind(doc_id) .fetch_all(&state.db) .await?; - let mut tallies: HashMap = HashMap::new(); - for r in rows { - let t = tallies.entry(r.proposal_id).or_default(); - let n = u32::try_from(r.n).unwrap_or(0); - match r.choice.as_str() { - "for" => t.for_votes = n, - "against" => t.against = n, + let rules = state.vote_rules(); + let add = |map: &mut HashMap, id: &str, choice: &str, n: u32| { + let t = map.entry(id.to_owned()).or_default(); + match choice { + "for" => t.for_votes = t.for_votes.saturating_add(n), + "against" => t.against = t.against.saturating_add(n), _ => {} } + }; + let (mut tallies, mut recorded, mut simulated) = (HashMap::new(), HashMap::new(), HashMap::new()); + for r in rows { + let n = u32::try_from(r.n).unwrap_or(0); + let stage = Stage::of_source(&r.source); + if rules.adopts(stage) { + add(&mut tallies, &r.proposal_id, &r.choice, n); + } + if rules.shown(stage) { + add(&mut recorded, &r.proposal_id, &r.choice, n); + if stage == Stage::Simulated { + add(&mut simulated, &r.proposal_id, &r.choice, n); + } + } } // A standing objection holds back the latest arrival of its pair (never a version // in force): no new *for*, and it cannot enter into force (ADR 0010). let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?; + + // The intentions (ADR 0012): seen at once in the totals shown, in place of the + // vote their session sees as its own; never counted for adoption. + let mut shown = recorded.clone(); + let mut pending: HashMap = HashMap::new(); + let mut seen_by: HashMap> = HashMap::new(); + for i in ballots::intents(state, doc_id).await? { + let Some(p) = proposals.iter().find(|p| p.id == i.proposal_id) else { continue }; + if !matches!(p.status.as_str(), "open" | "in_force") { + continue; + } + if !seen_by.contains_key(&i.requester) { + seen_by.insert(i.requester.clone(), ballots::my_votes(state, &i.requester, doc_id).await?); + } + let seen = seen_by + .get(&i.requester) + .and_then(|m| m.get(&p.id)) + .filter(|v| v.revision == p.revision) + .and_then(|v| Choice::parse(&v.choice)); + let w = buffer::weigh_intent(seen, i.choice, i.revision == p.revision, blocked.contains(&p.id)); + let t = shown.entry(p.id.clone()).or_default(); + *t = w.shift.apply(*t); + let q = pending.entry(p.id.clone()).or_default(); + q.for_votes = q.for_votes.saturating_add(w.pending.for_votes); + q.against = q.against.saturating_add(w.pending.against); + } let (electorate, electorate_source) = electorate(state, &group).await?; let measure = participation::stored(state, &group.slug).await?; let participation = Participation::of( @@ -373,6 +466,11 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { module_kinds, proposals, tallies, + recorded, + shown, + pending, + simulated, + rules, blocked, signal_counts, electorate, @@ -383,9 +481,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. @@ -397,18 +511,39 @@ impl DocState { self.tallies.get(id).copied().unwrap_or_default() } - /// The totals shown to people: today the live count, as `tally`. The one - /// place where a publication by steps would plug in — on the chain, who - /// voted and when is public, and live totals tell what (the secret of the - /// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads - /// `tally`; the published texts (`publish.rs`) would follow this one. + /// The totals shown to people: the live count, intentions included (ADR + /// 0012: « je vois ce que j'ai fait sans attendre »). The one place where a + /// publication by steps would plug in — on the chain, who voted and when is + /// public, and live totals tell what (the secret of the vote, review of + /// 74f96be). Adoption reads `tally`, never an intention. pub fn shown_tally(&self, id: &str) -> Tally { - self.tally(id) + self.shown.get(id).copied().unwrap_or_default() + } + + /// The part of `shown_tally` still waiting for its proof. + pub fn pending_tally(&self, id: &str) -> Tally { + self.pending.get(id).copied().unwrap_or_default() + } + + /// The part of `shown_tally` that comes from the simulation. + pub fn simulated_tally(&self, id: &str) -> Tally { + self.simulated.get(id).copied().unwrap_or_default() + } + + /// What is recorded and shown, without the intentions: what a published text says. + pub fn recorded_tally(&self, id: &str) -> Tally { + self.recorded.get(id).copied().unwrap_or_default() + } + + /// The simulated share of what adoption counts for a version (zero when the + /// simulation counts for display only). + pub fn simulated_counted(&self, id: &str) -> Tally { + if self.rules.adopts(Stage::Simulated) { self.simulated_tally(id) } else { Tally::default() } } /// 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. @@ -497,6 +632,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(), @@ -549,13 +685,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. @@ -569,22 +704,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 } @@ -641,6 +790,7 @@ fn proposal_json( me: &str, ) -> serde_json::Value { let (mine, ballot) = mine; + let seen = ballots::seen(p.revision, mine, ballot); let eval = st.shown_evaluation(unit, p); let (kind, target) = match p.kind() { // A module's version says what it holds (ADR 0009). @@ -659,16 +809,19 @@ 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), + "tallyPending": st.pending_tally(&p.id), + "tallySimulated": st.simulated_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), "votable": st.votable(p), "objections": st.signal_counts.get(&p.id).copied().unwrap_or(0), - "myVote": mine.map(|v| v.choice.as_str()), - "myVoteStale": mine.is_some_and(|v| v.revision != p.revision), - "myVoteChain": mine.is_some_and(|v| v.source == "chain"), + "myVote": seen.choice, + "myVoteState": seen.state, + "myVoteStale": seen.stale, + "myVoteChain": seen.chain, "myBallot": ballot, "translations": translations.cloned().unwrap_or_default(), }) @@ -691,6 +844,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() @@ -709,11 +863,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. @@ -743,7 +904,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(), @@ -752,7 +913,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), }))) } @@ -797,10 +958,17 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult crate::publish::waits(state, doc_id).await?, + _ => None, + }; 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 waits.is_some() { "suspended" } else { "pending" }, + "waitsFor": waits.map(crate::publish::Wait::as_str), "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/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..67b9efe 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -4,7 +4,8 @@ //! and runs four background workers: the electorate refresher (size of the Ğ1 //! web of trust), the adoption sweeper (with the deadlines of three days of //! objections and typo fixes), the ballot watcher (votes signed on the Ğ1 chain, -//! confirmed on the indexer) and the publisher (adopted texts pushed to the git +//! confirmed on the indexer, or simulated while the chain is closed — ADR 0012) +//! and the publisher (adopted texts pushed to the git //! repository that client applications read). pub mod access; @@ -28,8 +29,12 @@ 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; +pub mod simulation; pub mod team; pub mod typos; pub mod util; @@ -46,6 +51,7 @@ use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use std::str::FromStr; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tokio::sync::Notify; @@ -62,6 +68,9 @@ pub struct AppState { pub upcoming: Arc, /// The clock of the deadlines of three days (objections, typo fixes); tests move it. pub clock: clock::Clock, + /// Tests only, with the dev login: the chain of this server closed for a while + /// (`POST /api/dev/vote-chain`), to try the simulation (ADR 0012). + pub chain_closed: Arc, } impl AppState { @@ -78,8 +87,20 @@ impl AppState { publish_wake: Arc::new(Notify::new()), upcoming, clock: clock::Clock::default(), + chain_closed: Arc::new(AtomicBool::new(false)), }) } + + /// The vote on the chain, as it is now: `None` while it is closed (no vote + /// account, or closed for a test) — the vote is then simulated (ADR 0012). + pub fn chain(&self) -> Option<&config::VoteChain> { + self.cfg.vote.as_ref().filter(|_| !self.chain_closed.load(Ordering::Relaxed)) + } + + /// What each stage of a vote weighs (ADR 0012). + pub fn vote_rules(&self) -> ld_engine::buffer::Rules { + ld_engine::buffer::Rules { simulated_adopts: self.cfg.simulated_adopts } + } } /// Open (or create) the database and apply migrations. @@ -159,7 +180,9 @@ pub fn api_router() -> Router { .route("/messages/read", post(messages::read_all)) .route("/messages/{id}/read", post(messages::read)) .route("/dev/clock", post(deadlines::dev_clock)) + .route("/dev/vote-chain", post(ballots::dev_chain)) .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/main.rs b/crates/ld-server/src/main.rs index a451e9d..d261465 100644 --- a/crates/ld-server/src/main.rs +++ b/crates/ld-server/src/main.rs @@ -1,6 +1,13 @@ //! libreDecision server — entry point. Everything else lives in the library. +//! +//! `ld-server discard-simulation [--apply]` is the act « supprime » that ends the +//! simulation of the vote (ADR 0012): never run by the server itself. #[tokio::main] async fn main() -> anyhow::Result<()> { - ld_server::run().await + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("discard-simulation") => ld_server::simulation::run_cli(args.iter().any(|a| a == "--apply")).await, + _ => ld_server::run().await, + } } diff --git a/crates/ld-server/src/messages.rs b/crates/ld-server/src/messages.rs index 8941a57..2471f0d 100644 --- a/crates/ld-server/src/messages.rs +++ b/crates/ld-server/src/messages.rs @@ -1,7 +1,8 @@ //! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the //! application, in the menu, with the number of unread messages — no mail. //! -//! A message is about a subject (an objection, a typo fix) and carries the +//! A message is about a subject (an objection, a typo fix, a ballot set aside — +//! ADR 0012) and carries the //! gestures that subject asks of its recipient now — lift or reiterate, //! validate or refuse — and its deadline. A message dealt with offers no //! gesture any more. Its text is told by the interface, in the language of the @@ -12,7 +13,7 @@ use crate::AppState; use crate::auth::{IdentityRow, User}; use crate::error::{AppError, AppResult}; -use crate::{docs, objections, typos, util}; +use crate::{ballots, docs, objections, typos, util}; use axum::Json; use axum::extract::{Path, State}; use serde_json::{Value, json}; @@ -24,6 +25,9 @@ const SHOWN: i64 = 100; pub enum Topic { Objection, Typo, + /// A ballot set aside: never seen on the chain in time, refused, or its *for* + /// proven while an objection held the version back (ADR 0012). + Ballot, } impl Topic { @@ -31,6 +35,7 @@ impl Topic { match self { Self::Objection => "objection", Self::Typo => "typo", + Self::Ballot => "ballot", } } } @@ -168,6 +173,10 @@ async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult (json!({ "typo": v }), g), None => return Ok(None), }, + "ballot" => match ballots::for_message(state, me, &m.subject_id).await? { + Some(v) => (json!({ "ballot": v }), Vec::new()), + None => return Ok(None), + }, _ => return Ok(None), }; let mut out = json!({ diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs index 20ffe8b..b558e2d 100644 --- a/crates/ld-server/src/overview.rs +++ b/crates/ld-server/src/overview.rs @@ -160,10 +160,14 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult 'seed') AS adopted, - EXISTS (SELECT 1 FROM votes v + (EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id AND v.revision = p.revision AND (v.identity_id = ?1 OR v.ballot_id IN - (SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed'))) AS voted + (SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed'))) + -- An intention still waiting for the chain is one's vote already (ADR 0012). + OR EXISTS (SELECT 1 FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id + WHERE b.requested_by = ?1 AND b.status = 'pending' AND b.mode = 'chain' + AND l.proposal_id = p.id AND l.revision = p.revision AND l.choice <> 'none')) AS voted FROM units u JOIN proposals p ON p.unit_id = u.id WHERE u.doc_id = ?2 AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open') ORDER BY u.position, u.id", diff --git a/crates/ld-server/src/participation.rs b/crates/ld-server/src/participation.rs index 77860c0..d404caf 100644 --- a/crates/ld-server/src/participation.rs +++ b/crates/ld-server/src/participation.rs @@ -51,10 +51,13 @@ pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result< JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND p.status IN ('open', 'in_force', 'applied') AND v.revision = p.revision + AND (v.source <> 'simulated' OR ?) GROUP BY v.proposal_id HAVING MAX(v.cast_at) >= ?", ) .bind(group) + // The simulated votes weigh here as they weigh in adoption (ADR 0012). + .bind(state.vote_rules().adopts(ld_engine::buffer::Stage::Simulated)) .bind(since) .fetch_all(&state.db) .await?; diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 62b24f6..aad2276 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") }) @@ -586,6 +588,7 @@ pub async fn detail( let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id); let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id); + let seen = ballots::seen(p.revision, my_vote.as_ref(), my_ballot.as_ref()); let diff = match (&in_force, p.kind.as_str()) { (Some(v), "text") => Some(edit::diff(&v.body, &p.body)), _ => None, @@ -601,12 +604,12 @@ 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", - "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, + "seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(), + "tally": st.shown_tally(&p.id), "tallyPending": st.pending_tally(&p.id), + "tallySimulated": st.simulated_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), - "myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision), - "myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot, + "myVote": seen.choice, "myVoteState": seen.state, "myVoteStale": seen.stale, + "myVoteChain": seen.chain, "myBallot": my_ballot, "translations": tr.get(&p.id).cloned().unwrap_or_default(), }, "unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) }, @@ -620,7 +623,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()), }))) } @@ -633,8 +636,9 @@ pub struct VoteBody { choice: Option, } -/// One vote on the server, in a managed group. Where the vote goes through the -/// chain (Monnaie libre), it is a ballot (`ballots::create`), never this. +/// One vote written by the server: in a managed group, or in simulation while +/// the chain is closed (ADR 0012). Where the vote goes through the chain and the +/// chain is open (Monnaie libre), it is a ballot (`ballots::create`), never this. pub async fn vote( State(state): State, User(user): User, @@ -642,9 +646,7 @@ pub async fn vote( Json(b): Json, ) -> AppResult> { let (p, st) = guarded(&state, &user, &id).await?; - if trustin_access::vote_way(&st.group.to_group()) == trustin_access::VoteWay::Chain { - return Err(AppError::Conflict("chain_only")); - } + let way = ballots::server_way(&state, &st.group)?; if !matches!(p.status.as_str(), "open" | "in_force") { return Err(AppError::Conflict("not_votable")); } @@ -659,13 +661,12 @@ pub async fn vote( if !st.admits(&p, choice) { return Err(AppError::Conflict("objection")); } - if !docs::may_vote(&state, &user, &st.group).await? { + if !ballots::may_write(&state, &user, &st.group, way).await? { return Err(AppError::Conflict("members_only")); } let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice }; - ballots::write_server_votes(&state, &user.id, &[cast]).await?; - let entered = adoption::run(&state, &st.doc.id).await?; - Ok(Json(json!({ "entered": entered }))) + let entered = ballots::write(&state, &user, &st, way, &[cast]).await?; + Ok(Json(json!({ "entered": entered, "simulated": way == ballots::ServerWay::Simulated }))) } // --------------------------------------------------------------------------- diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index c5e58c1..19a4faa 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -83,6 +83,31 @@ 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 let Some(wait) = waits(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 — + // or, waiting for proven votes, it is asked again a while later. + let again = match wait { + Wait::Text => MAX_BACKOFF_SECS, + Wait::Proven => PROVEN_AGAIN_SECS, + }; + sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL") + .bind(util::in_seconds(again)) + .bind(&doc_id) + .execute(&state.db) + .await?; + match wait { + Wait::Text => 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)" + ), + Wait::Proven => tracing::info!( + doc = %doc_id, waiting = rows.len(), + "publication suspended: something in effect entered by simulated votes does not hold on the proven votes (LD_PUBLISH_SIMULATED=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 +134,75 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { Ok(()) } +/// Why a document waits before it is published. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Wait { + /// None of its articles is in force (ADR 0011). + Text, + /// Something in effect entered by simulated votes and does not hold on the + /// proven votes alone (ADR 0012). + Proven, +} + +impl Wait { + /// As the API says it (`publication.waitsFor`). + pub fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Proven => "proven", + } + } +} + +/// Waiting for proven votes, the queue is asked again this often: a proven vote that +/// carries an entry already in force queues nothing by itself. +const PROVEN_AGAIN_SECS: i64 = 600; + +/// Does the document wait before it is published, and for what: a first text +/// (`waits_for_a_text`), then proven votes (`waits_for_proven_votes`). +pub async fn waits(state: &AppState, doc_id: &str) -> anyhow::Result> { + if waits_for_a_text(state, doc_id).await? { + return Ok(Some(Wait::Text)); + } + if waits_for_proven_votes(state, doc_id).await? { + return Ok(Some(Wait::Proven)); + } + Ok(None) +} + +/// Does the document wait for proven votes before it is published? Yvv, 2026-11-03: +/// « Pas avant des votes prouvés ». A text — an order, a title, a level of inertia, a +/// removal — that entered by simulated votes is in force on the site, and sends +/// nothing to the repository of the texts while anything in effect that entered by +/// simulated votes does not hold on the proven votes alone, by the re-examination of +/// the act « supprime » (`simulation::unproven`, told only). The chain being closed, +/// nothing goes out of Monnaie libre before it opens. The one place of this rule; +/// `LD_PUBLISH_SIMULATED=1` publishes anyway (`Publish::simulated`). +pub async fn waits_for_proven_votes(state: &AppState, doc_id: &str) -> anyhow::Result { + if state.cfg.publish.as_ref().is_none_or(|p| p.simulated) { + return Ok(false); + } + let unproven = crate::simulation::unproven(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(!unproven.is_empty()) +} + +/// 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 +/// 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); + } + 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"); @@ -361,7 +455,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result anyhow::Result 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 + -- Every ballot of the buffer, chain or simulated, pending or proven: its history. + 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 ef6d42d..89bb0b3 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -9,18 +9,32 @@ //! 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; 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; +/// 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 »), +/// 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; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SeedDoc { @@ -51,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)] @@ -60,16 +79,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 +139,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?; + quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?; Ok(()) } @@ -130,6 +174,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 +198,31 @@ 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. 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); + } + 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(); @@ -174,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( @@ -185,7 +263,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 +296,10 @@ 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) @@ -260,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/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index d8971b5..efd6894 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -3,7 +3,18 @@ //! 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). +//! +//! Since 2026-11-03 (lot T), a text may be amended twice: F1.1, F1.2 and N1.1 say +//! the quorum by level (ADR 0013) instead of the floor C, and F1.2 and N1.1 had +//! been rewritten by the amendments of 02/11. An amendment that `follows` an +//! earlier one replaces the text that one wrote: it admits, besides the seed's +//! own revision 1, the revision 2 whose history is « initial » then « amendment » +//! and on which that earlier amendment, and it alone, was applied — nothing else. +//! The earlier one is then `superseded_by` it: the seed no longer gives its text, +//! so it decides nothing more, without an error (the decisions already taken stay). //! //! 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: @@ -12,9 +23,11 @@ //! nobody has touched the article**: //! //! - its version in force is the seed's own, a *seed a minima* (never voted -//! into force, never confirmed), at revision 1; -//! - nobody ever voted on it, corrected it, edited one of its translations or -//! named it in a contradiction signal; +//! into force, never confirmed), at revision 1 — or 2, after the amendment it +//! follows; +//! - nobody ever voted on it (a vote, or a line of a ballot of the buffer, pending +//! or proven, whatever became of it: ADR 0012), corrected it, edited one of its +//! translations or named it in a contradiction signal (an objection); //! - its text is exactly the one the amendment replaces (fingerprint). //! //! Each document is decided once, at the first start that knows the @@ -33,21 +46,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. @@ -65,6 +69,12 @@ pub struct Amendment { pub to: &'static str, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, + /// The earlier amendment of the same text whose text this one replaces, if any: + /// its revision 2 is then admitted (see the module). + pub follows: Option<&'static str>, + /// The later amendment whose text the seed gives instead, if any: this one then + /// decides nothing more, without an error. + pub superseded_by: Option<&'static str>, } pub const AMENDMENTS: &[Amendment] = &[ @@ -76,6 +86,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375", to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", reason: "texte de départ remplacé : I1", + follows: None, + superseded_by: None, }, // The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02). Amendment { @@ -86,6 +98,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6", to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60", reason: "proposition de départ réécrite : F1.2", + follows: None, + superseded_by: Some("2026-11-03-f1-2-quorum"), }, Amendment { id: "2026-11-02-n1-regles", @@ -95,6 +109,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142", to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e", reason: "proposition de départ réécrite : N1", + follows: None, + superseded_by: None, }, Amendment { id: "2026-11-02-n1-1-regles", @@ -104,6 +120,8 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee", to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0", reason: "proposition de départ réécrite : N1.1", + follows: None, + superseded_by: Some("2026-11-03-n1-1-quorum"), }, Amendment { id: "2026-11-02-o1-regles", @@ -113,6 +131,44 @@ pub const AMENDMENTS: &[Amendment] = &[ from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642", to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537", reason: "proposition de départ réécrite : O1", + follows: None, + superseded_by: None, + }, + // The articles that still described the floor C, after the quorum by level (ADR 0013; + // Yvv, 2026-11-02 and 03; lot T). F1.1 was never amended; F1.2 and N1.1 replace the + // text the amendments of 02/11 wrote. + Amendment { + id: "2026-11-03-f1-1-quorum", + doc: "acte-engagement-certification", + unit: "F1.1", + version: Version::Open("1.0.0"), + from: "258980be190192fe3c7b32f6319584b682621d01329d880b7056c28c84e9db0a", + to: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa", + reason: "proposition de départ réécrite : F1.1, le quorum par cran", + follows: None, + superseded_by: None, + }, + Amendment { + id: "2026-11-03-f1-2-quorum", + doc: "acte-engagement-certification", + unit: "F1.2", + version: Version::Open("1.0.0"), + from: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60", + to: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9", + reason: "proposition de départ réécrite : F1.2, le quorum par cran", + follows: Some("2026-11-02-f1-2-regles"), + superseded_by: None, + }, + Amendment { + id: "2026-11-03-n1-1-quorum", + doc: "acte-engagement-certification", + unit: "N1.1", + version: Version::Open("1.0.0"), + from: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0", + to: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48", + reason: "proposition de départ réécrite : N1.1, le quorum par cran", + follows: Some("2026-11-02-n1-1-regles"), + superseded_by: None, }, ]; @@ -132,6 +188,10 @@ pub struct Facts { pub revision: i64, /// Votes cast on it, whatever their revision. pub votes: i64, + /// Lines of ballots of the buffer naming it, pending or proven, whatever became of them. + pub ballots: i64, + /// The seed amendments applied on it, in order. + pub amendments: Vec, /// Kinds of its revisions, in order. pub revisions: Vec, /// Translations written or corrected by a member. @@ -162,17 +222,22 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict { if f.fingerprint == a.to { return Verdict::Present; } + // The text the amendment follows: written by it, and by it alone, at revision 2. + let after_earlier = a.follows.is_some_and(|id| f.revision == 2 && f.amendments == [id]); + let history: &[&str] = if after_earlier { &["initial", "amendment"] } else { &["initial"] }; let refusal = if f.origin != "seed" { "not_a_seed" } else if f.entered { "voted_into_force" } else if f.confirmed { "confirmed" - } else if f.revision != 1 { + } else if f.revision != 1 && !after_earlier { "revised" } else if f.votes > 0 { "votes" - } else if f.revisions != ["initial"] || f.typos > 0 { + } else if f.ballots > 0 { + "ballots" + } else if f.revisions != history || f.typos > 0 { "corrected" } else if f.member_translations > 0 { "translation_edited" @@ -232,7 +297,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), }) }); @@ -240,6 +306,15 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?))); match (version, text) { (Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)), + _ if a.superseded_by.is_some() => { + tracing::debug!( + amendment = a.id, + unit = a.unit, + superseded_by = a.superseded_by, + "seed amendment superseded: nothing decided" + ); + None + } _ => { tracing::error!( amendment = a.id, @@ -252,7 +327,12 @@ 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, @@ -295,12 +375,25 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> .bind(proposal_id) .fetch_one(&mut *conn) .await?; + // Every line of a ballot, chain or simulated, whatever its fate: a vote was cast. + let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?") + .bind(proposal_id) + .fetch_one(&mut *conn) + .await?; + let amendments: Vec = sqlx::query_scalar( + "SELECT amendment_id FROM seed_amendments WHERE proposal_id = ? AND outcome = 'applied' ORDER BY at, rowid", + ) + .bind(proposal_id) + .fetch_all(&mut *conn) + .await?; Ok(Some(Facts { origin: p.origin, entered: p.entered_at.is_some(), confirmed: p.confirmed_at.is_some(), revision: p.revision, votes, + ballots, + amendments, revisions, member_translations, signals, @@ -327,35 +420,40 @@ 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) { + let judged = found.as_ref().map_or(1, |f| f.revision); + 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)); @@ -363,21 +461,23 @@ async fn decide( let done = sqlx::query( "UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND status = ? AND origin = 'seed' AND entered_at IS NULL - AND confirmed_at IS NULL AND revision = 1", + AND confirmed_at IS NULL AND revision = ?", ) .bind(&title) .bind(&body) .bind(&now) .bind(pid) - .bind(a.version.status()) + .bind(*status) + .bind(judged) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { sqlx::query( "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) - VALUES (?, 2, 'amendment', ?, ?, NULL, ?)", + VALUES (?, ?, 'amendment', ?, ?, NULL, ?)", ) .bind(pid) + .bind(judged + 1) .bind(&title) .bind(&body) .bind(&now) @@ -448,6 +548,8 @@ mod tests { from: "old", to: "new", reason: "texte de départ remplacé : I1", + follows: None, + superseded_by: None, }; fn intact() -> Facts { @@ -494,5 +596,54 @@ mod tests { let open = Amendment { version: Version::Open("1.0.0"), ..A }; assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version")); assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply); + // A ballot of the buffer names it, pending or proven, whatever became of it (ADR 0012). + assert_eq!(verdict(Some(&Facts { ballots: 1, ..intact() }), &A), Verdict::Refuse("ballots")); + } + + /// The text an earlier amendment wrote, as production holds F1.2 and N1.1 since M5. + fn amended_by(earlier: &str) -> Facts { + Facts { + revision: 2, + revisions: vec!["initial".into(), "amendment".into()], + amendments: vec![earlier.into()], + ..intact() + } + } + + #[test] + fn a_second_amendment_admits_the_text_the_first_wrote_and_nothing_else() { + // Lot T (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2); the quorum + // texts replace them, after that amendment only. + let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A }; + assert_eq!(verdict(Some(&amended_by("m5")), &second), Verdict::Apply); + // A base born with the text of M5: its revision 1, as the seed made it. + assert_eq!(verdict(Some(&intact()), &second), Verdict::Apply); + // Not the amendment it follows, or none: a revision 2 of something else. + assert_eq!(verdict(Some(&amended_by("other")), &second), Verdict::Refuse("revised")); + let typo = Facts { revisions: vec!["initial".into(), "typo".into()], amendments: vec![], ..amended_by("m5") }; + assert_eq!(verdict(Some(&typo), &second), Verdict::Refuse("revised")); + // A history that is not « initial then amendment ». + let odd = Facts { revisions: vec!["initial".into(), "substantive".into()], ..amended_by("m5") }; + assert_eq!(verdict(Some(&odd), &second), Verdict::Refuse("corrected")); + // Further on, nothing is admitted. + let third = Facts { + revision: 3, + revisions: vec!["initial".into(), "amendment".into(), "typo".into()], + ..amended_by("m5") + }; + assert_eq!(verdict(Some(&third), &second), Verdict::Refuse("revised")); + // The same traces of a person keep it. + for (f, why) in [ + (Facts { votes: 1, ..amended_by("m5") }, "votes"), + (Facts { ballots: 2, ..amended_by("m5") }, "ballots"), + (Facts { typos: 1, ..amended_by("m5") }, "corrected"), + (Facts { member_translations: 1, ..amended_by("m5") }, "translation_edited"), + (Facts { signals: 1, ..amended_by("m5") }, "signalled"), + (Facts { fingerprint: "other".into(), ..amended_by("m5") }, "text_differs"), + ] { + assert_eq!(verdict(Some(&f), &second), Verdict::Refuse(why), "{f:?}"); + } + // An amendment that follows none never admits a revision 2. + assert_eq!(verdict(Some(&amended_by("m5")), &A), Verdict::Refuse("revised")); } } 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/src/simulation.rs b/crates/ld-server/src/simulation.rs new file mode 100644 index 0000000..c1bfc73 --- /dev/null +++ b/crates/ld-server/src/simulation.rs @@ -0,0 +1,326 @@ +//! The end of the simulation (ADR 0012). +//! +//! Yvv, 2026-11-02: « Il compte, puis s'efface ». A simulated vote counts like a +//! proven one, marked as simulated, until an act « supprime » of Yvv sets every +//! one of them aside; what they brought into force is then examined again, with +//! the proven votes only. Opening the chain does none of this by itself: the act +//! calls `discard` — `ld-server discard-simulation` tells what it would do, +//! `ld-server discard-simulation --apply` does it. Never run on its own. +//! +//! - **The sort**: the simulated ballots, their lines, their votes and the +//! messages about them are deleted; each identity that voted in simulation is +//! reconciled again, so that a proven vote of its own, if any, stands. +//! - **The re-examination**: every entry into force that counted simulated votes +//! (`adoptions.params.simulated`), the most recent first, still in effect, is +//! judged again on the proven votes alone, at the level it was voted at. Below +//! its threshold, it **returns among the proposals** and the version it had +//! displaced comes back into force (a removed text comes back, a level of +//! inertia goes back, the order and the title of before apply again); its +//! adoption is void. At or above, it stays, and its adoption says so +//! (`params.reexamined`): the act run again examines it no more. +//! +//! Told only (no `--apply`), the re-examination sets the simulated votes apart +//! without removing them: an entry that would return only once a more recent one +//! returned before it is told as no longer in effect. + +use crate::AppState; +use crate::adoption; +use crate::ballots; +use crate::docs::{self, DocState, ProposalRow, UnitRow}; +use crate::error::AppResult; +use crate::modules; +use crate::proposals; +use crate::util; +use ld_engine::inertia::InertiaLevel; +use ld_engine::threshold::Tally; +use serde::Serialize; +use serde_json::Value; + +/// One entry into force that counted simulated votes, judged again. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Reexamined { + pub doc_id: String, + pub proposal_id: String, + pub unit_id: String, + pub code: String, + /// `text`, `remove`, `inertia`, `order` or `title`. + pub kind: String, + pub at: String, + /// The votes it entered with, and the simulated share of them. + pub entered_with: Tally, + pub simulated: Tally, + /// The proven votes it holds now, and the *for* votes its level asks at them. + pub proven: Tally, + pub required: u32, + /// Still in force (or still applied): something else may have taken its place since. + pub in_effect: bool, + /// It holds on the proven votes alone: it stays. + pub holds: bool, + /// Returned among the proposals (`--apply` only). + pub returned: bool, + /// The version it had displaced, back in force when it returns. + pub displaced: Option, +} + +/// What the act does, or would do. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Discarded { + /// Done (`--apply`), or only told. + pub applied: bool, + pub ballots: i64, + pub lines: i64, + pub votes: i64, + pub messages: i64, + pub reexamined: Vec, +} + +#[derive(sqlx::FromRow)] +struct AdoptionRow { + id: i64, + proposal_id: String, + unit_id: String, + doc_id: String, + kind: String, + votes_for: i64, + votes_against: i64, + params: String, + at: String, +} + +fn tally_of(v: &Value) -> Tally { + serde_json::from_value(v.clone()).unwrap_or_default() +} + +fn minus(a: Tally, b: Tally) -> Tally { + Tally { for_votes: a.for_votes.saturating_sub(b.for_votes), against: a.against.saturating_sub(b.against) } +} + +/// Sets every simulated vote aside and examines again what they brought into +/// force — or, `apply` false, tells what it would do and changes nothing. +pub async fn discard(state: &AppState, apply: bool) -> AppResult { + const SIMULATED: &str = "SELECT id FROM ballots WHERE mode = 'simulated'"; + let (ballots_n, lines, votes, messages): (i64, i64, i64, i64) = sqlx::query_as(&format!( + "SELECT (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated'), + (SELECT COUNT(*) FROM ballot_lines WHERE ballot_id IN ({SIMULATED})), + (SELECT COUNT(*) FROM votes WHERE source = 'simulated'), + (SELECT COUNT(*) FROM messages WHERE topic = 'ballot' AND subject_id IN ({SIMULATED}))" + )) + .fetch_one(&state.db) + .await?; + let mut out = Discarded { applied: apply, ballots: ballots_n, lines, votes, messages, reexamined: Vec::new() }; + + if apply { + // The sort: who voted in simulation, on what — reconciled again once their simulated lines are gone. + let pairs: Vec<(String, String)> = sqlx::query_as( + "SELECT DISTINCT b.voter_id, l.proposal_id FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id + WHERE b.mode = 'simulated' AND b.voter_id IS NOT NULL", + ) + .fetch_all(&state.db) + .await?; + let now = util::now(); + let mut tx = state.db.begin().await?; + for sql in [ + format!("DELETE FROM messages WHERE topic = 'ballot' AND subject_id IN ({SIMULATED})"), + "DELETE FROM votes WHERE source = 'simulated'".to_owned(), + format!("DELETE FROM ballot_lines WHERE ballot_id IN ({SIMULATED})"), + "DELETE FROM ballots WHERE mode = 'simulated'".to_owned(), + ] { + sqlx::query(&sql).execute(&mut *tx).await?; + } + for (voter, proposal_id) in &pairs { + ballots::reconcile(&mut tx, voter, proposal_id, &now).await?; + } + tx.commit().await?; + tracing::warn!(ballots = ballots_n, votes, "simulated votes set aside (act « supprime »)"); + } + + // The re-examination, the most recent entry first. + for (a, params, simulated) in to_examine(state, None).await? { + if let Some(r) = reexamine(state, &a, ¶ms, simulated, apply).await? { + out.reexamined.push(r); + } + } + if apply { + // What waited for the proven votes may go out now (`publish::waits_for_proven_votes`). + sqlx::query("UPDATE outbox SET next_at = ? WHERE done_at IS NULL").bind(util::now()).execute(&state.db).await?; + state.publish_wake.notify_one(); + } + Ok(out) +} + +/// The entries into force still to examine, the most recent first: those that +/// counted simulated votes, not yet examined by an act — of one document, or all. +async fn to_examine(state: &AppState, doc_id: Option<&str>) -> AppResult> { + let adoptions = sqlx::query_as::<_, AdoptionRow>( + "SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions + WHERE ?1 IS NULL OR doc_id = ?1 + ORDER BY at DESC, id DESC", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + Ok(adoptions + .into_iter() + .filter_map(|a| { + let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null); + let simulated = tally_of(¶ms["simulated"]); + // Without simulated votes, or examined by an act already: nothing to examine. + (simulated.cast() > 0 && params["reexamined"].is_null()).then_some((a, params, simulated)) + }) + .collect()) +} + +/// What, in effect in a document, entered by simulated votes and does not hold on +/// the proven votes alone: the re-examination of the act, told only — nothing is +/// written. Empty: everything in effect holds on the proven votes. The publication +/// waits on it (Yvv, 2026-11-03: « Pas avant des votes prouvés », +/// `publish::waits_for_proven_votes`). +pub async fn unproven(state: &AppState, doc_id: &str) -> AppResult> { + let mut out = Vec::new(); + for (a, params, simulated) in to_examine(state, Some(doc_id)).await? { + if let Some(r) = reexamine(state, &a, ¶ms, simulated, false).await? + && r.in_effect + && !r.holds + { + out.push(r); + } + } + Ok(out) +} + +async fn reexamine( + state: &AppState, + a: &AdoptionRow, + params: &Value, + simulated: Tally, + apply: bool, +) -> AppResult> { + let st = docs::load_state(state, &a.doc_id).await?; + let Some(unit) = st.units.iter().find(|u| u.id == a.unit_id) else { return Ok(None) }; + let Ok(p) = proposals::load_proposal(state, &a.proposal_id).await else { return Ok(None) }; + let in_effect = match a.kind.as_str() { + "remove" => p.status == "applied" && unit.in_force_id.is_none(), + "inertia" => { + p.status == "applied" + && p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(docs::inertia_of(unit)) + } + _ => unit.in_force_id.as_deref() == Some(p.id.as_str()), + }; + // Applied: the simulated votes are gone. Told only: they still count, set them apart. + let proven = if apply { st.tally(&p.id) } else { minus(st.tally(&p.id), st.simulated_counted(&p.id)) }; + // At the level and on the quorum it was voted at (ADR 0013; an adoption of before + // holds the C of before, `constant`, which is no quorum: the article's quorum now). + let level = + serde_json::from_value::(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p)); + let quorum = params["quorum"].as_u64().and_then(|q| u32::try_from(q).ok()).unwrap_or_else(|| st.quorum_of(unit)); + let eval = st.scale().evaluate(proven, level, quorum).ok(); + let holds = eval.is_some_and(|e| e.reached); + let displaced = params["displaced"].as_str().map(str::to_owned); + let mut r = Reexamined { + doc_id: a.doc_id.clone(), + proposal_id: p.id.clone(), + unit_id: unit.id.clone(), + code: unit.code.clone(), + kind: a.kind.clone(), + at: a.at.clone(), + entered_with: Tally { + for_votes: u32::try_from(a.votes_for).unwrap_or(0), + against: u32::try_from(a.votes_against).unwrap_or(0), + }, + simulated, + proven, + required: eval.map_or(0, |e| e.required), + in_effect, + holds, + returned: false, + displaced: displaced.clone(), + }; + if apply && in_effect && !holds { + give_back(state, &st, unit, &p, a, params, displaced.as_deref()).await?; + r.returned = true; + } else if apply { + // It stays (or something else took its place since): examined once, never again. + let mut kept = params.clone(); + kept["reexamined"] = serde_json::json!({ "at": util::now(), "proven": r.proven, "required": r.required }); + sqlx::query("UPDATE adoptions SET params = ? WHERE id = ?") + .bind(kept.to_string()) + .bind(a.id) + .execute(&state.db) + .await?; + } + Ok(Some(r)) +} + +/// An entry that does not hold on the proven votes: the version returns among the +/// proposals, the one it displaced comes back, and its adoption is void. +async fn give_back( + state: &AppState, + st: &DocState, + unit: &UnitRow, + p: &ProposalRow, + a: &AdoptionRow, + params: &Value, + displaced: Option<&str>, +) -> AppResult<()> { + let now = util::now(); + let back = match displaced { + Some(d) => proposals::load_proposal(state, d).await.ok().filter(|v| v.status == "open"), + None => None, + }; + let mut tx = state.db.begin().await?; + sqlx::query("UPDATE proposals SET status = 'open', entered_at = NULL, left_at = ?, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(&p.id) + .execute(&mut *tx) + .await?; + if a.kind == "inertia" { + let from = serde_json::from_value::(params["from_inertia"].clone()).unwrap_or_default(); + sqlx::query("UPDATE units SET inertia = ? WHERE id = ?") + .bind(from.as_db()) + .bind(&unit.id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM inertia_changes WHERE proposal_id = ? AND cause = 'vote'") + .bind(&p.id) + .execute(&mut *tx) + .await?; + } else { + if let Some(b) = &back { + sqlx::query("UPDATE proposals SET status = 'in_force', left_at = NULL, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&b.id) + .execute(&mut *tx) + .await?; + } + sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") + .bind(back.as_ref().map(|b| &b.id)) + .bind(&unit.id) + .execute(&mut *tx) + .await?; + // A module does more: the order of before moves its articles back, the title of before names the document. + if let (Some(b), Some(_)) = (&back, st.module_of(&unit.id)) { + let leaving = UnitRow { in_force_id: Some(p.id.clone()), ..unit.clone() }; + modules::on_entry(&mut tx, st, &leaving, b).await?; + } + } + sqlx::query("DELETE FROM adoptions WHERE id = ?").bind(a.id).execute(&mut *tx).await?; + tx.commit().await?; + tracing::warn!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, "returned among the proposals: it does not hold on the proven votes"); + adoption::enqueue_publication(state, &st.doc, &format!("votes simulés écartés : {} réexaminé", unit.code)).await?; + Ok(()) +} + +/// `ld-server discard-simulation [--apply]`: the act « supprime » of the end of +/// the simulation (ADR 0012). Without `--apply`, it tells what it would do. +pub async fn run_cli(apply: bool) -> anyhow::Result<()> { + let cfg = crate::Config::from_env(); + let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display()); + let db = crate::open_db(&db_url).await?; + let state = AppState::new(cfg, db).await?; + let done = discard(&state, apply).await.map_err(|e| anyhow::anyhow!("{e}"))?; + println!("{}", serde_json::to_string_pretty(&done)?); + 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/src/wot.rs b/crates/ld-server/src/wot.rs index bb0a36b..3596034 100644 --- a/crates/ld-server/src/wot.rs +++ b/crates/ld-server/src/wot.rs @@ -10,7 +10,8 @@ use crate::util; use serde_json::json; use std::time::Duration; -const REFRESH: Duration = Duration::from_secs(6 * 3600); +/// W is read again this often. +pub const REFRESH: Duration = Duration::from_secs(6 * 3600); const PLAUSIBLE: std::ops::RangeInclusive = 100..=10_000_000; fn twox_128(data: &[u8]) -> [u8; 16] { diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index d6a2c64..e83a155 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) } @@ -61,6 +83,22 @@ async fn doc_id(c: &mut Client, group: &str) -> String { v["docs"][0]["id"].as_str().expect("doc").to_owned() } +/// The quorum of an article set to 2, as its author — an administrator, for a text of the +/// seed — sets it before the first vote (ADR 0013). In the test group, where the quorum +/// of every level is 1 and one voice passes, one voice of two then no longer does. +async fn two_voices(state: &AppState, group: &str, code: &str) { + let n = sqlx::query( + "UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)", + ) + .bind(code) + .bind(group) + .execute(&state.db) + .await + .expect("quorum") + .rows_affected(); + assert_eq!(n, 1, "{group} {code}"); +} + fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } @@ -88,7 +126,7 @@ async fn anonymous_sees_nothing_and_csrf_is_enforced() { } #[tokio::test] -async fn the_seed_a_minima_and_the_open_proposals() { +async fn the_seed_proposals_and_the_open_proposals() { let (mut c, _) = Client::new().await; c.login("Chiara", false).await; let (_, groups) = c.call("GET", "/api/groups", None).await; @@ -99,16 +137,10 @@ async fn the_seed_a_minima_and_the_open_proposals() { let id = doc_id(&mut c, "monnaie-libre").await; let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await; assert_eq!(s, StatusCode::OK); - let in_force: Vec<&str> = doc["units"] - .as_array() - .expect("units") - .iter() - .filter(|u| !u["inForce"].is_null()) - .filter_map(|u| u["code"].as_str()) - .collect(); - assert_eq!(in_force, ["I1", "E1", "E2"]); - let e1 = &unit(&doc, "E1")["inForce"]; - assert_eq!(e1["seedMinimal"], true); + // No article in force: I1, E1 and E2 are seed proposals (ADR 0011), open, put forward. + assert!(doc["units"].as_array().expect("units").iter().all(|u| u["inForce"].is_null())); + let e1 = &unit(&doc, "E1")["proposals"][0]; + assert_eq!((e1["seedProposal"].as_bool(), e1["status"].as_str()), (Some(true), Some("open"))); assert_eq!(e1["tally"]["for"], 0); assert_eq!(doc["doc"]["voteFloor"], 50.0); assert_eq!(doc["doc"]["electorate"], 5745); @@ -119,8 +151,11 @@ async fn the_seed_a_minima_and_the_open_proposals() { let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect(); assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant"); assert_eq!(e3["inertia"], 3); - // The two structural commitments start one level up (Yvv, 2026-10-30). - assert_eq!((unit(&doc, "E1")["inertia"].as_u64(), unit(&doc, "E2")["inertia"].as_u64()), (Some(4), Some(4))); + // The seed proposals start low (Yvv, 2026-11-02). + let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL); + for code in ["I1", "E1", "E2"] { + assert_eq!(unit(&doc, code)["inertia"].as_u64(), Some(low), "{code}"); + } // Translations seeded. assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit")); } @@ -131,15 +166,15 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published( a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; - // Dev identities are members of every group: W(essai) = 2, C = 1. + // Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level + // (ADR 0013): one voice passes in the test group. let id = doc_id(&mut a, "essai").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e3 = unit(&doc, "E3"); + assert_eq!(e3["proposals"][0]["evaluation"]["quorum"], 1); let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned(); let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; - assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough"); - let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; - assert_eq!(v["entered"][0]["code"], "E3"); + assert_eq!(v["entered"][0]["code"], "E3", "one voice in the test group"); let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e3 = unit(&doc, "E3"); assert_eq!(e3["inForce"]["id"], p.as_str()); @@ -155,7 +190,7 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published( #[tokio::test] async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() { - let (mut a, _) = Client::new().await; + let (mut a, _) = Client::production().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; @@ -172,9 +207,11 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() { .await; assert_eq!(s, StatusCode::OK, "{v}"); let rival = v["id"].as_str().expect("id").to_owned(); - a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; - let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; + // The quorum 1 of the test group (ADR 0013): its *for* against nothing replaces the seed. + let (_, v) = a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; assert_eq!(v["entered"][0]["kind"], "text"); + let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; + assert!(v["entered"].as_array().expect("e").is_empty(), "already in force"); let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e1 = unit(&doc, "E1"); assert_eq!(e1["inForce"]["id"], rival.as_str()); @@ -185,7 +222,7 @@ async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() { #[tokio::test] async fn editing_is_not_voting() { - let (mut a, _) = Client::new().await; + let (mut a, state) = Client::production().await; // An administrator of trustinWallet answers for the starting texts (no author). a.login("Yvv", true).await; let mut b = a.with(None); @@ -296,6 +333,8 @@ async fn editing_is_not_voting() { let essai = doc_id(&mut a, "essai").await; let (_, essai_doc) = a.call("GET", &format!("/api/docs/{essai}"), None).await; let uid = unit(&essai_doc, "E2")["id"].as_str().expect("u").to_owned(); + // Votes stay on it while it is edited: one voice of two does not make it pass. + two_voices(&state, "essai", "E2").await; let (_, v) = a .call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({ "unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true, @@ -343,10 +382,11 @@ async fn editing_is_not_voting() { /// the full cycle: tests/objections.rs). #[tokio::test] async fn an_objection_holds_back_the_version_that_is_not_in_force() { - let (mut a, _) = Client::new().await; + let (mut a, state) = Client::production().await; a.login("Yvv", false).await; let mut b = a.with(None); b.login("Chiara", false).await; + two_voices(&state, "essai", "K1").await; let id = doc_id(&mut a, "essai").await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned(); @@ -389,7 +429,11 @@ async fn inertia_change_is_voted_at_the_level_of_the_article() { let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "max" }))).await; assert_eq!(again["id"], p.as_str()); let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await; - assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(5), Some(4))); + let low = u64::from(ld_server::seed::SEED_PROPOSAL_LEVEL); + assert_eq!( + (d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), + (Some(5), Some(low)) + ); } #[tokio::test] @@ -400,7 +444,7 @@ async fn previews_and_assistant() { let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; assert_eq!(v["units"].as_array().expect("u").len(), 33); let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await; - assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes"); + assert_eq!(v["units"].as_array().expect("u").len(), 0, "nothing in force, nothing well rated at zero votes"); let (_, v) = a .call("POST", &format!("/api/docs/{id}/assist"), Some(json!({ "text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.", @@ -686,7 +730,7 @@ async fn a_team_link_makes_one_identity_per_opening() { } #[tokio::test] -async fn in_monnaie_libre_an_invitation_never_votes_on_the_server() { +async fn in_monnaie_libre_an_invitation_votes_on_the_server_only_in_simulation() { let (mut admin, _) = Client::new().await; admin.login("Yvv", true).await; let (_, v) = admin @@ -696,17 +740,21 @@ async fn in_monnaie_libre_an_invitation_never_votes_on_the_server() { let mut invited = admin.with(None); let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; assert_eq!(s, StatusCode::OK); - // She reads the text and may propose; her vote would go through the chain, for the Ğ1 - // identity of her wallet (tests/ballots.rs) — off here, without a vote account. + // She reads the text and may propose; her vote goes through the chain, for the Ğ1 + // identity of her wallet (tests/ballots.rs) — closed here, without a vote account: the + // vote is simulated, for the identity of her session, marked as such (ADR 0012). let id = doc_id(&mut invited, "monnaie-libre").await; let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await; assert_eq!(s, StatusCode::OK); - assert_eq!(doc["canVote"], false); - assert_eq!((doc["vote"]["way"].as_str(), doc["vote"]["reason"].as_str()), (Some("chain"), Some("chain_off"))); + assert_eq!(doc["canVote"], true); + assert_eq!((doc["vote"]["way"].as_str(), doc["vote"]["simulated"].as_bool()), (Some("server"), Some(true))); let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned(); let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; - assert_eq!(s, StatusCode::CONFLICT, "{v}"); - assert_eq!(v["error"], "chain_only"); + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["simulated"], true); + let (_, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await; + let voted = &unit(&doc, "E3")["proposals"][0]; + assert_eq!((voted["myVote"].as_str(), voted["myVoteState"].as_str()), (Some("for"), Some("simulated"))); // In the test group, every member votes. let essai = doc_id(&mut admin, "essai").await; let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await; @@ -741,7 +789,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() { std::fs::create_dir_all(&dir).expect("dir"); let remote = dir.join("textes.git"); assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success()); - let mut cfg = Config::for_tests(); + let mut cfg = production_config(); cfg.data_dir = dir.join("data"); cfg.publish = Some(ld_server::config::Publish { remote: remote.display().to_string(), @@ -749,6 +797,8 @@ 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, + simulated: false, }); let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(cfg, db).await.expect("state"); @@ -814,7 +864,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(), @@ -822,6 +872,8 @@ 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, + simulated: false, }); cfg }; @@ -904,12 +956,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); @@ -929,8 +987,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) @@ -951,10 +1011,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] @@ -965,13 +1027,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)]); } @@ -984,7 +1046,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"); } @@ -997,7 +1060,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(), @@ -1005,6 +1068,8 @@ 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, + simulated: false, }); let git = |args: &[&str]| { let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git"); @@ -1120,7 +1185,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 ceb714b..4db7e2c 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -268,6 +268,39 @@ impl Client { } } +/// The quorum of an article set to 2, as its author — an administrator, for a text of the +/// seed — sets it before the first vote (ADR 0013). In the test group, where the quorum +/// of every level is 1 and one voice passes, one voice of two then no longer does. +async fn two_voices(state: &AppState, group: &str, code: &str) { + let n = sqlx::query( + "UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = ?)", + ) + .bind(code) + .bind(group) + .execute(&state.db) + .await + .expect("quorum") + .rows_affected(); + assert_eq!(n, 1, "{group} {code}"); +} + +/// A web of two members and the quorum 2 at every level of a document, for the test +/// only (ADR 0013): two voices pass, one never does. +async fn small_web(state: &AppState, doc_id: &str) { + sqlx::query("UPDATE documents SET vote_floor = 1, quorums = '[2,2,2,2,2]' WHERE id = ?") + .bind(doc_id) + .execute(&state.db) + .await + .expect("quorums"); + sqlx::query( + "INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z') + ON CONFLICT(key) DO UPDATE SET value = '2'", + ) + .execute(&state.db) + .await + .expect("web"); +} + fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") } @@ -291,10 +324,43 @@ fn find<'a>(doc: &'a Value, id: &str) -> &'a Value { } fn tally(doc: &Value, id: &str) -> (u64, u64) { - let t = &find(doc, id)["tally"]; + pair(&find(doc, id)["tally"]) +} + +fn pair(t: &Value) -> (u64, u64) { (t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against")) } +/// The part of the totals shown still waiting for its proof (ADR 0012). +fn pending(doc: &Value, id: &str) -> (u64, u64) { + pair(&find(doc, id)["tallyPending"]) +} + +/// What adoption counts: never an intention without proof. +async fn counted(state: &AppState, doc: &Value, id: &str) -> (u32, u32) { + let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state"); + let t = st.tally(id); + (t.for_votes, t.against) +} + +/// « Ton vote » and where it stands. +fn mine<'a>(doc: &'a Value, id: &str) -> (Option<&'a str>, Option<&'a str>) { + let p = find(doc, id); + (p["myVote"].as_str(), p["myVoteState"].as_str()) +} + +/// The messages of a session about its ballots (ADR 0012): (kind, ballot id). +async fn ballot_messages(c: &mut Client) -> Vec<(String, String)> { + let m = c.ok("GET", "/api/messages", None).await; + m["messages"] + .as_array() + .expect("messages") + .iter() + .filter(|m| m["topic"] == "ballot") + .map(|m| (m["kind"].as_str().expect("kind").to_owned(), m["ballot"]["id"].as_str().expect("id").to_owned())) + .collect() +} + async fn rewind_check(state: &AppState) { // The page asks often, the indexer is read at most every few seconds: tests rewind that clock. sqlx::query("UPDATE ballots SET checked_at = NULL").execute(&state.db).await.expect("rewind"); @@ -318,7 +384,7 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { assert_eq!(doc["vote"]["chain"]["amount"], 10); assert_eq!(doc["vote"]["chain"]["test"], false); - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let e3 = version(&doc, "E3", "0"); let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await; let comment = b["comment"].as_str().expect("comment"); @@ -339,10 +405,14 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { ]; assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment); - // Nothing on the chain yet: pending, nothing counted. + // Nothing on the chain yet: the intention is seen at once — her vote, the totals + // shown — and waits for its proof; adoption does not count it (ADR 0012). assert_eq!(c.status(&b).await["status"], "pending"); let doc = c.doc("monnaie-libre").await; - assert_eq!(tally(&doc, &i1), (0, 0)); + assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0))); + assert_eq!((tally(&doc, &e3), pending(&doc, &e3)), ((0, 1), (0, 1))); + assert_eq!(counted(&state, &doc, &i1).await, (0, 0)); + assert_eq!(mine(&doc, &i1), (Some("for"), Some("pending"))); assert_eq!(find(&doc, &i1)["myBallot"]["status"], "pending"); assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for"); @@ -354,10 +424,11 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { assert_eq!(s["voter"], "Alice"); assert_eq!(s["block"], 3_400_000); let doc = c.doc("monnaie-libre").await; - assert_eq!(tally(&doc, &i1), (1, 0)); + assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (0, 0))); assert_eq!(tally(&doc, &e3), (0, 1)); + assert_eq!(counted(&state, &doc, &i1).await, (1, 0)); // The session that asked sees « ton vote », confirmed on the chain. - assert_eq!(find(&doc, &i1)["myVote"], "for"); + assert_eq!(mine(&doc, &i1), (Some("for"), Some("recorded"))); assert_eq!(find(&doc, &i1)["myVoteChain"], true); assert!(find(&doc, &i1)["myBallot"].is_null()); assert_eq!(find(&doc, &e3)["myVote"], "against"); @@ -373,8 +444,11 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { .await .expect("votes"); assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}"); - // One voice never makes a text pass (C = 50). - assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50); + // One voice never makes a text pass: at level 2, the quorum 12, the 11 voters missing + // counted as against — 9 « pour » are asked (ADR 0013). + let e = &find(&doc, &i1)["evaluation"]; + assert_eq!((e["quorum"].as_u64(), e["required"].as_u64(), e["yesNeeded"].as_u64()), (Some(12), Some(9), Some(9))); + assert_eq!(e["reached"], false); // Someone else sees the totals, not the vote. let mut yvv = c.other(); yvv.login("Yvv").await; @@ -404,7 +478,9 @@ async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() { assert_eq!(s["status"], "confirmed"); assert_eq!(s["entered"], json!([])); let d = c.ok("GET", &format!("/api/proposals/{p}"), None).await; - assert_eq!(d["proposal"]["evaluation"]["required"], 50); + // Level 3, the quorum 50: 39 « pour » of 50 voters, the 49 missing counted as against. + assert_eq!(d["proposal"]["evaluation"]["quorum"], 50); + assert_eq!(d["proposal"]["evaluation"]["required"], 39); assert_eq!(d["proposal"]["evaluation"]["wellRated"], true); assert_eq!(d["proposal"]["myVote"], "for"); assert_eq!(d["proposal"]["myVoteChain"], true); @@ -416,7 +492,7 @@ async fn a_silent_indexer_keeps_the_ballot_pending() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(100, b["comment"].as_str().expect("comment"), alice()); indexer.silent(true); @@ -442,7 +518,7 @@ async fn a_signer_who_is_not_a_member_is_refused_and_told() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let former = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay( 100, @@ -466,6 +542,17 @@ async fn a_signer_who_is_not_a_member_is_refused_and_told() { .await .expect("n"); assert_eq!(n, 0, "no identity is created for a refused ballot"); + // And in her messages, once each (ADR 0012). + let mut told = ballot_messages(&mut c).await; + told.sort(); + let mut expected = vec![ + ("ballot_refused".to_owned(), id_of(&former).to_owned()), + ("ballot_refused".to_owned(), id_of(&nobody).to_owned()), + ]; + expected.sort(); + assert_eq!(told, expected); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(ballot_messages(&mut c).await.len(), 2); } #[tokio::test] @@ -474,7 +561,7 @@ async fn a_wrong_amount_or_recipient_is_refused() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); // Bound to the wallet that signs: its own transaction without the transfer is refused, and told. let short = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]); @@ -491,7 +578,10 @@ async fn a_wrong_amount_or_recipient_is_refused() { assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}"); } assert_eq!(c.status(&gecko).await["status"], "pending"); - assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0)); + // The refused ones are set aside; the Ğecko code still waits: its intention shows, uncounted. + let doc = c.doc("monnaie-libre").await; + assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0))); + assert_eq!(counted(&state, &doc, &i1).await, (0, 0)); } #[tokio::test] @@ -500,7 +590,7 @@ async fn confirming_twice_changes_nothing() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(100, b["comment"].as_str().expect("c"), alice()); // The page and the background task at once. @@ -527,8 +617,8 @@ async fn the_last_one_stands_in_the_order_of_the_chain() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); - let e1 = version(&doc, "E1", "inForce"); + let i1 = version(&doc, "I1", "0"); + let e1 = version(&doc, "E1", "0"); // Three ballots of the same person, signed in this order on the chain: for (I1, E1), against (I1), none (E1). let first = c.ballot(&doc, &[(&i1, Some("for")), (&e1, Some("for"))], None).await; let second = c.ballot(&doc, &[(&i1, Some("against"))], None).await; @@ -609,7 +699,7 @@ async fn a_bound_ballot_ignores_a_copy_of_its_remark_by_another_account() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; let comment = b["comment"].as_str().expect("c"); // Charlie copies the remark seen in the pool and gets it in first. @@ -631,7 +721,7 @@ async fn a_ballot_not_seen_in_time_is_let_go() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; let expires = b["expiresAt"].as_str().expect("expires"); assert!( @@ -660,7 +750,7 @@ async fn a_ballot_the_wallet_refused_to_sign_is_let_go_at_once() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await; let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]); @@ -693,7 +783,7 @@ async fn a_session_sees_its_own_ballots_only() { let mut other = chiara.other(); other.login("Yvv").await; let doc = chiara.doc("monnaie-libre").await; - let (i1, e1) = (version(&doc, "I1", "inForce"), version(&doc, "E1", "inForce")); + let (i1, e1) = (version(&doc, "I1", "0"), version(&doc, "E1", "0")); // Both sign with the same wallet (one person, two sessions): two ballots, two versions. let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await; let b = other.ballot(&doc, &[(&e1, Some("against"))], None).await; @@ -711,7 +801,7 @@ async fn a_session_sees_its_own_ballots_only() { } #[tokio::test] -async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() { +async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() { let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; @@ -719,20 +809,38 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() let e4 = version(&doc, "E4", "0"); let e5 = version(&doc, "E5", "0"); let reason = "Ces deux textes disent le contraire l'un de l'autre."; - let e1 = version(&doc, "E1", "inForce"); - let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); + let e1 = version(&doc, "E1", "0"); + // Level 3, the quorum 50 (ADR 0013): 39 « pour » with no « contre ». + let needed = find(&doc, &e4)["evaluation"]["yesNeeded"].as_u64().expect("yesNeeded"); + assert_eq!(needed, 39); + let needed = u8::try_from(needed).expect("n"); + let keys: Vec = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); let mut last = Value::Null; + let mut luna = c.other(); + luna.login("Luna").await; for (n, account) in keys.iter().enumerate() { - let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await; + // The last voice comes from another session: its intention is seen at once. + let last_one = n + 1 == keys.len(); + let asker = if last_one { &mut luna } else { &mut c }; + let b = asker.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await; + if last_one { + // Thirty-nine voices shown, thirty-eight proven: nothing enters before the proof. + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((39, 0), (1, 0))); + assert_eq!(counted(&state, &d, &e4).await, (38, 0)); + let id = d["doc"]["id"].as_str().expect("id"); + assert!(ld_server::adoption::run(&state, id).await.expect("run").is_empty()); + assert_ne!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str()); + } let index = 10_000 + u32::try_from(n).expect("n"); indexer.pay( 1_000 + index, b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); - if n == 49 { + if n + 1 == keys.len() { // The last ballot is signed; before the chain confirms it, an objection holds - // E5 back against the text in force E1 (ADR 0010): no new ballot may hold it. + // E5 back against the seed proposal E1, written before it (ADR 0010, 0011): no new ballot may hold it. let o = c .ok( "POST", @@ -756,24 +864,38 @@ 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"); - let s = c.status(&last).await; + let s = luna.status(&last).await; assert_eq!(s["status"], "confirmed"); assert_eq!(s["entered"][0]["code"], "E4", "{s}"); let d = c.doc("monnaie-libre").await; 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)); + // Sealed before the objection, proven after it: its *for* on E5 is set aside + // (ADR 0012, revising 0010) — its *for* on E4 counts. Luna is told. + assert_eq!(tally(&d, &e5), (38, 0)); assert_eq!(find(&d, &e5)["votable"], false); assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); + let outcomes: Vec<(String, Option)> = + sqlx::query_as("SELECT proposal_id, outcome FROM ballot_lines WHERE ballot_id = ? ORDER BY proposal_id = ?") + .bind(id_of(&last)) + .bind(&e5) + .fetch_all(&state.db) + .await + .expect("lines"); + assert_eq!(outcomes, [(e4.clone(), Some("counted".into())), (e5.clone(), Some("objection".into()))]); + assert_eq!(ballot_messages(&mut luna).await, [("ballot_objection".to_owned(), id_of(&last).to_owned())]); + let seen = luna.doc("monnaie-libre").await; + let held = &find(&seen, &e5)["myBallot"]; + assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection"))); + assert_eq!(mine(&seen, &e4), (Some("for"), Some("recorded"))); } /// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a @@ -785,7 +907,7 @@ async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() { c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); - let (e1, e4, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E4", "0"), version(&doc, "E5", "0")); + let (e1, e4, e5) = (version(&doc, "E1", "0"), version(&doc, "E4", "0"), version(&doc, "E5", "0")); let o = c .ok( "POST", @@ -856,7 +978,7 @@ async fn a_long_author_is_refused_at_once() { c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); // The body may hold 256 KiB: an author of 250 000 characters passes it. for n in [80_000usize, 250_000] { let body = json!({ "lines": [{ "proposalId": i1, "choice": "for" }], "author": "z".repeat(n) }); @@ -875,7 +997,7 @@ async fn the_background_task_reaches_every_pending_ballot() { let (mut a, state) = Client::new(chain_config(&indexer)).await; a.login("Anna").await; let doc = a.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let mut b = a.other(); b.login("Bruno").await; let mut c = a.other(); @@ -910,7 +1032,7 @@ async fn letting_ballots_go_does_not_lift_the_cap() { a.login("Anna").await; let doc = a.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); for _ in 0..20 { let b = a.ballot(&doc, &[(&i1, Some("for"))], None).await; a.ok("DELETE", &format!("/api/ballots/{}", id_of(&b)), None).await; @@ -965,7 +1087,7 @@ async fn an_unbound_ballot_is_not_settled_by_an_unpaid_copy() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.add(99, 1, comment_of(&b), Signer { account: OTHER, identity: None }, &[]); indexer.add( @@ -992,7 +1114,7 @@ async fn an_unbound_ballot_can_be_taken_by_a_paid_copy() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; indexer.pay(99, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); indexer.pay(100, comment_of(&b), alice()); @@ -1012,7 +1134,7 @@ async fn an_older_pending_ballot_never_hides_a_newer_confirmed_vote() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); // A Ğecko code shown, never paid; then a vote from the wallet, confirmed. c.ballot(&doc, &[(&i1, Some("against"))], None).await; let newer = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; @@ -1035,7 +1157,7 @@ async fn a_comment_counts_once_its_block_is_final() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; let r = c.ballot(&doc, &[(&i1, Some("for"))], Some(BOB)).await; indexer.finalized(Some(99)); @@ -1050,7 +1172,10 @@ async fn a_comment_counts_once_its_block_is_final() { ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&b)).await.0, "pending"); assert_eq!(db_status(&state, id_of(&r)).await.0, "pending"); - assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0)); + // Her latest intention shows; nothing is counted before a final block. + let doc = c.doc("monnaie-libre").await; + assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0))); + assert_eq!(counted(&state, &doc, &i1).await, (0, 0)); indexer.finalized(Some(100)); ballots::sweep(&state).await.expect("sweep"); assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed"); @@ -1066,7 +1191,7 @@ async fn copies_never_hide_the_real_comment() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await; for n in 0..120 { indexer.pay(100 + n, comment_of(&b), Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) }); @@ -1099,7 +1224,7 @@ async fn a_gecko_ballot_is_settled_by_the_background_task_even_at_its_end() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; age(&state, "expires_at", -5, id_of(&b)).await; indexer.pay(100, comment_of(&b), alice()); @@ -1124,7 +1249,7 @@ async fn the_receipt_is_for_the_one_who_asked_and_proves_the_vote() { let (mut c, state) = Client::new(chain_config(&indexer)).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - let (i1, e3) = (version(&doc, "I1", "inForce"), version(&doc, "E3", "0")); + let (i1, e3) = (version(&doc, "I1", "0"), version(&doc, "E3", "0")); let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await; indexer.pay(100, comment_of(&b), alice()); ballots::sweep(&state).await.expect("sweep"); @@ -1183,7 +1308,7 @@ async fn a_name_known_only_from_a_chain_vote_stays_free() { let (mut admin, state) = Client::new(chain_config(&indexer)).await; admin.login_admin("Yvv").await; let doc = admin.doc("monnaie-libre").await; - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); for (block, signer) in [(100, alice()), (101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })] { let b = admin.ballot(&doc, &[(&i1, Some("for"))], None).await; @@ -1208,7 +1333,7 @@ async fn a_name_known_only_from_a_chain_vote_stays_free() { } #[tokio::test] -async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { +async fn voices_on_the_chain_bring_an_order_then_a_title_into_force() { // The modules (ADR 0009) are voted like articles: in a basket, in one transaction. let indexer = Indexer::start().await; let (mut c, state) = Client::new(chain_config(&indexer)).await; @@ -1245,8 +1370,16 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { .expect("title") .to_owned(); let doc = c.doc("monnaie-libre").await; - let keys: Vec = (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 +1388,26 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { b["comment"].as_str().expect("c"), Signer { account, identity: Some((index, "Membre", true)) }, ); - last = b; + sent.push(b); if n % 10 == 9 { ballots::sweep(&state).await.expect("sweep"); } } ballots::sweep(&state).await.expect("sweep"); - let s = c.status(&last).await; - assert_eq!(s["status"], "confirmed"); - let mut entered: Vec<(&str, &str)> = s["entered"] - .as_array() - .expect("entered") - .iter() - .map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default())) - .collect(); - entered.sort_unstable(); - assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}"); + // What entered at each confirmation, in the order of the chain. + let mut entered: Vec<(usize, String, String)> = Vec::new(); + for (n, b) in sent.iter().enumerate() { + let s = c.status(b).await; + assert_eq!(s["status"], "confirmed", "{s}"); + for e in s["entered"].as_array().expect("entered") { + entered.push((n + 1, e["code"].as_str().expect("code").into(), e["kind"].as_str().expect("kind").into())); + } + } + assert_eq!( + entered, + [(9, "ordre.conclusion".into(), "order".into()), (43, "titre".into(), "title".into())], + "the ninth voice, then the forty-third" + ); let d = c.doc("monnaie-libre").await; let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p")); assert!(p2 < p1, "K2 now reads before K1"); @@ -1287,17 +1424,19 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { // --------------------------------------------------------------------------- #[tokio::test] -async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes() { +async fn without_a_vote_account_monnaie_libre_votes_in_simulation_and_the_test_group_still_votes() { let (mut c, _) = Client::new(Config::for_tests()).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - assert_eq!(doc["vote"]["way"], "chain"); - assert_eq!(doc["vote"]["open"], false); - assert_eq!(doc["vote"]["reason"], "chain_off"); + // The chain closed: the vote is simulated, written by the server (ADR 0012). + assert_eq!(doc["vote"]["way"], "server"); + assert_eq!(doc["vote"]["open"], true); + assert_eq!(doc["vote"]["simulated"], true); + assert!(doc["vote"]["reason"].is_null()); assert!(doc["vote"]["chain"].is_null()); - assert_eq!(doc["canVote"], false); + assert_eq!(doc["canVote"], true); let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let (s, v) = c .call( "POST", @@ -1308,8 +1447,11 @@ 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"); + assert_eq!(essai["vote"]["simulated"], false); + let p = version(&essai, "I1", "0"); c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + let essai = c.doc("essai").await; + assert_eq!(mine(&essai, &p), (Some("for"), Some("recorded"))); } #[tokio::test] @@ -1353,9 +1495,9 @@ async fn what_a_ballot_may_not_hold() { c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); - let i1 = version(&doc, "I1", "inForce"); + let i1 = version(&doc, "I1", "0"); let essai = c.doc("essai").await; - let elsewhere = version(&essai, "I1", "inForce"); + let elsewhere = version(&essai, "I1", "0"); for (lines, author, code) in [ (json!([]), Value::Null, "lines"), ( @@ -1392,6 +1534,9 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() { a.login("Yvv").await; let mut b = a.other(); b.login("Chiara").await; + for code in ["E4", "E5", "E6"] { + two_voices(&state, "essai", code).await; + } let doc = a.doc("essai").await; let id = doc["doc"]["id"].as_str().expect("id").to_owned(); let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0")); @@ -1400,7 +1545,7 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() { ]); let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await; assert_eq!(v["count"], 3); - assert!(v["entered"].as_array().expect("e").is_empty(), "one voice never makes a text pass"); + assert!(v["entered"].as_array().expect("e").is_empty(), "one voice of two, on a quorum of 2"); // All or nothing: one line that does not hold, and no line is written. let (s, _) = b .call( @@ -1430,3 +1575,731 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() { assert_eq!(find(&doc, &e5)["myVote"], "against"); assert_eq!(find(&doc, &e5)["myVoteChain"], false); } + +// --------------------------------------------------------------------------- +// The buffer of the votes (ADR 0012): the intention at once, the proof of the +// chain, the reconciliation; the simulation while the chain is closed +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_simulated_vote_in_monnaie_libre_is_seen_at_once_in_my_vote_and_the_totals() { + let (mut c, state) = Client::new(Config::for_tests()).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let (i1, e3, e4) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"), version(&doc, "E4", "0")); + // One vote, without a wallet: written by the server, simulated. + let v = c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!((v["simulated"].as_bool(), v["entered"].as_array().map(Vec::len)), (Some(true), Some(0))); + let d = c.doc("monnaie-libre").await; + assert_eq!(tally(&d, &i1), (1, 0)); + assert_eq!(pending(&d, &i1), (0, 0), "nothing waits for the chain: the simulated confirmer proved it"); + assert_eq!(pair(&find(&d, &i1)["tallySimulated"]), (1, 0)); + assert_eq!(mine(&d, &i1), (Some("for"), Some("simulated"))); + assert_eq!(find(&d, &i1)["myVoteChain"], false); + // By default a simulated vote counts like a proven one, adoption included. + assert_eq!(counted(&state, &d, &i1).await, (1, 0)); + // Through the same buffer: a sealed ballot, simulated, proven at block 0, for the session. + let row: (String, String, Option, Option, String) = + sqlx::query_as("SELECT mode, status, block, voter_id, requested_by FROM ballots") + .fetch_one(&state.db) + .await + .expect("ballot"); + assert_eq!((row.0.as_str(), row.1.as_str(), row.2), ("simulated", "confirmed", Some(0))); + assert_eq!(row.3.as_deref(), Some(row.4.as_str()), "the voice of the session: there is no wallet"); + let source: String = sqlx::query_scalar("SELECT source FROM votes").fetch_one(&state.db).await.expect("vote"); + assert_eq!(source, "simulated"); + // A grouped vote: one ballot, every line; then a withdrawal. + let lines = json!([{ "proposalId": e3, "choice": "against" }, { "proposalId": e4, "choice": "for" }]); + let v = c.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await; + assert_eq!((v["count"].as_u64(), v["simulated"].as_bool()), (Some(2), Some(true))); + c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": null }))).await; + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &i1), tally(&d, &e3), tally(&d, &e4)), ((0, 0), (0, 1), (1, 0))); + assert_eq!(mine(&d, &i1), (None, None)); + assert_eq!(mine(&d, &e3), (Some("against"), Some("simulated"))); + // Someone else sees the totals, never the vote. + let mut yvv = c.other(); + yvv.login("Yvv").await; + let seen = yvv.doc("monnaie-libre").await; + assert_eq!((tally(&seen, &e3), mine(&seen, &e3)), ((0, 1), (None, None))); + // Her ballots, marked simulated, with a receipt. + let history = c.ok("GET", "/api/ballots", None).await; + let list = history["ballots"].as_array().expect("ballots"); + assert_eq!(list.len(), 3); + assert!( + list.iter().all(|b| b["mode"] == "simulated" && b["status"] == "confirmed" && b["block"].is_null()), + "{history}" + ); + let id = list[0]["id"].as_str().expect("id"); + let r = c.ok("GET", &format!("/api/ballots/{id}/receipt"), None).await; + assert_eq!(r["mode"], "simulated"); + // No ballot to sign while the chain is closed. + let one = json!({ "lines": [{ "proposalId": i1, "choice": "for" }] }); + let (s, v) = c.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(one)).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off"))); +} + +#[tokio::test] +async fn shown_but_never_adopted_when_simulated_votes_are_switched_off_for_adoption() { + // Yvv may choose « simulé = affiché, jamais compté pour l'adoption » (ADR 0012). + for adopts in [true, false] { + let mut cfg = Config::for_tests(); + cfg.simulated_adopts = adopts; + let (mut a, state) = Client::new(cfg).await; + a.login("Anna").await; + let doc = a.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + small_web(&state, &doc_id).await; + let e4 = version(&doc, "E4", "0"); + let mut b = a.other(); + b.login("Bruno").await; + a.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await; + let v = b.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await; + let entered = !v["entered"].as_array().expect("entered").is_empty(); + let d = a.doc("monnaie-libre").await; + assert_eq!(pair(&find(&d, &e4)["tallySimulated"]), (2, 0), "shown either way"); + assert_eq!(counted(&state, &d, &e4).await, if adopts { (2, 0) } else { (0, 0) }); + assert_eq!(entered, adopts, "adopts = {adopts}"); + } +} + +/// Yvv, 2026-11-02: « Il compte, puis s'efface ». Opening the chain sets nothing +/// aside; the act « supprime » does, and examines again what the simulated votes +/// brought into force, with the proven votes only (`simulation::discard`). +#[tokio::test] +async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_what_entered() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + small_web(&state, &doc_id).await; + let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0")); + // The seed proposal of I1 (ADR 0011), and a rival written before the votes. + let seed_i1 = version(&doc, "I1", "0"); + let i1_unit = unit(&doc, "I1")["id"].as_str().expect("unit").to_owned(); + let rival = c + .ok( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ "unitId": i1_unit, "title": "Raison d'être", "body": "Un autre texte pour I1, voté en simulation.", "submit": true })), + ) + .await["id"] + .as_str() + .expect("rival") + .to_owned(); + // The chain open: one proven voice on E5, one on E6 — not enough to enter; two on + // the seed proposal of I1, which enters on proven votes alone. + let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) }; + for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, charlie)] { + let b = c.ballot(&doc, &[(p, Some("for")), (&seed_i1, Some("for"))], None).await; + indexer.pay(block, comment_of(&b), signer); + } + ballots::sweep(&state).await.expect("sweep"); + let d = c.doc("monnaie-libre").await; + assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str()); + assert!(unit(&d, "E5")["inForce"].is_null() && unit(&d, "E6")["inForce"].is_null()); + // The chain closed: Luna, Bruno and Marc vote in simulation; E4, E5, E6 enter, and + // the rival of I1, three voices against two, takes the place of the seed proposal. + c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await; + let mut luna = c.other(); + luna.login("Luna").await; + let mut bruno = c.other(); + bruno.login("Bruno").await; + let mut marc = c.other(); + marc.login("Marc").await; + let lines = json!([ + { "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }, + { "proposalId": e6, "choice": "for" }, { "proposalId": rival, "choice": "for" }, + ]); + luna.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await; + let lines = json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": rival, "choice": "for" }]); + bruno.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await; + marc.ok("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await; + let d = c.doc("monnaie-libre").await; + for (code, id) in [("E4", &e4), ("E5", &e5), ("E6", &e6), ("I1", &rival)] { + assert_eq!(unit(&d, code)["inForce"]["id"], id.as_str(), "{code}"); + } + // The chain opens again: nothing is set aside by itself — the simulated votes still count, marked. + c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await; + let d = luna.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e5), pair(&find(&d, &e5)["tallySimulated"])), ((2, 0), (1, 0))); + assert_eq!(mine(&d, &e5), (Some("for"), Some("simulated"))); + let (s, v) = luna.call("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only"))); + // A second proven voice on E6, after it entered. + let b = c.ballot(&d, &[(&e6, Some("for"))], None).await; + indexer.pay(200, comment_of(&b), Signer { account: OTHER, identity: Some((9004, "Eve", true)) }); + ballots::sweep(&state).await.expect("sweep"); + // Told first, nothing changed. + let told = ld_server::simulation::discard(&state, false).await.expect("dry run"); + assert_eq!((told.applied, told.ballots, told.votes), (false, 3, 7)); + let verdicts = |list: &[ld_server::simulation::Reexamined]| { + let mut v: Vec<(String, bool, bool)> = list.iter().map(|r| (r.code.clone(), r.holds, r.returned)).collect(); + v.sort(); + v + }; + let expected_told: Vec<(String, bool, bool)> = + [("E4", false, false), ("E5", false, false), ("E6", true, false), ("I1", false, false)] + .iter() + .map(|(c, h, r)| ((*c).to_owned(), *h, *r)) + .collect(); + assert_eq!(verdicts(&told.reexamined), expected_told); + assert_eq!(unit(&c.doc("monnaie-libre").await, "E5")["inForce"]["id"], e5.as_str()); + // Done: the simulated votes are gone; what does not hold on the proven votes returns. + let done = ld_server::simulation::discard(&state, true).await.expect("act"); + let expected_done: Vec<(String, bool, bool)> = + [("E4", false, true), ("E5", false, true), ("E6", true, false), ("I1", false, true)] + .iter() + .map(|(c, h, r)| ((*c).to_owned(), *h, *r)) + .collect(); + assert_eq!(verdicts(&done.reexamined), expected_done); + let d = luna.doc("monnaie-libre").await; + assert!(unit(&d, "E4")["inForce"].is_null() && unit(&d, "E5")["inForce"].is_null()); + assert_eq!(unit(&d, "E6")["inForce"]["id"], e6.as_str(), "two proven voices: it stays"); + assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str(), "the text it had displaced comes back"); + assert_eq!(find(&d, &rival)["status"], "open"); + assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (None, None))); + let left: (i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM votes WHERE source = 'simulated'), (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated')", + ) + .fetch_one(&state.db) + .await + .expect("left"); + assert_eq!(left, (0, 0)); + let mut adopted: Vec = + sqlx::query_scalar("SELECT proposal_id FROM adoptions").fetch_all(&state.db).await.expect("adoptions"); + adopted.sort(); + let mut kept = vec![seed_i1.clone(), e6.clone()]; + kept.sort(); + assert_eq!(adopted, kept, "the adoptions that do not hold are void; those on proven votes alone stay"); + // Once is enough. + let again = ld_server::simulation::discard(&state, true).await.expect("again"); + assert_eq!((again.ballots, again.reexamined.len()), (0, 0)); + // Never without the dev login. + let mut cfg = chain_config(&indexer); + cfg.dev_login = false; + let (mut other, _) = Client::new(cfg).await; + let (s, _) = other.call("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn an_intention_never_confirmed_is_set_aside_and_told() { + let indexer = Indexer::start().await; + 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", "0"); + // A vote proven first: « contre ». + let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await; + indexer.pay(100, comment_of(&first), alice()); + ballots::sweep(&state).await.expect("sweep"); + // Then « pour », never paid: it takes the place of « contre » in the totals shown, for a while. + let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1))); + assert_eq!(mine(&d, &i1), (Some("for"), Some("pending"))); + // Someone else sees the totals shown, intention included. + let mut yvv = c.other(); + yvv.login("Yvv").await; + assert_eq!(tally(&yvv.doc("monnaie-libre").await, &i1), (1, 0)); + // Its window passes: set aside, and said — under the article, and in her messages. + age(&state, "expires_at", -5, id_of(&b)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&b)).await.0, "expired"); + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &i1), pending(&d, &i1)), ((0, 1), (0, 0))); + assert_eq!(mine(&d, &i1), (Some("against"), Some("recorded")), "the vote of before stays"); + assert_eq!(find(&d, &i1)["myBallot"]["status"], "expired"); + assert_eq!(ballot_messages(&mut c).await, [("ballot_expired".to_owned(), id_of(&b).to_owned())]); + let m = c.ok("GET", "/api/messages", None).await; + let message = &m["messages"][0]; + assert_eq!((message["done"].as_bool(), message["gestures"].as_array().map(Vec::len)), (Some(true), Some(0))); + assert_eq!(message["ballot"]["lines"][0]["code"], "I1"); + assert_eq!(message["ballot"]["lines"][0]["choice"], "for"); + assert_eq!(m["unread"], 1); + // A ballot the wallet never signed is let go at once: nothing to tell. + let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0)); + c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await; + assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 1)); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(ballot_messages(&mut c).await.len(), 1); + // Purged a week later, its message goes with it. + age(&state, "settled_at", -8 * 24 * 3600, id_of(&b)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(ballot_messages(&mut c).await.len(), 0); + assert_eq!(c.ok("GET", "/api/messages/unread", None).await["unread"], 0); +} + +#[tokio::test] +async fn two_votes_of_one_identity_from_two_sessions_the_chain_keeps_the_last() { + let indexer = Indexer::start().await; + let (mut chiara, state) = Client::new(chain_config(&indexer)).await; + chiara.login("Chiara").await; + let mut other = chiara.other(); + other.login("Yvv").await; + let doc = chiara.doc("monnaie-libre").await; + let i1 = version(&doc, "I1", "0"); + // One person, one wallet, two sessions: « pour » here, « contre » there. + let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await; + let b = other.ballot(&doc, &[(&i1, Some("against"))], None).await; + // Before the proofs, each intention counts for its session (ADR 0012, the cost). + assert_eq!(tally(&chiara.doc("monnaie-libre").await, &i1), (1, 1)); + // On the chain, « pour » comes last; the indexer reads it first. + indexer.pay(200, comment_of(&a), alice()); + ballots::sweep(&state).await.expect("sweep"); + indexer.pay(100, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + let both = (db_status(&state, id_of(&a)).await.0, db_status(&state, id_of(&b)).await.0); + assert_eq!(both, ("confirmed".into(), "confirmed".into())); + // One voice: the last in the order of the chain. + let d = chiara.doc("monnaie-libre").await; + assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (0, 0), (1, 0))); + let outcomes: Vec<(String, Option)> = + sqlx::query_as("SELECT ballot_id, outcome FROM ballot_lines ORDER BY ballot_id = ?") + .bind(id_of(&a)) + .fetch_all(&state.db) + .await + .expect("lines"); + assert_eq!( + outcomes, + [(id_of(&b).to_owned(), Some("superseded".into())), (id_of(&a).to_owned(), Some("counted".into()))] + ); + let votes: Vec<(String, String)> = + sqlx::query_as("SELECT identity_id, choice FROM votes").fetch_all(&state.db).await.expect("votes"); + assert_eq!(votes, [("idty:9001".to_owned(), "for".to_owned())]); + // The session whose vote was overtaken sees no vote of its own; nothing to tell. + let theirs = other.doc("monnaie-libre").await; + assert_eq!(mine(&theirs, &i1), (None, None)); + assert!(ballot_messages(&mut other).await.is_empty()); +} + +#[tokio::test] +async fn a_for_proven_while_an_objection_holds_the_version_is_set_aside() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let (e1, e5) = (version(&doc, "E1", "0"), version(&doc, "E5", "0")); + // Proven first: « contre » on E5. + let before = c.ballot(&doc, &[(&e5, Some("against"))], None).await; + indexer.pay(100, comment_of(&before), alice()); + ballots::sweep(&state).await.expect("sweep"); + // « Pour », sealed: seen at once in place of « contre ». + let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await; + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (Some("for"), Some("pending")))); + // An objection holds E5 back: the *for* still waiting weighs nothing. + let mut luna = c.other(); + luna.login("Luna").await; + luna.ok( + "POST", + &format!("/api/proposals/{e5}/objections"), + Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })), + ) + .await; + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e5), pending(&d, &e5)), ((0, 1), (0, 0))); + // The chain proves it now: set aside; « contre » stays; she is told. + indexer.pay(200, comment_of(&b), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed"); + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e5), counted(&state, &d, &e5).await), ((0, 1), (0, 1))); + assert_eq!(mine(&d, &e5), (Some("against"), Some("recorded"))); + let held = &find(&d, &e5)["myBallot"]; + assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection"))); + assert_eq!(ballot_messages(&mut c).await, [("ballot_objection".to_owned(), id_of(&b).to_owned())]); + // A withdrawal, proven while held, counts. + let w = c.ballot(&doc, &[(&e5, None)], None).await; + indexer.pay(300, comment_of(&w), alice()); + ballots::sweep(&state).await.expect("sweep"); + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((0, 0), (None, None))); +} + +#[tokio::test] +async fn a_waiting_intention_counts_as_my_vote_in_decisions() { + let indexer = Indexer::start().await; + let (mut c, _) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let without = |d: &Value| d["docs"][0]["withoutMyVote"].as_u64(); + let before = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await); + let i1 = version(&doc, "I1", "0"); + c.ballot(&doc, &[(&i1, Some("for"))], None).await; + let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await); + assert_eq!(after, before.map(|n| n - 1)); +} + +/// The evaluation as the engine gives it for these totals, on this version's letters. +async fn judged(state: &AppState, doc: &Value, id: &str, shown: (u32, u32)) -> Value { + let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state"); + let p = st.proposals.iter().find(|p| p.id == id).expect("version"); + let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit"); + let tally = ld_engine::threshold::Tally { for_votes: shown.0, against: shown.1 }; + let e = ld_engine::threshold::evaluate(tally, st.electorate, &st.params(u, p)).expect("evaluation"); + serde_json::to_value(e).expect("json") +} + +/// What adoption judges: never an intention, simulated votes by default (ADR 0012). +async fn adopted_evaluation(state: &AppState, doc: &Value, id: &str) -> Value { + let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state"); + let p = st.proposals.iter().find(|p| p.id == id).expect("version"); + let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit"); + serde_json::to_value(st.evaluation(u, p).expect("evaluation")).expect("json") +} + +/// The quorum (ADR 0013) is judged on the totals shown — votes recorded, pending and +/// simulated, the vote one sees as one's own replaced by one's intention: `quorum`, +/// `yesNeeded`, `noNeeded` and `missing` are those of the totals shown. Adoption counts +/// the proven votes and the simulated ones (`SIMULATED_ADOPTS`), never an intention. +#[tokio::test] +async fn the_quorum_is_judged_on_the_totals_shown_and_adoption_never_on_an_intention() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + // I1, a seed proposal at level 2: the quorum 12, 9 « pour » at no vote. + let i1 = version(&doc, "I1", "0"); + assert_eq!(find(&doc, &i1)["evaluation"], judged(&state, &doc, &i1, (0, 0)).await); + assert_eq!(find(&doc, &i1)["evaluation"]["yesNeeded"], 9); + // Proven: « contre ». Then « pour », sealed, not yet proven: it takes the place of + // « contre » in the totals shown, and in the evaluation shown. + let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await; + indexer.pay(100, comment_of(&first), alice()); + ballots::sweep(&state).await.expect("sweep"); + c.ballot(&doc, &[(&i1, Some("for"))], None).await; + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1))); + let shown = find(&d, &i1)["evaluation"].clone(); + assert_eq!(shown, judged(&state, &d, &i1, (1, 0)).await, "on the totals shown"); + assert_eq!((shown["cast"].as_u64(), shown["missing"].as_u64()), (Some(1), Some(8))); + let adoption = adopted_evaluation(&state, &d, &i1).await; + assert_eq!(adoption, judged(&state, &d, &i1, (0, 1)).await, "adoption: the proven « contre » only"); + assert!(adoption["missing"].as_u64() > Some(8), "the intention never brings adoption closer: {adoption}"); + // The detail says the same, and its five levels are asked at the votes shown. + let detail = c.ok("GET", &format!("/api/proposals/{i1}"), None).await; + assert_eq!(detail["proposal"]["evaluation"], shown); + assert!(detail["levels"].as_array().expect("levels").iter().all(|l| l["cast"] == 1), "{detail}"); + // The chain closed: a simulated « pour » counts in both, like a proven vote. + c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await; + let mut luna = c.other(); + luna.login("Luna").await; + luna.ok( + "POST", + &format!("/api/docs/{doc_id}/votes"), + Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })), + ) + .await; + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((2, 0), (1, 0), (1, 1))); + assert_eq!(find(&d, &i1)["evaluation"], judged(&state, &d, &i1, (2, 0)).await); + assert_eq!(adopted_evaluation(&state, &d, &i1).await, judged(&state, &d, &i1, (1, 1)).await); +} + +/// « Un vote existe » (ADR 0013, `quorum::voted_units`), for every state of the buffer +/// (ADR 0012): an intention still pending, a proven line whatever became of it (a +/// withdrawal, a vote overtaken, a *for* set aside by an objection), a simulated vote, +/// a simulated withdrawal — the history of the ballots keeps their trace. A ballot the +/// wallet never signed, one never seen on the chain in time, one the chain refused, is +/// no vote. +#[tokio::test] +async fn a_vote_exists_for_every_state_of_the_buffer() { + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let unit_id = |code: &str| unit(&doc, code)["id"].as_str().expect("unit").to_owned(); + let exists = |u: String| { + let state = state.clone(); + async move { ld_server::quorum::a_vote_exists(&state, &u).await.expect("query") } + }; + let (i1, e1, e2, e3, e4) = ( + version(&doc, "I1", "0"), + version(&doc, "E1", "0"), + version(&doc, "E2", "0"), + version(&doc, "E3", "0"), + version(&doc, "E4", "0"), + ); + for code in ["I1", "E1", "E2", "E3", "E4"] { + assert!(!exists(unit_id(code)).await, "{code}: nobody voted yet"); + } + // A ballot the wallet never signed: nothing. + let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await; + assert!(exists(unit_id("I1")).await, "an intention pending is a vote already"); + c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await; + assert!(!exists(unit_id("I1")).await, "let go at once: no vote"); + // Never seen on the chain in time: no vote. + let late = c.ballot(&doc, &[(&e2, Some("for"))], None).await; + age(&state, "expires_at", -2 * 3600, id_of(&late)).await; + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(db_status(&state, id_of(&late)).await.0, "expired"); + assert!(!exists(unit_id("E2")).await); + // Proven, then withdrawn on the chain: no row in `votes`, the trace stays. + let a = c.ballot(&doc, &[(&e1, Some("for"))], None).await; + indexer.pay(100, comment_of(&a), alice()); + ballots::sweep(&state).await.expect("sweep"); + let w = c.ballot(&doc, &[(&e1, None)], None).await; + indexer.pay(101, comment_of(&w), alice()); + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e1).await, (0, 0)); + assert!(exists(unit_id("E1")).await, "a vote withdrawn leaves its trace"); + // The chain closed: a simulated vote; a simulated vote, then withdrawn. + c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await; + c.ok("POST", &format!("/api/proposals/{e3}/vote"), Some(json!({ "choice": "against" }))).await; + assert!(exists(unit_id("E3")).await, "a simulated vote"); + c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await; + c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await; + assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e4).await, (0, 0)); + assert!(exists(unit_id("E4")).await, "a simulated vote withdrawn leaves its trace"); + // The quorum is held: the author (an administrator, for a text of the seed) may not set it. + let mut admin = c.other(); + admin.login_admin("Yvv").await; + for code in ["E1", "E3", "E4"] { + let (s, v) = + admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "{code}"); + } + let (s, _) = + admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E2")), Some(json!({ "quorum": 20 }))).await; + assert_eq!(s, StatusCode::OK, "E2: no vote"); + // The act « supprime » sets every simulated vote aside: with them, their trace. + ld_server::simulation::discard(&state, true).await.expect("act"); + assert!(!exists(unit_id("E3")).await && !exists(unit_id("E4")).await); + assert!(exists(unit_id("E1")).await, "a proven trace stays"); + let _ = doc_id; +} + +/// A bare repository of the texts, for the publication (ADR 0011, 0012). +struct Texts { + dir: std::path::PathBuf, + bare: std::path::PathBuf, +} + +impl Texts { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("ld-v-{name}-{}", 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 } + } + + /// A configuration that publishes there; `simulated`: `LD_PUBLISH_SIMULATED`. + fn config(&self, mut cfg: Config, simulated: bool) -> Config { + 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: false, + simulated, + }); + cfg + } + + /// The French text of the Acte as the repository has it; `None`: nothing published. + fn french(&self) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(&self.bare) + .args(["show", "main:monnaie-libre/acte-engagement-certification/fr.md"]) + .output() + .expect("git show"); + out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8")) + } +} + +impl Drop for Texts { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// A body of a version, as the document shows it. +fn body_of(doc: &Value, id: &str) -> String { + find(doc, id)["body"].as_str().expect("body").to_owned() +} + +/// Yvv, 2026-11-03: « Pas avant des votes prouvés ». A text that enters by simulated +/// votes is in force on the site, but sends nothing to the repository of the texts: +/// the publication waits until everything in effect holds on the proven votes alone — +/// the re-examination of the act « supprime » (`simulation`), told only. +#[tokio::test] +async fn a_text_entered_by_simulated_votes_is_in_force_but_never_published_before_proven_votes() { + let indexer = Indexer::start().await; + let texts = Texts::new("proven"); + let (mut c, state) = Client::new(texts.config(chain_config(&indexer), false)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + small_web(&state, &doc_id).await; + let (e4, e5) = (version(&doc, "E4", "0"), version(&doc, "E5", "0")); + // The chain closed: two simulated « pour », E4 enters — in force on the site. + c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await; + for name in ["Luna", "Bruno"] { + let mut s = c.other(); + s.login(name).await; + s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await; + } + assert_eq!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str()); + ld_server::publish::pass(&state).await.expect("pass"); + assert!(texts.french().is_none(), "not before proven votes"); + let publication = &c.doc("monnaie-libre").await["doc"]["publication"]; + assert_eq!((publication["state"].as_str(), publication["waitsFor"].as_str()), (Some("suspended"), Some("proven"))); + // The chain open: E5 enters on two proven votes — E4 still does not hold on them: nothing goes out. + c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await; + let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) }; + for (block, signer) in [(100, alice()), (101, charlie)] { + let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await; + indexer.pay(block, comment_of(&b), signer); + } + ballots::sweep(&state).await.expect("sweep"); + let d = c.doc("monnaie-libre").await; + assert_eq!(unit(&d, "E5")["inForce"]["id"], e5.as_str()); + ld_server::publish::pass(&state).await.expect("pass"); + assert!(texts.french().is_none(), "E4 does not hold on the proven votes yet"); + // The act « supprime »: E4 returns among the proposals; what is in force holds on the + // proven votes alone — it goes out. + ld_server::simulation::discard(&state, true).await.expect("act"); + ld_server::publish::pass(&state).await.expect("pass"); + let fr = texts.french().expect("published"); + let (b4, b5) = (body_of(&d, &e4), body_of(&d, &e5)); + assert!(fr.contains(b5.lines().next().expect("line")), "{fr}"); + assert!(!fr.contains(b4.lines().next().expect("line")), "{fr}"); + assert_eq!(c.doc("monnaie-libre").await["doc"]["publication"]["state"], "done"); +} + +/// Yvv may choose the other way (`LD_PUBLISH_SIMULATED=1`): published anyway. +#[tokio::test] +async fn with_the_switch_a_text_entered_by_simulated_votes_is_published() { + let texts = Texts::new("switch"); + let (mut c, state) = Client::new(texts.config(Config::for_tests(), true)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + small_web(&state, &doc_id).await; + let e4 = version(&doc, "E4", "0"); + for name in ["Luna", "Bruno"] { + let mut s = c.other(); + s.login(name).await; + s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await; + } + ld_server::publish::pass(&state).await.expect("pass"); + let fr = texts.french().expect("published"); + assert!(fr.contains(body_of(&doc, &e4).lines().next().expect("line")), "{fr}"); +} + +/// Production runs the migrations up to 0008 (M5, `3f10a0e`), its vote on the chain off. +/// The image of now applies 0009, 0010 and 0011, in this order; 0010 rewrites no row — +/// a ballot of before stays a ballot of the chain, its lines without outcome, the votes +/// as they were — and the first start writes nothing of the buffer. +#[tokio::test] +async fn on_a_base_like_production_the_three_migrations_pass_in_order_and_0010_rewrites_nothing() { + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true); + let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool"); + 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 0008"); + let tables = |pool: sqlx::SqlitePool| async move { + let ballots: Vec<(String, String, Option)> = + sqlx::query_as("SELECT id, status, block FROM ballots ORDER BY id") + .fetch_all(&pool) + .await + .expect("ballots"); + let lines: Vec<(String, String, String)> = + sqlx::query_as("SELECT ballot_id, proposal_id, choice FROM ballot_lines ORDER BY ballot_id") + .fetch_all(&pool) + .await + .expect("lines"); + let votes: Vec<(String, String, String, String, Option)> = sqlx::query_as( + "SELECT proposal_id, identity_id, choice, source, ballot_id FROM votes ORDER BY proposal_id, identity_id", + ) + .fetch_all(&pool) + .await + .expect("votes"); + (ballots, lines, votes) + }; + // The rows the image of before writes: a document, a ballot of the chain confirmed + // with its line and its vote, and one pending. + for sql in [ + "INSERT INTO groups (slug, name, policy, hidden, position) VALUES ('monnaie-libre', 'Monnaie libre', 'g1-members', 0, 0)", + "INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, created_at) + VALUES ('d1', 'monnaie-libre', 'acte', '{\"fr\":\"Acte\"}', '{}', 'fr', 50, '2026-10-21T00:00:00Z')", + "INSERT INTO units (id, doc_id, code, section, position, inertia, created_at) VALUES ('u1', 'd1', 'E4', 'fondamental', 1, '3', '2026-10-21T00:00:00Z')", + "INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, revision, created_at, updated_at) + VALUES ('p1', 'u1', 'text', 'E4', 'Un texte.', 'open', 'seed', 1, '2026-10-21T00:00:00Z', '2026-10-21T00:00:00Z')", + "INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-21T00:00:00Z')", + "INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES ('idty:9001', 'g1', 'Alice', 1, '2026-10-21T00:00:00Z')", + "INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, voter_id, block, + event_index, created_at, expires_at, settled_at) + VALUES ('b1', 'd1', 'dev:chiara', 's1', 'h1', '0xr1', 'g1vote', 10, 'confirmed', 'idty:9001', 100, 1, + '2026-11-01T00:00:00Z', '2026-11-02T00:00:00Z', '2026-11-01T00:01:00Z')", + "INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b1', 'p1', 1, 'for')", + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id) + VALUES ('p1', 'idty:9001', 'for', 1, '2026-11-01T00:01:00Z', 'chain', 100, 1, 'b1')", + "INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, created_at, expires_at) + VALUES ('b2', 'd1', 'dev:chiara', 's2', 'h2', '0xr2', 'g1vote', 10, 'pending', '2026-11-03T00:00:00Z', '2099-01-01T00:00:00Z')", + "INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', 'p1', 1, 'against')", + ] { + sqlx::query(sql).execute(&pool).await.unwrap_or_else(|e| panic!("{sql}: {e}")); + } + let written = tables(pool.clone()).await; + // The image of now. + let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state"); + let mut all = sqlx::migrate!("./migrations"); + all.set_ignore_missing(true); + all.run(&pool).await.expect("0009 to 0011"); + let order: Vec = sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY rowid") + .fetch_all(&pool) + .await + .expect("order"); + assert_eq!(order, (1..=11).collect::>(), "0009, then 0010, then 0011"); + assert_eq!(tables(pool.clone()).await, written, "no row rewritten"); + let added: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT b.id, b.mode, l.outcome FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id ORDER BY b.id", + ) + .fetch_all(&pool) + .await + .expect("columns"); + assert_eq!(added, [("b1".into(), "chain".into(), None), ("b2".into(), "chain".into(), None)]); + let index: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'ballots_doc'") + .fetch_one(&pool) + .await + .expect("index"); + assert_eq!(index, 1); + // The first start, the chain closed: the watcher passes, nothing of the buffer is written. + ballots::sweep(&state).await.expect("sweep"); + assert_eq!(tables(pool.clone()).await, written); + let simulated: i64 = sqlx::query_scalar( + "SELECT (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated') + (SELECT COUNT(*) FROM votes WHERE source = 'simulated') + + (SELECT COUNT(*) FROM messages WHERE topic = 'ballot')", + ) + .fetch_one(&pool) + .await + .expect("simulated"); + assert_eq!(simulated, 0); + // The ballots of before read as they did: a vote of the chain, recorded; the one + // pending, an intention that takes its place in the totals shown only. + let mine = ballots::my_votes(&state, "dev:chiara", "d1").await.expect("mine"); + assert_eq!(mine.get("p1").map(|v| (v.choice.as_str(), v.source.as_str())), Some(("for", "chain"))); + let st = ld_server::docs::load_state(&state, "d1").await.expect("state"); + let pair = |t: ld_engine::threshold::Tally| (t.for_votes, t.against); + assert_eq!( + (pair(st.tally("p1")), pair(st.shown_tally("p1")), pair(st.pending_tally("p1"))), + ((1, 0), (0, 1), (0, 1)) + ); +} diff --git a/crates/ld-server/tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.json b/crates/ld-server/tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.json new file mode 100644 index 0000000..171b38f --- /dev/null +++ b/crates/ld-server/tests/fixtures/seed-production-2026-11-02/acte-engagement-certification.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/inertia.rs b/crates/ld-server/tests/inertia.rs index 7ee777b..06b1ef1 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -1,13 +1,15 @@ //! Inertia in five levels (ADR 0007): the levels and the scale served, the //! usual participation of a group, the change of inertia voted at the level //! of its article, the migration of the four words of before, and the -//! amendment that raises E1 and E2 to level 4 in an existing base. +//! amendment that raised E1 and E2 to level 4 in an existing base (30/10; since +//! 2026-11-02 the seed puts them forward at a low level, ADR 0011: the scenarios +//! of 30/10 are replayed on the seed production ran then). use axum::body::Body; use axum::http::{Request, StatusCode, header}; use http_body_util::BodyExt; use ld_engine::inertia::{InertiaLevel, Scale, gradient}; -use ld_engine::threshold::required_ratio; +use ld_engine::threshold::{judged_voters, required_ratio}; use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; use serde_json::{Value, json}; use sqlx::SqlitePool; @@ -90,6 +92,17 @@ fn level(k: u8) -> InertiaLevel { InertiaLevel::new(k).expect("1 to 5") } +/// The level seed proposals start at (ADR 0011). +const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL; + +/// The quorums of Monnaie libre by level (ADR 0013, Yvv, 2026-11-02). +const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50]; + +/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1, E2 in force, E1 and E2 at 4. +fn production_seed_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02") +} + fn numbers(levels: &Value, key: &str) -> Vec { levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect() } @@ -99,16 +112,16 @@ async fn the_five_levels_and_the_scale_are_served() { let (mut c, _) = Client::new().await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - // 3 everywhere, 4 for the two structural commitments. - let raised: Vec<&str> = doc["units"] + // 3 everywhere, low for the three seed proposals (ADR 0011). + let other: Vec<&str> = doc["units"] .as_array() .expect("units") .iter() .filter(|u| u["inertia"] != 3) .map(|u| u["code"].as_str().expect("code")) .collect(); - assert_eq!(raised, ["E1", "E2"]); - assert!(unit(&doc, "E1")["inertia"] == 4 && unit(&doc, "E2")["inertia"] == 4); + assert_eq!(other, ["I1", "E1", "E2"]); + assert!(["I1", "E1", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW)); // No vote yet: the reference, 2C/W = 100 voters out of 5745. let p = &doc["doc"]["participation"]; @@ -127,22 +140,26 @@ async fn the_five_levels_and_the_scale_are_served() { assert_eq!(numbers(levels, "level"), [1.0, 2.0, 3.0, 4.0, 5.0]); assert_eq!(numbers(levels, "majorityPct"), [50.0, 50.0, 55.0, 60.0, 66.0]); assert_eq!(numbers(levels, "sharePct"), [60.0, 67.0, 75.0, 83.0, 90.0]); - let scale = Scale { electorate: 5745, floor: 50.0, participation: ratio }; + // The quorums by level (ADR 0013, Yvv: « 12 · 12 · 50 · 50 · 50 »). + assert_eq!(numbers(levels, "quorum"), [12.0, 12.0, 50.0, 50.0, 50.0]); + let scale = Scale { electorate: 5745, participation: ratio, quorums: QUORUMS }; for (l, k) in levels.as_array().expect("levels").iter().zip(InertiaLevel::ALL) { assert!((l["gradient"].as_f64().expect("G") - gradient(k, ratio)).abs() < 1e-12); - // At the usual participation, C plus s_k of the votes beyond C. + // At the usual participation, above every quorum: s_k of the voters, nothing in front. assert_eq!(l["cast"], 100); - assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k).expect("valid")))); - let at_usual = 50.0 + k.share_pct() / 100.0 * 50.0; - assert_eq!(l["required"].as_f64(), Some(at_usual.ceil())); + let q = scale.quorum(k, None); + assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k, q).expect("valid")))); + assert_eq!(l["required"].as_f64(), Some(k.share_pct().ceil())); } + assert_eq!(numbers(levels, "required"), [60.0, 67.0, 75.0, 83.0, 90.0]); // A version: the five levels at its own participation, and the level it is voted at. - let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned(); + let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned(); let d = c.get(&format!("/api/proposals/{e1}")).await; - assert_eq!(d["proposal"]["votedAtInertia"], 4); + assert_eq!(d["proposal"]["votedAtInertia"], LOW); assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]); - assert_eq!(numbers(&d["levels"], "required"), [50.0; 5], "nothing passes under C"); + // No vote: judged on the quorum, the voters missing counted as against (ADR 0013). + assert_eq!(numbers(&d["levels"], "required"), [8.0, 9.0, 39.0, 43.0, 46.0], "the quorum, not C"); assert_eq!(d["participation"]["source"], "reference"); assert_eq!(d["electorateSource"], "wot"); } @@ -160,25 +177,28 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled( let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(bad.clone()))).await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("inertia")), "{bad}"); } - let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(4)))).await; + let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(LOW)))).await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("same_inertia"))); - let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(1)))).await; + let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(5)))).await; assert_eq!(s, StatusCode::OK, "{v}"); let p = v["id"].as_str().expect("id").to_owned(); let d = a.get(&format!("/api/proposals/{p}")).await; - assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(1), Some(4))); - // W(essai) = 2: both vote for, the article goes to level 1. - a.vote(&p, "for").await; - let v = b.vote(&p, "for").await; + assert_eq!( + (d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), + (Some(5), Some(u64::from(LOW))) + ); + // W(essai) = 2, the quorum 1 everywhere in the test group (ADR 0013): one voice + // passes, the article goes to level 5. + let v = a.vote(&p, "for").await; assert_eq!(v["entered"][0]["kind"], "inertia"); let doc = a.doc("essai").await; let e2u = unit(&doc, "E2"); - assert_eq!(e2u["inertia"], 1); + assert_eq!(e2u["inertia"], 5); let changes = e2u["inertiaChanges"].as_array().expect("changes"); assert_eq!(changes.len(), 1); assert_eq!( (changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()), - (Some(4), Some(1), Some("vote")) + (Some(u64::from(LOW)), Some(5), Some("vote")) ); // The adoption keeps the level it was judged at, and the scale. let params: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = ?") @@ -187,15 +207,17 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled( .await .expect("adoption"); let params: Value = serde_json::from_str(¶ms).expect("json"); + let judged = Scale { electorate: 2, participation: 0.5, quorums: [1; 5] }.params(level(LOW), 1); assert_eq!( (params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()), - (Some(4), Some(60.0), Some(83.0)) + (Some(u64::from(LOW)), Some(judged.majority_pct), Some(level(LOW).share_pct())) ); + assert_eq!((params["quorum"].as_u64(), params.get("constant")), (Some(1), None), "the quorum, no C"); assert_eq!( (params["participation"].as_f64(), params["participation_source"].as_str()), (Some(0.5), Some("reference")) ); - assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(4), 0.5)).abs() < 1e-12)); + assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(LOW), 0.5)).abs() < 1e-12)); } #[tokio::test] @@ -240,9 +262,9 @@ async fn the_usual_participation_is_the_median_of_five_voted_proposals() { } // Thresholds follow: the proposal with 4 voters is judged on this scale. let d = first.get(&format!("/api/proposals/{}", open[4])).await; - let scale = Scale { electorate: 10, floor: 1.0, participation: 0.3 }; + let scale = Scale { electorate: 10, participation: 0.3, quorums: [1; 5] }; let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio"); - assert!((ratio - required_ratio(10, 4, &scale.params(level(3)))).abs() < 1e-12); + assert!((ratio - required_ratio(10, 4, &scale.params(level(3), 1))).abs() < 1e-12); // A new vote does not move it within the day… people[4].vote(&open[0], "against").await; @@ -358,9 +380,9 @@ async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() { // The base as production holds it on 2026-10-30, then the new version // --------------------------------------------------------------------------- -/// The seed as it was then: no modules yet (ADR 0009 came after). +/// The seed as it was then: no modules yet (ADR 0009 came after), I1, E1 and E2 in force. fn seed_of_the_time(tag: &str) -> std::path::PathBuf { - let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json"); + let today = production_seed_dir().join("acte-engagement-certification.json"); let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json"); seed.as_object_mut().expect("object").remove("modules"); let dir = std::env::temp_dir().join(format!("ld-levels-seed-{tag}-{}", std::process::id())); @@ -397,8 +419,11 @@ async fn start_new_version(pool: &SqlitePool) -> AppState { restart(pool).await } +/// Started again with the seed production ran after 30/10 (E1 and E2 at 4). async fn restart(pool: &SqlitePool) -> AppState { - let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state"); + let mut cfg = Config::for_tests(); + cfg.seed_dir = production_seed_dir(); + let state = AppState::new(cfg, pool.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } @@ -449,11 +474,26 @@ fn decided(rows: &[Decision]) -> Vec<(&str, &str, &str, Option<&str>)> { rows.iter().map(|(g, u, o, r)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref())).collect() } +/// Since 2026-11-02 the seed gives E1 and E2 the low level of seed proposals: the +/// raise of 30/10 is superseded, decided in production and nowhere else. #[tokio::test] -async fn a_new_base_starts_with_e1_and_e2_at_level_4() { - let (_, state) = Client::new().await; +async fn a_new_base_starts_low_and_the_raise_of_30_10_decides_nothing() { + let (mut c, state) = Client::new().await; + assert_eq!(decided(&inertia_decisions(&state.db).await), []); + let changes: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n"); + assert_eq!(changes, 0, "born low: nothing changed"); + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + assert!(["E1", "E2"].iter().all(|code| unit(&doc, code)["inertia"] == LOW)); + // On the seed of production, the raise is still there to decide: present at birth. + let mut cfg = Config::for_tests(); + cfg.seed_dir = production_seed_dir(); + let db = open_db("sqlite::memory:").await.expect("db"); + let born = AppState::new(cfg, db).await.expect("state"); + seed::ensure(&born).await.expect("seed"); assert_eq!( - decided(&inertia_decisions(&state.db).await), + decided(&inertia_decisions(&born.db).await), [ ("essai", "E1", "present", None), ("essai", "E2", "present", None), @@ -461,9 +501,6 @@ async fn a_new_base_starts_with_e1_and_e2_at_level_4() { ("monnaie-libre", "E2", "present", None), ] ); - let changes: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n"); - assert_eq!(changes, 0, "born at 4: nothing changed"); } #[tokio::test] @@ -594,10 +631,13 @@ async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4() let d = c.get(&format!("/api/proposals/{e1_ml}")).await; let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio"); let p = d["participation"]["ratio"].as_f64().expect("p"); - let scale = Scale { electorate: 5745, floor: 50.0, participation: p }; - assert!((ratio - required_ratio(5745, 1, &scale.params(level(4)))).abs() < 1e-12); - assert!((ratio - required_ratio(5745, 1, &scale.params(level(3)))).abs() > 1e-6, "not at the old level"); - assert_eq!(d["proposal"]["evaluation"]["required"], 50); + // One voter, under the quorum of level 4 (50): judged on 50 voters, 49 counted against. + let scale = Scale { electorate: 5745, participation: p, quorums: QUORUMS }; + let voters = judged_voters(1, 50); + assert!((ratio - required_ratio(5745, voters, &scale.params(level(4), 50))).abs() < 1e-12); + assert!((ratio - required_ratio(5745, voters, &scale.params(level(3), 50))).abs() > 1e-6, "not at the old level"); + assert_eq!(d["proposal"]["evaluation"]["quorum"], 50); + assert_eq!(d["proposal"]["evaluation"]["required"], 43); // Republished where the document publishes; never from the test group. let queued: Vec<(String, String)> = sqlx::query_as( "SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id @@ -637,8 +677,11 @@ async fn an_inertia_changed_by_a_vote_keeps_its_level() { let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(json!({ "target": 5 }))).await; assert_eq!(s, StatusCode::OK, "{v}"); let p = v["id"].as_str().expect("id").to_owned(); - a.vote(&p, "for").await; - assert_eq!(b.vote(&p, "for").await["entered"][0]["kind"], "inertia"); + // One voice of the two: before the quorum amendment, the C = 1 of the test group is + // read as the quorum of every level (ADR 0013). + assert_eq!(a.vote(&p, "for").await["entered"][0]["kind"], "inertia"); + let (s, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("not_votable")), "already decided"); let state = restart(&pool).await; assert_eq!( diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index 8bf1fd3..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; @@ -183,14 +198,16 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() { let id = doc["doc"]["id"].as_str().expect("id"); let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; assert_eq!(all["units"].as_array().expect("units").len(), 33); - // The list of the group: the articles in force; no module has an open proposal yet. + // The list of the group: the articles in force — none, I1, E1 and E2 are seed proposals + // (ADR 0011), three more open versions —; no module has an open proposal yet. let g = c.ok("GET", "/api/groups/monnaie-libre", None).await; - assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(3), Some(32))); + assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35))); } #[tokio::test] async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() { - let (mut yvv, mut chiara, _) = two().await; + let (mut yvv, mut chiara, state) = two().await; + two_voices(&state, "ordre.conclusion").await; let doc = yvv.doc("essai").await; let conclusion = order_of(&doc, "conclusion").clone(); let module = conclusion["id"].as_str().expect("id").to_owned(); @@ -240,7 +257,9 @@ async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_f #[tokio::test] async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { - let (mut yvv, mut chiara, _) = two().await; + let (mut yvv, mut chiara, state) = two().await; + // The order stays under vote with one voice. + two_voices(&state, "ordre.inertie").await; let doc = yvv.doc("essai").await; let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned(); let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); @@ -250,7 +269,8 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { .expect("id") .to_owned(); yvv.vote(&p, "for").await; - // The list of the group counts it among the proposals in progress, never among the articles. + // The list of the group counts it among the proposals in progress, never among the articles + // (none in force: I1, E1 and E2 are seed proposals, ADR 0011). let g = yvv.ok("GET", "/api/groups/essai", None).await; let before = doc["units"] .as_array() @@ -259,7 +279,7 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { .map(|u| u["proposals"].as_array().expect("p").iter().filter(|x| x["status"] == "open").count() as u64); assert_eq!( (g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), - (Some(3), Some(before.sum::() + 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 @@ -398,6 +418,7 @@ async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected() #[tokio::test] async fn another_title_enters_into_force_and_its_translations_are_to_review() { let (mut yvv, mut chiara, state) = two().await; + two_voices(&state, "titre").await; let doc = yvv.doc("essai").await; let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned(); let uri = format!("/api/units/{module}/title"); @@ -492,8 +513,17 @@ async fn a_new_article_goes_at_the_end_of_the_section_asked_for() { // A base born without modules, as production holds it, then the new version // --------------------------------------------------------------------------- +/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a +/// minima. Since that day the seed puts them forward as seed proposals (ADR 0011, +/// tests/seed_proposals.rs); the addition of the modules is replayed on the seed of then. +fn production_config() -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02"); + cfg +} + fn seed_without_modules(tag: &str) -> std::path::PathBuf { - let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json"); + let today = production_config().seed_dir.join("acte-engagement-certification.json"); let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json"); seed.as_object_mut().expect("object").remove("modules"); let dir = std::env::temp_dir().join(format!("ld-modules-seed-{tag}-{}", std::process::id())); @@ -554,7 +584,7 @@ async fn an_existing_document_receives_its_modules_once_from_what_it_holds() { let reads = read_order(&c.doc("essai").await, "conclusion"); // The new version starts: each document receives its modules, from what it holds. - let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state"); + let state = AppState::new(production_config(), before.db.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); let rows: Vec<(String, String, Option, 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 +649,7 @@ async fn an_existing_document_receives_its_modules_once_from_what_it_holds() { assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"])); // Once only: the next start adds nothing. - let again = AppState::new(Config::for_tests(), state.db.clone()).await.expect("state"); + let again = AppState::new(production_config(), state.db.clone()).await.expect("state"); seed::ensure(&again).await.expect("seed"); let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n"); 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/overview.rs b/crates/ld-server/tests/overview.rs index 25701b7..9e5c782 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,28 +158,33 @@ async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() { async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { // In the test group, which votes on the server (Monnaie libre votes on the chain: // tests/ballots.rs counts « sans ton vote » there). - let mut c = Client::new().await; + let (mut c, state) = Client::with_state().await; c.login("Chiara", false).await; + // Someone else, who will not vote: W(essai) = 2. + let mut yvv = c.other(); + yvv.login("Yvv", false).await; let d = decisions(&mut c, "essai").await; assert_eq!(d["canVote"], true); let docs = d["docs"].as_array().expect("docs"); assert_eq!(docs.len(), 1); assert_eq!(docs[0]["slug"], "acte-engagement-certification"); assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification"); - // 3 seeds a minima + 30 open articles, the two variants counting once; the title of the - // document and the order of 7 sections (« ordonnancement » holds one article: nothing to decide). + // 3 seed proposals (ADR 0011) + 30 open articles, the two variants counting once; the title + // of the document and the order of 7 sections (« ordonnancement » holds one article: nothing + // to decide). assert_eq!(docs[0]["toVote"], 41); assert_eq!(docs[0]["withoutMyVote"], 41); let doc = the_doc(&mut c, "essai").await; + // Votes that adopt nothing: the quorum 2 (ADR 0013), one voice of two. + two_voices(&state, "E3").await; + two_voices(&state, "I1").await; c.vote(&version(&doc, "E3", "0"), "for").await; c.vote(&version(&doc, "E3", "1"), "against").await; - c.vote(&version(&doc, "I1", "inForce"), "for").await; + c.vote(&version(&doc, "I1", "0"), "for").await; let d = decisions(&mut c, "essai").await; assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39))); // Someone else has not voted yet. - let mut yvv = c.other(); - yvv.login("Yvv", false).await; assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41); } @@ -170,11 +194,11 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou yvv.login("Yvv", false).await; let mut chiara = yvv.other(); chiara.login("Chiara", false).await; - // Dev identities are members of every group: W(essai) = 2, C = 1; two « for » adopt. + // Dev identities are members of every group: W(essai) = 2, the quorum 1 at every level + // (ADR 0013): one « for » adopts. let doc = the_doc(&mut yvv, "essai").await; let e3 = version(&doc, "E3", "0"); - yvv.vote(&e3, "for").await; - let v = chiara.vote(&e3, "for").await; + let v = yvv.vote(&e3, "for").await; assert_eq!(v["entered"][0]["code"], "E3"); let d = decisions(&mut yvv, "essai").await; assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40))); @@ -239,15 +263,16 @@ async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() { } #[tokio::test] -async fn where_one_does_not_vote_there_is_no_count_of_ones_own() { +async fn while_the_chain_is_closed_a_guest_votes_in_simulation_and_has_a_count_of_ones_own() { let mut admin = Client::new().await; admin.login("Yvv", true).await; let mut guest = admin.other(); guest.invited(&mut admin, &["monnaie-libre", "essai"]).await; + // No vote account: Monnaie libre votes in simulation, for whoever opens it (ADR 0012). let d = decisions(&mut guest, "monnaie-libre").await; - assert_eq!(d["canVote"], false); + assert_eq!(d["canVote"], true); assert_eq!(d["docs"][0]["toVote"], 41); - assert!(d["docs"][0]["withoutMyVote"].is_null()); + assert_eq!(d["docs"][0]["withoutMyVote"], 41); // In the test group, the same person votes. let d = decisions(&mut guest, "essai").await; assert_eq!(d["canVote"], true); 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 new file mode 100644 index 0000000..b65f5bc --- /dev/null +++ b/crates/ld-server/tests/seed_proposals.rs @@ -0,0 +1,714 @@ +//! 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/acte-engagement-certification.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json") +} + +/// The seed of production, with I1 the Préambule as before 2026-10-30. +fn seed_dir(tag: &str, preambule: bool) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ld-seed-proposals-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + let mut seed = production_seed(); + if preambule { + let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1"); + for (lang, title, body) in PREAMBULE { + i1["versions"][0]["title"][lang] = json!(title); + i1["versions"][0]["body"][lang] = json!(body); + } + } + std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); + dir +} + +/// Production as of 2026-11-02: born with the Préambule, then started with the seed it +/// runs (its amendments: I1 becomes the Raison d'être). +async fn production(tag: &str, cfg: Config) -> AppState { + let mut first = cfg.clone(); + first.seed_dir = seed_dir(&format!("{tag}-first"), true); + let db = open_db("sqlite::memory:").await.expect("db"); + let born = AppState::new(first, db).await.expect("state"); + seed::documents(&born).await.expect("first seed"); + let mut then = cfg; + then.seed_dir = seed_dir(&format!("{tag}-then"), false); + let state = AppState::new(then, born.db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed of production"); + state +} + +/// The same base, started again with today's seed (and its amendments). +async fn restart(before: &AppState, cfg: Config) -> AppState { + let state = AppState::new(cfg, before.db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +#[derive(Debug, PartialEq, sqlx::FromRow)] +struct Version { + id: String, + status: String, + revision: i64, + title: String, + body: String, + in_force: bool, +} + +async fn version(state: &AppState, group: &str, code: &str) -> Version { + sqlx::query_as( + "SELECT p.id, p.status, p.revision, p.title, p.body, IFNULL(u.in_force_id = p.id, 0) AS in_force + FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id + WHERE d.group_slug = ? AND u.code = ? AND p.origin = 'seed' AND p.kind = 'text'", + ) + .bind(group) + .bind(code) + .fetch_one(&state.db) + .await + .expect("version") +} + +async fn kinds(state: &AppState, id: &str) -> Vec { + 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); + } + } + // The articles of the quorum (F1.1, F1.2, N1.1) are amended at the same start, by the + // seed amendments of lot T (`tests/seed_quorum_texts.rs`): left out here. + let translations = |state: AppState| async move { + sqlx::query_as::<_, (String, String, String, String, String)>( + "SELECT t.proposal_id, t.lang, t.title, t.body, t.source_hash FROM translations t + JOIN proposals p ON p.id = t.proposal_id JOIN units u ON u.id = p.unit_id + WHERE u.code NOT IN ('F1.1', 'F1.2', 'N1.1') ORDER BY t.proposal_id, t.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, + simulated: false, + }); + 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, the quorum 1 at every level of the test group (ADR 0013) — one + // voice passes; one against holds a voice for (the share M of two voters). + let doc = a.doc("essai").await; + let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned(); + let v = b.vote(&i1, "against").await; + assert!(v["entered"].as_array().expect("entered").is_empty()); + let v = a.vote(&i1, "for").await; + assert!(v["entered"].as_array().expect("entered").is_empty(), "one for, one against: not enough"); + let v = b.vote(&i1, "for").await; + assert_eq!(v["entered"][0]["code"], "I1"); + let doc = a.doc("essai").await; + let p = &unit(&doc, "I1")["inForce"]; + assert_eq!(p["id"], i1.as_str()); + assert!(p["enteredAt"].is_string()); + assert_eq!((p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()), (Some(false), Some(false))); + let g = a.get("/api/groups/essai").await; + assert_eq!(g["docs"][0]["inForce"], 1); + let adopted: (String, i64, i64) = + sqlx::query_as("SELECT kind, votes_for, electorate FROM adoptions WHERE proposal_id = ?") + .bind(&i1) + .fetch_one(&state.db) + .await + .expect("adoption"); + assert_eq!(adopted, ("text".to_owned(), 2, 2)); + + // Whatever the number of members, one voice against none passes in the test group: at + // 16, as at 2 (the quorum 1; before 2026-11-03, C = 1 + B^W held it under 16). + for i in 0..14 { + let mut m = a.other(); + m.login(&format!("Membre {i}"), false).await; + } + let doc = a.doc("essai").await; + let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned(); + let v = a.vote(&e1, "for").await; + assert_eq!(v["entered"][0]["code"], "E1", "16 members: one voice"); + let electorate: i64 = sqlx::query_scalar("SELECT electorate FROM adoptions WHERE proposal_id = ?") + .bind(&e1) + .fetch_one(&state.db) + .await + .expect("adoption"); + assert_eq!(electorate, 16); +} diff --git a/crates/ld-server/tests/seed_quorum_texts.rs b/crates/ld-server/tests/seed_quorum_texts.rs new file mode 100644 index 0000000..3a4033c --- /dev/null +++ b/crates/ld-server/tests/seed_quorum_texts.rs @@ -0,0 +1,1058 @@ +//! Lot T (2026-11-03): the articles of the seed that still described the floor C of +//! « pour » votes — F1.1 « Formule du seuil WoT », F1.2 « Paramètres par défaut », +//! N1.1 « Niveaux d'inertie » — say the quorum by level (ADR 0013). +//! +//! Every number of these texts is computed here by the engine (or read from the seed), +//! in each language; their formulas are evaluated against the engine; their form follows +//! the rules of the texts (`docs/chantiers/acte-certification/textes-quorum-seed.md`). +//! A guarded seed amendment replaces them in production, on a base made as production +//! is after M6, only where nobody touched them. + +use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds}; +use ld_engine::threshold::{FormulaParams, judged_voters, no_needed, required_ratio, yes_needed}; +use ld_server::seed_amend::{self, AMENDMENTS, Version}; +use ld_server::{AppState, Config, open_db, seed}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const CODES: [&str; 3] = ["F1.1", "F1.2", "N1.1"]; +const LANGS: [&str; 3] = ["fr", "en", "es"]; +const GROUPS: [&str; 2] = ["essai", "monnaie-libre"]; +/// The illustration of N1.1: the web of trust and the usual turnout of the smith vote +/// of 2024 (ADR 0007, 0013), not the size of the web today. +const EXAMPLE_W: u32 = 7224; +const EXAMPLE_MEDIAN: u32 = 120; + +// --------------------------------------------------------------------------- +// The seeds +// --------------------------------------------------------------------------- + +fn read(path: PathBuf) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") +} + +fn today_seed() -> Value { + read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) +} + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +/// The seed production runs since M5 (3f10a0e), unchanged by M6 for these articles. +fn production_seed() -> Value { + read(fixtures().join("seed-production-2026-11-02/acte-engagement-certification.json")) +} + +/// The four rule articles as production was born with them (the seed of 21/10). +fn rules_before() -> Value { + read(fixtures().join("seed-regles-avant.json")) +} + +fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value { + seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +/// The title and body of the first version of an article, in a language. +fn text<'a>(seed: &'a Value, code: &str, lang: &str) -> (&'a str, &'a str) { + let v = &unit(seed, code)["versions"][0]; + (v["title"][lang].as_str().expect("title"), v["body"][lang].as_str().expect("body")) +} + +/// A seed with the first version of `codes` taken from `from` (title and body). +fn with_texts(base: &Value, from: &Value, codes: &[&str]) -> Value { + let mut seed = base.clone(); + for u in seed["units"].as_array_mut().expect("units") { + let code = u["code"].as_str().expect("code").to_owned(); + if codes.contains(&code.as_str()) { + let v = from["units"].get(&code).unwrap_or_else(|| &unit(from, &code)["versions"][0]); + u["versions"][0]["title"] = v["title"].clone(); + u["versions"][0]["body"] = v["body"].clone(); + } + } + seed +} + +fn seed_dir(tag: &str, seed: &Value) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ld-seed-quorum-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); + dir +} + +// --------------------------------------------------------------------------- +// The figures: from the engine and the seed, never by hand +// --------------------------------------------------------------------------- + +struct Figures { + /// Q_k of Monnaie libre (the seed). + quorums: [u32; 5], + /// Twice the reference quorum of the document, in voters (the engine's p0 · W). + reference: u32, + /// The lowest quorum an author may set in Monnaie libre. + lowest_quorum: u32, + base: f64, + refresh_hours: u64, + default_level: u8, + seed_level: u8, + title_level: u8, + order_level: u8, + /// The level of every other article of the seed. + others_level: u8, + min_sample: u32, + /// The bounds of the usual turnout, in percent. + bounds: (f64, f64), + /// « pour » asked at the example's 120 voters, level 1 to 5. + example: [u32; 5], + /// Under the quorum: (level, quorum, « pour » that pass, « contre » at most). + under: [(u8, u32, u32, u32); 2], +} + +fn level(k: u8) -> InertiaLevel { + InertiaLevel::new(k).expect("1 to 5") +} + +fn figures(seed: &Value) -> Figures { + let ml = + seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == "monnaie-libre").expect("ml"); + let quorums: [u32; 5] = serde_json::from_value(ml["quorums"].clone()).expect("quorums"); + let vote_floor = ml["voteFloor"].as_f64().expect("voteFloor"); + let p0 = Participation::of(EXAMPLE_W, vote_floor, None, 0); + assert!(!p0.bounded, "the reference turnout is not brought back within the bounds"); + let reference = p0.voters(EXAMPLE_W); + assert!((reference - reference.round()).abs() < 1e-9, "a whole number of voters: {reference}"); + let scale = + Scale { electorate: EXAMPLE_W, participation: f64::from(EXAMPLE_MEDIAN) / f64::from(EXAMPLE_W), quorums }; + let example = InertiaLevel::ALL.map(|k| scale.required(EXAMPLE_MEDIAN, k, scale.quorum(k, None)).expect("valid")); + let under = [2u8, 3].map(|k| { + let q = scale.quorum(level(k), None); + let p = scale.params(level(k), q); + let yes = yes_needed(0, EXAMPLE_W, &p).expect("valid"); + let most = no_needed(yes, EXAMPLE_W, &p).expect("valid") - 1; + assert!(yes + most <= q, "level {k}: the example stays under the quorum"); + (k, q, yes, most) + }); + let levels: Vec = seed["units"] + .as_array() + .expect("units") + .iter() + .filter_map(|u| u["inertia"].as_u64()) + .map(|k| u8::try_from(k).expect("level")) + .collect(); + assert!(levels.windows(2).all(|w| w[0] == w[1]), "every other article at one level: {levels:?}"); + let module = |m: &str| u8::try_from(seed["modules"][m]["inertia"].as_u64().expect("module level")).expect("level"); + assert_eq!(ld_server::participation::WINDOW_DAYS, 365, "« depuis un an »"); + assert_eq!(ld_server::participation::EVERY_HOURS, 24, "« une fois par jour »"); + Figures { + quorums, + reference: reference.round() as u32, + lowest_quorum: quorum_bounds(EXAMPLE_W, false).0, + base: FormulaParams::DEFAULT_BASE, + refresh_hours: ld_server::wot::REFRESH.as_secs() / 3600, + default_level: InertiaLevel::DEFAULT.get(), + seed_level: seed::SEED_PROPOSAL_LEVEL, + title_level: module("title"), + order_level: module("order"), + others_level: levels[0], + min_sample: Participation::MIN_SAMPLE, + bounds: (Participation::LOWEST * 100.0, Participation::HIGHEST * 100.0), + example, + under, + } +} + +/// A whole number as the language writes it: 7 224, 7,224, 7224. +fn int(lang: &str, n: u32) -> String { + let digits = n.to_string(); + if n < 1000 || lang == "es" { + return digits; + } + let sep = if lang == "fr" { " " } else { "," }; + let (head, tail) = digits.split_at(digits.len() - 3); + format!("{head}{sep}{tail}") +} + +/// A decimal: 0,1 or 0.1. +fn dec(lang: &str, v: f64) -> String { + let s = v.to_string(); + if lang == "en" { s } else { s.replace('.', ",") } +} + +/// A percentage: 60 %, 60%. +fn pct(lang: &str, v: f64) -> String { + if lang == "en" { format!("{}%", dec(lang, v)) } else { format!("{} %", dec(lang, v)) } +} + +/// The name of a level, as the interface says it (`inertia.name.k` in `web/src/lib/i18n`; +/// the image builds the server without the interface's sources, so they are written here). +fn level_name(lang: &str, k: u8) -> String { + let names = match lang { + "fr" => ["très basse", "basse", "moyenne", "haute", "très haute"], + "en" => ["very low", "low", "medium", "high", "very high"], + _ => ["muy baja", "baja", "media", "alta", "muy alta"], + }; + names[usize::from(k - 1)].to_owned() +} + +/// The titles of the seed proposals (Raison d'être, Unicité du compte, Réciprocité). +fn seed_proposal_titles(seed: &Value, lang: &str) -> Vec { + seed["units"] + .as_array() + .expect("units") + .iter() + .filter(|u| u["versions"][0]["seedProposal"] == true) + .map(|u| u["versions"][0]["title"][lang].as_str().expect("title").to_owned()) + .collect() +} + +/// « a, b et c », “a, b and c”, «a, b y c». +fn and_list(lang: &str, items: &[String]) -> String { + let and = match lang { + "fr" => "et", + "en" => "and", + _ => "y", + }; + let (last, head) = items.split_last().expect("items"); + format!("{} {and} {last}", head.join(", ")) +} + +/// What is at a level at the start, as N1.1 says it. +fn at_start(f: &Figures, seed: &Value, lang: &str, k: u8) -> String { + let proposals = k == f.seed_level; + let order = k == f.order_level; + let title = k == f.title_level; + let others = k == f.others_level; + let titles = seed_proposal_titles(seed, lang).join(", "); + match (proposals, order, title, others, lang) { + (false, false, false, false, "fr") => "Aucun article au départ.".into(), + (false, false, false, false, "en") => "No article at the start.".into(), + (false, false, false, false, _) => "Ningún artículo al inicio.".into(), + (true, true, false, false, "fr") => format!("{titles}, et l'ordre des articles de chaque section."), + (true, true, false, false, "en") => format!("{titles}, and the order of the articles of each section."), + (true, true, false, false, _) => format!("{titles}, y el orden de los artículos de cada sección."), + (false, false, false, true, l) => { + assert_eq!(k, f.default_level, "the other articles are at the default level"); + match l { + "fr" => "Le cran par défaut : tous les autres articles, et chaque nouvel article.".into(), + "en" => "The default level: all other articles, and every new article.".into(), + _ => "El nivel por defecto: todos los demás artículos, y todo artículo nuevo.".into(), + } + } + (false, false, true, false, "fr") => "Le titre du document.".into(), + (false, false, true, false, "en") => "The title of the document.".into(), + (false, false, true, false, _) => "El título del documento.".into(), + other => panic!("level {k}: the seed changed what starts there ({other:?}), rewrite « Niveaux d'inertie »"), + } +} + +/// The phrases of a text that carry its numbers, each built from the figures. +fn claims(code: &str, lang: &str, f: &Figures, seed: &Value) -> Vec { + let q = f.quorums; + let (lo, hi) = (InertiaLevel::LOWEST.get(), InertiaLevel::HIGHEST.get()); + let d = level(f.default_level); + let qd = q[usize::from(f.default_level - 1)]; + let name = |k: u8| level_name(lang, k); + let sp = and_list(lang, &seed_proposal_titles(seed, lang)); + let (m, w) = (EXAMPLE_MEDIAN, int(lang, EXAMPLE_W)); + let e = f.example; + let ed = e[usize::from(f.default_level - 1)]; + let [(k2, q2, y2, n2), (k3, q3, y3, n3)] = f.under; + match (code, lang) { + ("F1.1", _) => vec![], + ("F1.2", "fr") => vec![ + format!("relu sur la chaîne toutes les {} heures", f.refresh_hours), + format!( + "Chaque article prend celui de son cran : {}, {}, {}, {} et {} dans Monnaie libre, du cran {lo} au cran {hi}", + q[0], q[1], q[2], q[3], q[4] + ), + format!( + "Dans Monnaie libre, jamais moins de {} : une seule voix ne fait jamais passer un texte", + f.lowest_quorum + ), + format!("**B = {}**", dec(lang, f.base)), + format!( + "Le cran par défaut est le {} ({}) : M = {}, un quorum de {qd} dans Monnaie libre, et le groupe demande {} de « pour » quand il vote comme d'habitude", + f.default_level, + name(f.default_level), + pct(lang, d.majority_pct()), + pct(lang, d.share_pct()) + ), + format!( + "{sp} sont des Propositions seed, mises en avant pour lancer le vote, au cran {} ({})", + f.seed_level, + name(f.seed_level) + ), + format!( + "Le titre du document est au cran {} ({}), l'ordre des articles de chaque section au cran {}, tous les autres articles au cran {}", + f.title_level, + name(f.title_level), + f.order_level, + f.others_level + ), + ], + ("F1.2", "en") => vec![ + format!("read again from the chain every {} hours", f.refresh_hours), + format!( + "Each article takes the one of its level: {}, {}, {}, {} and {} in Monnaie libre, from level {lo} to level {hi}", + q[0], q[1], q[2], q[3], q[4] + ), + format!("In Monnaie libre, never less than {}: a single vote never passes a text", f.lowest_quorum), + format!("**B = {}**", dec(lang, f.base)), + format!( + "The default level is {} ({}): M = {}, a quorum of {qd} in Monnaie libre, and the group asks for {} of votes in favour when it votes as usual", + f.default_level, + name(f.default_level), + pct(lang, d.majority_pct()), + pct(lang, d.share_pct()) + ), + format!( + "{sp} are seed proposals, put forward to start the vote, at level {} ({})", + f.seed_level, + name(f.seed_level) + ), + format!( + "The title of the document is at level {} ({}), the order of the articles of each section at level {}, all other articles at level {}", + f.title_level, + name(f.title_level), + f.order_level, + f.others_level + ), + ], + ("F1.2", _) => vec![ + format!("leído de nuevo en la cadena cada {} horas", f.refresh_hours), + format!( + "Cada artículo toma el de su nivel: {}, {}, {}, {} y {} en Monnaie libre, del nivel {lo} al nivel {hi}", + q[0], q[1], q[2], q[3], q[4] + ), + format!("En Monnaie libre, nunca menos de {}: un solo voto nunca hace pasar un texto", f.lowest_quorum), + format!("**B = {}**", dec(lang, f.base)), + format!( + "El nivel por defecto es el {} ({}): M = {}, un quórum de {qd} en Monnaie libre, y el grupo pide un {} de votos a favor cuando vota como de costumbre", + f.default_level, + name(f.default_level), + pct(lang, d.majority_pct()), + pct(lang, d.share_pct()) + ), + format!( + "{sp} son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel {} ({})", + f.seed_level, + name(f.seed_level) + ), + format!( + "El título del documento está en el nivel {} ({}), el orden de los artículos de cada sección en el nivel {}, todos los demás artículos en el nivel {}", + f.title_level, + name(f.title_level), + f.order_level, + f.others_level + ), + ], + ("N1.1", _) => { + let mut out: Vec = InertiaLevel::ALL + .iter() + .map(|k| { + let i = usize::from(k.get() - 1); + let (n, s, mk, what) = ( + name(k.get()), + pct(lang, k.share_pct()), + pct(lang, k.majority_pct()), + at_start(f, seed, lang, k.get()), + ); + match lang { + "fr" => format!( + "- **Cran {} · {n}** : quorum {} ; {s} comme d'habitude, {mk} quand tout le monde vote. {what}", + k.get(), + q[i] + ), + "en" => format!( + "- **Level {} · {n}**: quorum {}; {s} as usual, {mk} when everyone votes. {what}", + k.get(), + q[i] + ), + _ => format!( + "- **Nivel {} · {n}**: quórum {}; {s} como de costumbre, {mk} cuando vota todo el mundo. {what}", + k.get(), + q[i] + ), + } + }) + .collect(); + let sd = pct(lang, d.share_pct()); + // « 75 % des 120 votants, soit 90 »: the share of the default level, exactly. + assert_eq!(f64::from(ed), (d.share_pct() * f64::from(m) / 100.0).ceil()); + let (b0, b1) = (pct(lang, f.bounds.0), pct(lang, f.bounds.1)); + let (ms, r) = (f.min_sample, f.reference); + out.extend(match lang { + "fr" => [ + "propositions du groupe votées depuis un an".to_owned(), + format!("Tant que le groupe n'a pas voté sur {ms} propositions, elle vaut deux fois le quorum de référence du document : {r} votants dans Monnaie libre"), + format!("Elle reste entre {b0} et {b1} de l'électorat, et se mesure une fois par jour"), + format!( + "avec {w} membres et une participation habituelle de {m} votants, quand {m} personnes votent, il faut {}, {}, {}, {} ou {} voix pour, du cran {lo} au cran {hi} ; au cran {}, {sd} des {m} votants, soit {ed}", + e[0], e[1], e[2], e[3], e[4], f.default_level + ), + format!("au cran {k2} (quorum {q2}), {y2} voix pour passent avec {n2} « contre » au plus ; au cran {k3} (quorum {q3}), il faut {y3} voix pour, avec {n3} « contre » au plus"), + ], + "en" => [ + "the group's proposals voted on over the past year".to_owned(), + format!("Until the group has voted on {ms} proposals, it is twice the document's reference quorum: {r} voters in Monnaie libre"), + format!("It stays between {b0} and {b1} of the electorate, and is measured once a day"), + format!( + "with {w} members and a usual turnout of {m} voters, when {m} people vote, it takes {}, {}, {}, {} or {} votes in favour, from level {lo} to level {hi}; at level {}, {sd} of the {m} voters, i.e. {ed}", + e[0], e[1], e[2], e[3], e[4], f.default_level + ), + format!("at level {k2} (quorum {q2}), {y2} votes in favour pass with {n2} votes against at most; at level {k3} (quorum {q3}), it takes {y3} votes in favour, with {n3} votes against at most"), + ], + _ => [ + "propuestas del grupo votadas en el último año".to_owned(), + format!("Hasta que el grupo haya votado {ms} propuestas, vale dos veces el quórum de referencia del documento: {r} votantes en Monnaie libre"), + format!("Se mantiene entre el {b0} y el {b1} del electorado, y se mide una vez al día"), + format!( + "con {w} miembros y una participación habitual de {m} votantes, cuando votan {m} personas, hacen falta {}, {}, {}, {} o {} votos a favor, del nivel {lo} al nivel {hi}; en el nivel {}, el {sd} de los {m} votantes, es decir {ed}", + e[0], e[1], e[2], e[3], e[4], f.default_level + ), + format!("en el nivel {k2} (quórum {q2}), {y2} votos a favor pasan con {n2} en contra como mucho; en el nivel {k3} (quórum {q3}), hacen falta {y3} votos a favor, con {n3} en contra como mucho"), + ], + }); + out + } + _ => unreachable!(), + } +} + +/// The spans of code of a text, in order. +fn code_spans(body: &str) -> Vec<&str> { + body.split('`').skip(1).step_by(2).collect() +} + +/// The word a formula is named by, in each language. +fn named(lang: &str, fr: &'static str, en: &'static str, es: &'static str) -> &'static str { + match lang { + "fr" => fr, + "en" => en, + _ => es, + } +} + +/// The code a text may hold: its formulas, exactly. +fn formulas(code: &str, lang: &str) -> Vec { + let threshold = named(lang, "Seuil", "Threshold", "Umbral"); + let share = named(lang, "Part exigée", "Share asked", "Proporción exigida"); + match code { + "F1.1" => vec!["T' = max(T, Q)".into(), format!("{threshold} = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'")], + "F1.2" => vec!["B^W".into()], + _ => vec![format!("{share} = M + (1 − M) × (1 − (T'/W)^G)"), "G = ln(1 − (s − M)/(1 − M)) / ln(p̃)".into()], + } +} + +#[test] +fn every_number_of_the_quorum_texts_is_the_engine_s() { + let seed = today_seed(); + let f = figures(&seed); + for code in CODES { + for lang in LANGS { + let (_, body) = text(&seed, code, lang); + let mut rest = body.to_owned(); + for claim in claims(code, lang, &f, &seed) { + assert!(rest.contains(&claim), "{code} {lang}: missing « {claim} »\n{body}"); + rest = rest.replacen(&claim, "", 1); + } + let spans = code_spans(body); + assert_eq!(spans, formulas(code, lang), "{code} {lang}: the formulas"); + for span in spans { + rest = rest.replacen(&format!("`{span}`"), "", 1); + } + let rest = rest.replace("Ğ1", ""); + let stray: Vec = rest.chars().filter(char::is_ascii_digit).collect(); + assert!(stray.is_empty(), "{code} {lang}: a number no engine figure stands for: {stray:?}\n{rest}"); + } + } +} + +// --------------------------------------------------------------------------- +// The formulas: evaluated as written, against the engine +// --------------------------------------------------------------------------- + +/// A tiny evaluator for the formulas of the texts: + − × / ^, parentheses, `max`, `ln`. +struct Formula<'a> { + tokens: Vec, + at: usize, + env: &'a HashMap, +} + +fn tokens(src: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = src.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + } else if c.is_ascii_digit() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + out.push(chars[start..i].iter().collect()); + } else if c.is_alphabetic() { + let start = i; + // A name: letters, a combining mark (p̃), a prime (T'). + while i < chars.len() && (chars[i].is_alphabetic() || ('\u{300}'..='\u{36f}').contains(&chars[i])) { + i += 1; + } + if i < chars.len() && chars[i] == '\'' { + i += 1; + } + out.push(chars[start..i].iter().collect()); + } else { + out.push(c.to_string()); + i += 1; + } + } + out +} + +impl Formula<'_> { + fn peek(&self) -> Option<&str> { + self.tokens.get(self.at).map(String::as_str) + } + + fn take(&mut self, t: &str) -> bool { + let found = self.peek() == Some(t); + if found { + self.at += 1; + } + found + } + + fn expr(&mut self) -> f64 { + let mut v = self.term(); + loop { + if self.take("+") { + v += self.term(); + } else if self.take("−") { + v -= self.term(); + } else { + return v; + } + } + } + + fn term(&mut self) -> f64 { + let mut v = self.power(); + loop { + if self.take("×") { + v *= self.power(); + } else if self.take("/") { + v /= self.power(); + } else { + return v; + } + } + } + + fn power(&mut self) -> f64 { + let base = self.atom(); + if self.take("^") { base.powf(self.power()) } else { base } + } + + fn atom(&mut self) -> f64 { + let t = self.peek().expect("a term").to_owned(); + self.at += 1; + if t == "(" { + let v = self.expr(); + assert!(self.take(")"), "a closing parenthesis"); + return v; + } + if let Ok(n) = t.parse::() { + return n; + } + if self.take("(") { + let a = self.expr(); + let v = match t.as_str() { + "max" => { + assert!(self.take(","), "max(a, b)"); + a.max(self.expr()) + } + "ln" => a.ln(), + other => panic!("unknown function {other}"), + }; + assert!(self.take(")"), "a closing parenthesis"); + return v; + } + *self.env.get(&t).unwrap_or_else(|| panic!("unknown letter {t}")) + } +} + +/// The value of `name = expression`, the name checked. +fn eval(formula: &str, name: &str, env: &HashMap) -> f64 { + let (lhs, rhs) = formula.split_once(" = ").expect("name = expression"); + assert_eq!(lhs, name); + let mut f = Formula { tokens: tokens(rhs), at: 0, env }; + let v = f.expr(); + assert_eq!(f.at, f.tokens.len(), "the whole of {rhs}"); + v +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} + +#[test] +fn the_formulas_of_the_texts_are_those_the_engine_computes() { + let seed = today_seed(); + for lang in LANGS { + let (_, f11) = text(&seed, "F1.1", lang); + let (_, n11) = text(&seed, "N1.1", lang); + let [voters, threshold] = <[&str; 2]>::try_from(code_spans(f11)).expect("two formulas in F1.1"); + let [share, grad] = <[&str; 2]>::try_from(code_spans(n11)).expect("two formulas in N1.1"); + let mut checked = 0; + for w in [2u32, 40, 5707, 7224] { + for median in [None, Some(1.0), Some(9.0), Some(120.0), Some(360.0)] { + let p = Participation::of(w, 50.0, median, 9).ratio; + let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] }; + for k in InertiaLevel::ALL { + for q in [1u32, 2, 12, 50].into_iter().filter(|q| *q <= w) { + let params = scale.params(k, q); + for t in [0u32, 1, 9, 12, 13, 50, 51, 120, 500, w].into_iter().filter(|t| *t <= w) { + let mut env: HashMap = HashMap::from([ + ("T".into(), f64::from(t)), + ("Q".into(), f64::from(q)), + ("W".into(), f64::from(w)), + ("B".into(), params.base), + ("M".into(), params.majority_pct / 100.0), + ("G".into(), params.gradient), + ("s".into(), k.share_pct() / 100.0), + ("p̃".into(), p), + ]); + let judged = eval(voters, "T'", &env); + assert_eq!(judged, f64::from(judged_voters(t, q)), "{lang}: T'"); + env.insert("T'".into(), judged); + let ratio = required_ratio(w, judged_voters(t, q), ¶ms); + let engine = params.base.powf(f64::from(w)) + ratio * judged; + let written = eval(threshold, named(lang, "Seuil", "Threshold", "Umbral"), &env); + assert!( + close(written, engine), + "{lang} W {w} k {} Q {q} T {t}: {written} ≠ {engine}", + k.get() + ); + let asked = + eval(share, named(lang, "Part exigée", "Share asked", "Proporción exigida"), &env); + assert!(close(asked, ratio), "{lang}: the share"); + assert!(close(eval(grad, "G", &env), gradient(k, p)), "{lang}: G"); + checked += 1; + } + } + } + } + } + assert!(checked > 500, "{checked}"); + } +} + +// --------------------------------------------------------------------------- +// The rules of form (textes-regles-seed.md, « Avant de coller ») +// --------------------------------------------------------------------------- + +#[test] +fn the_quorum_texts_follow_the_rules_of_form() { + let seed = today_seed(); + let titles: HashMap<&str, Vec<&str>> = LANGS + .iter() + .map(|l| { + let all = seed["units"] + .as_array() + .expect("units") + .iter() + .flat_map(|u| u["versions"].as_array().expect("versions")) + .map(|v| v["title"][*l].as_str().expect("title")) + .collect(); + (*l, all) + }) + .collect(); + for code in CODES { + for lang in LANGS { + let (title, body) = text(&seed, code, lang); + let at = format!("{code} {lang}"); + // A title kept: the article still says what its title says. + assert_eq!(title, text(&production_seed(), code, lang).0, "{at}: the title is kept"); + // Length: under the limit of a proposal (`proposals.rs`, MAX_BODY, in characters). + assert!(body.chars().count() <= 4000, "{at}: {} characters", body.chars().count()); + // A list reads as a list only in a block of its own (`markdown.ts`, render). + for block in body.split("\n\n") { + let lines: Vec<&str> = block.lines().collect(); + if lines.iter().any(|l| l.starts_with("- ")) { + assert!(lines.iter().all(|l| l.starts_with("- ")), "{at}: a list without its blank line\n{block}"); + } + } + // No asterisk but in bold pairs: a formula never multiplies with one. + assert!(!body.replace("**", "").contains('*'), "{at}: a lone asterisk"); + assert_eq!(body.matches("**").count() % 2, 0, "{at}: bold pairs"); + for span in code_spans(body) { + assert!(!span.contains('*') && !span.contains('-'), "{at}: × and −, never * or -: {span}"); + } + // Straight apostrophes; the quotes of each language. + assert!(!body.contains('’'), "{at}: a curly apostrophe"); + let refs: Vec<&str> = match lang { + "fr" => { + assert!(!body.contains('“') && !body.contains('”'), "{at}"); + assert_eq!(body.matches("« ").count(), body.matches('«').count(), "{at}: « spaced"); + assert_eq!(body.matches(" »").count(), body.matches('»').count(), "{at}: » spaced"); + body.split("« ").skip(1).filter_map(|s| s.split(" »").next()).collect() + } + "en" => { + assert!(!body.contains('«') && !body.contains('»'), "{at}"); + assert!(!body.contains("favor ") && !body.contains("favor."), "{at}: British English, favour"); + body.split('“').skip(1).filter_map(|s| s.split('”').next()).collect() + } + _ => { + assert!(!body.contains('“') && !body.contains('”'), "{at}"); + assert!(!body.contains("« ") && !body.contains(" »"), "{at}: «» tight"); + body.split('«').skip(1).filter_map(|s| s.split('»').next()).collect() + } + }; + // Another article is named by its title, never by its code. + let words = ["non", "pour", "contre", "no"]; + for r in refs.iter().filter(|r| !words.contains(r)) { + assert!(titles[lang].contains(r), "{at}: « {r} » is the title of no article"); + } + for other in seed["units"].as_array().expect("units") { + let c = other["code"].as_str().expect("code"); + let named = body.split(|ch: char| !(ch.is_alphanumeric() || ch == '.')).any(|w| w == c); + assert!(!named, "{at}: names the article {c} by its code"); + } + } + } +} + +// --------------------------------------------------------------------------- +// Production: a guarded amendment, on a base made as production is after M6 +// --------------------------------------------------------------------------- + +/// The amendment of an article, by lot T. +fn amendment_t(code: &str) -> &'static seed_amend::Amendment { + AMENDMENTS + .iter() + .find(|a| a.unit == code && a.id.starts_with("2026-11-03-")) + .unwrap_or_else(|| panic!("no amendment of lot T for {code}")) +} + +#[test] +fn the_shipped_amendments_go_from_production_to_the_seed_of_today() { + let (prod, today) = (production_seed(), today_seed()); + for code in CODES { + let a = amendment_t(code); + assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}"); + let (t, b) = text(&prod, code, "fr"); + assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds"); + let (t, b) = text(&today, code, "fr"); + assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed today"); + assert_ne!(a.from, a.to, "{code}"); + assert_eq!(a.superseded_by, None, "{code}"); + // F1.1 was never amended; F1.2 and N1.1 follow the amendment of M5, which wrote the + // text they replace and which the seed no longer gives: it is superseded. + match a.follows { + None => assert_eq!(code, "F1.1"), + Some(earlier) => { + let m5 = AMENDMENTS.iter().find(|e| e.id == earlier).expect("the earlier amendment"); + assert_eq!((m5.unit, m5.to, m5.superseded_by), (code, a.from, Some(a.id)), "{code}"); + } + } + } +} + +/// Every proposal as it stands: (id, status, revision, title, body). +type Row = (String, String, i64, String, String); + +async fn proposals(state: &AppState) -> Vec { + sqlx::query_as("SELECT id, status, revision, title, body FROM proposals ORDER BY id") + .fetch_all(&state.db) + .await + .expect("proposals") +} + +async fn translations(state: &AppState) -> Vec<(String, String, String, String, String)> { + sqlx::query_as("SELECT proposal_id, lang, title, body, source_hash FROM translations ORDER BY proposal_id, lang") + .fetch_all(&state.db) + .await + .expect("translations") +} + +async fn start(db: &sqlx::SqlitePool, cfg: Config) -> AppState { + let state = AppState::new(cfg, db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +fn config(dir: PathBuf) -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = dir; + cfg +} + +/// Production's history, for these articles: born on 21/10 (the rule articles of +/// before), started by M5 (3f10a0e: its seed, its amendments), then by M6 (94ddb17: the +/// seed of today with the texts of M5 — seed proposals, quorums by level). +async fn production_after_m6(tag: &str) -> AppState { + let prod = production_seed(); + let born = with_texts(&prod, &rules_before(), &["F1.2", "N1", "N1.1", "O1"]); + let db = open_db("sqlite::memory:").await.expect("db"); + let first = AppState::new(config(seed_dir(&format!("{tag}-born"), &born)), db.clone()).await.expect("state"); + seed::documents(&first).await.expect("born"); + start(&db, config(seed_dir(&format!("{tag}-m5"), &prod))).await; + let m6 = with_texts(&today_seed(), &prod, &CODES); + start(&db, config(seed_dir(&format!("{tag}-m6"), &m6))).await +} + +/// The open proposal of the seed for an article: (id, title, body, revision). +async fn proposal(state: &AppState, group: &str, code: &str) -> (String, String, String, i64) { + sqlx::query_as( + "SELECT p.id, p.title, p.body, p.revision 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.status = 'open' AND p.origin = 'seed' AND p.label = '1.0.0'", + ) + .bind(group) + .bind(code) + .fetch_one(&state.db) + .await + .expect("seed proposal") +} + +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 of lot T: (group, unit, outcome, reason). +async fn decisions(state: &AppState) -> Vec<(String, String, String, Option)> { + sqlx::query_as( + "SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s + JOIN documents d ON d.id = s.doc_id + WHERE s.amendment_id LIKE '2026-11-03-%' ORDER BY d.group_slug, s.unit_code", + ) + .fetch_all(&state.db) + .await + .expect("decisions") +} + +async fn queued(state: &AppState) -> 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 '%quorum par cran%' ORDER BY o.id", + ) + .fetch_all(&state.db) + .await + .expect("outbox") +} + +#[tokio::test] +async fn production_after_m6_gets_the_quorum_texts_once() { + let base = production_after_m6("apply").await; + let (prod, today) = (production_seed(), today_seed()); + // The base is as production after M6: F1.1 as born, F1.2 and N1.1 as M5 wrote them. + for group in GROUPS { + for code in CODES { + let (id, title, body, revision) = proposal(&base, group, code).await; + assert_eq!((title.as_str(), body.as_str()), text(&prod, code, "fr"), "{group} {code}"); + let m5 = code != "F1.1"; + assert_eq!(revision, if m5 { 2 } else { 1 }, "{group} {code}"); + let expected: &[&str] = if m5 { &["initial", "amendment"] } else { &["initial"] }; + assert_eq!(kinds(&base, &id).await, expected, "{group} {code}"); + } + } + let seed_proposals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE seed_featured = 1") + .fetch_one(&base.db) + .await + .expect("count"); + let quorums: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE quorums IS NOT NULL") + .fetch_one(&base.db) + .await + .expect("count"); + assert_eq!((seed_proposals, quorums), (6, 2), "M6: seed proposals and quorums written"); + assert!(decisions(&base).await.is_empty(), "the seed of M6 decides nothing of lot T"); + let (before, before_tr) = (proposals(&base).await, translations(&base).await); + + // Started with today's seed. + let state = start(&base.db, Config::for_tests()).await; + let mut touched = Vec::new(); + for group in GROUPS { + for code in CODES { + let (id, title, body, revision) = proposal(&state, group, code).await; + assert_eq!((title.as_str(), body.as_str()), text(&today, code, "fr"), "{group} {code}"); + let m5 = code != "F1.1"; + assert_eq!(revision, if m5 { 3 } else { 2 }, "{group} {code}"); + let expected: &[&str] = if m5 { &["initial", "amendment", "amendment"] } else { &["initial", "amendment"] }; + assert_eq!(kinds(&state, &id).await, expected, "{group} {code}"); + // The history keeps the text it replaced, and says the new one, without author. + let last: (i64, String, String, Option) = sqlx::query_as( + "SELECT revision, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1", + ) + .bind(&id) + .fetch_one(&state.db) + .await + .expect("revision"); + assert_eq!(last, (revision, title.clone(), body.clone(), None), "{group} {code}"); + for lang in ["en", "es"] { + let (t, b, hash, origin, by): (String, String, String, String, Option) = sqlx::query_as( + "SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?", + ) + .bind(&id) + .bind(lang) + .fetch_one(&state.db) + .await + .expect("translation"); + assert_eq!((t.as_str(), b.as_str()), text(&today, code, lang), "{group} {code} {lang}"); + assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date"); + assert_eq!((origin.as_str(), by), ("seed", None)); + } + touched.push(id); + } + } + let expected: Vec<(String, String, String, Option)> = GROUPS + .iter() + .flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None))) + .collect(); + assert_eq!(decisions(&state).await, expected); + // Nothing else is touched: no other proposal, no other translation, no vote. + let keep = |rows: Vec| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>(); + assert_eq!(keep(proposals(&state).await), keep(before.clone())); + let others = |rows: Vec<(String, String, String, String, String)>| { + rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::>() + }; + assert_eq!(others(translations(&state).await), others(before_tr)); + let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes"); + assert_eq!(votes, 0); + // Monnaie libre republishes, once per article; the test group never. + let rows = queued(&state).await; + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}"); + // Decided once: a second start changes nothing. + let again = start(&state.db, Config::for_tests()).await; + assert_eq!(decisions(&again).await, expected); + assert_eq!(proposals(&again).await, proposals(&state).await); + assert_eq!(queued(&again).await.len(), 3); +} + +async fn identity(state: &AppState, id: &str) { + sqlx::query( + "INSERT INTO identities (id, kind, display_name, created_at) VALUES (?, 'dev', ?, '2026-11-03T00:00:00Z')", + ) + .bind(id) + .bind(id) + .execute(&state.db) + .await + .expect("identity"); +} + +#[tokio::test] +async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() { + let base = production_after_m6("refuse").await; + let doc: String = sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = 'monnaie-libre'") + .fetch_one(&base.db) + .await + .expect("doc"); + identity(&base, "dev:vera").await; + // In Monnaie libre, a simulated vote on F1.2 (the simulation of M6 counts it)… + let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await; + sqlx::query( + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) + VALUES (?, 'dev:vera', 'for', 2, '2026-11-03T00:00:00Z', 'simulated')", + ) + .bind(&f12) + .execute(&base.db) + .await + .expect("vote"); + // …and a ballot still pending on N1.1: the vote is cast, it is not proven yet. + let (n11, ..) = proposal(&base, "monnaie-libre", "N1.1").await; + sqlx::query( + "INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode, + created_at, expires_at) + VALUES ('b1', ?, 'dev:vera', 'salt', 'digest-b1', 'remark-b1', '', 0, 'pending', 'simulated', + '2026-11-03T00:00:00Z', '2026-11-04T00:00:00Z')", + ) + .bind(&doc) + .execute(&base.db) + .await + .expect("ballot"); + sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b1', ?, 2, 'against')") + .bind(&n11) + .execute(&base.db) + .await + .expect("line"); + // In the test group, a member corrected the English of F1.1. + let (f11, ..) = proposal(&base, "essai", "F1.1").await; + sqlx::query( + "UPDATE translations SET origin = 'member', updated_by = 'dev:vera' WHERE proposal_id = ? AND lang = 'en'", + ) + .bind(&f11) + .execute(&base.db) + .await + .expect("translation"); + + let state = start(&base.db, Config::for_tests()).await; + let refused = + |g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned())); + let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None); + assert_eq!( + decisions(&state).await, + [ + refused("essai", "F1.1", "translation_edited"), + applied("essai", "F1.2"), + applied("essai", "N1.1"), + applied("monnaie-libre", "F1.1"), + refused("monnaie-libre", "F1.2", "votes"), + refused("monnaie-libre", "N1.1", "ballots"), + ] + ); + let (prod, today) = (production_seed(), today_seed()); + for (group, code, kept) in [ + ("essai", "F1.1", true), + ("essai", "F1.2", false), + ("monnaie-libre", "F1.1", false), + ("monnaie-libre", "F1.2", true), + ("monnaie-libre", "N1.1", true), + ] { + let (_, title, body, revision) = proposal(&state, group, code).await; + let expected = if kept { text(&prod, code, "fr") } else { text(&today, code, "fr") }; + assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}"); + let m5 = i64::from(code != "F1.1"); + assert_eq!(revision, 1 + m5 + i64::from(!kept), "{group} {code}"); + } + // Republished for the one article applied in Monnaie libre only. + let rows = queued(&state).await; + assert_eq!( + rows.iter().map(|(g, r)| (g.as_str(), r.contains("F1.1"))).collect::>(), + [("monnaie-libre", true)] + ); + // The refusal stands: decided once, it is not tried again. + let again = start(&state.db, Config::for_tests()).await; + assert_eq!(decisions(&again).await, decisions(&state).await); +} + +#[tokio::test] +async fn a_new_base_is_born_with_the_quorum_texts() { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = start(&db, Config::for_tests()).await; + let today = today_seed(); + for group in GROUPS { + for code in CODES { + let (_, title, body, revision) = proposal(&state, group, code).await; + assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 1), "{group} {code}"); + } + } + let got = decisions(&state).await; + assert_eq!(got.len(), 6); + assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); + // The amendments of M5 on F1.2 and N1.1, superseded, decide nothing on a new base. + let m5: Vec = sqlx::query_scalar( + "SELECT unit_code FROM seed_amendments WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles')", + ) + .fetch_all(&state.db) + .await + .expect("m5"); + assert!(m5.is_empty(), "{m5:?}"); +} diff --git a/crates/ld-server/tests/seed_rules.rs b/crates/ld-server/tests/seed_rules.rs index f60cb48..ae8279d 100644 --- a/crates/ld-server/tests/seed_rules.rs +++ b/crates/ld-server/tests/seed_rules.rs @@ -1,6 +1,10 @@ //! The four rule articles of the seed rewritten after the rules applied (Yvv, //! 2026-11-02): F1.2, N1, N1.1 and O1 are open proposals at zero votes, not texts //! in force; a guarded seed amendment replaces them where nobody touched them. +//! +//! Played with the seed M5 shipped (3f10a0e, the fixture of production): since lot T +//! (2026-11-03), today's seed gives F1.2 and N1.1 the texts of the quorum, and their +//! amendments of 02/11 are superseded (`tests/seed_quorum_texts.rs`). use ld_server::seed_amend::{self, AMENDMENTS, Version}; use ld_server::{AppState, Config, open_db, seed}; @@ -21,6 +25,15 @@ fn today_seed() -> Value { read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) } +/// The seed M5 shipped: production's since 2026-11-02. +fn m5_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02") +} + +fn m5_seed() -> Value { + read(m5_dir().join("acte-engagement-certification.json")) +} + fn version<'a>(seed: &'a Value, code: &str) -> &'a Value { let u = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit"); &u["versions"][0] @@ -30,12 +43,12 @@ fn fr(v: &Value) -> (&str, &str) { (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body")) } -/// Today's seed with the four rule articles of before. +/// The seed of M5 with the four rule articles of before. fn seed_dir_before(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("ld-seed-rules-{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 = m5_seed(); let old = before(); for u in seed["units"].as_array_mut().expect("units") { let code = u["code"].as_str().expect("code").to_owned(); @@ -59,9 +72,11 @@ async fn base_before(tag: &str) -> AppState { state } -/// The same base, started again with today's seed and its amendments. +/// The same base, started again with the seed of M5 and its amendments. async fn restart(before: &AppState) -> AppState { - let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state"); + let mut cfg = Config::for_tests(); + cfg.seed_dir = m5_dir(); + let state = AppState::new(cfg, before.db.clone()).await.expect("state"); seed::ensure(&state).await.expect("seed"); state } @@ -92,19 +107,29 @@ async fn decisions(state: &AppState) -> Vec<(String, String, String, Option assert_eq!(now, a.to, "{code}: still the text of today"), + Some(later) => { + let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment"); + assert_eq!((l.unit, l.follows, l.from, l.to), (code, Some(a.id), a.to, now.as_str()), "{code}"); + } + } } } @@ -123,7 +148,7 @@ async fn untouched_rule_articles_are_replaced_once_and_a_vote_keeps_its_text() { .await .expect("vote"); let state = restart(&base).await; - let (old, today) = (before(), today_seed()); + let (old, today) = (before(), m5_seed()); for group in ["essai", "monnaie-libre"] { for code in CODES { let (id, title, body, revision) = proposal(&state, group, code).await; @@ -202,6 +227,9 @@ async fn a_new_base_is_born_with_the_rules_of_today() { assert_eq!(((title.as_str(), body.as_str()), revision), (fr(version(&today, code)), 1), "{group} {code}"); } } - assert!(decisions(&state).await.iter().all(|(_, _, o, _)| o == "present")); - assert_eq!(decisions(&state).await.len(), 8); + // N1 and O1: present. F1.2 and N1.1: their amendments, superseded, decide nothing. + let got = decisions(&state).await; + assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}"); + let units: Vec<&str> = got.iter().map(|(_, u, _, _)| u.as_str()).collect(); + assert_eq!(units, ["N1", "O1", "N1", "O1"]); } diff --git a/crates/ld-server/tests/typos.rs b/crates/ld-server/tests/typos.rs index 69b7776..2cbbb1d 100644 --- a/crates/ld-server/tests/typos.rs +++ b/crates/ld-server/tests/typos.rs @@ -15,9 +15,15 @@ struct Client { } impl Client { + /// A base seeded as production is before its seed proposal amendment (ADR 0011): + /// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused. + /// These scenarios need texts in force; the seed of today has none. async fn new() -> (Self, AppState) { let db = open_db("sqlite::memory:").await.expect("db"); - let state = AppState::new(Config::for_tests(), db).await.expect("state"); + let mut cfg = Config::for_tests(); + cfg.seed_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02"); + let state = AppState::new(cfg, db).await.expect("state"); seed::ensure(&state).await.expect("seed"); (Self { router: app(state.clone()), cookie: None }, state) } @@ -121,6 +127,10 @@ async fn someone_elses_typo_on_a_proposal_waits_for_its_author() { yvv.login("Yvv", false).await; let doc = chiara.doc("essai").await; let p = chiara.propose(&doc, TITLE, VOTED).await; + // Its author sets the quorum 2 before the first vote (ADR 0013): in the test group, one + // voice of two then no longer makes it pass, and it stays hers to answer for. + let u = chiara.detail(&p).await["unit"]["id"].as_str().expect("unit").to_owned(); + chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await; yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; let w = yvv.fix(&p, TITLE, FIX_1).await; diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 5c54455..154098b 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -23,7 +23,13 @@ //! 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); +//! - while the chain is closed, the vote is simulated: whoever may ask for a +//! ballot votes on the server, marked as simulated — `casts_simulated` +//! (2026-11-02, lot V, ADR 0012). +//! - 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}; @@ -152,6 +158,17 @@ pub fn chain_vote_counts(member: bool) -> bool { member } +/// While the chain is closed (no vote account yet), a group that votes on the +/// chain votes in simulation (Yvv, 2026-11-02: « Simule le vote à tout endroit », +/// ADR 0012): whoever may ask for a ballot casts a simulated vote, written by the +/// server, marked as such, for the identity of the session — there is no wallet. +/// One person signed in two ways may then hold two voices: accepted while the +/// simulation lasts — Yvv, 2026-11-02: « Il compte, puis s'efface »: every +/// simulated vote counts until an act « supprime » sets them all aside. +pub fn casts_simulated(identity: &Identity, group: &Group, granted: bool, chain_open: bool) -> bool { + !chain_open && asks_ballot(identity, group, granted) +} + /// Access management stays with trustinWallet's administrators. pub fn manages_access(identity: &Identity) -> bool { identity.admin @@ -184,6 +201,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 +239,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), ] } @@ -276,6 +311,20 @@ mod tests { assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote"); } + #[test] + fn while_the_chain_is_closed_whoever_may_ask_a_ballot_votes_in_simulation() { + let ml = group("monnaie-libre"); + let invited = who(IdentityKind::Invite, false, false); + let member = who(IdentityKind::G1, true, false); + assert!(casts_simulated(&invited, &ml, true, false)); + assert!(casts_simulated(&member, &ml, false, false)); + assert!(!casts_simulated(&invited, &ml, false, false), "not without opening the group"); + // The chain open: a ballot to sign, never a simulated vote. + assert!(!casts_simulated(&member, &ml, false, true)); + // A managed group votes on the server, never in simulation. + assert!(!casts_simulated(&invited, &group("essai"), true, false)); + } + #[test] fn in_a_managed_group_every_member_votes_on_the_server() { let essai = group("essai"); @@ -308,6 +357,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/docs/adr/0002-vote-permanent.md b/docs/adr/0002-vote-permanent.md index 399b0a1..2e8be7f 100644 --- a/docs/adr/0002-vote-permanent.md +++ b/docs/adr/0002-vote-permanent.md @@ -3,6 +3,13 @@ > **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*. +> **Le plancher C est remplacé par [0013](0013-quorum.md)** (2026-11-03) : un quorum Q par +> cran (12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 dans le groupe d'essai), que l'auteur +> d'un article peut changer avant le premier vote ; sous le quorum, les votants qui manquent +> comptent comme « non ». `vote_floor` reste, comme quorum de référence de p0. **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/0007-inertie-cinq-crans.md b/docs/adr/0007-inertie-cinq-crans.md index 54c829d..d899960 100644 --- a/docs/adr/0007-inertie-cinq-crans.md +++ b/docs/adr/0007-inertie-cinq-crans.md @@ -2,6 +2,10 @@ > Complète [0002](0002-vote-permanent.md) : la règle « l'inertie se vote au cran au-dessus » > y est remplacée par l'héritage décrit ici. Décision d'Yvv du 2026-10-30 (revue, L10). +> **Le C de la formule est remplacé par [0013](0013-quorum.md)** (2026-11-03) : R = B^W + +> ratio(T')·T', T' = max(T, Q), un quorum Q par cran. M, G_k, s_k, p̃ et ses bornes restent ; +> « au-delà de C » se lit désormais « parmi les T' votants » ; les nombres ci-dessous sont ceux +> d'avant (ceux d'après : ADR 0013). **Contexte.** Quatre niveaux (`low`, `standard`, `high`, `max`) fixaient M et G une fois pour toutes. Yvv, 2026-10-30 : « L'indice d'inertie doit avoir 5 niveaux. Tu mets par défaut au diff --git a/docs/adr/0008-vote-par-transaction.md b/docs/adr/0008-vote-par-transaction.md index e21b1bd..7649b84 100644 --- a/docs/adr/0008-vote-par-transaction.md +++ b/docs/adr/0008-vote-par-transaction.md @@ -6,6 +6,10 @@ > changés sans toucher au reste. Corrigé le 2026-10-31 après la relecture indépendante de > `74f96be` (lot B2) : finalité, recherche filtrée et paginée, tâche de fond, plafond, bulletin > Ğecko, bulletins lâchés, reçu. +> **Complétée par [0012](0012-tampon-des-votes.md)** (2026-11-02, lot V) : le bulletin en attente +> est une intention, visible aussitôt pour la personne et dans les totaux montrés (jamais dans +> l'adoption) ; la réconciliation remplace « le dernier fait foi » ligne à ligne ; sans +> `LD_VOTE_ACCOUNT`, le vote est simulé au lieu d'être fermé. **Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée. Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1 diff --git a/docs/adr/0010-objections-et-coquilles-validees.md b/docs/adr/0010-objections-et-coquilles-validees.md index 27794ef..373b149 100644 --- a/docs/adr/0010-objections-et-coquilles-validees.md +++ b/docs/adr/0010-objections-et-coquilles-validees.md @@ -6,6 +6,9 @@ > coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch. > Lot F, 2026-10-31. **Révisée le 2026-11-01** (décision d'Yvv, lot G) : sur une version > retenue, on peut encore retirer son vote ou voter « contre », jamais ajouter un « pour ». +> **Révisée par [0012](0012-tampon-des-votes.md)** (2026-11-02, lot V) : un « pour » dont la +> preuve arrive sur la chaîne pendant qu'une objection retient la version est écarté ; il ne +> compte plus « tel qu'il est signé ». **Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte @@ -37,7 +40,8 @@ d'un autre sans lui demander. Yvv, 2026-10-30 : direct, vote groupé, bulletin de la chaîne : `409 objection`, et une ligne refusée refuse tout le vote). La version ne peut que perdre des voix tant qu'elle est retenue ; à la levée, elle n'entre qu'avec les voix encore là. Les votes déjà faits restent ; un bulletin scellé avant - l'objection et confirmé après compte tel qu'il est signé, sans la faire entrer. Règle : + l'objection et confirmé après compte tel qu'il est signé pour « contre » et le retrait ; son + « pour » est écarté (0012). Règle : `ld_engine::objection::may_cast`, appliquée par `DocState::admits`. - Une personne n'a qu'une objection debout sur une même paire (`objection_exists`). - **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé** 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/0012-tampon-des-votes.md b/docs/adr/0012-tampon-des-votes.md new file mode 100644 index 0000000..2eeed2b --- /dev/null +++ b/docs/adr/0012-tampon-des-votes.md @@ -0,0 +1,296 @@ +# 0012 — Le tampon des votes : l'intention tout de suite, la preuve de la chaîne, la réconciliation + +> Complète [0008](0008-vote-par-transaction.md) (vote par la chaîne Ğ1) et révise une phrase de +> [0010](0010-objections-et-coquilles-validees.md) (un bulletin scellé avant une objection et +> confirmé après). Demande d'Yvv du 2026-11-02. **Deux décisions d'Yvv font foi** : le poids +> d'un vote simulé, « Il compte, puis s'efface » (2026-11-02, 06 h 26), et la publication d'un +> texte entré par des votes simulés, « Pas avant des votes prouvés » (2026-11-03, formulaire d'orch). La +> façon de compter une intention et ce qu'on dit quand elle est écartée restent les choix du lot +> V, isolés dans `ld_engine::buffer` pour être changés seuls. Se lit avec +> [0013](0013-quorum.md) (le quorum, jugé sur les totaux montrés). Lot V, 2026-11-02 et 03. + +**Contexte.** Yvv, 2026-11-02 : « Simule le vote à tout endroit. […] du point de vue de +l'architecture, il faut prévoir le "tampon" entre la base des votes et la mise à jour par les +indexeurs de la chaîne. Il faut prévoir le bon process pour une ux moderne "je vois ce que j'ai +fait sans attendre", avec un contrôle a posteriori de l'intégrité du vote (bien unique, bien le +dernier qui vaut). » Et ses décisions déjà prises : une personne, une voix, autant de fois +qu'elle veut, le dernier vote fait foi ; dans Monnaie libre l'intégrité vient de la chaîne Ğ1 +(0,1 Ğ1 par vote, un seul geste, un bulletin scellé) ; les totaux se voient en direct et +**chacun voit son propre vote en direct** (« tout le monde a maintenant cette habitude, +difficile de régresser ») ; rien n'est dit à l'écran du recoupement entre la chaîne et les +totaux ; sur une version retenue par une objection, « contre » et retrait, jamais « pour ». + +Jusqu'ici, dans Monnaie libre, un bulletin restait « en attente » sans rien compter jusqu'à ce +que l'indexeur le confirme (6 à 30 secondes, une journée au plus par Ğecko), et sans +`LD_VOTE_ACCOUNT` le vote était éteint (« pas encore ouvert »). Un bulletin scellé avant une +objection et payé après comptait « pour » (relevé par une relecture). + +## Décision + +### Le cheminement d'un vote : trois temps + +``` + geste ──► INTENTION ──────────► PREUVE ─────────────► RÉCONCILIATION ──► votes (ce qui compte) + ballots (pending) la chaîne, lue par ld_engine::buffer + visible aussitôt l'indexeur ; ou le ::reconcile, par + (ton vote, totaux) confirmateur simulé identité et version + │ + └── jamais prouvée dans sa fenêtre, refusée par la chaîne, + dépassée, « pour » prouvé sur une version retenue : ÉCARTÉE +``` + +1. **L'intention** est le geste lui-même : un bulletin scellé (`ballots`, `ballot_lines`, + 0008), un seul pour tout un panier. Elle est écrite **avant** que le portefeuille signe et se + voit **aussitôt** : pour la personne (« ton vote », état `pending`) et dans les totaux + montrés (`DocState::shown_tally`). Rien n'est parti (refus du portefeuille, nœud muet, + fenêtre fermée avant la signature) : la page la lâche aussitôt (`DELETE`, 0008) et elle + disparaît des totaux. Un code Ğecko montré est gardé, comme avant. +2. **La preuve** vient de la chaîne, lue par l'indexeur, sans rien changer à 0008 : la + remarque exacte, le virement du signataire vers le compte des votes, une identité Ğ1 membre + au moment de la confirmation, un bloc définitif. Pendant la simulation (plus bas), un + **confirmateur simulé** tient la place de l'indexeur. +3. **La réconciliation** a lieu à chaque preuve, pour chaque version du bulletin, et pour + l'identité qui a signé : elle relit **toutes** les lignes prouvées de cette identité sur + cette version et en tire la voix qui tient (`ld_engine::buffer::reconcile`, pur, testé) : + - **une seule voix par identité Ğ1** et par version ; + - **la dernière dans l'ordre de la chaîne** (le bloc, puis le rang de l'évènement), quel que + soit l'ordre dans lequel l'indexeur les a lues : le résultat ne dépend pas de l'ordre des + confirmations ; « retirer mon vote » (`none`) qui tient efface la voix ; + - elle **écarte** : ce que la chaîne ne confirme jamais dans la fenêtre de validité (24 + heures, plus une heure de grâce si l'indexeur se tait ou si le bloc n'est pas encore + définitif : `expired`) ; ce que la chaîne refuse (`refused` : pas d'identité, pas membre, + virement manquant) ; ce qu'un vote plus récent de la même identité a dépassé + (`superseded`) ; et **un « pour » dont la preuve arrive pendant qu'une objection retient la + version** (`objection`, voir plus bas). + + Chaque ligne d'un bulletin prouvé porte son issue (`ballot_lines.outcome` : `counted`, + `superseded`, `objection`), recalculée à chaque réconciliation sauf `objection`, fixée au + moment de la preuve. La table `votes` reste ce qui compte : une ligne par identité et par + version, écrite par la réconciliation (sources `chain`, `simulated`) ou par le serveur dans + un groupe géré (`server`). Chaque vote compté se vérifie toujours contre la chaîne en + recalculant H depuis les lignes et le sel (0008). + +**Dans un groupe géré** (essai, Axiom…), il n'y a pas de tampon : le serveur est l'autorité, le +vote est écrit d'un coup et il est enregistré (`recorded`) dès l'écriture, comme avant. + +### Les états d'un vote, et ce que chacun pèse + +| État | Ce que c'est | `myVoteState` | Totaux montrés (`shown_tally`) | Adoption (`tally`) | +|---|---|---|---|---| +| enregistré | prouvé par la chaîne, ou vote du serveur d'un groupe géré | `recorded` | oui | oui | +| en attente | une intention sans preuve, dans sa fenêtre | `pending` | oui, **à la place** du vote que la personne voit comme le sien ; jamais un « pour » sur une version retenue | **jamais** | +| simulé | un vote de la simulation, chaîne fermée | `simulated` | oui, **jusqu'à l'acte « supprime »** | oui, **jusqu'à l'acte « supprime »** (décision d'Yvv, « Il compte, puis s'efface ») | +| écarté | expiré, refusé, dépassé, « pour » sur une version retenue | — | non | non | + +- **L'adoption ne compte jamais une intention non confirmée.** Une entrée en vigueur ne repose + que sur des votes prouvés, sur les votes du serveur dans un groupe géré, et sur les votes + simulés jusqu'à l'acte qui les écarte (plus bas). Le balayage d'adoption et la mesure de la + participation p̃ lisent les mêmes poids (`ld_engine::buffer::Rules`). +- **Le quorum se juge sur les totaux montrés** (ADR 0013) : l'évaluation qu'on montre + (`evaluation` : `quorum`, `yesNeeded`, `noNeeded`, `missing`) et les cinq crans du détail se + calculent sur `shown_tally` — votes enregistrés, en attente et simulés, l'intention à la place + du vote vu de la personne (`DocState::shown_evaluation`) ; l'adoption, sur `tally` + (`DocState::evaluation`). +- **Une intention compte pour la session qui l'a demandée** : elle remplace, dans les totaux + montrés, le vote que cette session voit comme le sien (`my_votes`), et la dernière intention + de la session sur une version remplace les précédentes. Une intention sur une révision + dépassée ne compte pas (elle serait « à reconfirmer ») mais retire bien le vote d'avant ; un + « pour » en attente sur une version retenue ne pèse rien tant qu'elle est retenue (il serait + écarté) : `ld_engine::buffer::weigh_intent`. +- `tallyPending` dit la part des totaux montrés qui attend encore sa preuve ; `tallySimulated` + la part qui vient de la simulation. Les textes publiés (`publish.rs`) ne publient jamais une + intention : ils lisent `recorded_tally` (ce qui est enregistré, simulation comprise tant + qu'elle compte), ou `tally` pour les articles en vigueur, comme avant ; et rien ne part tant + qu'une entrée due à des votes simulés ne tient pas sur les seuls votes prouvés (« La + publication », plus bas). + +### Ce que la personne voit, à chaque étape + +| Étape | Sous l'article (`myVote`, `myVoteState`, `myBallot`) | Totaux | Boîte « Messages » | +|---|---|---|---| +| elle vote (groupe géré) | son choix, `recorded` | aussitôt | — | +| elle vote, la chaîne ouverte | son choix, `pending` ; « En attente de la chaîne Ğ1 : … » | aussitôt, avec son choix | — | +| la chaîne le confirme | son choix, `recorded` ; « inscrit sur la chaîne Ğ1 » | inchangés (l'intention devient preuve) | — | +| rien n'est parti (portefeuille, nœud, fenêtre fermée) | son vote d'avant | son intention retirée | — | +| jamais vu sur la chaîne en 24 h | son vote d'avant ; « Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps » | son intention retirée | « Ton vote … n'a pas été vu sur la chaîne Ğ1 en 24 heures : il est écarté. Tu peux voter à nouveau. » | +| refusé par la chaîne | son vote d'avant ; « Ton dernier vote n'a pas été compté : … » | son intention retirée | « Ton vote … n'a pas été compté : … » | +| un « pour » prouvé sur une version retenue | son vote d'avant ; « Ton dernier vote n'a pas été compté : une objection retenait cette version… » | inchangés | « Ton « pour » sur … est écarté : une objection retenait cette version quand la chaîne a confirmé ton vote. Ton vote d'avant reste. » | +| dépassé par un vote plus récent de la même identité | le plus récent | le plus récent | — (c'est son propre geste) | +| elle vote, la chaîne fermée (simulation) | son choix, `simulated` | aussitôt | — | + +Rien n'est dit à l'écran sur le recoupement entre la chaîne et les totaux (décision d'Yvv). +Un message n'offre aucun geste : il dit ce qui s'est passé et mène au texte, où l'on revote. + +### La simulation, tant que la chaîne est fermée + +Sans `LD_VOTE_ACCOUNT`, on vote **quand même, partout, dans Monnaie libre**, sans portefeuille : +le bouton de vote s'affiche (l'accès dit `open: true`, `simulated: true`, `way: "server"`), le +vote part par l'écriture du serveur (`POST /api/proposals/{id}/vote`, `POST +/api/docs/{id}/votes`) et passe **par le même tampon** : un bulletin scellé (`ballots.mode = +'simulated'`, son sel et son empreinte, sans compte ni montant), puis le **confirmateur simulé** +le prouve aussitôt, dans la même requête (et le balayage reprend un bulletin simulé resté en +attente) : la voix est celle de **l'identité de la session** (il n'y a pas de portefeuille), sa +position est « bloc 0 », au rang de son arrivée ; puis la même réconciliation, le même +balayage d'adoption, les mêmes messages. L'architecture est ainsi éprouvée de bout en bout, en +production, sans la chaîne. Qui peut voter en simulation : la règle de trustinWallet, +`trustin_access::casts_simulated` (qui ouvre le groupe, la chaîne fermée) — une personne +invitée comprise. + +**La décision d'Yvv, 2026-11-02 à 06 h 26 : « Il compte, puis s'efface ».** + +- **Il compte.** Un vote simulé compte dans les totaux et **pour l'entrée en vigueur**, comme un + vote prouvé, avec la marque « simulé » (`myVoteState`, `tallySimulated`, `mode` du bulletin, + « Tes bulletins »). Avec les quorums de l'ADR 0013, neuf « pour » simulés suffisent à faire + entrer I1, E1 ou E2 au cran 2 (quorum 12) : le texte est alors en vigueur sur le site, et ne + se publie pas (« La publication », plus bas). La règle : `ld_engine::buffer::SIMULATED_ADOPTS + = true`, reprise par `Config::simulated_adopts` ; à `false`, « simulé = affiché, jamais compté + pour l'adoption » (les totaux montrés ne changent pas, l'adoption et p̃ les ignorent). Les deux + branches sont testées. +- **L'ouverture de la chaîne n'efface rien d'elle-même.** Dès que `LD_VOTE_ACCOUNT` est posé + (un acte « déploie »), on vote par la chaîne : un nouveau vote simulé est refusé (`409 + chain_only`). Les votes simulés déjà là **continuent de compter**, marqués, jusqu'à l'acte qui + suit. Une identité Ğ1 (une session ouverte par la clé Ğ1, `idty:`) qui vote ensuite + par la chaîne passe devant son vote simulé (bloc 0, avant tout bloc réel). +- **Puis il s'efface, par un acte « supprime » d'Yvv**, jamais déclenché par le code : ni le + serveur, ni l'ouverture de la chaîne, ni un balayage ne l'appellent. Il est préparé et testé + ici : `simulation::discard`, en ligne de commande dans l'image : + ```sh + ld-server discard-simulation # à blanc : dit ce qu'il ferait, n'écrit rien + ld-server discard-simulation --apply # l'acte + ``` + Il lit la base du volume (`LD_DATA_DIR`, `libredecision.db`) et imprime un JSON : les + bulletins, lignes, votes et messages simulés, et chaque entrée réexaminée (article, sorte, + votes d'entrée et leur part simulée, votes prouvés, « pour » exigés, tient ou non, rendue ou + non, la version déplacée). Avec `--apply` : + 1. **le tri** : il efface les messages des bulletins simulés, les votes simulés + (`votes.source = 'simulated'`), leurs lignes et les bulletins simulés, puis réconcilie de + nouveau chaque identité qui avait voté en simulation (son vote prouvé, s'il y en a un, + tient) ; + 2. **le réexamen** : chaque entrée en vigueur qui a compté des votes simulés + (`adoptions.params.simulated`), la plus récente d'abord, encore en vigueur, est jugée de + nouveau sur les seuls votes prouvés, au cran et sur le quorum où elle a été votée + (`params.level`, `params.quorum`). **Sous son seuil, elle retourne parmi les + propositions** : la version qu'elle avait déplacée revient en vigueur (un texte retiré + revient, un cran d'inertie redescend, l'ordre et le titre d'avant s'appliquent de + nouveau), son adoption est effacée, une publication est mise en file. **À son seuil ou + au-dessus, elle reste**, et son adoption le dit (`params.reexamined`) ; + 3. les envois en attente de la publication repartent. + + Répété, l'acte ne trouve plus rien (aucun bulletin simulé, aucune entrée à réexaminer). + **À blanc**, le réexamen met les votes simulés de côté sans les effacer : une entrée qui ne + reviendrait qu'après le retour d'une plus récente est dite « plus en vigueur ». +- Pour les tests seulement, et seulement avec la connexion de développement, `POST + /api/dev/vote-chain {"open": false}` ferme la chaîne d'un serveur qui en a une (le contrôle + e2e de la simulation tourne sur le serveur jetable des e2e, qui a sa chaîne simulée). + +### La publication : « Pas avant des votes prouvés » + +**La décision d'Yvv, 2026-11-03 (formulaire d'orch) : « Pas avant des votes prouvés ».** Un texte qui +entre en vigueur grâce à des votes simulés est **en vigueur sur le site**, mais **ne déclenche +pas la publication** vers le dépôt des textes, que lisent les applis clientes. La publication +d'un document attend que tout ce qui y est en vigueur et y est entré par des votes simulés — +texte, retrait, cran d'inertie, ordre, titre — **tienne sur les seuls votes prouvés**, selon le +réexamen même de l'acte « supprime », fait à blanc (`simulation::unproven`). La chaîne étant +fermée, **rien ne se publie dans Monnaie libre avant son ouverture** ; après elle, la file part +dès que l'acte a rendu ce qui ne tient pas, ou dès que des votes prouvés portent ce qui est +entré. + +- Un seul endroit : `publish::waits_for_proven_votes`, après `publish::waits_for_a_text` (ADR + 0011), tous deux lus par `publish::waits`. Comme pour l'attente du premier texte, ce n'est pas + un échec : aucune tentative comptée, la file attend et se relit toutes les dix minutes ; l'API + dit `publication.state = "suspended"` et `publication.waitsFor = "proven"` (`"text"` pour + l'attente du premier texte). +- La bascule, pour publier quand même si Yvv revenait sur ce choix : `LD_PUBLISH_SIMULATED=1` + (`Publish::simulated`). Testé dans les deux sens. +- Plus strict qu'« un texte prouvé suffit » : tant qu'une entrée simulée ne tient pas, même un + texte prouvé du même document attend, pour que le dépôt ne porte jamais une entrée que les + votes prouvés ne portent pas. + +### Un « pour » prouvé pendant une objection (révise 0010) + +0010 disait : « un bulletin scellé avant l'objection et confirmé après compte tel qu'il est +signé, sans la faire entrer ». Une relecture a relevé qu'un « pour » entrait ainsi sur une +version qui ne doit plus en prendre. Désormais, **un « pour » dont la preuve arrive pendant +qu'une objection retient sa version est écarté** (`objection`) : il ne compte pas, comme s'il +n'avait pas été émis ; le vote d'avant de la même identité, s'il y en a un, reste ; la personne +qui l'a demandé reçoit un message. « Contre » et retrait, prouvés pendant l'objection, comptent +(Yvv, 01/11 : « Retirer ou "contre" »). « Arrive » se lit à l'heure du serveur qui lit la +preuve : la chaîne ne donne que des blocs, et l'objection une heure. Une objection levée avant +la preuve laisse le « pour » compter. + +### « Un vote existe » (ADR 0013) + +Le quorum d'un article se fixe avant le premier vote (`quorum::voted_units`, la seule +requête). Le tampon gardant l'historique des bulletins, elle compte : un vote qui compte +(serveur, chaîne, **simulé**) ; toute ligne d'un bulletin **en attente** ou **prouvé**, de la +chaîne ou de la simulation, **quel que soit son sort** (la voix qui compte, un vote dépassé, un +« pour » écarté par une objection, **un retrait**) ; une adoption. Un vote retiré sur la chaîne +ou en simulation laisse donc sa trace. Un bulletin lâché sans signature, jamais vu à temps ou +refusé par la chaîne n'est pas un vote. Deux limites : dans un groupe géré, sans tampon, un vote +retiré avant toute adoption ne laisse rien (ADR 0013) ; l'acte « supprime » emporte la trace des +votes simulés avec eux. + +### L'API, contrat partagé avec les lots U et S + +Dans le JSON d'une proposition (`GET /api/docs/{id}`, `GET /api/proposals/{id}`) : + +- `tally: {for, against}` — les totaux montrés, intentions comprises ; +- `tallyPending: {for, against}` — la part encore sans preuve ; +- `tallySimulated: {for, against}` — la part simulée (ajouté) ; +- `evaluation` — sur les totaux montrés (ADR 0013 : `quorum`, `yesNeeded`, `noNeeded`) ; +- `myVote: 'for' | 'against' | null` — ce que la personne voit comme son vote maintenant : son + dernier geste, une intention en attente comprise (un retrait en attente : `null`) ; +- `myVoteState: 'recorded' | 'pending' | 'simulated' | null` ; +- inchangés : `myVoteStale`, `myVoteChain`, `myBallot` (qui peut dire `status: 'refused'`, + `reason: 'objection'` pour un « pour » écarté). + +Dans l'accès (`vote` du JSON du document) : `{ way, open, simulated, reason, chain }` ; +pendant la simulation, `way: "server"`, `open: true`, `simulated: true`, `chain: null`. Un +vote écrit par le serveur répond `{ entered, simulated }` (et `count` pour un vote groupé). +Les bulletins (`GET /api/ballots/{id}`, `GET /api/ballots`, le reçu) gagnent `mode: 'chain' | +'simulated'` (le bloc d'un bulletin simulé n'est jamais montré). Les messages gagnent le sujet +`ballot`, de sortes `ballot_expired`, `ballot_refused`, `ballot_objection`, avec `ballot: {id, +status, reason, mode, createdAt, settledAt, lines: [{proposalId, unitId, code, title, choice, +outcome}]}`. La publication d'un document (`doc.publication`) gagne `waitsFor: 'text' | +'proven' | null`. + +## Les données + +Migration `0010_vote_buffer.sql`, additive (S a pris `0009` et `0011` ; les trois passent dans +l'ordre sur une base neuve comme sur une base arrêtée à `0008`, la production) : + +- `ballots.mode TEXT NOT NULL DEFAULT 'chain'` (`chain` | `simulated`) : tous les bulletins + d'avant restent des bulletins de la chaîne ; +- `ballot_lines.outcome TEXT` (`counted` | `superseded` | `objection`, `NULL` tant que le + bulletin n'est pas prouvé ; les lignes d'avant restent à `NULL` et se lisent comme « pas + écartée par une objection » : la prochaine réconciliation de leur identité les qualifie) ; +- un index `ballots(doc_id, status)` pour lire les intentions d'un document ; +- `votes.source` prend une valeur de plus, `simulated` (colonne texte, rien à changer) ; + `messages.topic` aussi, `ballot` ; `adoptions.params` (JSON) garde de plus, pour l'acte, la + part simulée des votes d'entrée (`simulated`), la version déplacée (`displaced`), le cran + d'avant (`from_inertia`), et après l'acte `reexamined`. + +Aucune ligne existante n'est réécrite, et le premier démarrage n'écrit rien du tampon (la +chaîne fermée en production : aucun bulletin). La purge (0008) efface avec un bulletin lâché +les messages qui en parlent. + +## Coût + +- **Les totaux montrés sont provisoires** le temps de la preuve : une intention compte pour la + session qui l'a demandée. Si une même identité vote depuis deux sessions à la fois, ou si une + session signe avec un autre portefeuille que celui de son vote précédent, les totaux montrés + peuvent s'écarter d'une voix jusqu'à la preuve ; la réconciliation remet le compte juste. Une + personne qui ouvre Monnaie libre peut faire paraître une voix dans les totaux montrés pendant + 24 heures sans payer (une par session et par version, 20 bulletins au plus) : elle ne compte + jamais pour l'adoption. +- L'intention compte dès que le bulletin est scellé, quelques secondes avant la signature ; si + rien ne part, la page la lâche aussitôt. +- Un « pour » payé juste avant une objection mais lu juste après (l'indexeur suit à quelques + secondes) est écarté : la personne revote à la levée. +- **La simulation rend possible deux voix pour une personne** (une session par invitation, une + session Ğ1), ce que la chaîne empêche : accepté tant qu'elle dure, et effacé par l'acte + « supprime ». Chaque geste simulé laisse un bulletin en base jusqu'à l'acte. +- **Un texte entré par des votes simulés se lit en vigueur sur le site sans être publié** ; le + dépôt des textes garde la dernière publication jusqu'aux votes prouvés. L'acte peut faire + retourner parmi les propositions un texte que des personnes ont lu en vigueur. +- Un message sur un bulletin lâché disparaît avec lui à la purge (une semaine). diff --git a/docs/adr/0013-quorum.md b/docs/adr/0013-quorum.md new file mode 100644 index 0000000..6649486 --- /dev/null +++ b/docs/adr/0013-quorum.md @@ -0,0 +1,135 @@ +# 0013 — Le quorum : les votants qui manquent comptent comme « non » + +> Remplace, dans [0002](0002-vote-permanent.md) et [0007](0007-inertie-cinq-crans.md), le +> plancher C de voix pour placé devant la formule. Décisions d'Yvv du 2026-11-02 et du +> 2026-11-03. La migration est la `0011` : la `0010` reste au lot V. + +**Contexte.** Depuis le 2026-10-21, `R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C)` : +C voix pour d'abord (50 dans Monnaie libre, 1 dans le groupe d'essai), puis la part de l'inertie +sur les votes au-delà. Yvv, 2026-11-02 : « C suit le cran, mais c'est une proposition par +défaut, variable modifiable par l'auteur initial du texte » ; « 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. Si c'est 60 % et c = 10, alors il faut 6 voix pour et ça passe. » Au +formulaire : « La courbe d'aujourd'hui, après le quorum » ; les quorums par défaut +« 12 · 12 · 50 · 50 · 50 », le groupe d'essai à 1 partout ; « L'auteur, avant le 1er vote ». +Le 2026-11-03, à « Sous le quorum, les votants qui manquent comptent-ils comme des « non » ? » : +« Ils comptent comme non ». + +## Décision + +**La formule, jugée sur un quorum Q** (`crates/ld-engine/src/threshold.rs`) : + +``` +T' = max(T, Q) +R = B^W + (M + (1 − M)·(1 − (T'/W)^G)) · T' +``` + +Une version passe si au moins une voix est exprimée et si « pour » ≥ ⌈R⌉. Sous le quorum, les +votants qui manquent comptent comme « non » : la part de l'inertie est demandée à Q votants. +La règle tient dans **une seule fonction nommée**, `threshold::judged_voters`, qui porte la +phrase d'Yvv. M, G et les cinq crans ne changent pas (ADR 0007) : la courbe est celle +d'aujourd'hui, après le quorum. L'exemple d'Yvv (60 %, Q = 10) donne 6 « pour » ; 6 « pour » +contre 5 « contre » ne passent pas, 7 contre 4 passent. + +**Monotone.** Un « pour » de plus ne nuit jamais, un « contre » de plus n'aide jamais : à +« pour » fixé, un « contre » de plus laisse T' à Q ou l'augmente, et ratio(T')·T' ne décroît pas +avec T' (sa dérivée reste entre 0,06 et 1 pour les cinq crans, p̃ dans ses bornes). Les tests +le vérifient sur une grille (`an_against_never_helps`, l'équivalence de `passes` avec +`yes_needed` et `no_needed`). + +**Ce que l'écran peut dire** (`Evaluation`) : `quorum` ; `yesNeeded`, le total de « pour » qui +passe avec les « contre » d'aujourd'hui ; `noNeeded`, le total de « contre » qui tient les +« pour » d'aujourd'hui en échec (0 : le quorum les tient déjà) ; `missing = yesNeeded − pour` ; +`wellRated`, la part de l'inertie tenue sur les T votants venus, le quorum mis de côté +(l'aperçu « si les bien cotées passent »). + +**Un quorum par cran**, le défaut d'un article à ce cran (`documents.quorums`) : Monnaie libre +**12 · 12 · 50 · 50 · 50**, le groupe d'essai **1 partout**. Une proposition d'inertie se juge +sur le quorum de son article (elle hérite de son cran, ADR 0007) ; un module, sur celui de son +cran. + +**Le quorum d'un article** (`units.quorum`, NULL : celui du cran) est fixé par l'auteur de sa +première version — pour un texte du seed, sans auteur, par un administrateur —, et seulement +**avant le premier vote** : `trustin_access::sets_quorum`, la route `PUT /units/{id}/quorum` +(`{ "quorum": n | null }`, en-tête CSRF), dans `crates/ld-server/src/quorum.rs`. Bornes : de 2 +à W (une voix seule ne fait jamais passer un texte), de 1 à W dans le groupe d'essai +(`ld_engine::inertia::quorum_bounds`, `trustin_access::TEST_GROUP`). Chaque changement est +consigné (`quorum_changes` : qui, de quoi, à quoi, quand). Refus : 403 à qui n'est pas +l'auteur, 400 `quorum` hors des bornes, 409 `voted` dès qu'un vote existe. + +**« Un vote existe »** : une seule fonction, `quorum::voted_units` (et `quorum::a_vote_exists` +pour une unité), que le lot V étendra là et nulle part ailleurs. Sur l'une des versions de +l'article (textes, retrait, changement d'inertie ; toute révision) : un vote du serveur +(`votes`) ; une ligne de bulletin de la chaîne en attente ou confirmé (un bulletin refusé ou +expiré n'est pas un vote) ; une adoption (une version entrée en vigueur par le vote en garde la +trace, même ses votes retirés). **Limite connue** : un vote du serveur retiré avant toute +adoption ne laisse pas de ligne (`votes` ne garde que le dernier choix) ; après lui, l'auteur +peut de nouveau fixer le quorum. + +**La participation de référence** reste p0 = 2Q₀/W, Q₀ le quorum de référence du document +(`documents.vote_floor` : 50 dans Monnaie libre, 1 dans le groupe d'essai), **jamais le quorum +d'un article** : un auteur qui baisserait le sien déplacerait l'échelle de tout le groupe. + +## Une base existante + +Un document seedé n'est jamais réinstancié. Tant que `documents.quorums` est NULL, chaque cran +lit `vote_floor` (le C d'avant : 50 et 1). L'**amendement de quorums** +`2026-11-03-quorums-par-cran` (`crates/ld-server/src/quorum_amend.rs`) décide une fois par +document, seulement si le seed d'aujourd'hui donne ces quorums à l'instance : NULL → écrit +(`applied`) ; les mêmes → `present` ; d'autres → refusé (`quorums_set`), gardés tels quels. +Chaque décision est consignée (`quorum_amendments` : ce qui était là, ce que dit le seed, +l'issue, la raison) et dans les journaux ; là où le document publie (Monnaie libre), une ligne +s'ajoute à la file (« quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013) »), qui attend le +premier texte (ADR 0011). Rien d'autre n'est touché : ni le quorum propre d'un article, ni un +vote, ni un texte. + +## Les nombres + +Monnaie libre, W = 5 745 (repli des tests), participation de référence (100 votants) : + +| | cran 1 | cran 2 | cran 3 | cran 4 | cran 5 | +|---|---|---|---|---|---| +| quorum | 12 | 12 | 50 | 50 | 50 | +| « pour » à 0 vote (C d'avant : 50) | 8 | 9 | 39 | 43 | 46 | +| « pour » à 100 votants (C d'avant : 80 · 84 · 88 · 92 · 95) | 60 | 67 | 75 | 83 | 90 | + +Au cran 2, quorum 12 : 9 « pour » passent avec 3 « contre » au plus ; 9 contre 4 demandent 11 +(`yesNeeded`), il en manque 2 ; 8 « pour » ne passent pas même sans « contre » (`noNeeded` 0). + +W = 7 224 (tests `ld-engine/src/inertia.rs`), « pour » exigés : + +| Médiane | T | cran 1 | cran 2 | cran 3 | cran 4 | cran 5 | +|---|---|---|---|---|---|---| +| 120 votants | 120 | 72 | 81 | 90 | 100 | 108 | +| 120 votants | 500 | 284 | 310 | 347 | 386 | 424 | +| 120 votants | 2 000 | 1 068 | 1 123 | 1 252 | 1 389 | 1 537 | +| 360 votants | 120 | 76 | 86 | 96 | 106 | 113 | +| 360 votants | 360 | 216 | 242 | 270 | 299 | 324 | +| 360 votants | 500 | 296 | 328 | 367 | 407 | 443 | +| 360 votants | 2 000 | 1 092 | 1 164 | 1 301 | 1 446 | 1 598 | + +Le cas de référence de la formule (vote forgeron v2.0.0 : W 7 224, 120 votants, M 50 %, G 0,2, +Q 0 → 94 voix) reste le test de la formule elle-même. + +## Les données + +Migration `0011` (additive) : `documents.quorums` (JSON des cinq quorums), `units.quorum`, les +tables `quorum_changes` et `quorum_amendments`. Le seed : `"quorums"` sur chaque instance. +`vote_floor` reste, lu pour p0. Les adoptions nouvelles gardent `quorum` dans leurs `params` +(celles d'avant gardent `constant`, lu tel quel). L'API : `doc.quorums`, `levels[].quorum`, +l'unité `quorum` (le sien ou `null`), `quorumDefault`, `canSetQuorum` ; le `document.json` +publié : `quorums`. + +## Coût + +- Les seuils de Monnaie libre baissent à faible participation : 9 « pour » au lieu de 50 pour + I1, E1 et E2 au cran 2 ; 39 au lieu de 50 au cran 3. À la participation habituelle, la part + s_k s'applique à tous les votants, plus seulement au-delà de C. +- **Dans le groupe d'essai, une voix seule passe**, à tout cran et quel que soit le nombre de + membres (avant : deux voix sous 16 membres). Les tests qui demandent deux voix y posent le + quorum 2 de l'article, comme son auteur le ferait. +- En production, si les propositions d'inertie I1 → 5 et E1 → 5 restent ouvertes (ADR 0011), + I1 et E1 restent aux crans 3 et 4 : leur quorum est 50, pas 12. +- Le texte F1.1 du seed (« Seuil = C + B^W + … ») et N1.1 (« le groupe demande d'abord C voix + pour ») décrivent encore C : orch les réécrira au prochain tour de textes, avec la relecture + d'Yvv (ce lot ne touche pas aux textes du seed). diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e456e..cd01ec4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,3 +15,6 @@ 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 | +| [0012](0012-tampon-des-votes.md) | Le tampon des votes : l'intention visible tout de suite, la preuve de la chaîne, la réconciliation a posteriori ; la simulation tant que la chaîne est fermée (complète 0008, révise 0010) | 2026-11-02 | +| [0013](0013-quorum.md) | Le quorum par cran (12 · 12 · 50 · 50 · 50), fixé par l'auteur avant le premier vote ; sous le quorum, les votants qui manquent comptent comme « non » (remplace le C de 0002 et 0007) | 2026-11-03 | diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index baffd48..f1b0e30 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -14,9 +14,14 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. ## Ce que voit la personne -1. **Accueil sobre** (revue du 2026-10-30) : « Rendre possible la décision collective », puis le - leitmotiv en trois lignes (contexte et cadrage · processus et modalités · boîte à outils), puis - **directement le choix du groupe** (sans session : la connexion). Le pavé des quatre outils +1. **Accueil sous la voûte** (revues du 2026-10-30 et du 2026-11-02) : en haut, la clé de voûte + en grand (logo « Clé de voûte », jamais incliné), et **sous l'arche, centrés, les trois points** + du leitmotiv, dans cet ordre : boîte à outils · contexte et cadrage · processus et modalités + (Yvv, 02/11 : « mets les 3 points avec boîte à outils en premier, centrés, sous la voûte assez + grande pour les abriter ») ; puis le titre **centré, sur deux lignes**, coupé après « possible » + (« Rendre possible » / « la décision collective », une clé i18n par ligne, jamais de `
` dans + une traduction) ; puis **directement le choix du groupe** (sans session : la connexion). Les + e2e C1 et M1 gardent cette disposition (lot H, `a3c0a76`). Le pavé des quatre outils accueille dans le groupe choisi, suivi de ce qui y est « En cours ». Le menu complet à gauche : Décisions · Docs de référence (avec le nombre de docs en cours) · Mandats · Protocoles ; seul Docs de référence réagit, les autres s'ouvrent sur leurs « À venir », éteints. Décisions : une @@ -29,11 +34,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**. @@ -49,10 +58,10 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. pourquoi), puis l'inertie et la formule. Les autres gestes ouvrent un panneau latéral (une feuille sur mobile), lui aussi dans l'URL : **Panier**, **Propositions**, **Proposer**, **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, + **« Réglages de la formule »** (W, B, la participation habituelle du groupe et les cinq + crans avec leur quorum), atteints depuis l'en-tête du document et depuis chaque vote. +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 @@ -82,46 +91,64 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre. ### Le seuil — formule inertielle Ğ1 ``` -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' ``` W = électorat (Monnaie libre : taille de la toile, lue sur la chaîne toutes les 6 h ; un groupe -géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **C = plancher de -votants « pour »** (Monnaie libre : **50** ; groupe d'essai : 1). Adoptée si « pour » ≥ ⌈R⌉. +géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **Q = quorum** +(ADR 0013, Yvv 2026-11-02 et 03) : sous Q votants, **ceux qui manquent comptent comme « non »** +(« Ils comptent comme non »). Adoptée si « pour » ≥ ⌈R⌉. Chaque cran a son quorum, le défaut +d'un article à ce cran : Monnaie libre **12 · 12 · 50 · 50 · 50**, groupe d'essai **1 partout** +(une voix seule y passe). **L'auteur de la première version d'un article** (un administrateur +pour un texte du seed) peut en fixer un autre, **avant le premier vote** (« L'auteur, avant le +1er vote ») : de 2 à W, de 1 à W dans le groupe d'essai ; chaque changement est consigné. Il +remplace le plancher C (« C suit le cran, mais c'est une proposition par défaut […] c'est +plutôt un quorum »). **Cinq crans d'inertie** (Yvv, 2026-10-30 ; ADR 0007). Seuls M et G dépendent du cran k : -M_k est la majorité à pleine participation, s_k la part de « pour » exigée **au-delà de C** quand -le groupe vote comme d'habitude (participation p̃), et G_k s'en déduit : +M_k est la majorité à pleine participation, s_k la part de « pour » exigée des votants quand le +groupe vote comme d'habitude (participation p̃), et G_k s'en déduit : 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 | -| 3 | moyenne | 55 % | 75 % | partout ailleurs | -| 4 | haute | 60 % | 83 % | Unicité du compte, Réciprocité, le titre du document | -| 5 | très haute | 66 % | 90 % | | +| Cran | Nom | M_k | s_k | Quorum (Monnaie libre) | Au départ | +|---|---|---|---|---|---| +| 1 | très basse | 50 % | 60 % | 12 | | +| 2 | basse | 50 % | 67 % | 12 | les trois Propositions seed (Raison d'être, Unicité du compte, Réciprocité), l'ordre des articles de chaque section | +| 3 | moyenne | 55 % | 75 % | 50 | partout ailleurs | +| 4 | haute | 60 % | 83 % | 50 | le titre du document | +| 5 | très haute | 66 % | 90 % | 50 | | **La participation habituelle p̃** = la médiane des votants des propositions du groupe votées -depuis un an (au moins 5), sur W ; avant, la référence 2C/W (Monnaie libre : 100 votants) ; +depuis un an (au moins 5), sur W ; avant, la référence 2Q₀/W, Q₀ le quorum de référence du +document (`vote_floor` : 50 dans Monnaie libre, 100 votants ; jamais le quorum d'un article) ; toujours ramenée entre 1 % et 50 %. Mesurée une fois par jour. Le seuil croît avec le cran, la -part exigée se relâche vers M quand plus de monde vote, et rien ne passe sous C voix pour. +part exigée se relâche vers M quand plus de monde vote, et sous le quorum les votants qui +manquent comptent comme « non ». -Avec W = 7 224, C = 50 et une médiane de 120 votants, il faut, à 120 votants, 92, 97, 103, 109 -et 113 voix pour ; à 500, 306, 329, 362, 397 et 432 ; à 2 000, 1 091, 1 145, 1 271, 1 404 et -1 549. +Avec W = 5 745 et la référence (100 votants), il faut, sans aucun vote, 8, 9, 39, 43 et 46 voix +pour (crans 1 à 5) ; à 100 votants, 60, 67, 75, 83 et 90. Au cran 2 (quorum 12), 9 « pour » +passent avec 3 « contre » au plus. Avec W = 7 224 et une médiane de 120 votants, il faut, à 120 +votants, 72, 81, 90, 100 et 108 ; à 500, 284, 310, 347, 386 et 424 ; à 2 000, 1 068, 1 123, 1 252, +1 389 et 1 537 (ADR 0013). -Moteur : `crates/ld-engine/src/threshold.rs` (la formule ; son cas de référence, lettres -données : 7224 / 120 votants, M 50 %, G 0,2, C 0 → 94 voix) et `crates/ld-engine/src/inertia.rs` -(les crans, la médiane, la référence, les bornes) ; mesure : `crates/ld-server/src/participation.rs`. +Moteur : `crates/ld-engine/src/threshold.rs` (la formule, la règle du quorum dans +`judged_voters`, `yes_needed` / `no_needed` ; son cas de référence, lettres données : 7224 / 120 +votants, M 50 %, G 0,2, Q 0 → 94 voix) et `crates/ld-engine/src/inertia.rs` (les crans, leurs +quorums, la médiane, la référence, les bornes) ; mesure : `crates/ld-server/src/participation.rs` ; +le quorum d'un article : `crates/ld-server/src/quorum.rs` (la route, « un vote existe » dans +`voted_units`). ### Le vote permanent - Toute version d'un article (proposition ou texte en vigueur) se vote à tout moment. - Une proposition **remonte en vigueur** dès qu'elle atteint son seuil **et** tient une marge (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. + proposition, avec ses votes. Un *seed a minima* (sans vote) a une marge négative : 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 +158,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) @@ -141,7 +170,7 @@ Revue du 2026-10-30 : « En fin de section, il y a un "module" qui permet de cha titre d'un article reste voté avec son texte. - Un module se vote **comme un article** : déplié, dans le panier, par la chaîne Ğ1 dans - Monnaie libre, avec la marge du texte en place (seed a minima : −C) et son cran. Son inertie + Monnaie libre, avec la marge du texte en place (seed a minima : négative) et son cran. Son inertie se vote aussi. Il compte dans « Décisions » : le seed passe de 33 à **41 modules à voter** (33 articles, le titre, l'ordre de 7 sections ; l'ordonnancement n'a qu'un article). - **L'ordre d'une section** est la liste de ses articles qui ont un texte en vigueur ou une @@ -281,7 +310,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 +326,21 @@ 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`). + +De même, les **quorums par cran** du seed (ADR 0013) s'écrivent dans un document qui n'en a pas, +par un amendement gardé (`2026-11-03-quorums-par-cran`, `crates/ld-server/src/quorum_amend.rs`), +une seule fois par document : écrits là où il n'y en a pas, gardés s'ils sont autres (refus +`quorums_set`) ; décisions consignées dans `quorum_amendments`, une ligne à la file là où le +document publie. Jusque-là, chaque cran lit `vote_floor` (le C d'avant). + ### Accès — trustinWallet Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter, @@ -319,7 +366,7 @@ gérés (essai, Axiom…), tout membre vote directement, sans portefeuille. ## Le groupe d'essai -Caché, plancher C = 1, sans publication : on y éprouve tout (adoption, coquilles, découpage) +Caché, quorum 1 à chaque cran (une voix seule passe), sans publication : on y éprouve tout (adoption, coquilles, découpage) sans toucher au texte de la communauté. Chiara et Yvv en sont membres. ## Décisions prises (2026-10-21, avec Yvv) @@ -330,7 +377,9 @@ sans toucher au texte de la communauté. Chiara et Yvv en sont membres. | Domaine | le produit à la racine `decision.librodrome.org` ; la maquette sur `maquette.decision.librodrome.org` | | 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) | +| Garde-fou du vote permanent | nombre minimal de votants « pour » (C) ; remplacé le 2026-11-03 par un quorum par cran, les votants qui manquent comptant comme « non » (ADR 0013) | +| 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) | +| Quorum (2026-11-02 et 03) | 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 dans le groupe d'essai ; l'auteur d'un article le change avant le premier vote ; sous le quorum, les votants qui manquent comptent comme « non » (ADR 0013) | | 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 | @@ -373,6 +422,9 @@ Les raisons longues sont dans `docs/adr/`. - ~~Texte « Paramètres par défaut » du seed (F1.2)~~ — réécrit avec N1, N1.1 et O1 selon les règles appliquées (Yvv, 2026-11-02) ; un amendement de seed gardé remplace ces propositions ouvertes à 0 vote, et Yvv relit les textes en ligne. +- **Textes du quorum** (F1.1, F1.2, N1.1 ; lot T, 2026-11-03) : réécrits pour le quorum par cran, à + relire dans `textes-quorum-seed.md` ; un second amendement de seed gardé les remplace là où personne + n'y a touché (pour F1.2 et N1.1, la garde admet la révision 2 écrite par M5, et rien d'autre). - Anglais : l'interface est en anglais américain, le seed en anglais britannique. ## Ce qui reste après la livraison diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 4294bc8..950cb49 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -5,47 +5,51 @@ 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é, quorum 1 à chaque cran — une voix seule y passe, ADR 0013 —, 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 (une voix +suffit, deux 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. | # | Contrôle | Geste humain | Attendu | |---|---|---|---| -| 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 | +| C1 | L'accueil, sans session | ouvrir `/` sans être connectée | la clé de voûte en grand, jamais inclinée ; sous l'arche, centrés et dans cet ordre : « boîte à outils », « contexte et cadrage », « processus et modalités » ; puis le titre centré sur deux lignes, « Rendre possible » / « la décision collective » ; « 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 | -| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance) ; **au clavier, les pastilles** : un seul arrêt de Tab, les flèches gauche / droite passent d'une ambiance à l'autre et la choisissent ; l'anneau de focus, en pointillé, se lit sur la page (contraste 3:1 au moins, dans les quatre ambiances) et ne se confond pas avec l'anneau plein de l'ambiance choisie ; les trois gestes visibles sans survol ; le vote se déplie sous l'article ; « Réglages de la formule » depuis le vote : le panneau en feuille, l'onglet « Réglages » visible, le tableau des cinq crans tient dans la feuille ; « Voter en lot » : le panier tient dans la feuille, son bouton « Signer et envoyer » reste en vue | +| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil sous la voûte tient à 412 et à 360 de large (les trois points dans l'ouverture, rien ne déborde) et montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance) ; **au clavier, les pastilles** : un seul arrêt de Tab, les flèches gauche / droite passent d'une ambiance à l'autre et la choisissent ; l'anneau de focus, en pointillé, se lit sur la page (contraste 3:1 au moins, dans les quatre ambiances) et ne se confond pas avec l'anneau plein de l'ambiance choisie ; les trois gestes visibles sans survol ; le vote se déplie sous l'article ; « Réglages de la formule » depuis le vote : le panneau en feuille, l'onglet « Réglages » visible, le tableau des cinq crans tient dans la feuille ; « Voter en lot » : le panier tient dans la feuille, son bouton « Signer et envoyer » reste en vue | | C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis | | C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur | | 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), B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois 50, le quorum de référence : 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 % ; quorum 12, 12, 50, 50, 50 (ADR 0013) ; voix pour à 100 votants 60, 67, 75, 83, 90, « Voix pour : à 100 votants, comme d'habitude. », « Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude. », « Quorum : en dessous, les votants qui manquent comptent comme « non ». … » ; depuis le vote déplié : « Inertie · 2 / 5 · basse », la ligne du cran 2 marquée « cet article », à 0 votant 8, 9, 39, 43, 46 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. » | +| 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 : la part de « pour » demandée, à 2 votants comme d'habitude. » | | C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris | | C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste | | C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » | | C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » | | C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page | | C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote--.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé | +| C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « Tes bulletins » | « Voter en lot » est là ; aucune fenêtre de portefeuille ; « Ton vote : contre » aussitôt, et un « contre » de plus dans les totaux ; dans l'API, `myVoteState` « simulated » et `tallySimulated` ; dans « Tes bulletins » : « Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1 », sans bloc | ## Au-delà de la liste @@ -83,3 +87,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-11-02 | automate | local, lot G sur `5d1f865` (décision d'Yvv du 01/11 : sur une version retenue, « contre » et retrait, jamais « pour » ; C28 ajusté : « Contre » et le retrait sous l'article, le panier) | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-11-02 | automate | local, lot G fusionné (`0919b46`) et les articles des règles réécrits (amendement de seed sur une proposition ouverte) | C1–C33, M1 | 34 / 34 verts (35 tests) | +| 2026-11-03 | automate | local, lot S (Propositions seed, ADR 0011 ; le quorum par cran, ADR 0013 : C3, C4, C5, C9, C18 à C20, C23, C24, C27, M1 repris) | C1–C33, M1 | 34 / 34 verts (35 tests) | diff --git a/docs/chantiers/acte-certification/tableau.md b/docs/chantiers/acte-certification/tableau.md index 557e094..4b5cd94 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 02). +> É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 @@ -16,6 +28,66 @@ 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. ». 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. + +**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 +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). 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 T, puis U (validé par orch le 03/11, reçu avant 05 h 38). 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. **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`) : **fini et fusionné** le 03/11 à 05 h 20 (`349c8ed`). + - les Propositions seed : I1, E1 et E2 ouvertes, au cran 2 (ADR 0011) ; publication suspendue sans texte en vigueur ; + - le quorum par cran (ADR 0013) : 12 · 12 · 50 · 50 · 50 en Monnaie libre, 1 à l'essai ; règle « les votants manquants comptent non » (`judged_voters`) ; `PUT /units/{id}/quorum` ; « un vote existe » = `voted_units`, adoptions comprises ; + - la licence `AGPL-3.0-or-later`. + Boucle verte dans sa copie : 265 tests Rust, 56 vitest, e2e 35 / 35. Pour M6, sur une copie de la production : vérifier les propositions qui ont déjà assez de voix (9 au cran 2, 39 au cran 3), qui entreraient au premier balayage. +- **V** (`a78bacc…`, `lot/v-tampon`) : le tampon des votes, le vote simulé partout tant que la chaîne + est fermée, qui compte, puis s'efface par l'acte « supprime » (`ld-server discard-simulation + [--apply]`, jamais appelé automatiquement) ; la publication « pas avant des votes prouvés » + (`publish::waits_for_proven_votes`, bascule `LD_PUBLISH_SIMULATED`, règle stricte) ; `voted_units` + étendu ; ADR 0012 réécrite. Fini le 03/11 vers 06 h 05 (`98b4530`) : 286 tests Rust, 56 vitest, e2e + 36 / 36 (C34). Puis la marque « simulé » sous l'article (VoteFold, minimal, `d57326d`), puisque M6 + part sans U. **Fusionné le 03/11 à 06 h 18** (`94ddb17`). +- **T** (`lot-t-textes`, `lot/t-textes`) : **fini et fusionné** le 03/11 à 07 h 33 (`bb4bdf5`). F1.1, + F1.2 et N1.1 disent le quorum par cran (fr, en, es), chaque nombre calculé par le moteur ; amendement + gardé `2026-11-03-f1-1/f1-2/n1-1-quorum` (garde étendue à la révision 2 de M5) ; fichier de relecture + pour Yvv `textes-quorum-seed.md` (quatre points à trancher, Q13). Boucle verte : 294 tests Rust, e2e + 36 / 36 (C18 hors délai une fois, vert à la relance). **Course** : un vote, même simulé, sur un de ces + articles avant la mise en ligne fait refuser l'amendement pour lui, définitivement. Proposé : M6b, + dès la relecture d'Yvv. +- **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 « N oui pour passer · N non bloquent »** et **le réglage du quorum par l'auteur avant + le premier vote** (le serveur les fournit depuis S, aucun écran ne les montre encore). Note `UX-lecteur.md` commitée (`d4531da`), 4 fichiers + commencés. En attente de V. + **Relancé le 03/11 à 07 h 33**, avec la fusion de H, S, V et T, les reprises de la relecture de M6 + (« Vote enregistré. », « effacement à l'ouverture de la chaîne ») ; part en M7. + +**M6 en ligne** depuis le 03/11 à 07 h 03 (`94ddb17`, pipeline 37) : H, S et V. Production `94ddb175` ; +`main` et `chantier/acte-certification` poussés vers origin par orch. Le dépôt des textes reste à `9e0f45a` +(publication suspendue). Copies de répétition retirées par l'intégrateur le 03/11 à 07 h 06 (scratchpad +`m6/rep`, `m6/retour`, la base locale de `3f10a0e`) ; orch a retiré ses trois copies de la base. +Suite : **T** (en cours), puis **U** (M7), un sous-agent à la fois ; PRÊT et actes par orch. + 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é. @@ -51,7 +123,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 @@ -78,6 +152,11 @@ 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.) | **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 | +| Q11 | Un texte entré en vigueur par des votes simulés (9 « pour » au cran 2 dans Monnaie libre) se publie-t-il dans le dépôt des textes ? | **tranché** (03/11, formulaire d'orch, reçu avant 05 h 35 ; le relais disait « vers 05 h 45 ») : « Pas avant des votes prouvés » → en vigueur sur le site, pas publié ; la publication attend un texte passé par les seuls votes prouvés (dans Monnaie libre, rien avant l'ouverture de la chaîne) ; codé par V, écrit au dossier M6 | +| Q12 | En production, Yvv a ouvert le 29/10 deux propositions d'inertie, I1 → 5 et E1 → 5 (0 vote). Ouvertes au démarrage de M6, elles retiennent I1 et E1 aux crans 3 et 4 pour de bon (amendement décidé une fois). Les retirer ? | **tranché** (03/11, formulaire d'orch, reçu à 05 h 51) : « Oui, retire-les » → avant le démarrage de M6, par le geste d'Yvv à l'écran (« Les miennes » › la ligne d'inertie › « Coquilles, traductions, objections, historique » › « Retirer ma proposition »), vérifié sur `3f10a0e` en local ; fait par Yvv à l'écran (annoncé à 06 h 07), dossier `actes/2026-11-03-m6-propositions-seed-quorum-tampon.md` | +| Q13 | Les textes du quorum (F1.1, F1.2, N1.1 ; `textes-quorum-seed.md`) : annoncer l'intention de remonter l'inertie ? « Propositions seed » dans un texte voté ? garder l'exemple 7 224 / 120 ? nommer le groupe d'essai ? | **défauts appliqués** : non ; « Propositions seed » ; garder ; ne pas le nommer. Posé à orch le 03/11 à 07 h 34, pour Yvv à son retour | 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 @@ -93,28 +172,36 @@ 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`). +- M6 (H, S, V) : **exécuté** le 03/11/2026 par orch sous mandat, en ligne à 07 h 03 (pipeline 37) ; figé le 03/11 à 06 h 22 sur `94ddb17` (`actes/2026-11-03-m6-propositions-seed-quorum-tampon.md`) ; répétition faite sur une copie de la production de 06 h 11 ; relecture indépendante « 94ddb17 : ok » inscrite à 06 h 42 ; retour corrigé (couper `LD_PUBLISH_REMOTE` avant, répété) ; PRÊT envoyé à orch (dev-63), qui exécute sous mandat. Restes mineurs de `71819f9` et du lot B : faits dans le lot D. +Liste d'attente (relecture de `94ddb17`, M6) : +- `sbom-publish` échoue encore (pipelines 36 et 37) : écart ancien (certificat du serveur + Dependency-Track refusé, HANDOFF), étape en `failure: ignore` ; +- **acte « supprime »** (ouverture de la chaîne) : le jouer serveur arrêté, ou ajouter des gardes + `WHERE in_force_id = …` à `give_back` (`simulation.rs:200`, `:258-298`) ; `give_back` remet la version + déplacée telle qu'elle est (révisée entre-temps, une révision non votée reviendrait) ; `confirm_seeds` + (`adoption.rs:49`) : un module confirmé par des votes simulés le reste ; p̃ mesurée votes simulés + compris jusqu'au lendemain de l'acte ; +- **lien d'équipe** : il multiplie les voix simulées (jusqu'à 500 ouvertures) ; l'ADR 0012 l. 290 dit + « deux voix » : à corriger ; à dire à Yvv ; +- **U** : « Vote enregistré. » (`VoteFold.svelte:123`) et « N votes enregistrés. » (`BallotPanel.svelte:95`) + après un vote simulé ; `fr.ts:263`, `:348` (et en, es) : « jusqu'à son effacement à l'ouverture de la + chaîne » → c'est l'acte « supprime » qui efface ; +- `document.json` comptera les votes simulés une fois la publication permise (l'ADR le dit) ; + `simulation::unproven` recharge l'état du document à chaque affichage tant que la file attend + (performance) ; `reached` / `missing` = 0 alors que la marge retient le rival (antérieur). + Liste d'attente (relecture de `033b331+4d4adcc`, après M4) : - 0008 : lever les doublons debout avant de créer `signals_one_standing` (sans objet en production : 0 signalement) ; - un ordre ne départage pas deux articles à la même position (deux créations simultanées) ; - `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. diff --git a/docs/chantiers/acte-certification/textes-quorum-seed.md b/docs/chantiers/acte-certification/textes-quorum-seed.md new file mode 100644 index 0000000..93b26f4 --- /dev/null +++ b/docs/chantiers/acte-certification/textes-quorum-seed.md @@ -0,0 +1,402 @@ +# Textes du seed : le quorum par cran (F1.1, F1.2, N1.1) + +> Préparé le 2026-11-03 pour ta relecture, Yvv (lot T), d'après tes décisions : le quorum (02/11 : +> « 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 ») ; « La courbe d'aujourd'hui, après le quorum » ; sous le +> quorum, « Ils comptent comme non » (03/11) ; les quorums 12 · 12 · 50 · 50 · 50 dans Monnaie libre ; +> « L'auteur, avant le 1er vote » ; les Propositions seed au cran 2 (ADR 0011). Les nouveaux textes sont +> collés dans `seeds/acte-engagement-certification.json` et dans un amendement gardé, sur la branche +> `lot/t-textes` : **rien n'est en ligne**. + +## Avant de lire + +- **Ce qui change.** Trois articles décrivaient encore l'ancien plancher C de voix « pour » : + « Formule du seuil WoT » (F1.1), « Paramètres par défaut » (F1.2), « Niveaux d'inertie » (N1.1). + Ils disent désormais le quorum, dans cet ordre : d'abord le sens (sous le quorum, les votants qui + manquent comptent comme « non » ; au-delà, la part de « pour » du cran), puis la formule en code, + telle que le moteur la calcule (`crates/ld-engine/src/threshold.rs:3-6`). F1.2 et N1.1 disent aussi + le départ d'après l'ADR 0011 : aucun article en vigueur, Raison d'être, Unicité du compte et + Réciprocité en Propositions seed au cran 2, le titre du document au cran 4. +- **Les titres** sont gardés tous les trois : ils restent vrais. +- **Les nombres.** Chaque nombre de ces textes, dans les trois langues, est calculé par le moteur ou + lu dans le seed par un test (`crates/ld-server/tests/seed_quorum_texts.rs`, + `every_number_of_the_quorum_texts_is_the_engine_s`) : un chiffre que rien ne justifie fait échouer + le test. Les trois formules écrites dans les textes sont évaluées telles quelles et comparées au + moteur sur une grille de cas (`the_formulas_of_the_texts_are_those_the_engine_computes`). +- **La forme**, vérifiée par le test `the_quorum_texts_follow_the_rules_of_form` et par `render()` + (`web/src/lib/markdown.ts`) : sous-ensemble de Markdown, ni tableau ni intertitre ; une ligne vide + avant chaque liste (toutes les listes sortent en `
    `) ; renvois par le titre (« Formule du seuil + WoT », « Paramètres par défaut », « Niveaux d'inertie »), jamais par le code ; apostrophes droites ; + « » espacés en français, “ ” en anglais britannique (*favour*), «» serrés en espagnol ; formules en + code, avec × et −, jamais d'astérisque. +- **Longueur** (signes) : fr : F1.1 1081 · F1.2 2055 · N1.1 2372 ; en : F1.1 1058 · F1.2 1977 · N1.1 2246 ; es : F1.1 1083 · F1.2 2035 · N1.1 2454. Limite d'une proposition : 4 000 (`crates/ld-server/src/proposals.rs:32`) ; + au-delà de 700, l'assistant dit « texte long » (`crates/ld-engine/src/assist.rs:25`), comme pour les + textes actuels de F1.2 et N1.1. +- **Le ton** est celui de F1.2, N1 et O1 tels que M5 les a posés : le groupe, la personne, jamais le + système. Rien sur le vote simulé, ni sur le recoupement entre la chaîne et les totaux. + +## La production + +Les trois articles y sont des propositions ouvertes du seed, à 0 vote. Un **amendement de seed +gardé** (`crates/ld-server/src/seed_amend.rs`, `2026-11-03-f1-1-quorum`, `-f1-2-quorum`, +`-n1-1-quorum`) remplace leur texte au démarrage, une seule fois par document, **seulement si +personne n'y a touché** : origine du seed, jamais entré en vigueur ni confirmé ; aucun vote ; aucune +ligne de bulletin, de la chaîne ou de la simulation, en attente ou prouvée, quel qu'en soit le sort ; +aucune coquille ; aucune traduction écrite par un membre ; aucune objection ; le texte d'avant +identique à l'empreinte attendue. + +- **F1.1** est encore à sa révision 1 (le texte du 21/10) : la garde de toujours. +- **F1.2 et N1.1** sont à la révision 2 depuis M5 (02/11). La garde est étendue pour eux, et pour eux + seuls : elle admet la révision 2 dont l'historique est « texte initial » puis « texte de départ + remplacé », et sur laquelle l'amendement de M5 de cet article, lui seul, a été appliqué + (`seed_amendments`) ; le texte doit être celui que M5 a posé. Rien d'autre n'est admis. +- Les amendements de M5 sur F1.2 et N1.1 sont **dépassés** : le seed ne donne plus leur texte, ils ne + décident plus rien (sans erreur au journal) ; leurs décisions du 02/11 restent. + +Appliqué, dans une transaction : +- `proposals` : le titre et le texte (fr) remplacés, la révision + 1 (F1.1 : 2 ; F1.2 et N1.1 : 3) ; +- `revisions` : une ligne « texte de départ remplacé » (`amendment`), sans auteur, avec le nouveau + texte : l'historique de l'article garde l'ancien et montre le diff ; +- `translations` : en et es remplacés, liés à la nouvelle empreinte ; +- `seed_amendments` : la décision (`applied`, `refused` et sa raison, ou `present`), aussi au journal ; +- `outbox` : dans Monnaie libre seulement, une ligne par article (« proposition de départ réécrite : + F1.1, le quorum par cran »…), qui attend comme les autres le premier texte en vigueur ; l'essai ne + publie jamais. + +Rien d'autre n'est touché : ni un vote, ni un bulletin, ni une autre proposition, ni le quorum ou le +cran d'un article. Testé sur une base faite comme la production après M6 (née avec les textes du +21/10, démarrée par le seed de M5 puis par celui de M6) : appliqué une fois ; refusé là où quelqu'un a +voté (raison `votes`), là où un bulletin est en attente (`ballots`), là où un membre a écrit une +traduction (`translation_edited`) ; au redémarrage, rien n'est rejoué. + +**À savoir avant la mise en ligne** : le vote simulé est ouvert dans Monnaie libre depuis M6. Si +quelqu'un vote sur l'un de ces trois articles avant que ce code parte, l'amendement le refuse là, et le +texte d'avant reste (on le proposerait alors par l'application). Le refus est consigné une fois pour +toutes : l'acte « supprime » de la fin de la simulation ne le fera pas rejouer. + +## F1.1 — Formule du seuil WoT + +**Aujourd'hui** (en production à la révision 1, le texte du 21/10) : en tête, en gras, `Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` ; « C = constante de base (plancher fixe) », « B = exposant de base (B^W tend vers 0) », « M = ratio de majorité cible (M = majority_pct / 100) » ; la liste des lettres suit « Où : » sans ligne vide, et s'affiche avec ses tirets ; « Seuil » reste en français dans les versions anglaise et espagnole. + +**Ce qui est faux** : il n'y a plus de plancher C. Chaque cran a un quorum Q ; sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran s'applique à tous les votants. Le moteur calcule `R = B^W + (M + (1 − M)(1 − (T'/W)^G)) · T'`, avec `T' = max(T, Q)`. B est la base (W l'exposant). + +**Titre** gardé : il reste vrai. Le texte dit d'abord le sens, puis la formule, puis les lettres ; leurs valeurs sont dans les deux articles voisins. + +### F1.1 · Français (fait foi) + +Titre : **Formule du seuil WoT** + +```markdown +Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote. + +`T' = max(T, Q)` +`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'` + +Une version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus. + +- **T** : les votes exprimés sur la version, pour et contre. +- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque. +- **W** : le nombre de membres de la toile de confiance Ğ1. +- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas. +- **M** : la majorité demandée quand tout le monde vote. +- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent. + +Leurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ». +``` + +### F1.1 · English + +Titre : **WoT threshold formula** + +```markdown +The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes. + +`T' = max(T, Q)` +`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'` + +A version passes when its votes in favour reach the threshold, rounded up to a whole number. + +- **T**: the votes cast on the version, for and against. +- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing. +- **W**: the number of members of the Ğ1 web of trust. +- **B**: the base; raised to the power W, it is so small that it does not count. +- **M**: the majority asked for when everyone votes. +- **G**: the gradient: the larger it is, the higher the share asked for when few people vote. + +Their values are in “Default parameters” and “Inertia levels”. +``` + +### F1.1 · Español + +Titre : **Fórmula del umbral WoT** + +```markdown +El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo. + +`T' = max(T, Q)` +`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'` + +Una versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior. + +- **T**: los votos emitidos sobre la versión, a favor y en contra. +- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan. +- **W**: el número de miembros de la red de confianza Ğ1. +- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta. +- **M**: la mayoría pedida cuando vota todo el mundo. +- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente. + +Sus valores están en «Parámetros por defecto» y «Niveles de inercia». +``` + +## F1.2 — Paramètres par défaut + +**Aujourd'hui** (en production à la révision 2, le texte de M5 du 02/11) : « **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe » ; « le groupe demande 75 % de « pour » au-delà de C » ; « Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute) ». + +**Ce qui est faux** : C est devenu le quorum du cran (12 · 12 · 50 · 50 · 50 dans Monnaie libre), que l'auteur de la première version change avant le premier vote ; la part du cran s'applique à tous les votants, plus « au-delà de C » ; au départ, aucun article n'est en vigueur, et Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed au cran 2. + +**Titre** gardé. L'introduction, « Un vote permanent, sans durée » et les autres lettres sont ceux de M5, mot pour mot : seuls la ligne de C (devenue Q) et « Par défaut » changent. + +### F1.2 · Français (fait foi) + +Titre : **Paramètres par défaut** + +```markdown +Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule. + +**Un vote permanent, sans durée** + +- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi. +- 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. +- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur. + +**Les lettres** + +- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures. +- **T** : les votes exprimés sur la version, pour et contre. +- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte. +- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas. +- **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. + +**Par défaut** + +- Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce cran. +- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3. +``` + +### F1.2 · English + +Titre : **Default parameters** + +```markdown +The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs. + +**A permanent vote, with no duration** + +- 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. +- 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. +- If several proposals get there, the one with the widest margin comes into force. + +**The letters** + +- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours. +- **T**: the votes cast on the version, for and against. +- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: a single vote never passes a text. +- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count. +- **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. + +**By default** + +- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level. +- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3. +``` + +### F1.2 · Español + +Titre : **Parámetros por defecto** + +```markdown +Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación. + +**Una votación permanente, sin duració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. +- 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. +- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio. + +**Las letras** + +- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas. +- **T**: los votos emitidos sobre la versión, a favor y en contra. +- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto. +- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta. +- **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. + +**Por defecto** + +- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel. +- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3. +``` + +## N1.1 — Niveaux d'inertie + +**Aujourd'hui** (en production à la révision 2, le texte de M5) : « le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C » ; la part exigée sur T ; « Cran 4 · haute […] Unicité du compte, Réciprocité, le titre du document » ; la participation de référence « vaut deux fois C » ; l'exemple « 92, 97, 103, 109 ou 113 voix pour […] Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103 ». + +**Ce qui est faux** : le quorum remplace C ; la part se calcule sur T', les votants ou le quorum s'il en manque ; au cran 2 sont les trois Propositions seed et l'ordre de chaque section, au cran 4 le titre seul ; la référence est deux fois le quorum de référence du document (50 dans Monnaie libre), jamais le quorum d'un article ; avec le quorum, l'exemple donne 72, 81, 90, 100 ou 108 voix pour ; et dans la formule de G, p̃ est une part de l'électorat, pas un nombre de votants. + +**Titre** gardé : il reste vrai. Chaque cran dit maintenant son quorum ; l'exemple dit aussi deux cas sous le quorum. + +### N1.1 · Français (fait foi) + +Titre : **Niveaux d'inertie** + +```markdown +À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La 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. + +`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque. + +**Les cinq crans** : le quorum dans Monnaie libre, 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. + +- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ. +- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section. +- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article. +- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document. +- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ. + +**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 le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour. + +**G** se calcule pour que la part exigée, à la participation habituelle, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃ la participation habituelle divisée par W. + +Exemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme « non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50), il faut 39 voix pour, avec 11 « contre » au plus. +``` + +### N1.1 · English + +Titre : **Inertia levels** + +```markdown +At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The 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. + +`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing. + +**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start. + +- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start. +- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section. +- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article. +- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document. +- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start. + +**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 the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day. + +**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W. + +Example: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most. +``` + +### N1.1 · Español + +Titre : **Niveles de inercia** + +```markdown +En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La 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. + +`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan. + +**Los cinco niveles**: el quórum en Monnaie libre, 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. + +- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio. +- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección. +- **Nivel 3 · media**: quórum 50; 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. +- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento. +- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio. + +**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 el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día. + +**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W. + +Ejemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho. +``` + +## Les autres articles, cherchés et laissés + +Recherche dans tout le seed, en fr, en et es : C, un plancher, un minimum de « pour », un seuil, une +inertie, le cran de départ de Raison d'être, Unicité du compte et Réciprocité, les nombres 12, 50, +100. + +| Article | Ce qu'il dit | Laissé, parce que | +|---|---|---| +| N1 « Réglage de l'inertie » | un cran de 1 à 5 ; « un article au cran 4 ne descend qu'avec les voix que demande le cran 4 » | vrai : le cran 4 y est un exemple, pas le cran de départ d'un article ; rien sur C | +| O1 « Ordonnancement du document » | l'ordre de chaque section au cran 2, le titre au cran 4, « au départ » | vrai (ADR 0009, 0011) | +| F1 « Formule de vote » | renvoie à g1vote | rien sur C ni sur un cran | +| F1.3 « Processus de dépôt officiel » | « atteint le seuil d'adoption », puis IPFS, `system.remark`, Cesium, Gecko | rien sur C ; mais il décrit un dépôt qui n'est pas celui d'aujourd'hui (pas d'IPFS ni d'ancrage, une publication git) : **hors de ce lot, à reprendre à un prochain tour** si tu le veux | +| K1, K2 | 100 certifications, 5 jours… ; le DU | les nombres du protocole et de la monnaie, sans rapport | +| E1, E2, I1 | leur propre texte | rien sur leur cran | + +## Points à trancher + +1. **Ton intention de remonter l'inertie** une fois les textes passés : elle n'est pas écrite (ce n'est + pas une règle). Si tu veux que F1.2 l'annonce sans la promettre, une phrase vraie suffit : « Leur + cran se change ensuite par un vote, comme celui de tout article (voir « Réglage de l'inertie »). » +2. **« Propositions seed »** dans un texte voté : c'est le mot de l'interface (« Proposition seed », + « Seed proposal », « Propuesta seed »). Autre choix : « propositions mises en avant ». +3. **L'exemple de N1.1** (7 224 membres, 120 votants) reste celui de M5 et des tests (le vote + forgeron de 2024) : une illustration, pas la toile d'aujourd'hui (5 707 membres en production le + 02/11). À ce W et à la participation de référence, les deux exemples sous le quorum donnent les + mêmes nombres de « pour » : 9 au cran 2, 39 au cran 3 (`crates/ld-engine/src/inertia.rs`, + `the_control_figures_of_the_quorum`). À retirer si tu ne veux pas de chiffre daté dans un texte voté. +4. **Le groupe d'essai** (quorum 1 partout) n'est pas nommé : les textes donnent les quorums « dans + Monnaie libre », comme F1.2 donnait C = 50. +5. **F1.1 a changé de plan** : le sens d'abord, la formule ensuite, les lettres en liste (la liste + d'aujourd'hui ne s'affiche pas comme liste, faute de ligne vide). B y est « la base » (W l'exposant : + « exposant de base » était faux), M n'y est plus `majority_pct / 100`, et « Seuil » est traduit en + anglais et en espagnol (*Threshold*, *Umbral*) : trois défauts relevés par M5 (`textes-regles-seed.md`, + point 7). +6. **N1.1, la formule de G** : p̃ y est « la participation habituelle divisée par W » (relevé par M5 à + la mise en ligne : G se calcule sur une part de l'électorat, pas sur un nombre de votants). +7. **Ce qui deviendra caduc une fois en ligne**, non touché par ce lot : `docs/adr/0013-quorum.md:133-135` + (« Le texte F1.1 du seed […] et N1.1 […] décrivent encore C ») et + `docs/adr/0011-propositions-seed.md:89-90` (F1.2 et N1.1 disent encore Unicité et Réciprocité au + cran 4). + +## Les chiffres et leur source + +Chaque nombre est vérifié par `every_number_of_the_quorum_texts_is_the_engine_s` +(`crates/ld-server/tests/seed_quorum_texts.rs`), qui le calcule ainsi : + +| Chiffre | Où | Source | +|---|---|---| +| `T' = max(T, Q)` ; `Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'` | F1.1 | `threshold::judged_voters`, `required_ratio`, `wot_threshold` (`crates/ld-engine/src/threshold.rs:57-85`) ; évaluées telles qu'écrites | +| `Part exigée = M + (1 − M) × (1 − (T'/W)^G)` | N1.1 | `threshold::required_ratio` ; évaluée telle qu'écrite | +| `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)` | N1.1 | `inertia::gradient` (`crates/ld-engine/src/inertia.rs:190-197`) ; évaluée telle qu'écrite | +| Quorums 12, 12, 50, 50, 50, du cran 1 au cran 5 | F1.2, N1.1 | le seed, `instances[monnaie-libre].quorums` ; `InertiaLevel::LOWEST`, `HIGHEST` | +| Jamais moins de 2 | F1.2 | `inertia::quorum_bounds(W, false)` | +| Qui change le quorum, avant le premier vote | F1.2 | `trustin_access::sets_quorum` (`crates/trustin-access/src/lib.rs:204-217`) ; ADR 0013 | +| W relu toutes les 6 heures | F1.2 | `wot::REFRESH` (`crates/ld-server/src/wot.rs:13-14`) | +| B = 0,1 | F1.2 | `FormulaParams::DEFAULT_BASE` | +| Cran 3 par défaut : M = 55 %, quorum 50, 75 % | F1.2, N1.1 | `InertiaLevel::DEFAULT`, `majority_pct`, `share_pct` ; le quorum du cran | +| M = 50, 50, 55, 60, 66 % ; s = 60, 67, 75, 83, 90 % | N1.1 | `InertiaLevel::majority_pct`, `share_pct` | +| Propositions seed au cran 2 | F1.2, N1.1 | `seed::SEED_PROPOSAL_LEVEL` ; les versions `seedProposal` du seed, leurs titres dans chaque langue | +| Le titre au cran 4, l'ordre de chaque section au cran 2 | F1.2, N1.1 | le seed, `modules.title.inertia`, `modules.order.inertia` | +| Tous les autres articles au cran 3 ; rien aux crans 1 et 5 | F1.2, N1.1 | le seed : chaque `inertia` (toutes égales, vérifié) | +| 5 propositions ; 100 votants (deux fois le quorum de référence) | N1.1 | `Participation::MIN_SAMPLE` ; `Participation::of(W, voteFloor, None, 0).voters(W)`, `voteFloor` = 50 | +| Entre 1 % et 50 % | N1.1 | `Participation::LOWEST`, `HIGHEST` | +| Depuis un an ; une fois par jour | N1.1 | `participation::WINDOW_DAYS` = 365, `EVERY_HOURS` = 24 | +| 72, 81, 90, 100, 108 voix pour à 120 votants | N1.1 | `Scale::required(120, k, Q_k)`, W 7 224, médiane 120 ; déjà `inertia.rs`, `the_values_shown_to_yvv` | +| 75 % des 120 votants, soit 90 | N1.1 | la part du cran 3 × 120, égale au seuil du moteur (vérifié) | +| Cran 2 (quorum 12) : 9 « pour », 3 « contre » au plus | N1.1 | `threshold::yes_needed(0)`, puis `no_needed(9) − 1`, W 7 224, médiane 120 | +| Cran 3 (quorum 50) : 39 « pour », 11 « contre » au plus | N1.1 | idem ; les deux exemples restent sous le quorum (vérifié) | + +Les règles sans chiffre : + +| Règle | Où | Source | +|---|---|---| +| Sous le quorum, les votants qui manquent comptent comme « non » | F1.1, F1.2, N1.1 | `threshold::judged_voters` ; ADR 0013 | +| Au-delà du quorum, la part du cran parmi tous les votants | F1.1, N1.1 | `threshold::wot_threshold` ; ADR 0013 (« la courbe d'aujourd'hui, après le quorum ») | +| Une version passe quand ses « pour » atteignent le seuil arrondi au-dessus | F1.1 | `threshold::passes`, `clamp_ceil` | +| La participation habituelle ne dépend pas du quorum d'un article | N1.1 | `inertia::Participation::reference_ratio` (le quorum de référence du document) ; ADR 0013 | +| Au départ, aucun article en vigueur | F1.2 | ADR 0011 ; le seed (aucune version `in_force` hors modules) | + +## Les textes dans le seed + +Déjà collés (le `_source` du seed le dit) ; une base neuve naît avec eux, et l'amendement les écrit +dans une base existante. Rien à recopier. diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 171b38f..079cc98 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 », 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. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran.", "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 } ], @@ -37,11 +39,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 +105,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 +127,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", @@ -670,9 +672,9 @@ "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." + "fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».", + "en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.", + "es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»." } } ] @@ -692,9 +694,9 @@ "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." + "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- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : 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 %, un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, 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- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: 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%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, 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- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: 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 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3." } } ] @@ -758,9 +760,9 @@ "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." + "fr": "À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La 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)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, 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** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 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** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 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 le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. 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, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃ la participation habituelle divisée par W.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme « non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50), il faut 39 voix pour, avec 11 « contre » au plus.", + "en": "At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The 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)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, 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**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 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 the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. 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, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.", + "es": "En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La 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)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, 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**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 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**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 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 el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. 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, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho." } } ] diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index ebf61fd..d793b5c 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'; @@ -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); @@ -138,3 +178,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..0d914fd 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' }); @@ -73,11 +79,11 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier, 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'); - await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible(); + // 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 : 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/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 009571b..7f0d737 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -1,10 +1,13 @@ -import { expect, test } from '@playwright/test'; -import { BOB, DOC, 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'; -test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => { +// 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('/'); - 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(); @@ -34,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); @@ -77,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 }) => { @@ -148,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 }) => { @@ -229,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. @@ -264,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'); @@ -280,35 +300,40 @@ 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 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']); + // 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'); - // 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(); @@ -317,25 +342,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'); @@ -343,5 +368,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..92c2c9f 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(); @@ -178,8 +176,9 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => { await signIn(page, 'Chiara'); - // The e2e server has its vote account: the page is shown what a server without one answers - // (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`). + // The e2e server has its vote account: the page is shown a vote closed, as a server without + // one answered before lot V. Since lot V (ADR 0012) a server without one votes in simulation + // instead (the server: `without_a_vote_account_monnaie_libre_votes_in_simulation_…`; here, C34). await page.route(/\/api\/docs\/[^/?]+$/, async (route) => { const res = await route.fetch(); const body = await res.json(); @@ -192,14 +191,13 @@ 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. - 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 ». @@ -216,3 +214,56 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; await unit.getByRole('button', { name: 'Le vote' }).click(); await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); }); + +/** The version of an article of Monnaie libre, by its title, as the API tells it to `page`. */ +async function version(page: import('@playwright/test').Page, title: string) { + const g = await (await page.request.get('/api/groups/monnaie-libre')).json(); + const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); + type P = { title: string; tally: { against: number }; tallySimulated: { against: number }; myVoteState: string | null }; + const all: P[] = d.units.flatMap((u: { proposals: P[] }) => u.proposals); + const p = all.find((x) => x.title === title); + if (!p) throw new Error(`no version « ${title} »`); + return { vote: d.vote as { way: string; open: boolean; simulated: boolean }, p }; +} + +test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simulé compte aussitôt, marqué « simulé » dans tes bulletins', async ({ page }) => { + // Lot V (ADR 0012, Yvv 2026-11-02: « Il compte, puis s'efface »). The e2e server has its + // chain: it is closed for this control only, then opened again. The vote goes to a + // proposal no other control reads, and only « contre »: nothing enters into force. + await signIn(page, 'Sacha'); + const chainOpen = async (open: boolean) => + (await (await page.request.post('/api/dev/vote-chain', { data: { open }, headers: { 'x-requested-with': 'libredecision' } })).json()).open; + const title = 'Logiciel auditable'; + expect(await chainOpen(false)).toBe(false); + try { + const before = await version(page, title); + expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true }); + await page.goto(DOC); + // The basket is there, without a wallet: the vote leaves by the server. + await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible(); + await deepRow(page, title).click(); + const fold = page.getByRole('region', { name: `Le vote de « ${title} »` }); + await fold.getByRole('button', { name: 'Contre', exact: true }).click(); + await expect(page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' })).toHaveCount(0); + await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/); + await expect(fold.getByText(`0 pour · ${before.p.tally.against + 1} contre`)).toBeVisible(); + // Under the article: marked « simulé », never « enregistré »; the note, once, by the buttons. + await expect(fold.locator('.voted')).toHaveText('Ton vote : contre · simulé'); + await expect(fold.getByText('enregistré', { exact: false })).toHaveCount(0); + await expect(fold.getByText("La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé.", { exact: false })).toHaveCount(1); + const n = before.p.tallySimulated.against + 1; + await expect(fold.getByText(n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`, { exact: true })).toBeVisible(); + // Counted at once, marked as simulated. + const after = await version(page, title); + expect(after.p.myVoteState).toBe('simulated'); + expect(after.p.tallySimulated.against).toBe(before.p.tallySimulated.against + 1); + // In her ballots: simulated, counted for her session, never a block of the chain. + await page.goto('/bulletins'); + const ballot = page.locator('li.ballot').first(); + await expect(ballot.locator('.state')).toHaveText("Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1"); + await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0); + } finally { + // The next controls need the chain. + expect(await chainOpen(true)).toBe(true); + } +}); diff --git a/web/package-lock.json b/web/package-lock.json index 48cd297..38f12fa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "libredecision-web", "version": "0.3.0", - "license": "AGPL-3.0-only", + "license": "AGPL-3.0-or-later", "devDependencies": { "@fontsource-variable/literata": "^5.3.0", "@fontsource-variable/nunito": "^5.3.0", diff --git a/web/package.json b/web/package.json index fa427a9..3775f71 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "libredecision-web", "version": "0.3.0", "private": true, - "license": "AGPL-3.0-only", + "license": "AGPL-3.0-or-later", "type": "module", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3002 --strictPort", 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/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 @@
    -

    {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,127 @@ .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 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; + position: relative; + width: var(--w); + margin-inline: calc(50% - var(--w) / 2); + aspect-ratio: 92 / 59; + overflow-x: clip; + 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 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.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 { - list-style: none; - margin: 1.4rem 0 0; + position: absolute; + inset: calc(2100% / 59) calc(2700% / 92) 0; + margin: 0; padding: 0; + list-style: none; display: grid; - gap: 0.2rem; + align-content: center; + 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; + text-rendering: geometricPrecision; } - .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; + } + } diff --git a/web/src/routes/bulletins/+page.svelte b/web/src/routes/bulletins/+page.svelte index 5c8220d..f08f170 100644 --- a/web/src/routes/bulletins/+page.svelte +++ b/web/src/routes/bulletins/+page.svelte @@ -27,6 +27,8 @@ function standing(b: BallotEntry): string { if (b.status === 'pending') return t('ballots.status.pending'); + // A vote of the simulation (ADR 0012): counted for the session, never on the chain. + if (b.status === 'confirmed' && b.mode === 'simulated') return t('ballots.status.simulated', { name: b.voter ?? '' }); if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' }); if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) }); return t('ballots.status.expired'); @@ -62,7 +64,7 @@ {#each data.value.ballots as b (b.id)}
  • {i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}

    -

    +

    {standing(b)}{#if b.block !== null} · {t('ballots.block', { n: i18n.number(b.block) })}{/if}

    {b.comment} @@ -159,7 +161,8 @@ .state.confirmed { color: var(--moss); } - .state.pending { + .state.pending, + .state.confirmed.simulated { color: var(--water); } .state.refused, 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)} diff --git a/web/src/routes/messages/+page.svelte b/web/src/routes/messages/+page.svelte index e0bb3b0..7ba8692 100644 --- a/web/src/routes/messages/+page.svelte +++ b/web/src/routes/messages/+page.svelte @@ -2,7 +2,8 @@ // The box « Messages » of trustinWallet, in the application (2026-10-31, lot F, // ADR 0010): what waits for a gesture of the person — an objection to answer, // to lift or to reiterate, a typo fix to validate or refuse —, each with its - // deadline. A message dealt with offers no gesture any more. No mail. + // deadline. A message dealt with offers no gesture any more. No mail. A ballot set + // aside (ADR 0012) is told here too, without gesture: one votes again in the text. import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { api } from '$lib/api'; @@ -44,10 +45,27 @@ await reload(); } + /** The lines of a ballot a message is about: those set aside by an objection, or all. */ + function linesOf(m: Message) { + const lines = m.ballot?.lines ?? []; + const held = lines.filter((l) => l.outcome === 'objection'); + return m.kind === 'ballot_objection' && held.length ? held : lines; + } + /** The text of a message, in the language of the reader, from its subject. */ function textOf(m: Message): string { const o = m.objection; const fix = m.typo; + const b = m.ballot; + if (b) { + const articles = linesOf(m) + .map((l) => l.code) + .join(', '); + const date = i18n.date(b.createdAt); + if (m.kind === 'ballot_expired') return t('messages.ballot_expired', { date, articles }); + if (m.kind === 'ballot_refused') return t('messages.ballot_refused', { date, articles, reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) }); + return t('messages.ballot_objection', { articles }); + } if (o) { const held = o.heldVersion?.title ?? ''; if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' }); @@ -73,6 +91,8 @@ function hrefOf(m: Message): string { const v = m.objection?.heldVersion ?? m.typo?.version ?? null; const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`; + const line = linesOf(m)[0]; + if (line) return `${base}?${panelSearch({ tab: 'details', unitId: line.unitId, proposalId: line.proposalId }, null)}`; if (!v) return base; return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`; } @@ -149,7 +169,7 @@

    {textOf(m)}

    {#if m.objection?.reason && m.kind !== 'objection_answered'}

    {m.objection.reason}

    {/if} {#if m.typo}{/if} - {#if m.done} + {#if m.done && m.topic !== 'ballot'}

    {outcomeOf(m)}

    {:else if m.dueAt}

    {t('messages.due', { date: i18n.date(m.dueAt) })}

    diff --git a/web/tests/unit/basket.test.ts b/web/tests/unit/basket.test.ts index f1abf4a..9d3991e 100644 --- a/web/tests/unit/basket.test.ts +++ b/web/tests/unit/basket.test.ts @@ -6,8 +6,8 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa return { id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed', label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, - confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3, - blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, + confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, tallyPending: { for: 0, against: 0 }, tallySimulated: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3, + blocked: false, votable: true, objections: 0, myVote: null, myVoteState: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, ...over, }; } diff --git a/web/tests/unit/inertia.test.ts b/web/tests/unit/inertia.test.ts index 39fed47..2830c34 100644 --- a/web/tests/unit/inertia.test.ts +++ b/web/tests/unit/inertia.test.ts @@ -14,6 +14,20 @@ describe('five levels of inertia', () => { expect(INERTIA_LEVELS.map((k) => en[`inertia.name.${k}`])).toEqual(['very low', 'low', 'medium', 'high', 'very high']); expect(INERTIA_LEVELS.map((k) => es[`inertia.name.${k}`])).toEqual(['muy baja', 'baja', 'media', 'alta', 'muy alta']); }); + it('the threshold is told with the quorum, no longer with C (ADR 0013)', () => { + const told = [ + 'deep.note', 'formula.reading', 'settings.reading', 'settings.share.note.one', 'settings.share.note.other', + 'settings.usual.reference.one', 'settings.usual.reference.other', + ] as const; + for (const cat of [fr, en, es]) { + for (const k of told) expect(cat[k], k).not.toMatch(/\bC\b/); + expect(cat['deep.note']).not.toContain('{floor}'); + expect(cat['formula.Q']).toContain('{n}'); + expect(cat['settings.usual.reference.other']).toContain('{q}'); + } + expect(fr['formula.reading']).toContain('les votants qui manquent comptent comme « non »'); + expect(fr['settings.quorum.note']).toContain('avant le premier vote'); + }); it('a change of inertia is said to be voted at the level of the article, no longer one above', () => { for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}'); expect(fr['inertia.change.note']).toContain("à l'inertie de l'article"); diff --git a/web/tests/unit/versions.test.ts b/web/tests/unit/versions.test.ts index 383086a..1f904f7 100644 --- a/web/tests/unit/versions.test.ts +++ b/web/tests/unit/versions.test.ts @@ -6,9 +6,9 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa return { id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed', label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, - confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, + confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, tallyPending: { for: 0, against: 0 }, tallySimulated: { for: 0, against: 0 }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, - votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, + votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteState: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {}, ...over, }; }