From 4b81a2e909aa38706424c0ccfdb8fe630313367d Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 05:22:24 +0100 Subject: [PATCH] =?UTF-8?q?WIP=20lot=20V=20:=20le=20tampon=20c=C3=A2bl?= =?UTF-8?q?=C3=A9=20dans=20le=20serveur=20(migration=200010,=20simulation,?= =?UTF-8?q?=20discard-simulation),=20tests=20en=20cours=20de=20reprise=20?= =?UTF-8?q?=E2=80=94=20repris=20tel=20quel=20apr=C3=A8s=20la=20coupure=20d?= =?UTF-8?q?u=2002/11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/buffer.rs | 55 +- .../ld-server/migrations/0010_vote_buffer.sql | 19 + crates/ld-server/src/adoption.rs | 7 +- crates/ld-server/src/ballots.rs | 657 ++++++++++++++---- crates/ld-server/src/config.rs | 5 + crates/ld-server/src/docs.rs | 121 +++- crates/ld-server/src/lib.rs | 21 +- crates/ld-server/src/main.rs | 9 +- crates/ld-server/src/messages.rs | 13 +- crates/ld-server/src/overview.rs | 8 +- crates/ld-server/src/participation.rs | 3 + crates/ld-server/src/proposals.rs | 25 +- crates/ld-server/src/publish.rs | 4 +- crates/ld-server/src/simulation.rs | 269 +++++++ crates/ld-server/tests/api.rs | 18 +- crates/ld-server/tests/ballots.rs | 482 ++++++++++++- 16 files changed, 1503 insertions(+), 213 deletions(-) create mode 100644 crates/ld-server/migrations/0010_vote_buffer.sql create mode 100644 crates/ld-server/src/simulation.rs diff --git a/crates/ld-engine/src/buffer.rs b/crates/ld-engine/src/buffer.rs index 95beb41..a974e8f 100644 --- a/crates/ld-engine/src/buffer.rs +++ b/crates/ld-engine/src/buffer.rs @@ -25,10 +25,12 @@ use crate::objection; use crate::threshold::Tally; use serde::Serialize; -/// Whether a simulated vote counts for adoption, while the chain is closed. -/// The default of lot V, until Yvv answers: like a proven vote (with C = 50 and -/// five people, nothing enters into force in Monnaie libre). `false` turns it to -/// « shown, never counted for adoption »; the totals shown do not change. +/// 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. @@ -39,7 +41,8 @@ pub enum Stage { Recorded, /// An intention still waiting for its proof, within its window. Pending, - /// A vote of the simulation, while the chain is closed. + /// A vote of the simulation (cast while the chain was closed), until the act + /// that sets them aside. Simulated, } @@ -59,21 +62,26 @@ impl Stage { } } -/// What the stages weigh, given whether the chain is open. +/// 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 { - /// The vote account is set: votes go through the chain, the simulation is over. - pub chain_open: bool, /// 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? + /// 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 => true, - Stage::Simulated => !self.chain_open, + Stage::Recorded | Stage::Pending | Stage::Simulated => true, } } @@ -83,7 +91,7 @@ impl Rules { match stage { Stage::Recorded => true, Stage::Pending => false, - Stage::Simulated => !self.chain_open && self.simulated_adopts, + Stage::Simulated => self.simulated_adopts, } } } @@ -316,24 +324,19 @@ mod tests { #[test] fn what_each_stage_weighs() { - let closed = Rules { chain_open: false, simulated_adopts: SIMULATED_ADOPTS }; + let rules = Rules::default(); + assert_eq!(rules.simulated_adopts, SIMULATED_ADOPTS); for stage in [Stage::Recorded, Stage::Pending, Stage::Simulated] { - assert!(closed.shown(stage), "{stage:?}: everything is seen while the chain is closed"); + assert!(rules.shown(stage), "{stage:?}: everything is seen, at once"); } - assert!(closed.adopts(Stage::Recorded)); - assert!(!closed.adopts(Stage::Pending), "never an intention without proof"); - assert!(closed.adopts(Stage::Simulated), "the default: a simulated vote counts like a proven one"); - // The switch: shown, never counted for adoption. - let shown_only = Rules { simulated_adopts: false, ..closed }; + 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)); - // The chain opens: the simulated votes weigh nothing any more, anywhere. - for adopts in [true, false] { - let open = Rules { chain_open: true, simulated_adopts: adopts }; - assert!(!open.shown(Stage::Simulated) && !open.adopts(Stage::Simulated)); - assert!(open.shown(Stage::Pending) && !open.adopts(Stage::Pending)); - assert!(open.shown(Stage::Recorded) && open.adopts(Stage::Recorded)); - } assert_eq!(Stage::of_source("simulated"), Stage::Simulated); assert_eq!(Stage::of_source("chain"), Stage::Recorded); assert_eq!(Stage::of_source("server"), Stage::Recorded); 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 f418f69..af2e955 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -192,15 +192,20 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { /// What an adoption keeps of the formula it was judged by. The four letters /// stay where the rows of before have them (`majority_pct`, `gradient`, `base`, -/// `constant`: read as they were written); the level and the scale join them. +/// `constant`: read as they were written); the level and the scale join them — +/// and, for the act that ends the simulation (ADR 0012, `simulation::discard`), +/// the simulated share of the votes it entered with, the version it displaced +/// and the level of inertia of before. fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value { let level = st.voting_level(unit, p); let f = st.scale().params(level); + let simulated = st.simulated_counted(&p.id); serde_json::json!({ "majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant, "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..ab5bf31 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,84 @@ 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 +352,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 +405,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 +413,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 +490,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 +549,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 +568,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 +661,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 +694,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 +746,9 @@ 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 +771,256 @@ 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 +1041,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 +1109,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 +1137,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 +1153,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 +1171,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 +1182,137 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult AppResult> { + #[derive(sqlx::FromRow)] + struct Row { + requested_by: String, + proposal_id: String, + revision: i64, + choice: String, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT b.requested_by, l.proposal_id, l.revision, l.choice FROM ballots b + JOIN ballot_lines l ON l.ballot_id = b.id + WHERE b.doc_id = ? AND b.status = 'pending' AND b.mode = 'chain' + AND NOT EXISTS (SELECT 1 FROM ballots c JOIN ballot_lines cl ON cl.ballot_id = c.id + WHERE c.requested_by = b.requested_by AND cl.proposal_id = l.proposal_id + AND c.status = 'confirmed' + AND (c.created_at > b.created_at OR (c.created_at = b.created_at AND c.rowid > b.rowid))) + ORDER BY b.created_at, b.rowid", + ) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let mut latest: HashMap<(String, String), Intent> = HashMap::new(); + for r in rows { + let Some(choice) = Choice::parse(&r.choice) else { continue }; + let key = (r.requested_by.clone(), r.proposal_id.clone()); + latest.insert(key, Intent { requester: r.requested_by, proposal_id: r.proposal_id, revision: r.revision, choice }); + } + Ok(latest.into_values().collect()) +} + +/// « Ton vote » as the person sees it now (ADR 0012): its latest gesture — an +/// intention still waiting for the chain — or the vote that counts for it. +pub struct Seen { + pub choice: Option, + /// `recorded`, `pending`, `simulated`, or none. + pub state: Option<&'static str>, + /// It votes a revision gone by: « to reconfirm ». + pub stale: bool, + /// Proven on the chain. + pub chain: bool, +} + +pub fn seen(revision: i64, vote: Option<&MyVote>, ballot: Option<&MyBallot>) -> Seen { + if let Some(b) = ballot.filter(|b| b.status == "pending") { + let choice = (b.choice != Choice::None.as_str()).then(|| b.choice.clone()); + return Seen { + stale: choice.is_some() && b.revision != revision, + choice, + state: Some(Stage::Pending.as_str()), + chain: false, + }; + } + match vote { + Some(v) => Seen { + choice: Some(v.choice.clone()), + state: Some(Stage::of_source(&v.source).as_str()), + stale: v.revision != revision, + chain: v.source == "chain", + }, + None => Seen { choice: None, state: None, stale: false, chain: false }, + } +} + +/// A ballot, for a message of the box of the session that asked for it (ADR +/// 0012): what became of it, line by line. No gesture: one votes again in the text. +pub async fn for_message(state: &AppState, viewer: &IdentityRow, id: &str) -> AppResult> { + #[derive(sqlx::FromRow)] + struct LineRow { + proposal_id: String, + unit_id: String, + code: String, + title: String, + choice: String, + outcome: Option, + } + let row = sqlx::query_as::<_, BallotRow>(&format!("{BALLOT_SELECT} WHERE id = ?")) + .bind(id) + .fetch_optional(&state.db) + .await?; + let Some(row) = row.filter(|r| r.requested_by == viewer.id) else { return Ok(None) }; + let lines = sqlx::query_as::<_, LineRow>( + "SELECT l.proposal_id, p.unit_id, u.code, p.title, l.choice, l.outcome FROM ballot_lines l + JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id + WHERE l.ballot_id = ? ORDER BY u.position, u.id", + ) + .bind(id) + .fetch_all(&state.db) + .await?; + Ok(Some(json!({ + "id": row.id, "status": row.status, "reason": row.reason, "mode": row.mode(), + "createdAt": row.created_at, "settledAt": row.settled_at, + "lines": lines.iter().map(|l| json!({ + "proposalId": l.proposal_id, "unitId": l.unit_id, "code": l.code, "title": l.title, + "choice": l.choice, "outcome": l.outcome, + })).collect::>(), + }))) +} + /// How this session votes in a group, and whether it may now. pub async fn access(state: &AppState, user: &IdentityRow, group: &docs::GroupRow) -> AppResult { let g = group.to_group(); let granted = docs::granted(state, &g.slug, &user.id).await?; let id = user.to_identity(); let way = trustin_access::vote_way(&g); - let (open, reason) = match way { - VoteWay::Server => { - let open = trustin_access::votes_in(&id, &g, granted); - (open, (!open).then_some("members_only")) - } - VoteWay::Chain => match (&state.cfg.vote, trustin_access::asks_ballot(&id, &g, granted)) { - (None, _) => (false, Some("chain_off")), - (Some(_), false) => (false, Some("members_only")), - (Some(_), true) => (true, None), - }, + // While the chain is closed, a group of the chain votes in simulation: the vote + // leaves the page by a write of the server, marked as simulated (ADR 0012). + let simulated = way == VoteWay::Chain && state.chain().is_none(); + let open = match way { + VoteWay::Server => trustin_access::votes_in(&id, &g, granted), + VoteWay::Chain if simulated => trustin_access::casts_simulated(&id, &g, granted, false), + VoteWay::Chain => trustin_access::asks_ballot(&id, &g, granted), }; - let chain = match (way, &state.cfg.vote) { + let reason = (!open).then_some("members_only"); + let chain = match (way, state.chain()) { (VoteWay::Chain, Some(c)) => json!({ "account": c.account, "amount": c.amount, "nodes": c.nodes, "test": c.test_node.is_some(), "testNode": c.test_node, "expiresHours": EXPIRES_HOURS, }), _ => serde_json::Value::Null, }; - Ok(json!({ "way": way, "open": open, "reason": reason, "chain": chain })) + let leaves = if simulated { VoteWay::Server } else { way }; + Ok(json!({ "way": leaves, "open": open, "simulated": simulated, "reason": reason, "chain": chain })) } diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index cfa1cf5..a90cb3c 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -73,6 +73,9 @@ 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 while the chain is closed + /// (ADR 0012): `ld_engine::buffer::SIMULATED_ADOPTS`, the default of lot V. + pub simulated_adopts: bool, } fn var(name: &str) -> Option { @@ -152,6 +155,7 @@ impl Config { wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745), build: var("LD_BUILD").unwrap_or_else(|| "dev".into()), vote, + simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS, data_dir, } } @@ -183,6 +187,7 @@ impl Config { wot_fallback: 5745, build: "test".into(), vote: None, + simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS, } } } diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index af2d971..d501240 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::{Evaluation, Tally}; @@ -265,7 +266,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, @@ -333,30 +348,70 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { struct TallyRow { proposal_id: String, choice: String, + source: String, n: i64, } let rows = sqlx::query_as::<_, TallyRow>( - "SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id + "SELECT v.proposal_id, v.choice, v.source, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id - WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice", + WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice, v.source", ) .bind(doc_id) .fetch_all(&state.db) .await?; - let mut tallies: HashMap = HashMap::new(); - for r in rows { - let t = tallies.entry(r.proposal_id).or_default(); - let n = u32::try_from(r.n).unwrap_or(0); - match r.choice.as_str() { - "for" => t.for_votes = n, - "against" => t.against = n, + let rules = state.vote_rules(); + let add = |map: &mut HashMap, id: &str, choice: &str, n: u32| { + let t = map.entry(id.to_owned()).or_default(); + match choice { + "for" => t.for_votes = t.for_votes.saturating_add(n), + "against" => t.against = t.against.saturating_add(n), _ => {} } + }; + let (mut tallies, mut recorded, mut simulated) = (HashMap::new(), HashMap::new(), HashMap::new()); + for r in rows { + let n = u32::try_from(r.n).unwrap_or(0); + let stage = Stage::of_source(&r.source); + if rules.adopts(stage) { + add(&mut tallies, &r.proposal_id, &r.choice, n); + } + if rules.shown(stage) { + add(&mut recorded, &r.proposal_id, &r.choice, n); + if stage == Stage::Simulated { + add(&mut simulated, &r.proposal_id, &r.choice, n); + } + } } // A standing objection holds back the latest arrival of its pair (never a version // in force): no new *for*, and it cannot enter into force (ADR 0010). let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?; + + // The intentions (ADR 0012): seen at once in the totals shown, in place of the + // vote their session sees as its own; never counted for adoption. + let mut shown = recorded.clone(); + let mut pending: HashMap = HashMap::new(); + let mut seen_by: HashMap> = HashMap::new(); + for i in ballots::intents(state, doc_id).await? { + let Some(p) = proposals.iter().find(|p| p.id == i.proposal_id) else { continue }; + if !matches!(p.status.as_str(), "open" | "in_force") { + continue; + } + if !seen_by.contains_key(&i.requester) { + seen_by.insert(i.requester.clone(), ballots::my_votes(state, &i.requester, doc_id).await?); + } + let seen = seen_by + .get(&i.requester) + .and_then(|m| m.get(&p.id)) + .filter(|v| v.revision == p.revision) + .and_then(|v| Choice::parse(&v.choice)); + let w = buffer::weigh_intent(seen, i.choice, i.revision == p.revision, blocked.contains(&p.id)); + let t = shown.entry(p.id.clone()).or_default(); + *t = w.shift.apply(*t); + let q = pending.entry(p.id.clone()).or_default(); + q.for_votes = q.for_votes.saturating_add(w.pending.for_votes); + q.against = q.against.saturating_add(w.pending.against); + } let (electorate, electorate_source) = electorate(state, &group).await?; let measure = participation::stored(state, &group.slug).await?; let participation = Participation::of( @@ -373,6 +428,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, @@ -397,13 +457,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`. @@ -641,6 +722,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). @@ -661,14 +743,17 @@ fn proposal_json( "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", "tally": st.shown_tally(&p.id), + "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(), }) diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index db55f58..944609f 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; @@ -30,6 +31,7 @@ pub mod proposals; pub mod publish; pub mod seed; pub mod seed_amend; +pub mod simulation; pub mod team; pub mod typos; pub mod util; @@ -46,6 +48,7 @@ use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use std::str::FromStr; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tokio::sync::Notify; @@ -62,6 +65,9 @@ pub struct AppState { pub upcoming: Arc, /// The clock of the deadlines of three days (objections, typo fixes); tests move it. pub clock: clock::Clock, + /// Tests only, with the dev login: the chain of this server closed for a while + /// (`POST /api/dev/vote-chain`), to try the simulation (ADR 0012). + pub chain_closed: Arc, } impl AppState { @@ -78,8 +84,20 @@ impl AppState { publish_wake: Arc::new(Notify::new()), upcoming, clock: clock::Clock::default(), + chain_closed: Arc::new(AtomicBool::new(false)), }) } + + /// The vote on the chain, as it is now: `None` while it is closed (no vote + /// account, or closed for a test) — the vote is then simulated (ADR 0012). + pub fn chain(&self) -> Option<&config::VoteChain> { + self.cfg.vote.as_ref().filter(|_| !self.chain_closed.load(Ordering::Relaxed)) + } + + /// What each stage of a vote weighs (ADR 0012). + pub fn vote_rules(&self) -> ld_engine::buffer::Rules { + ld_engine::buffer::Rules { simulated_adopts: self.cfg.simulated_adopts } + } } /// Open (or create) the database and apply migrations. @@ -159,6 +177,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}/order", post(order::propose)) .route("/units/{id}/title", post(doc_title::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 62b24f6..d435f72 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -586,6 +586,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, @@ -602,11 +603,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.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", - "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, + "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) }, @@ -633,8 +634,9 @@ pub struct VoteBody { choice: Option, } -/// One vote on the server, in a managed group. Where the vote goes through the -/// chain (Monnaie libre), it is a ballot (`ballots::create`), never this. +/// One vote written by the server: in a managed group, or in simulation while +/// the chain is closed (ADR 0012). Where the vote goes through the chain and the +/// chain is open (Monnaie libre), it is a ballot (`ballots::create`), never this. pub async fn vote( State(state): State, User(user): User, @@ -642,9 +644,7 @@ pub async fn vote( Json(b): Json, ) -> AppResult> { let (p, st) = guarded(&state, &user, &id).await?; - if trustin_access::vote_way(&st.group.to_group()) == trustin_access::VoteWay::Chain { - return Err(AppError::Conflict("chain_only")); - } + let way = ballots::server_way(&state, &st.group)?; if !matches!(p.status.as_str(), "open" | "in_force") { return Err(AppError::Conflict("not_votable")); } @@ -659,13 +659,12 @@ pub async fn vote( if !st.admits(&p, choice) { return Err(AppError::Conflict("objection")); } - if !docs::may_vote(&state, &user, &st.group).await? { + if !ballots::may_write(&state, &user, &st.group, way).await? { return Err(AppError::Conflict("members_only")); } let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice }; - ballots::write_server_votes(&state, &user.id, &[cast]).await?; - let entered = adoption::run(&state, &st.doc.id).await?; - Ok(Json(json!({ "entered": entered }))) + let entered = ballots::write(&state, &user, &st, way, &[cast]).await?; + Ok(Json(json!({ "entered": entered, "simulated": way == ballots::ServerWay::Simulated }))) } // --------------------------------------------------------------------------- diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index c5e58c1..1fc31ae 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -390,7 +390,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result anyhow::Result, +} + +/// 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. + let adoptions = sqlx::query_as::<_, AdoptionRow>( + "SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions + ORDER BY at DESC, id DESC", + ) + .fetch_all(&state.db) + .await?; + for a in adoptions { + let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null); + let simulated = tally_of(¶ms["simulated"]); + if simulated.cast() == 0 { + continue; + } + if let Some(r) = reexamine(state, &a, ¶ms, simulated, apply).await? { + out.reexamined.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)) }; + let level = serde_json::from_value::(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p)); + let eval = st.scale().evaluate(proven, level).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; + } + 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 d6a2c64..4a6795e 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -686,7 +686,7 @@ async fn a_team_link_makes_one_identity_per_opening() { } #[tokio::test] -async fn in_monnaie_libre_an_invitation_never_votes_on_the_server() { +async fn in_monnaie_libre_an_invitation_votes_on_the_server_only_in_simulation() { let (mut admin, _) = Client::new().await; admin.login("Yvv", true).await; let (_, v) = admin @@ -696,17 +696,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; diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index ceb714b..5e6f764 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -291,10 +291,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"); @@ -339,10 +372,14 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() { ]; assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment); - // Nothing on the chain yet: pending, nothing counted. + // Nothing on the chain yet: the intention is seen at once — her vote, the totals + // shown — and waits for its proof; adoption does not count it (ADR 0012). assert_eq!(c.status(&b).await["status"], "pending"); let doc = c.doc("monnaie-libre").await; - assert_eq!(tally(&doc, &i1), (0, 0)); + assert_eq!((tally(&doc, &i1), pending(&doc, &i1)), ((1, 0), (1, 0))); + assert_eq!((tally(&doc, &e3), pending(&doc, &e3)), ((0, 1), (0, 1))); + assert_eq!(counted(&state, &doc, &i1).await, (0, 0)); + assert_eq!(mine(&doc, &i1), (Some("for"), Some("pending"))); assert_eq!(find(&doc, &i1)["myBallot"]["status"], "pending"); assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for"); @@ -354,10 +391,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"); @@ -466,6 +504,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] @@ -491,7 +540,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] @@ -722,8 +774,21 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() let e1 = version(&doc, "E1", "inForce"); let keys: Vec = (1..=50u8).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 fiftieth voice comes from another session: its intention is seen at once. + let asker = if n == 49 { &mut luna } else { &mut c }; + let b = asker.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await; + if n == 49 { + // Fifty voices shown, forty-nine proven: nothing enters before the proof. + let d = c.doc("monnaie-libre").await; + assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((50, 0), (1, 0))); + assert_eq!(counted(&state, &d, &e4).await, (49, 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, @@ -764,16 +829,29 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it() } } ballots::sweep(&state).await.expect("sweep"); - let s = c.status(&last).await; + let s = luna.status(&last).await; assert_eq!(s["status"], "confirmed"); assert_eq!(s["entered"][0]["code"], "E4", "{s}"); let d = c.doc("monnaie-libre").await; assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str()); - // A ballot sealed before the objection counts, as a vote already cast: kept, but - // the version held back cannot enter into force. - assert_eq!(tally(&d, &e5), (50, 0)); + // Sealed before the objection, proven after it: its *for* on E5 is set aside + // (ADR 0012, revising 0010) — its *for* on E4 counts. Luna is told. + assert_eq!(tally(&d, &e5), (49, 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 @@ -1050,7 +1128,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"); @@ -1287,15 +1368,17 @@ async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { // --------------------------------------------------------------------------- #[tokio::test] -async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes() { +async fn without_a_vote_account_monnaie_libre_votes_in_simulation_and_the_test_group_still_votes() { let (mut c, _) = Client::new(Config::for_tests()).await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - assert_eq!(doc["vote"]["way"], "chain"); - assert_eq!(doc["vote"]["open"], false); - assert_eq!(doc["vote"]["reason"], "chain_off"); + // The chain closed: the vote is simulated, written by the server (ADR 0012). + assert_eq!(doc["vote"]["way"], "server"); + assert_eq!(doc["vote"]["open"], true); + assert_eq!(doc["vote"]["simulated"], true); + assert!(doc["vote"]["reason"].is_null()); assert!(doc["vote"]["chain"].is_null()); - assert_eq!(doc["canVote"], false); + assert_eq!(doc["canVote"], true); let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); let i1 = version(&doc, "I1", "inForce"); let (s, v) = c @@ -1308,8 +1391,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", "inForce"); 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] @@ -1430,3 +1516,365 @@ 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", "inForce"), 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(); + // A small floor and a small web, for the test only: two voices may pass. + sqlx::query("UPDATE documents SET vote_floor = 1 WHERE id = ?") + .bind(&doc_id) + .execute(&state.db) + .await + .expect("floor"); + 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"); + 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(); + // A small floor and a small web, for the test only: two voices pass, one never does. + sqlx::query("UPDATE documents SET vote_floor = 1 WHERE id = ?").bind(&doc_id).execute(&state.db).await.expect("floor"); + 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"); + let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0")); + let seed_i1 = version(&doc, "I1", "inForce"); + 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. + for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) })] { + let b = c.ballot(&doc, &[(p, Some("for"))], None).await; + indexer.pay(block, comment_of(&b), signer); + } + ballots::sweep(&state).await.expect("sweep"); + // The chain closed: Luna and Bruno vote in simulation; E4, E5, E6 and the rival of I1 enter. + 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 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; + 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, 2, 6)); + 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 adopted: Vec = + sqlx::query_scalar("SELECT proposal_id FROM adoptions").fetch_all(&state.db).await.expect("adoptions"); + assert_eq!(adopted, [e6.clone()], "the adoptions that do not hold are void"); + // 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", "inForce"); + // 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", "inForce"); + // 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", "inForce"), 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", "inForce"); + 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)); +}