From e7dda2ff47056f82f1fdc7e94a888d3cff96335d Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 05:31:46 +0100 Subject: [PATCH] =?UTF-8?q?ADR=200012=20:=20le=20tampon=20des=20votes=20?= =?UTF-8?q?=E2=80=94=20l'intention=20tout=20de=20suite,=20la=20preuve=20de?= =?UTF-8?q?=20la=20cha=C3=AEne,=20la=20r=C3=A9conciliation=20;=20la=20simu?= =?UTF-8?q?lation=20tant=20que=20la=20cha=C3=AEne=20est=20ferm=C3=A9e=20(l?= =?UTF-8?q?ot=20V)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demande d'Yvv du 02/11 : « je vois ce que j'ai fait sans attendre », contrôle a posteriori (bien unique, bien le dernier qui vaut), et le vote simulé partout dans Monnaie libre tant que LD_VOTE_ACCOUNT n'est pas posé. Révise une phrase de 0010 : un « pour » prouvé pendant une objection est écarté. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0008-vote-par-transaction.md | 4 + .../0010-objections-et-coquilles-validees.md | 6 +- docs/adr/0012-tampon-des-votes.md | 220 ++++++++++++++++++ docs/adr/README.md | 1 + 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0012-tampon-des-votes.md 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/0012-tampon-des-votes.md b/docs/adr/0012-tampon-des-votes.md new file mode 100644 index 0000000..c55c2f9 --- /dev/null +++ b/docs/adr/0012-tampon-des-votes.md @@ -0,0 +1,220 @@ +# 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 ; la façon de compter une intention, ce qu'on dit +> quand elle est écartée et le poids d'un vote simulé sont les choix par défaut du lot V, isolés +> dans `ld_engine::buffer` pour être changés seuls. Lot V, 2026-11-02. + +**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, tant que la chaîne est fermée | oui, tant que la chaîne est fermée (**défaut**, bascule ci-dessous) | +| é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, par défaut, + sur les votes simulés tant que la chaîne est fermée. Le balayage d'adoption et la mesure de + la participation p̃ lisent les mêmes poids (`ld_engine::buffer::Rules`). +- **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::pending_shift`. +- `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. + +### 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). + +- **Défaut, en attendant la réponse d'Yvv** : un vote simulé compte dans les totaux et dans + l'adoption **comme un vote prouvé** ; il est marqué « simulé » (`myVoteState`, + `tallySimulated`, `mode` du bulletin, « Tes bulletins »). Avec C = 50 et cinq personnes au + courant, rien n'entrera en vigueur dans Monnaie libre. +- **La bascule** vers « simulé = affiché, jamais compté pour l'adoption » tient en une ligne : + `ld_engine::buffer::SIMULATED_ADOPTS = false` (repris par `Config::simulated_adopts`). Les + totaux montrés n'en changent pas ; l'adoption et p̃ les ignorent alors. Les deux branches sont + testées. +- **L'ouverture de la chaîne les écarte tous.** Dès que `LD_VOTE_ACCOUNT` est posé (un acte + « déploie »), les votes simulés ne pèsent plus rien, nulle part : ni totaux, ni adoption, ni + p̃, ni « ton vote », ni « Décisions » (`Rules::chain_open`) ; un nouveau vote simulé est + refusé (`409 chain_only`, on vote par la chaîne). Une identité qui vote ensuite par la chaîne + passe devant ses votes simulés (bloc 0, avant tout bloc réel). Les lignes restent en base + jusqu'à leur **effacement, un acte « supprime » d'Yvv**, préparé ici, jamais exécuté par le + code : + ```sql + DELETE FROM votes WHERE source = 'simulated'; + DELETE FROM messages WHERE topic = 'ballot' + AND subject_id IN (SELECT id FROM ballots WHERE mode = 'simulated'); + DELETE FROM ballot_lines WHERE ballot_id IN (SELECT id FROM ballots WHERE mode = 'simulated'); + DELETE FROM ballots WHERE mode = 'simulated'; + ``` + Ce qui serait entré en vigueur par des votes simulés y reste (une adoption ne se défait pas) ; + avec la règle par défaut et C = 50, rien n'y sera entré dans Monnaie libre. +- 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). + +### 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. + +### 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é) ; +- `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`. +Les bulletins (`GET /api/ballots/{id}`, `GET /api/ballots`, le reçu) gagnent `mode: 'chain' | +'simulated'`. 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}]}`. + +## Les données + +Migration `0010_vote_buffer.sql`, additive : + +- `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`. + +Aucune ligne existante n'est réécrite. 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é à l'ouverture. + Chaque geste simulé laisse un bulletin en base jusqu'à l'acte « supprime ». +- Un message sur un bulletin lâché disparaît avec lui à la purge (une semaine). diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e456e..d13ae8e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,3 +15,4 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan | [0008](0008-vote-par-transaction.md) | Le vote par transaction sur la chaîne Ğ1 : bulletin scellé, confirmation par l'indexeur, le dernier fait foi | 2026-10-30 | | [0009](0009-modules-ordre-et-titre.md) | Les modules : l'ordre des articles de chaque section (inertie 2), le titre du document (inertie 4) | 2026-10-31 | | [0010](0010-objections-et-coquilles-validees.md) | Objections « redit » / « contredit », coquilles validées, messagerie (remplace 0004, complète 0003) | 2026-10-31 | +| [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 |