diff --git a/CLAUDE.md b/CLAUDE.md index cdc0a8d..388636c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,10 +40,13 @@ crates/ # 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, - # sets_quorum, groupes par défaut (TEST_GROUP) + # 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, @@ -55,7 +58,9 @@ crates/ # jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs · # seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ; # le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé, - # bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments) + # 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é) · quorum.rs (le quorum # d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) · @@ -87,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 @@ -109,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 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/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-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/src/adoption.rs b/crates/ld-server/src/adoption.rs index 76629be..65d7c1f 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -193,15 +193,19 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { /// 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. +/// (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.params(unit, p); + let simulated = st.simulated_counted(&p.id); serde_json::json!({ "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 afac818..e0a47e7 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -24,6 +24,12 @@ pub struct Publish { /// 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 @@ -78,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 { @@ -116,6 +126,7 @@ impl Config { 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| { @@ -158,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, } } @@ -189,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 349cb9d..23327be 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -13,6 +13,7 @@ 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::{self, Evaluation, FormulaParams, Tally}; @@ -302,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, @@ -371,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( @@ -411,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, @@ -451,13 +511,34 @@ 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`. @@ -709,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). @@ -729,14 +811,17 @@ fn proposal_json( "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_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), "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(), }) @@ -873,15 +958,17 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult crate::publish::waits_for_a_text(state, doc_id).await?, - _ => false, + // Waiting, not failing: no article in force (ADR 0011), or something in effect that + // does not hold on the proven votes yet (ADR 0012) — nothing goes out. + let waits = match &last { + Some(r) if r.done_at.is_none() => 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 if suspended { "suspended" } 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/lib.rs b/crates/ld-server/src/lib.rs index 6a46f52..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; @@ -33,6 +34,7 @@ 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; @@ -49,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; @@ -65,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 { @@ -81,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. @@ -162,6 +180,7 @@ 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)) 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 79bb0aa..aad2276 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -588,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, @@ -604,11 +605,11 @@ pub async fn detail( "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.seed_minimal(), "seedProposal": p.seed_proposal(), - "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, + "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) }, @@ -635,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, @@ -644,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")); } @@ -661,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 6d69dac..19a4faa 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -83,18 +83,29 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect(); let last = rows.iter().map(|d| d.id).max().unwrap_or(0); let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0); - if waits_for_a_text(state, &doc_id).await? { + 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. + // 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(MAX_BACKOFF_SECS)) + .bind(util::in_seconds(again)) .bind(&doc_id) .execute(&state.db) .await?; - tracing::info!( - doc = %doc_id, waiting = rows.len(), - "publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)" - ); + 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 { @@ -123,6 +134,58 @@ 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 @@ -421,7 +484,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> 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 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/tests/api.rs b/crates/ld-server/tests/api.rs index 1a06bce..e83a155 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -730,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 @@ -740,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; @@ -794,6 +798,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() { 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"); @@ -868,6 +873,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives author_name: "libreDecision".into(), author_email: "decision@example.org".into(), without_articles: false, + simulated: false, }); cfg }; @@ -1063,6 +1069,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i 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"); diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index b77b129..4db7e2c 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -284,6 +284,23 @@ async fn two_voices(state: &AppState, group: &str, code: &str) { 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") } @@ -307,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"); @@ -355,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"); @@ -370,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"); @@ -487,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] @@ -512,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] @@ -747,8 +816,22 @@ async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds 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, @@ -790,16 +873,29 @@ async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds } } 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), (39, 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 @@ -1076,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"); @@ -1325,15 +1424,17 @@ async fn voices_on_the_chain_bring_an_order_then_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", "0"); let (s, v) = c @@ -1346,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))); + 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] @@ -1471,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/overview.rs b/crates/ld-server/tests/overview.rs index 7ec6756..9e5c782 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -263,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/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index 281d4cf..6b4f77e 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -564,6 +564,7 @@ impl Remote { author_name: "libreDecision".into(), author_email: "decision@example.org".into(), without_articles, + simulated: false, }); cfg } diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index b183965..154098b 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -24,6 +24,9 @@ //! `lifts_objection` / `reiterates_objection`; //! - each person reads their own messages only — `reads_message` //! (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). @@ -155,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 @@ -297,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"); 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..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/README.md b/docs/adr/README.md index ca3f59b..cd01ec4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,4 +16,5 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan | [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/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 49032a2..950cb49 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -49,6 +49,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | 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 diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index 5b66089..92c2c9f 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -176,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(); @@ -213,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/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index 92bbd7d..4c6212f 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -133,7 +133,7 @@
-

