forked from yvv/decision
WIP lot V : le tampon câblé dans le serveur (migration 0010, simulation, discard-simulation), tests en cours de reprise — repris tel quel après la coupure du 02/11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -192,15 +192,20 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
||||
|
||||
/// What an adoption keeps of the formula it was judged by. The four letters
|
||||
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
|
||||
/// `constant`: read as they were written); the level and the scale join them.
|
||||
/// `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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+534
-123
@@ -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<ServerWay> {
|
||||
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<bool> {
|
||||
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<Vec<Entered>> {
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
@@ -176,16 +250,84 @@ pub async fn vote_many(
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<Vec<Entered>> {
|
||||
let lines: Vec<ballot::Line> = 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<Vec<Entered>> {
|
||||
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<AppState>, Json(b): Json<DevChainBody>) -> AppResult<Json<serde_json::Value>> {
|
||||
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<String>,
|
||||
/// `chain`, or `simulated` (ADR 0012); read as `chain` when absent.
|
||||
#[sqlx(default)]
|
||||
pub mode: String,
|
||||
#[sqlx(default)]
|
||||
pub settled_at: Option<String>,
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
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<AppState>, User(user): User) -> AppResult<
|
||||
lines: i64,
|
||||
created_at: String,
|
||||
settled_at: Option<String>,
|
||||
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<AppState>, 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<AppState>, 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<serde_json::Value>
|
||||
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:<index>` 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<ChainPosition>,
|
||||
/// 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<Vec<Entered>> {
|
||||
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<ChainPosition> = 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<Entered> = 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<i64>,
|
||||
event_index: Option<i64>,
|
||||
revision: i64,
|
||||
choice: String,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
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<Proven> = 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<u64> {
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
||||
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<Has
|
||||
Ok(rows.into_iter().map(|v| (v.proposal_id.clone(), v)).collect())
|
||||
}
|
||||
|
||||
/// What a ballot of the person, not yet counted, says of one version: pending on
|
||||
/// the chain, or refused, or let go.
|
||||
/// What a ballot of the person, not counted, says of one version: pending on the
|
||||
/// chain (an intention, ADR 0012), refused, let go — or its *for* set aside by an
|
||||
/// objection (`refused`, reason `objection`).
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MyBallot {
|
||||
@@ -832,12 +1119,16 @@ pub struct MyBallot {
|
||||
pub status: String,
|
||||
pub reason: Option<String>,
|
||||
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<HashMap<String, MyBallot>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
@@ -846,9 +1137,11 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
||||
status: String,
|
||||
reason: Option<String>,
|
||||
choice: String,
|
||||
revision: i64,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
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<H
|
||||
let mut latest: HashMap<String, MyBallot> = HashMap::new();
|
||||
let mut pending: HashMap<String, MyBallot> = 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<H
|
||||
pending.insert(r.proposal_id, b);
|
||||
}
|
||||
_ => {
|
||||
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<H
|
||||
Ok(latest)
|
||||
}
|
||||
|
||||
/// The latest intention of a session on a version, still waiting for its proof.
|
||||
pub struct Intent {
|
||||
pub requester: String,
|
||||
pub proposal_id: String,
|
||||
pub revision: i64,
|
||||
pub choice: Choice,
|
||||
}
|
||||
|
||||
/// The intentions of a document (ADR 0012): for each session and each version,
|
||||
/// its most recent pending ballot of the chain asked after its last confirmed one
|
||||
/// on that version — the rule of `my_ballots`, for everyone.
|
||||
pub async fn intents(state: &AppState, doc_id: &str) -> AppResult<Vec<Intent>> {
|
||||
#[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<String>,
|
||||
/// `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<Option<serde_json::Value>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LineRow {
|
||||
proposal_id: String,
|
||||
unit_id: String,
|
||||
code: String,
|
||||
title: String,
|
||||
choice: String,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
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::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// How this session votes in a group, and whether it may now.
|
||||
pub async fn access(state: &AppState, user: &IdentityRow, group: &docs::GroupRow) -> AppResult<serde_json::Value> {
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ pub struct Config {
|
||||
pub build: String,
|
||||
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
|
||||
pub vote: Option<VoteChain>,
|
||||
/// 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<String> {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-18
@@ -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<String, ModuleKind>,
|
||||
pub proposals: Vec<ProposalRow>,
|
||||
/// What adoption counts (ADR 0012): recorded votes, and simulated ones by
|
||||
/// default — never an intention without proof.
|
||||
pub tallies: HashMap<String, Tally>,
|
||||
/// What is recorded and shown: recorded votes and simulated ones, without the
|
||||
/// intentions (what a published text may say).
|
||||
pub recorded: HashMap<String, Tally>,
|
||||
/// 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<String, Tally>,
|
||||
/// The part of `shown` still waiting for its proof.
|
||||
pub pending: HashMap<String, Tally>,
|
||||
/// The part of `shown` that comes from the simulation.
|
||||
pub simulated: HashMap<String, Tally>,
|
||||
/// 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<String>,
|
||||
@@ -333,30 +348,70 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
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<String, Tally> = 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<String, Tally>, 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<String, Tally> = HashMap::new();
|
||||
let mut seen_by: HashMap<String, HashMap<String, MyVote>> = 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<DocState> {
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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<overview::Upcoming>,
|
||||
/// 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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -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<String> = 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<O
|
||||
Some((v, g)) => (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!({
|
||||
|
||||
@@ -160,10 +160,14 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
|
||||
"SELECT p.id AS id, u.id AS unit_id,
|
||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> '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",
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<AppState>,
|
||||
User(user): User,
|
||||
@@ -642,9 +644,7 @@ pub async fn vote(
|
||||
Json(b): Json<VoteBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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 })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -390,7 +390,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
||||
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tally = st.shown_tally(&p.id);
|
||||
let tally = st.recorded_tally(&p.id);
|
||||
json!({
|
||||
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
||||
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
||||
@@ -409,7 +409,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
||||
.iter()
|
||||
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
||||
.collect();
|
||||
let tally = st.shown_tally(&p.id);
|
||||
let tally = st.recorded_tally(&p.id);
|
||||
Some(json!({
|
||||
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! The end of the simulation (ADR 0012).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « Il compte, puis s'efface ». A simulated vote counts like a
|
||||
//! proven one, marked as simulated, until an act « supprime » of Yvv sets every
|
||||
//! one of them aside; what they brought into force is then examined again, with
|
||||
//! the proven votes only. Opening the chain does none of this by itself: the act
|
||||
//! calls `discard` — `ld-server discard-simulation` tells what it would do,
|
||||
//! `ld-server discard-simulation --apply` does it. Never run on its own.
|
||||
//!
|
||||
//! - **The sort**: the simulated ballots, their lines, their votes and the
|
||||
//! messages about them are deleted; each identity that voted in simulation is
|
||||
//! reconciled again, so that a proven vote of its own, if any, stands.
|
||||
//! - **The re-examination**: every entry into force that counted simulated votes
|
||||
//! (`adoptions.params.simulated`), the most recent first, still in effect, is
|
||||
//! judged again on the proven votes alone, at the level it was voted at. Below
|
||||
//! its threshold, it **returns among the proposals** and the version it had
|
||||
//! displaced comes back into force (a removed text comes back, a level of
|
||||
//! inertia goes back, the order and the title of before apply again); its
|
||||
//! adoption is void. At or above, it stays.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, ProposalRow, UnitRow};
|
||||
use crate::error::AppResult;
|
||||
use crate::modules;
|
||||
use crate::proposals;
|
||||
use crate::util;
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use ld_engine::threshold::Tally;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// One entry into force that counted simulated votes, judged again.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Reexamined {
|
||||
pub doc_id: String,
|
||||
pub proposal_id: String,
|
||||
pub unit_id: String,
|
||||
pub code: String,
|
||||
/// `text`, `remove`, `inertia`, `order` or `title`.
|
||||
pub kind: String,
|
||||
pub at: String,
|
||||
/// The votes it entered with, and the simulated share of them.
|
||||
pub entered_with: Tally,
|
||||
pub simulated: Tally,
|
||||
/// The proven votes it holds now, and the *for* votes its level asks at them.
|
||||
pub proven: Tally,
|
||||
pub required: u32,
|
||||
/// Still in force (or still applied): something else may have taken its place since.
|
||||
pub in_effect: bool,
|
||||
/// It holds on the proven votes alone: it stays.
|
||||
pub holds: bool,
|
||||
/// Returned among the proposals (`--apply` only).
|
||||
pub returned: bool,
|
||||
/// The version it had displaced, back in force when it returns.
|
||||
pub displaced: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Reexamined>,
|
||||
}
|
||||
|
||||
#[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<Discarded> {
|
||||
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<Option<Reexamined>> {
|
||||
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::<InertiaLevel>(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::<InertiaLevel>(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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> = (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<String>)> =
|
||||
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<i64>, Option<String>, 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<String> =
|
||||
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<String>)> =
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user