Files
decision/docs/adr/0012-tampon-des-votes.md
T
YvvandClaude Opus 5.5 e7dda2ff47 ADR 0012 : le tampon des votes — l'intention tout de suite, la preuve de la chaîne, la réconciliation ; la simulation tant que la chaîne est fermée (lot V)
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 <noreply@anthropic.com>
2026-11-02 05:31:46 +01:00

16 KiB

0012 — Le tampon des votes : l'intention tout de suite, la preuve de la chaîne, la réconciliation

Complète 0008 (vote par la chaîne Ğ1) et révise une phrase de 0010 (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 :
    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).