{t('tally.short', { for: p.tally.for, against: p.tally.against })}

+

{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} {i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}{/if}

{#if p.evaluation}

{t('tally.required', { n: p.evaluation.required })}

{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}

@@ -186,8 +186,8 @@ {/if} {#if p.myVoteStale}

{t('vote.stale')}

- {:else if p.myVote} -

{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · {t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}

+ {:else if p.myVote && p.myVoteState !== 'pending'} +

{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · {t(p.myVoteChain ? 'vote.mine.chain' : p.myVoteState === 'simulated' ? 'vote.mine.simulated' : 'vote.mine.saved')}

{:else if ballot?.status !== 'pending'}

{t('vote.mine.none')}

{/if} @@ -215,6 +215,8 @@ {#if chain && access.chain}

{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}

{t('vote.chain.counts')}

+ {:else if access.simulated} +

{t('vote.simulated.note')}

{/if} {/if}

{t('vote.private')}

diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 7094e24..1d7e944 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -255,6 +255,10 @@ export const en: Messages = { 'vote.mine.chain': 'recorded on the Ğ1 chain', 'vote.mine.saved': 'saved', 'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.', + 'vote.mine.simulated': 'simulated', + 'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until it is erased when the chain opens.', + 'vote.tally.simulated.one': 'of which {n} simulated', + 'vote.tally.simulated.other': 'of which {n} simulated', 'vote.mine.refused': 'Your last vote was not counted: {reason}', 'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.', 'vote.mine.receipts': 'Your ballots and receipts', @@ -269,6 +273,7 @@ export const en: Messages = { 'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.', 'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.', 'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.', + 'ballot.reason.objection': 'an objection was holding this version back when the Ğ1 chain confirmed your vote; your earlier vote stands.', 'basket.title': 'Voting basket', 'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.', 'basket.doc': 'The whole document', @@ -336,6 +341,7 @@ export const en: Messages = { 'ballots.lines.other': '{n} votes', 'ballots.status.pending': 'Waiting for the Ğ1 chain', 'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”', + 'ballots.status.simulated': 'Simulated: counted for “{name}” until the Ğ1 chain opens', 'ballots.status.refused': 'Not counted: {reason}', 'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count', 'ballots.block': 'block {n}', @@ -475,7 +481,7 @@ export const en: Messages = { 'objection.respond.contredit': 'Reword', 'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.', 'messages.title': 'Messages', - 'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.', + 'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more. You also read here what became of a vote that was not counted.', 'messages.none': 'No messages for now.', 'messages.readAll': 'Mark all as read', 'messages.read': 'Mark as read', @@ -484,12 +490,16 @@ export const en: Messages = { 'messages.open': 'See it in the text', 'messages.topic.objection': 'Objection', 'messages.topic.typo': 'Typo', + 'messages.topic.ballot': 'Vote', 'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.', 'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.', 'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. Answer again.', 'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.', 'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.', 'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.', + 'messages.ballot_expired': 'Your vote of {date} ({articles}) was not seen on the Ğ1 chain within 24 hours: it is set aside. You can vote again.', + 'messages.ballot_refused': 'Your vote of {date} ({articles}) was not counted: {reason} You can vote again.', + 'messages.ballot_objection': 'Your “for” on {articles} is set aside: an objection was holding this version back when the Ğ1 chain confirmed your vote. Your earlier vote stands.', 'messages.respond': 'Answer', 'messages.outcome.answered': 'answered', 'messages.outcome.reiterated': 'objection reiterated', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 507d7b3..11e1102 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -255,6 +255,10 @@ export const es: Messages = { 'vote.mine.chain': 'inscrito en la cadena Ğ1', 'vote.mine.saved': 'registrado', 'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.', + 'vote.mine.simulated': 'simulado', + 'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», hasta que se borre al abrirse la cadena.', + 'vote.tally.simulated.one': 'de ellos {n} simulado', + 'vote.tally.simulated.other': 'de ellos {n} simulados', 'vote.mine.refused': 'Tu último voto no se contó: {reason}', 'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.', 'vote.mine.receipts': 'Tus papeletas y recibos', @@ -269,6 +273,7 @@ export const es: Messages = { 'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.', 'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.', 'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.', + 'ballot.reason.objection': 'una objeción retenía esta versión cuando la cadena Ğ1 confirmó tu voto; tu voto anterior se mantiene.', 'basket.title': 'Cesta de votación', 'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.', 'basket.doc': 'Todo el documento', @@ -336,6 +341,7 @@ export const es: Messages = { 'ballots.lines.other': '{n} votos', 'ballots.status.pending': 'Esperando a la cadena Ğ1', 'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»', + 'ballots.status.simulated': 'Simulado: contado para «{name}» hasta que se abra la cadena Ğ1', 'ballots.status.refused': 'No contado: {reason}', 'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta', 'ballots.block': 'bloque {n}', @@ -475,7 +481,7 @@ export const es: Messages = { 'objection.respond.contredit': 'Reformular', 'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.', 'messages.title': 'Mensajes', - 'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.', + 'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada. Aquí lees también qué fue de un voto que no se contó.', 'messages.none': 'Ningún mensaje por ahora.', 'messages.readAll': 'Marcar todo como leído', 'messages.read': 'Marcar como leído', @@ -484,12 +490,16 @@ export const es: Messages = { 'messages.open': 'Verlo en el texto', 'messages.topic.objection': 'Objeción', 'messages.topic.typo': 'Errata', + 'messages.topic.ballot': 'Voto', 'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.', 'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.', 'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». Responde de nuevo.', 'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.', 'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».', 'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.', + 'messages.ballot_expired': 'Tu voto del {date} ({articles}) no se vio en la cadena Ğ1 en 24 horas: queda descartado. Puedes votar de nuevo.', + 'messages.ballot_refused': 'Tu voto del {date} ({articles}) no se contó: {reason} Puedes votar de nuevo.', + 'messages.ballot_objection': 'Tu «a favor» sobre {articles} queda descartado: una objeción retenía esta versión cuando la cadena Ğ1 confirmó tu voto. Tu voto anterior se mantiene.', 'messages.respond': 'Responder', 'messages.outcome.answered': 'respuesta dada', 'messages.outcome.reiterated': 'objeción reiterada', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index db80759..bfa4b0b 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -259,6 +259,10 @@ export const fr = { 'vote.mine.chain': 'inscrit sur la chaîne Ğ1', 'vote.mine.saved': 'enregistré', 'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.', + 'vote.mine.simulated': 'simulé', + 'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », jusqu'à son effacement à l'ouverture de la chaîne.", + 'vote.tally.simulated.one': 'dont {n} simulé', + 'vote.tally.simulated.other': 'dont {n} simulés', 'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}", 'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.", 'vote.mine.receipts': 'Tes bulletins et reçus', @@ -273,6 +277,7 @@ export const fr = { 'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.", 'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.", 'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.', + 'ballot.reason.objection': "une objection retenait cette version quand la chaîne Ğ1 a confirmé ton vote ; ton vote d'avant reste.", 'basket.title': 'Panier de vote', 'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.', 'basket.doc': 'Tout le document', @@ -340,6 +345,7 @@ export const fr = { 'ballots.lines.other': '{n} votes', 'ballots.status.pending': 'En attente de la chaîne Ğ1', 'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »", + 'ballots.status.simulated': "Simulé : compté pour « {name} » jusqu'à l'ouverture de la chaîne Ğ1", 'ballots.status.refused': 'Pas compté : {reason}', 'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas', 'ballots.block': 'bloc {n}', @@ -479,7 +485,7 @@ export const fr = { 'objection.respond.contredit': 'Reformuler', 'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.", 'messages.title': 'Messages', - 'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.', + 'messages.intro': "Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien. Tu y lis aussi ce qu'il advient d'un vote qui n'a pas été compté.", 'messages.none': "Aucun message pour l'instant.", 'messages.readAll': 'Tout marquer comme lu', 'messages.read': 'Marquer comme lu', @@ -488,12 +494,16 @@ export const fr = { 'messages.open': 'Voir dans le texte', 'messages.topic.objection': 'Objection', 'messages.topic.typo': 'Coquille', + 'messages.topic.ballot': 'Vote', 'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.", 'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.", 'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». Réponds à nouveau.', 'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.', 'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».', 'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.', + 'messages.ballot_expired': "Ton vote du {date} ({articles}) n'a pas été vu sur la chaîne Ğ1 en 24 heures : il est écarté. Tu peux voter à nouveau.", + 'messages.ballot_refused': "Ton vote du {date} ({articles}) n'a pas été compté : {reason} Tu peux voter à nouveau.", + 'messages.ballot_objection': "Ton « pour » sur {articles} est écarté : une objection retenait cette version quand la chaîne Ğ1 a confirmé ton vote. Ton vote d'avant reste.", 'messages.respond': 'Répondre', 'messages.outcome.answered': 'réponse donnée', 'messages.outcome.reiterated': 'objection réitérée', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 92f6cd1..6f5def8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -117,7 +117,14 @@ export interface Proposal { seedMinimal: boolean; /** A seed proposal (ADR 0011): open, put forward by the seed to start the vote, not in force. */ seedProposal?: boolean; + /** The totals shown: recorded, pending and simulated votes, each intention in place of + * the vote its session sees as its own (ADR 0012). */ tally: Tally; + /** The part of `tally` still without proof (an intention pending on the chain). */ + tallyPending: Tally; + /** The part of `tally` that comes from the simulation (ADR 0012). */ + tallySimulated: Tally; + /** On the totals shown (`tally`), the quorum included (ADR 0013). */ evaluation: Evaluation | null; votedAtInertia: Inertia; /** Held back by a standing objection: the latest arrival of the two (never a text in force). */ @@ -126,11 +133,17 @@ export interface Proposal { votable: boolean; /** Standing objections around this version. */ objections: number; + /** What the person sees as her vote now: her latest gesture, an intention pending included. */ myVote: 'for' | 'against' | null; + /** Where « ton vote » stands (ADR 0012): `recorded` — proven by the chain, or a vote of + * the server in a managed group; `pending` — an intention waiting for the chain; + * `simulated` — a vote of the simulation, while the chain is closed. */ + myVoteState: 'recorded' | 'pending' | 'simulated' | null; myVoteStale: boolean; /** « Ton vote » was cast on the Ğ1 chain (confirmed). */ myVoteChain: boolean; - /** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */ + /** A ballot of this session not counted yet: pending on the chain, or refused, or let go — + * or its *for* set aside by an objection (`refused`, reason `objection`, ADR 0012). */ myBallot: MyBallot | null; translations: Partial>; /** An order version: the order it gives its section now. */ @@ -142,7 +155,7 @@ export type Choice = 'for' | 'against' | 'none'; export interface MyBallot { id: string; status: 'pending' | 'refused' | 'expired'; - reason: 'not_member' | 'no_identity' | 'transfer' | null; + reason: 'not_member' | 'no_identity' | 'transfer' | 'objection' | null; choice: Choice; } @@ -162,8 +175,12 @@ export interface ChainSettings { /** How this session votes in the group of the document, and whether it may now. */ export interface VoteAccess { + /** `server` too while the chain is closed: the vote is then simulated (`simulated`). */ way: 'server' | 'chain'; open: boolean; + /** The chain is closed: the vote leaves by a write of the server, marked as simulated, + * and counts until an act « supprime » sets it aside (ADR 0012). */ + simulated: boolean; reason: 'members_only' | 'chain_off' | null; chain: ChainSettings | null; } @@ -181,6 +198,8 @@ export interface Ballot { voter: string | null; entered: Entered[]; expiresAt: string; + /** `simulated`: a ballot of the simulation, proven by the server, never on the chain. */ + mode: 'chain' | 'simulated'; } /** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */ @@ -195,6 +214,7 @@ export interface BallotEntry { lines: number; createdAt: string; settledAt: string | null; + mode: Ballot['mode']; } export interface Unit { @@ -234,6 +254,9 @@ export interface Publication { doneAt?: string | null; commit?: string | null; attempts?: number; + /** Why it is suspended: no article in force yet (`text`, ADR 0011), or something in force + * entered by simulated votes that the proven votes do not carry yet (`proven`, ADR 0012). */ + waitsFor?: 'text' | 'proven' | null; } export interface DocView { @@ -345,11 +368,29 @@ export interface TypoFix { version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null }; } -/** A message of one's box: about an objection or a typo fix, with its gestures now. */ +/** A ballot set aside, as a message tells it (ADR 0012): line by line, what became of it. */ +export interface BallotNotice { + id: string; + status: Ballot['status']; + reason: MyBallot['reason']; + mode: Ballot['mode']; + createdAt: string; + settledAt: string | null; + lines: { proposalId: string; unitId: string; code: string; title: string; choice: Choice; outcome: 'counted' | 'superseded' | 'objection' | null }[]; +} + +/** A message of one's box: about an objection, a typo fix or a ballot set aside, with its gestures now. */ export interface Message { id: string; - topic: 'objection' | 'typo'; - kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending'; + topic: 'objection' | 'typo' | 'ballot'; + kind: + | 'objection_raised' + | 'objection_answered' + | 'objection_reiterated' + | 'typo_pending' + | 'ballot_expired' + | 'ballot_refused' + | 'ballot_objection'; cycle: number; createdAt: string; dueAt: string | null; @@ -361,4 +402,5 @@ export interface Message { doc: { id: string; groupSlug: string; slug: string; title: LangMap }; objection?: Objection; typo?: TypoFix; + ballot?: BallotNotice; } 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/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/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, }; }