forked from yvv/decision
Fusion du lot V : le tampon des votes (ADR 0012), le vote simulé partout tant que la chaîne est fermée, marqué « simulé », qui compte puis s'efface par l'acte « supprime » ; la publication pas avant des votes prouvés
Boucle verte dans la copie du lot (98b4530puisd57326d) : fmt, clippy, 286 tests Rust, svelte-check 0/0, 56 vitest, build, e2e 36 / 36 (C34). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,10 +40,13 @@ crates/
|
|||||||
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection
|
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection
|
||||||
# (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction
|
# (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction
|
||||||
# (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre
|
# (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre
|
||||||
# d'une section : qui en est, complété à la fin, places), pending (modules à voter)
|
# d'une section : qui en est, complété à la fin, places), pending (modules à voter),
|
||||||
|
# buffer (le tampon des votes, ADR 0012 : réconciliation, poids des états,
|
||||||
|
# SIMULATED_ADOPTS)
|
||||||
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
|
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
|
||||||
# chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message,
|
# chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message,
|
||||||
# sets_quorum, groupes par défaut (TEST_GROUP)
|
# sets_quorum, casts_simulated (le vote simulé, chaîne fermée), groupes par défaut
|
||||||
|
# (TEST_GROUP)
|
||||||
ld-server/
|
ld-server/
|
||||||
migrations/ # schéma SQLite — additif seulement
|
migrations/ # schéma SQLite — additif seulement
|
||||||
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
||||||
@@ -55,7 +58,9 @@ crates/
|
|||||||
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
|
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
|
||||||
# seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ;
|
# seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ;
|
||||||
# le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé,
|
# le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé,
|
||||||
# bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments)
|
# bulletins de la chaîne Ğ1, le tampon : intention, preuve, réconciliation ; le vote
|
||||||
|
# simulé, « ton vote ») · simulation.rs (l'acte « supprime » de la fin de la simulation :
|
||||||
|
# `ld-server discard-simulation [--apply]`, ADR 0012) · indexer.rs (txComments)
|
||||||
# · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une
|
# · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une
|
||||||
# section, le titre du document) · module_amend.rs (ajout gardé) · quorum.rs (le quorum
|
# section, le titre du document) · module_amend.rs (ajout gardé) · quorum.rs (le quorum
|
||||||
# d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) ·
|
# d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) ·
|
||||||
@@ -87,7 +92,7 @@ web/src/
|
|||||||
# · g/[group]/decisions · g/[group]/mandats ·
|
# · g/[group]/decisions · g/[group]/mandats ·
|
||||||
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
||||||
# /groupes, /decisions… redirigent
|
# /groupes, /decisions… redirigent
|
||||||
web/e2e/ # Playwright C1–C33 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
web/e2e/ # Playwright C1–C34 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
||||||
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
||||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml · maquette.yml
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
@@ -109,7 +114,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
|
|||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 34 contrôles (35 tests, C25 en deux)
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 35 contrôles (36 tests, C25 en deux)
|
||||||
|
|
||||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd web && npm run dev # :3002 à chaud
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
//! The buffer between the votes and the chain (ADR 0012).
|
||||||
|
//!
|
||||||
|
//! Yvv, 2026-11-02: « il faut prévoir le "tampon" entre la base des votes et la
|
||||||
|
//! mise à jour par les indexeurs de la chaîne. Il faut prévoir le bon process
|
||||||
|
//! pour une ux moderne "je vois ce que j'ai fait sans attendre", avec un
|
||||||
|
//! contrôle a posteriori de l'intégrité du vote (bien unique, bien le dernier
|
||||||
|
//! qui vaut). »
|
||||||
|
//!
|
||||||
|
//! A vote goes through three times:
|
||||||
|
//! - **the intention**, the gesture itself (a sealed ballot, `ballot`): seen at
|
||||||
|
//! once by the person and in the totals shown, never counted for adoption;
|
||||||
|
//! - **the proof**, the chain read by the indexer (or, while the chain is
|
||||||
|
//! closed, the simulated confirmer);
|
||||||
|
//! - **the reconciliation**, after each proof: one voice per identity and per
|
||||||
|
//! version, the last in the order of the chain, whatever the order the proofs
|
||||||
|
//! were read in (`reconcile`). What the chain never confirms in time, refuses,
|
||||||
|
//! or what a more recent vote of the same identity overtook, is set aside — and
|
||||||
|
//! a *for* proven while an objection holds its version back.
|
||||||
|
//!
|
||||||
|
//! What each stage of a vote weighs, in the totals shown and in adoption, is
|
||||||
|
//! `Rules`. Pure: the facts come from the caller.
|
||||||
|
|
||||||
|
use crate::ballot::{ChainPosition, Choice};
|
||||||
|
use crate::objection;
|
||||||
|
use crate::threshold::Tally;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Whether a simulated vote counts for adoption. Yvv, 2026-11-02: « Il compte,
|
||||||
|
/// puis s'efface » — like a proven vote, marked as simulated, until an act
|
||||||
|
/// « supprime » sets every simulated vote aside and examines again, with the
|
||||||
|
/// proven votes only, what they brought into force (`ld_server::simulation`).
|
||||||
|
/// `false` would turn it to « shown, never counted for adoption »; the totals
|
||||||
|
/// shown would not change.
|
||||||
|
pub const SIMULATED_ADOPTS: bool = true;
|
||||||
|
|
||||||
|
/// Where a vote stands.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Stage {
|
||||||
|
/// Proven by the chain, or written by the server in a managed group.
|
||||||
|
Recorded,
|
||||||
|
/// An intention still waiting for its proof, within its window.
|
||||||
|
Pending,
|
||||||
|
/// A vote of the simulation (cast while the chain was closed), until the act
|
||||||
|
/// that sets them aside.
|
||||||
|
Simulated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stage {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Recorded => "recorded",
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Simulated => "simulated",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stage of a vote that counts (`votes.source`): `simulated`, or recorded
|
||||||
|
/// (`server`, `chain`). An intention is never in `votes`.
|
||||||
|
pub fn of_source(source: &str) -> Self {
|
||||||
|
if source == "simulated" { Self::Simulated } else { Self::Recorded }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the stages weigh. Opening the chain changes nothing by itself: the
|
||||||
|
/// simulated votes weigh until the act that sets them aside (Yvv, 2026-11-02).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Rules {
|
||||||
|
/// See `SIMULATED_ADOPTS`.
|
||||||
|
pub simulated_adopts: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Rules {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { simulated_adopts: SIMULATED_ADOPTS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rules {
|
||||||
|
/// Does a vote at this stage weigh in the totals shown? Every stage: the
|
||||||
|
/// intention at once, the simulated vote with its mark.
|
||||||
|
pub fn shown(&self, stage: Stage) -> bool {
|
||||||
|
match stage {
|
||||||
|
Stage::Recorded | Stage::Pending | Stage::Simulated => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does a vote at this stage weigh in adoption (and in the usual participation)?
|
||||||
|
/// Never an intention without proof.
|
||||||
|
pub fn adopts(&self, stage: Stage) -> bool {
|
||||||
|
match stage {
|
||||||
|
Stage::Recorded => true,
|
||||||
|
Stage::Pending => false,
|
||||||
|
Stage::Simulated => self.simulated_adopts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proven line of one identity on one version: where its proof sits on the
|
||||||
|
/// chain, its choice, and whether an objection held the version back when the
|
||||||
|
/// proof arrived.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Proven {
|
||||||
|
pub position: ChainPosition,
|
||||||
|
pub choice: Choice,
|
||||||
|
pub held: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What became of a proven line.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// The voice of the identity on this version (a withdrawal too: then no voice).
|
||||||
|
Counted,
|
||||||
|
/// A more recent proven line of the same identity decided.
|
||||||
|
Superseded,
|
||||||
|
/// A *for* proven while an objection held the version back: never counts.
|
||||||
|
Objection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Counted => "counted",
|
||||||
|
Self::Superseded => "superseded",
|
||||||
|
Self::Objection => "objection",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
[Self::Counted, Self::Superseded, Self::Objection].into_iter().find(|o| o.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A *for* whose proof arrives while an objection holds its version back is set
|
||||||
|
/// aside (ADR 0012, revising 0010): against and a withdrawal still count (Yvv,
|
||||||
|
/// 2026-11-01: « Retirer ou "contre" »).
|
||||||
|
pub fn discarded_by_objection(choice: Choice, held: bool) -> bool {
|
||||||
|
!objection::may_cast(held, choice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reconciliation of one identity on one version.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Reconciled {
|
||||||
|
/// One per line, in the order given.
|
||||||
|
pub outcomes: Vec<Outcome>,
|
||||||
|
/// The line that stands, if any: `None` when every line was set aside (the
|
||||||
|
/// vote of before, if any, is left as it is).
|
||||||
|
pub standing: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One voice per identity and per version, the last in the order of the chain:
|
||||||
|
/// the block, then the rank of the event — whatever the order the lines come in.
|
||||||
|
/// A *for* proven while held is set aside first and never takes a place in the
|
||||||
|
/// order. Two lines at the same place (never on the chain): the later given.
|
||||||
|
pub fn reconcile(lines: &[Proven]) -> Reconciled {
|
||||||
|
let mut outcomes = vec![Outcome::Superseded; lines.len()];
|
||||||
|
let mut standing: Option<usize> = None;
|
||||||
|
for (i, l) in lines.iter().enumerate() {
|
||||||
|
if discarded_by_objection(l.choice, l.held) {
|
||||||
|
outcomes[i] = Outcome::Objection;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if standing.is_none_or(|j| lines[j].position <= l.position) {
|
||||||
|
standing = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(i) = standing {
|
||||||
|
outcomes[i] = Outcome::Counted;
|
||||||
|
}
|
||||||
|
Reconciled { outcomes, standing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change of the totals shown, for and against.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Shift {
|
||||||
|
pub for_votes: i64,
|
||||||
|
pub against: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shift {
|
||||||
|
/// The totals, shifted; never below zero.
|
||||||
|
pub fn apply(self, t: Tally) -> Tally {
|
||||||
|
let add = |n: u32, d: i64| u32::try_from((i64::from(n) + d).clamp(0, i64::from(u32::MAX))).unwrap_or(n);
|
||||||
|
Tally { for_votes: add(t.for_votes, self.for_votes), against: add(t.against, self.against) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an intention weighs in the totals shown.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Weighed {
|
||||||
|
/// What it changes in the totals shown.
|
||||||
|
pub shift: Shift,
|
||||||
|
/// The part of the totals shown it adds that still waits for its proof.
|
||||||
|
pub pending: Tally,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest intention of a session on a version, still without proof, takes
|
||||||
|
/// the place — in the totals shown only — of the vote this session sees as its
|
||||||
|
/// own:
|
||||||
|
/// - `seen`: that vote, if it counts in the totals shown now (*for* or *against*);
|
||||||
|
/// - `intent`: the choice of the intention (`None`: a withdrawal);
|
||||||
|
/// - `current`: it votes the revision in place (otherwise it would be « to
|
||||||
|
/// reconfirm »: it adds nothing, but still takes the vote of before away);
|
||||||
|
/// - `held`: an objection holds the version back — a *for* then weighs nothing
|
||||||
|
/// at all (it would be set aside), the vote of before stays.
|
||||||
|
pub fn weigh_intent(seen: Option<Choice>, intent: Choice, current: bool, held: bool) -> Weighed {
|
||||||
|
if discarded_by_objection(intent, held) {
|
||||||
|
return Weighed::default();
|
||||||
|
}
|
||||||
|
let one = |c: Option<Choice>| match c {
|
||||||
|
Some(Choice::For) => Tally { for_votes: 1, against: 0 },
|
||||||
|
Some(Choice::Against) => Tally { for_votes: 0, against: 1 },
|
||||||
|
Some(Choice::None) | None => Tally::default(),
|
||||||
|
};
|
||||||
|
let new = one(current.then_some(intent));
|
||||||
|
let old = one(seen);
|
||||||
|
Weighed {
|
||||||
|
shift: Shift {
|
||||||
|
for_votes: i64::from(new.for_votes) - i64::from(old.for_votes),
|
||||||
|
against: i64::from(new.against) - i64::from(old.against),
|
||||||
|
},
|
||||||
|
pending: new,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FOR: Choice = Choice::For;
|
||||||
|
const AGAINST: Choice = Choice::Against;
|
||||||
|
const NONE: Choice = Choice::None;
|
||||||
|
|
||||||
|
fn at(block: u32, event: u32) -> ChainPosition {
|
||||||
|
ChainPosition { block, event }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(block: u32, event: u32, choice: Choice) -> Proven {
|
||||||
|
Proven { position: at(block, event), choice, held: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of each line, keyed by its position: comparable across orders.
|
||||||
|
fn by_position(lines: &[Proven]) -> (Vec<(ChainPosition, Outcome)>, Option<ChainPosition>) {
|
||||||
|
let r = reconcile(lines);
|
||||||
|
let mut out: Vec<(ChainPosition, Outcome)> =
|
||||||
|
lines.iter().zip(&r.outcomes).map(|(l, o)| (l.position, *o)).collect();
|
||||||
|
out.sort_by_key(|(p, _)| *p);
|
||||||
|
(out, r.standing.map(|i| lines[i].position))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_in_the_order_of_the_chain_stands_whatever_the_order_of_reading() {
|
||||||
|
let a = line(200, 2, FOR);
|
||||||
|
let b = line(200, 4, AGAINST);
|
||||||
|
let c = line(300, 1, NONE);
|
||||||
|
let expected = (
|
||||||
|
vec![(a.position, Outcome::Superseded), (b.position, Outcome::Superseded), (c.position, Outcome::Counted)],
|
||||||
|
Some(c.position),
|
||||||
|
);
|
||||||
|
// Every order the indexer may read them in gives the same voice.
|
||||||
|
for order in [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]] {
|
||||||
|
assert_eq!(by_position(&order), expected, "{order:?}");
|
||||||
|
}
|
||||||
|
// The withdrawal stands: no voice.
|
||||||
|
let r = reconcile(&[a, b, c]);
|
||||||
|
assert_eq!(r.standing.map(|i| [a, b, c][i].choice), Some(NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_voice_per_identity_never_two() {
|
||||||
|
for lines in [
|
||||||
|
vec![],
|
||||||
|
vec![line(5, 1, FOR)],
|
||||||
|
vec![line(5, 1, FOR), line(5, 2, FOR), line(4, 9, AGAINST)],
|
||||||
|
vec![Proven { held: true, ..line(9, 1, FOR) }, line(8, 1, AGAINST), line(7, 1, FOR)],
|
||||||
|
] {
|
||||||
|
let r = reconcile(&lines);
|
||||||
|
assert_eq!(r.outcomes.len(), lines.len());
|
||||||
|
let counted = r.outcomes.iter().filter(|o| **o == Outcome::Counted).count();
|
||||||
|
assert_eq!(counted, usize::from(r.standing.is_some()), "{lines:?}");
|
||||||
|
assert!(counted <= 1);
|
||||||
|
}
|
||||||
|
assert_eq!(reconcile(&[]).standing, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_for_proven_while_held_is_set_aside_and_the_vote_of_before_stays() {
|
||||||
|
let before = line(100, 1, AGAINST);
|
||||||
|
let held_for = Proven { held: true, ..line(200, 1, FOR) };
|
||||||
|
for lines in [[before, held_for], [held_for, before]] {
|
||||||
|
let (outcomes, standing) = by_position(&lines);
|
||||||
|
assert_eq!(outcomes, vec![(before.position, Outcome::Counted), (held_for.position, Outcome::Objection)]);
|
||||||
|
assert_eq!(standing, Some(before.position), "the *for* takes no place in the order");
|
||||||
|
}
|
||||||
|
// Alone, it leaves nothing standing: the vote of before, if any, is left as it is.
|
||||||
|
let r = reconcile(&[held_for]);
|
||||||
|
assert_eq!((r.outcomes, r.standing), (vec![Outcome::Objection], None));
|
||||||
|
// Against and a withdrawal, proven while held, count.
|
||||||
|
for choice in [AGAINST, NONE] {
|
||||||
|
let held = Proven { held: true, ..line(300, 1, choice) };
|
||||||
|
let r = reconcile(&[before, held_for, held]);
|
||||||
|
assert_eq!(r.standing, Some(2), "{choice:?}");
|
||||||
|
assert_eq!(r.outcomes, vec![Outcome::Superseded, Outcome::Objection, Outcome::Counted]);
|
||||||
|
}
|
||||||
|
assert!(discarded_by_objection(FOR, true));
|
||||||
|
assert!(!discarded_by_objection(FOR, false));
|
||||||
|
assert!(!discarded_by_objection(AGAINST, true) && !discarded_by_objection(NONE, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_lines_at_the_same_place_the_later_given_stands() {
|
||||||
|
let r = reconcile(&[line(0, 3, FOR), line(0, 3, AGAINST)]);
|
||||||
|
assert_eq!(r.standing, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_simulated_vote_at_block_zero_comes_before_any_vote_of_the_chain() {
|
||||||
|
// The simulation proves at block 0, in the order votes arrive (ADR 0012).
|
||||||
|
let simulated = line(0, 41, FOR);
|
||||||
|
let chain = line(1, 0, AGAINST);
|
||||||
|
assert_eq!(by_position(&[chain, simulated]).1, Some(chain.position));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_each_stage_weighs() {
|
||||||
|
let rules = Rules::default();
|
||||||
|
assert_eq!(rules.simulated_adopts, SIMULATED_ADOPTS);
|
||||||
|
for stage in [Stage::Recorded, Stage::Pending, Stage::Simulated] {
|
||||||
|
assert!(rules.shown(stage), "{stage:?}: everything is seen, at once");
|
||||||
|
}
|
||||||
|
assert!(rules.adopts(Stage::Recorded));
|
||||||
|
assert!(!rules.adopts(Stage::Pending), "never an intention without proof");
|
||||||
|
// Yvv, 2026-11-02: « Il compte, puis s'efface » — like a proven vote, until the act.
|
||||||
|
assert!(SIMULATED_ADOPTS && rules.adopts(Stage::Simulated));
|
||||||
|
// The other way, kept at hand: shown, never counted for adoption.
|
||||||
|
let shown_only = Rules { simulated_adopts: false };
|
||||||
|
assert!(shown_only.shown(Stage::Simulated) && !shown_only.adopts(Stage::Simulated));
|
||||||
|
assert!(shown_only.adopts(Stage::Recorded) && !shown_only.adopts(Stage::Pending));
|
||||||
|
assert_eq!(Stage::of_source("simulated"), Stage::Simulated);
|
||||||
|
assert_eq!(Stage::of_source("chain"), Stage::Recorded);
|
||||||
|
assert_eq!(Stage::of_source("server"), Stage::Recorded);
|
||||||
|
for o in [Outcome::Counted, Outcome::Superseded, Outcome::Objection] {
|
||||||
|
assert_eq!(Outcome::parse(o.as_str()), Some(o));
|
||||||
|
}
|
||||||
|
assert_eq!(Outcome::parse("maybe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift(for_votes: i64, against: i64) -> Shift {
|
||||||
|
Shift { for_votes, against }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tally(for_votes: u32, against: u32) -> Tally {
|
||||||
|
Tally { for_votes, against }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_intention_takes_the_place_of_the_vote_one_sees_as_one_s_own() {
|
||||||
|
let w = |seen, intent, current, held| weigh_intent(seen, intent, current, held);
|
||||||
|
// A first vote.
|
||||||
|
assert_eq!(w(None, FOR, true, false), Weighed { shift: shift(1, 0), pending: tally(1, 0) });
|
||||||
|
// From *for* to *against*: one *for* less, one *against* more, that one waiting.
|
||||||
|
assert_eq!(w(Some(FOR), AGAINST, true, false), Weighed { shift: shift(-1, 1), pending: tally(0, 1) });
|
||||||
|
// The same choice again: nothing moves, the voice now waits for its proof.
|
||||||
|
assert_eq!(w(Some(FOR), FOR, true, false), Weighed { shift: shift(0, 0), pending: tally(1, 0) });
|
||||||
|
// A withdrawal takes the vote away at once.
|
||||||
|
assert_eq!(w(Some(AGAINST), NONE, true, false), Weighed { shift: shift(0, -1), pending: tally(0, 0) });
|
||||||
|
assert_eq!(w(None, NONE, true, false), Weighed::default());
|
||||||
|
// On a revision gone by: it adds nothing, it still takes the vote of before away.
|
||||||
|
assert_eq!(w(Some(FOR), AGAINST, false, false), Weighed { shift: shift(-1, 0), pending: tally(0, 0) });
|
||||||
|
// Held back by an objection: a *for* weighs nothing (it would be set aside)...
|
||||||
|
assert_eq!(w(Some(AGAINST), FOR, true, true), Weighed::default());
|
||||||
|
assert_eq!(w(None, FOR, true, true), Weighed::default());
|
||||||
|
// ...against and a withdrawal do.
|
||||||
|
assert_eq!(w(Some(FOR), AGAINST, true, true), Weighed { shift: shift(-1, 1), pending: tally(0, 1) });
|
||||||
|
assert_eq!(w(Some(FOR), NONE, true, true), Weighed { shift: shift(-1, 0), pending: tally(0, 0) });
|
||||||
|
// A « seen » withdrawal is no vote.
|
||||||
|
assert_eq!(w(Some(NONE), FOR, true, false), w(None, FOR, true, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shift_never_takes_the_totals_below_zero() {
|
||||||
|
assert_eq!(shift(-1, 2).apply(tally(3, 0)), tally(2, 2));
|
||||||
|
assert_eq!(shift(-1, -1).apply(tally(0, 0)), tally(0, 0));
|
||||||
|
assert_eq!(shift(1, 0).apply(tally(u32::MAX, 0)), tally(u32::MAX, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
pub mod address;
|
pub mod address;
|
||||||
pub mod assist;
|
pub mod assist;
|
||||||
pub mod ballot;
|
pub mod ballot;
|
||||||
|
pub mod buffer;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
pub mod correction;
|
pub mod correction;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -193,15 +193,19 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
|||||||
/// What an adoption keeps of the formula it was judged by. The letters stay where
|
/// What an adoption keeps of the formula it was judged by. The letters stay where
|
||||||
/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before
|
/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before
|
||||||
/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum
|
/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum
|
||||||
/// (ADR 0013), the level and the scale join them.
|
/// (ADR 0013), the level and the scale join them — and, for the act that ends the
|
||||||
|
/// simulation (ADR 0012, `simulation::discard`), the simulated share of the votes it
|
||||||
|
/// entered with, the version it displaced and the level of inertia of before.
|
||||||
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
|
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
|
||||||
let level = st.voting_level(unit, p);
|
let level = st.voting_level(unit, p);
|
||||||
let f = st.params(unit, p);
|
let f = st.params(unit, p);
|
||||||
|
let simulated = st.simulated_counted(&p.id);
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
|
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
|
||||||
"level": level, "share_pct": level.share_pct(),
|
"level": level, "share_pct": level.share_pct(),
|
||||||
"participation": st.participation.ratio, "participation_source": st.participation.source,
|
"participation": st.participation.ratio, "participation_source": st.participation.source,
|
||||||
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
|
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
|
||||||
|
"simulated": simulated, "displaced": unit.in_force_id, "from_inertia": docs::inertia_of(unit),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+544
-123
@@ -21,6 +21,21 @@
|
|||||||
//! Ğecko code). A ballot is read one last time at its end (24 hours), then let
|
//! Ğ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
|
//! 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).
|
//! 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::AppState;
|
||||||
use crate::adoption::{self, Entered};
|
use crate::adoption::{self, Entered};
|
||||||
@@ -28,6 +43,7 @@ use crate::auth::{IdentityRow, User};
|
|||||||
use crate::docs::{self, DocState};
|
use crate::docs::{self, DocState};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::indexer::{self, Found, Reading};
|
use crate::indexer::{self, Found, Reading};
|
||||||
|
use crate::messages::{self, Topic};
|
||||||
use crate::util;
|
use crate::util;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
@@ -35,10 +51,13 @@ use axum::http::{HeaderValue, header};
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use ld_engine::address;
|
use ld_engine::address;
|
||||||
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
||||||
|
use ld_engine::buffer::{self, Outcome, Proven, Stage};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use trustin_access::VoteWay;
|
use trustin_access::VoteWay;
|
||||||
@@ -167,7 +186,62 @@ pub(crate) async fn write_server_votes(state: &AppState, identity: &str, casts:
|
|||||||
Ok(())
|
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(
|
pub async fn vote_many(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
User(user): User,
|
User(user): User,
|
||||||
@@ -176,16 +250,87 @@ pub async fn vote_many(
|
|||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let st = docs::load_state(&state, &doc_id).await?;
|
let st = docs::load_state(&state, &doc_id).await?;
|
||||||
docs::require_open(&state, &user, &st.group).await?;
|
docs::require_open(&state, &user, &st.group).await?;
|
||||||
if trustin_access::vote_way(&st.group.to_group()) == VoteWay::Chain {
|
let way = server_way(&state, &st.group)?;
|
||||||
return Err(AppError::Conflict("chain_only"));
|
if !may_write(&state, &user, &st.group, way).await? {
|
||||||
}
|
|
||||||
if !docs::may_vote(&state, &user, &st.group).await? {
|
|
||||||
return Err(AppError::Forbidden);
|
return Err(AppError::Forbidden);
|
||||||
}
|
}
|
||||||
let casts = casts(&st, &b.lines)?;
|
let casts = casts(&st, &b.lines)?;
|
||||||
write_server_votes(&state, &user.id, &casts).await?;
|
let entered = write(&state, &user, &st, way, &casts).await?;
|
||||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
Ok(Json(json!({ "count": casts.len(), "entered": entered, "simulated": way == ServerWay::Simulated })))
|
||||||
Ok(Json(json!({ "count": casts.len(), "entered": entered })))
|
}
|
||||||
|
|
||||||
|
/// 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 +355,34 @@ pub struct BallotRow {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub expires_at: String,
|
pub expires_at: String,
|
||||||
pub checked_at: Option<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,
|
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 {
|
impl BallotRow {
|
||||||
pub fn comment(&self) -> String {
|
pub fn comment(&self) -> String {
|
||||||
format!("{}{}", ballot::PREFIX, self.digest)
|
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 {
|
fn salt() -> String {
|
||||||
@@ -244,7 +408,7 @@ pub async fn create(
|
|||||||
if !trustin_access::asks_ballot(&user.to_identity(), &group, granted) {
|
if !trustin_access::asks_ballot(&user.to_identity(), &group, granted) {
|
||||||
return Err(AppError::Forbidden);
|
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 casts = casts(&st, &b.lines)?;
|
||||||
let author = match b.author.as_deref().map(str::trim).filter(|a| !a.is_empty()) {
|
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"))?),
|
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
|
||||||
@@ -252,7 +416,7 @@ pub async fn create(
|
|||||||
};
|
};
|
||||||
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
|
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
|
||||||
let held: i64 = sqlx::query_scalar(
|
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 > ?))",
|
AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))",
|
||||||
)
|
)
|
||||||
.bind(&user.id)
|
.bind(&user.id)
|
||||||
@@ -329,7 +493,9 @@ pub async fn status(
|
|||||||
if row.requested_by != user.id {
|
if row.requested_by != user.id {
|
||||||
return Err(AppError::NotFound);
|
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?;
|
check(&state, &row).await?;
|
||||||
row = load(&state, &id).await?;
|
row = load(&state, &id).await?;
|
||||||
}
|
}
|
||||||
@@ -386,9 +552,10 @@ pub async fn mine(State(state): State<AppState>, User(user): User) -> AppResult<
|
|||||||
lines: i64,
|
lines: i64,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
settled_at: Option<String>,
|
settled_at: Option<String>,
|
||||||
|
mode: String,
|
||||||
}
|
}
|
||||||
let rows = sqlx::query_as::<_, Row>(&format!(
|
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,
|
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
|
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
|
FROM ballots b JOIN documents d ON d.id = b.doc_id LEFT JOIN identities i ON i.id = b.voter_id
|
||||||
@@ -404,7 +571,9 @@ pub async fn mine(State(state): State<AppState>, User(user): User) -> AppResult<
|
|||||||
json!({
|
json!({
|
||||||
"id": r.id, "doc": { "id": r.doc_id, "group": r.group_slug, "slug": r.slug, "title": title_json(&r.title) },
|
"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,
|
"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();
|
.collect();
|
||||||
@@ -495,7 +664,8 @@ pub async fn receipt(State(state): State<AppState>, User(user): User, Path(id):
|
|||||||
"amount": row.amount,
|
"amount": row.amount,
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"reason": row.reason,
|
"reason": row.reason,
|
||||||
"block": row.block,
|
"mode": row.mode(),
|
||||||
|
"block": row.block_shown(),
|
||||||
"event": extra.event_id,
|
"event": extra.event_id,
|
||||||
"signer": extra.signer,
|
"signer": extra.signer,
|
||||||
"voter": voter,
|
"voter": voter,
|
||||||
@@ -527,14 +697,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!([]));
|
row.entered.as_deref().and_then(|e| serde_json::from_str(e).ok()).unwrap_or_else(|| json!([]));
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"id": row.id, "status": row.status, "reason": row.reason, "comment": row.comment(),
|
"id": row.id, "status": row.status, "reason": row.reason, "comment": row.comment(),
|
||||||
"account": row.account, "amount": row.amount, "author": row.author, "block": row.block,
|
"account": row.account, "amount": row.amount, "author": row.author, "block": row.block_shown(),
|
||||||
"voter": voter, "entered": entered, "expiresAt": row.expires_at,
|
"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
|
/// 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.
|
/// — 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<()> {
|
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||||
|
if row.simulated() {
|
||||||
|
return settle_simulated(state, row).await.map(|_| ());
|
||||||
|
}
|
||||||
let now = util::now();
|
let now = util::now();
|
||||||
let due = row.expires_at <= now;
|
let due = row.expires_at <= now;
|
||||||
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
|
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
|
||||||
@@ -576,7 +749,12 @@ pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
|||||||
match verdict {
|
match verdict {
|
||||||
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
|
||||||
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
|
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,
|
Verdict::Paid { .. } => refuse(state, row, found, "not_member").await,
|
||||||
other => refuse(state, row, found, other.reason().unwrap_or("refused")).await,
|
other => refuse(state, row, found, other.reason().unwrap_or("refused")).await,
|
||||||
@@ -599,153 +777,257 @@ async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str)
|
|||||||
.await?;
|
.await?;
|
||||||
if done.rows_affected() == 1 {
|
if done.rows_affected() == 1 {
|
||||||
tracing::info!(ballot = %row.id, reason, "ballot refused");
|
tracing::info!(ballot = %row.id, reason, "ballot refused");
|
||||||
|
tell(state, &row.id, &row.requested_by, &row.doc_id, "ballot_refused").await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ballot counts: written for the identity of the signer, line by line,
|
/// Tells the session that asked for a ballot what became of it (ADR 0012): a
|
||||||
/// where no more recent ballot of that identity decided already. A ballot sealed
|
/// message of its box, without gesture — one votes again in the text.
|
||||||
/// before an objection counts as it was signed — a *for* too, as a vote already
|
async fn tell(state: &AppState, ballot_id: &str, requester: &str, doc_id: &str, kind: &str) -> AppResult<()> {
|
||||||
/// cast —, and the version held back still cannot enter (ADR 0010).
|
messages::notify(state, requester, doc_id, Topic::Ballot, ballot_id, kind, 1, None).await
|
||||||
async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> {
|
}
|
||||||
let voter = format!("idty:{index}");
|
|
||||||
let position = found.comment.position;
|
/// 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();
|
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 _one = CONFIRM.lock().await;
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
// The identity of the signer, as the chain has it now: a member (just
|
if let Some(found) = proof.found {
|
||||||
// checked), with its name and its account. Undone with the rest if the
|
// The identity of the signer, as the chain has it now: a member (just
|
||||||
// ballot was settled in the meantime.
|
// checked), with its name and its account. Undone with the rest if the
|
||||||
let admin = state.cfg.admin_g1_ids.contains(&voter);
|
// ballot was settled in the meantime.
|
||||||
sqlx::query(
|
let admin = state.cfg.admin_g1_ids.contains(&proof.voter);
|
||||||
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
|
sqlx::query(
|
||||||
VALUES (?, 'g1', ?, ?, 1, ?, ?)
|
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
|
||||||
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address,
|
VALUES (?, 'g1', ?, ?, 1, ?, ?)
|
||||||
g1_member = 1, admin = MAX(identities.admin, excluded.admin)",
|
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(&proof.voter)
|
||||||
.bind(&found.signer)
|
.bind(found.name.clone().unwrap_or_else(|| proof.voter.clone()))
|
||||||
.bind(admin)
|
.bind(&found.signer)
|
||||||
.bind(&now)
|
.bind(admin)
|
||||||
.execute(&mut *tx)
|
.bind(&now)
|
||||||
.await?;
|
.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(
|
let won = sqlx::query(
|
||||||
"UPDATE ballots SET status = 'confirmed', voter_id = ?, signer = ?, block = ?, event_index = ?,
|
"UPDATE ballots SET status = 'confirmed', voter_id = ?, signer = ?, block = ?, event_index = ?,
|
||||||
event_id = ?, settled_at = ? WHERE id = ? AND status = 'pending'",
|
event_id = ?, settled_at = ? WHERE id = ? AND status = 'pending'",
|
||||||
)
|
)
|
||||||
.bind(&voter)
|
.bind(&proof.voter)
|
||||||
.bind(&found.signer)
|
.bind(proof.found.map(|f| &f.signer))
|
||||||
.bind(i64::from(position.block))
|
.bind(i64::from(position.block))
|
||||||
.bind(i64::from(position.event))
|
.bind(i64::from(position.event))
|
||||||
.bind(&found.event_id)
|
.bind(proof.found.map(|f| &f.event_id))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(&row.id)
|
.bind(&row.id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if won.rows_affected() != 1 {
|
if won.rows_affected() != 1 {
|
||||||
// Settled in the meantime: confirming twice changes nothing.
|
// Settled in the meantime: proving twice changes nothing.
|
||||||
tx.rollback().await?;
|
tx.rollback().await?;
|
||||||
return Ok(());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let lines: Vec<(String, i64, String)> =
|
let lines: Vec<(String, String)> =
|
||||||
sqlx::query_as("SELECT proposal_id, revision, choice FROM ballot_lines WHERE ballot_id = ?")
|
sqlx::query_as("SELECT proposal_id, choice FROM ballot_lines WHERE ballot_id = ?")
|
||||||
.bind(&row.id)
|
.bind(&row.id)
|
||||||
.fetch_all(&mut *tx)
|
.fetch_all(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
for (proposal_id, revision, choice) in lines {
|
for (proposal_id, choice) in &lines {
|
||||||
let others: Vec<(i64, i64)> = sqlx::query_as(
|
let Some(choice) = Choice::parse(choice) else {
|
||||||
"SELECT b.block, b.event_index FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id
|
tracing::warn!(ballot = %row.id, %choice, "unknown choice in a ballot line");
|
||||||
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) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
match Choice::parse(&choice) {
|
if buffer::discarded_by_objection(choice, held.contains(proposal_id)) {
|
||||||
Some(Choice::None) => {
|
sqlx::query("UPDATE ballot_lines SET outcome = ? WHERE ballot_id = ? AND proposal_id = ?")
|
||||||
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
|
.bind(Outcome::Objection.as_str())
|
||||||
.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))
|
|
||||||
.bind(&row.id)
|
.bind(&row.id)
|
||||||
|
.bind(proposal_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
set_aside = true;
|
||||||
None => tracing::warn!(ballot = %row.id, %choice, "unknown choice in a ballot line"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (proposal_id, _) in &lines {
|
||||||
|
reconcile(&mut tx, &proof.voter, proposal_id, &now).await?;
|
||||||
|
}
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
}
|
position
|
||||||
tracing::info!(ballot = %row.id, block = position.block, event = position.event, "ballot confirmed");
|
};
|
||||||
|
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?;
|
let entered: Vec<Entered> = adoption::run(state, &row.doc_id).await?;
|
||||||
sqlx::query("UPDATE ballots SET entered = ? WHERE id = ?")
|
sqlx::query("UPDATE ballots SET entered = ? WHERE id = ?")
|
||||||
.bind(serde_json::to_string(&entered).unwrap_or_else(|_| "[]".into()))
|
.bind(serde_json::to_string(&entered).unwrap_or_else(|_| "[]".into()))
|
||||||
.bind(&row.id)
|
.bind(&row.id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lets go of one pending ballot not seen on the chain in time.
|
/// Lets go of one pending ballot not seen on the chain in time.
|
||||||
async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
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(util::now())
|
||||||
.bind(&row.id)
|
.bind(&row.id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
if done.rows_affected() == 1 {
|
||||||
|
tell(state, &row.id, &row.requested_by, &row.doc_id, "ballot_expired").await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lets go of the pending ballots past their end and the grace after it: those
|
/// Lets go of the pending ballots past their end and the grace after it: those
|
||||||
/// the indexer could not be read for in time.
|
/// the indexer could not be read for in time.
|
||||||
pub async fn expire(state: &AppState) -> AppResult<u64> {
|
pub async fn expire(state: &AppState) -> AppResult<u64> {
|
||||||
let done = sqlx::query(
|
let gone: Vec<(String, String, String)> = sqlx::query_as(
|
||||||
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
|
"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::now())
|
||||||
.bind(util::in_seconds(-GRACE_SECS))
|
.bind(util::in_seconds(-GRACE_SECS))
|
||||||
.execute(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.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
|
/// Purges the ballots let go long enough ago, lines and all, and the messages
|
||||||
/// never counted: nothing of a vote is lost.
|
/// that told of them. A ballot let go never counted: nothing of a vote is lost.
|
||||||
pub async fn purge(state: &AppState) -> AppResult<u64> {
|
pub async fn purge(state: &AppState) -> AppResult<u64> {
|
||||||
const WHICH: &str = "SELECT id FROM ballots WHERE status = 'expired' AND settled_at IS NOT NULL
|
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 < ?)";
|
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 (dropped, expired) = (util::in_seconds(-PURGE_DROPPED_SECS), util::in_seconds(-PURGE_EXPIRED_SECS));
|
||||||
let mut tx = state.db.begin().await?;
|
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})"))
|
sqlx::query(&format!("DELETE FROM ballot_lines WHERE ballot_id IN ({WHICH})"))
|
||||||
.bind(&dropped)
|
.bind(&dropped)
|
||||||
.bind(&expired)
|
.bind(&expired)
|
||||||
@@ -766,8 +1048,19 @@ pub async fn purge(state: &AppState) -> AppResult<u64> {
|
|||||||
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
||||||
expire(state).await?;
|
expire(state).await?;
|
||||||
purge(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!(
|
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}"
|
ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}"
|
||||||
))
|
))
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
@@ -823,8 +1116,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())
|
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
|
/// What a ballot of the person, not counted, says of one version: pending on the
|
||||||
/// the chain, or refused, or let go.
|
/// chain (an intention, ADR 0012), refused, let go — or its *for* set aside by an
|
||||||
|
/// objection (`refused`, reason `objection`).
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MyBallot {
|
pub struct MyBallot {
|
||||||
@@ -832,12 +1126,16 @@ pub struct MyBallot {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
pub choice: 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
|
/// 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
|
/// 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
|
/// or proven with its line set aside by an objection (a confirmed one shows as its
|
||||||
/// (dropped) says nothing; one asked before a confirmed vote says nothing either.
|
/// 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>> {
|
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct Row {
|
struct Row {
|
||||||
@@ -846,9 +1144,11 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
|||||||
status: String,
|
status: String,
|
||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
choice: String,
|
choice: String,
|
||||||
|
revision: i64,
|
||||||
|
outcome: Option<String>,
|
||||||
}
|
}
|
||||||
let rows = sqlx::query_as::<_, Row>(
|
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
|
JOIN ballot_lines l ON l.ballot_id = b.id
|
||||||
WHERE b.doc_id = ? AND b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
|
WHERE b.doc_id = ? AND b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
|
||||||
ORDER BY b.created_at, b.rowid",
|
ORDER BY b.created_at, b.rowid",
|
||||||
@@ -860,7 +1160,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 latest: HashMap<String, MyBallot> = HashMap::new();
|
||||||
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
let mut pending: HashMap<String, MyBallot> = HashMap::new();
|
||||||
for r in rows {
|
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() {
|
match b.status.as_str() {
|
||||||
// A confirmed vote: what was asked before it says nothing more.
|
// A confirmed vote: what was asked before it says nothing more.
|
||||||
"confirmed" => {
|
"confirmed" => {
|
||||||
@@ -871,6 +1178,9 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
|||||||
pending.insert(r.proposal_id, b);
|
pending.insert(r.proposal_id, b);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
if set_aside {
|
||||||
|
pending.remove(&r.proposal_id);
|
||||||
|
}
|
||||||
latest.insert(r.proposal_id, b);
|
latest.insert(r.proposal_id, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -879,29 +1189,140 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
|||||||
Ok(latest)
|
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.
|
/// 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> {
|
pub async fn access(state: &AppState, user: &IdentityRow, group: &docs::GroupRow) -> AppResult<serde_json::Value> {
|
||||||
let g = group.to_group();
|
let g = group.to_group();
|
||||||
let granted = docs::granted(state, &g.slug, &user.id).await?;
|
let granted = docs::granted(state, &g.slug, &user.id).await?;
|
||||||
let id = user.to_identity();
|
let id = user.to_identity();
|
||||||
let way = trustin_access::vote_way(&g);
|
let way = trustin_access::vote_way(&g);
|
||||||
let (open, reason) = match way {
|
// While the chain is closed, a group of the chain votes in simulation: the vote
|
||||||
VoteWay::Server => {
|
// leaves the page by a write of the server, marked as simulated (ADR 0012).
|
||||||
let open = trustin_access::votes_in(&id, &g, granted);
|
let simulated = way == VoteWay::Chain && state.chain().is_none();
|
||||||
(open, (!open).then_some("members_only"))
|
let open = match way {
|
||||||
}
|
VoteWay::Server => trustin_access::votes_in(&id, &g, granted),
|
||||||
VoteWay::Chain => match (&state.cfg.vote, trustin_access::asks_ballot(&id, &g, granted)) {
|
VoteWay::Chain if simulated => trustin_access::casts_simulated(&id, &g, granted, false),
|
||||||
(None, _) => (false, Some("chain_off")),
|
VoteWay::Chain => trustin_access::asks_ballot(&id, &g, granted),
|
||||||
(Some(_), false) => (false, Some("members_only")),
|
|
||||||
(Some(_), true) => (true, None),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
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!({
|
(VoteWay::Chain, Some(c)) => json!({
|
||||||
"account": c.account, "amount": c.amount, "nodes": c.nodes,
|
"account": c.account, "amount": c.amount, "nodes": c.nodes,
|
||||||
"test": c.test_node.is_some(), "testNode": c.test_node, "expiresHours": EXPIRES_HOURS,
|
"test": c.test_node.is_some(), "testNode": c.test_node, "expiresHours": EXPIRES_HOURS,
|
||||||
}),
|
}),
|
||||||
_ => serde_json::Value::Null,
|
_ => 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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ pub struct Publish {
|
|||||||
/// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it
|
/// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it
|
||||||
/// (ADR 0011).
|
/// (ADR 0011).
|
||||||
pub without_articles: bool,
|
pub without_articles: bool,
|
||||||
|
/// Publish a document even while a text, an order, a title or a level of inertia
|
||||||
|
/// in effect entered by simulated votes does not hold on the proven votes alone
|
||||||
|
/// (`LD_PUBLISH_SIMULATED`). Off — Yvv, 2026-11-03: « Pas avant des votes
|
||||||
|
/// prouvés »: in force on the site, never published before the proven votes carry
|
||||||
|
/// it (ADR 0012, `publish::waits_for_proven_votes`).
|
||||||
|
pub simulated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
|
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
|
||||||
@@ -78,6 +84,10 @@ pub struct Config {
|
|||||||
pub build: String,
|
pub build: String,
|
||||||
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
|
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
|
||||||
pub vote: Option<VoteChain>,
|
pub vote: Option<VoteChain>,
|
||||||
|
/// Whether a simulated vote counts for adoption (ADR 0012):
|
||||||
|
/// `ld_engine::buffer::SIMULATED_ADOPTS` — Yvv, 2026-11-02: « Il compte, puis
|
||||||
|
/// s'efface »: it counts until the act « supprime » sets it aside.
|
||||||
|
pub simulated_adopts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn var(name: &str) -> Option<String> {
|
fn var(name: &str) -> Option<String> {
|
||||||
@@ -116,6 +126,7 @@ impl Config {
|
|||||||
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
|
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
|
||||||
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
|
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
|
||||||
without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"),
|
without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"),
|
||||||
|
simulated: flag("LD_PUBLISH_SIMULATED"),
|
||||||
});
|
});
|
||||||
let dev_login = flag("LD_DEV_LOGIN");
|
let dev_login = flag("LD_DEV_LOGIN");
|
||||||
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
|
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
|
||||||
@@ -158,6 +169,7 @@ impl Config {
|
|||||||
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
|
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
|
||||||
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
|
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
|
||||||
vote,
|
vote,
|
||||||
|
simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS,
|
||||||
data_dir,
|
data_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +201,7 @@ impl Config {
|
|||||||
wot_fallback: 5745,
|
wot_fallback: 5745,
|
||||||
build: "test".into(),
|
build: "test".into(),
|
||||||
vote: None,
|
vote: None,
|
||||||
|
simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-23
@@ -13,6 +13,7 @@ use crate::util::{self, LangMap};
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use ld_engine::ballot::Choice;
|
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::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
|
||||||
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
|
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
|
||||||
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
|
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
|
||||||
@@ -302,7 +303,21 @@ pub struct DocState {
|
|||||||
/// The units that are modules (ADR 0009), by unit id; any other is an article.
|
/// The units that are modules (ADR 0009), by unit id; any other is an article.
|
||||||
pub module_kinds: HashMap<String, ModuleKind>,
|
pub module_kinds: HashMap<String, ModuleKind>,
|
||||||
pub proposals: Vec<ProposalRow>,
|
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>,
|
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
|
/// Versions held back by a standing objection: against or a withdrawal only, never
|
||||||
/// a new *for* (`admits`), and never entering into force.
|
/// a new *for* (`admits`), and never entering into force.
|
||||||
pub blocked: HashSet<String>,
|
pub blocked: HashSet<String>,
|
||||||
@@ -371,30 +386,70 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
|||||||
struct TallyRow {
|
struct TallyRow {
|
||||||
proposal_id: String,
|
proposal_id: String,
|
||||||
choice: String,
|
choice: String,
|
||||||
|
source: String,
|
||||||
n: i64,
|
n: i64,
|
||||||
}
|
}
|
||||||
let rows = sqlx::query_as::<_, TallyRow>(
|
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
|
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)
|
.bind(doc_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
let mut tallies: HashMap<String, Tally> = HashMap::new();
|
let rules = state.vote_rules();
|
||||||
for r in rows {
|
let add = |map: &mut HashMap<String, Tally>, id: &str, choice: &str, n: u32| {
|
||||||
let t = tallies.entry(r.proposal_id).or_default();
|
let t = map.entry(id.to_owned()).or_default();
|
||||||
let n = u32::try_from(r.n).unwrap_or(0);
|
match choice {
|
||||||
match r.choice.as_str() {
|
"for" => t.for_votes = t.for_votes.saturating_add(n),
|
||||||
"for" => t.for_votes = n,
|
"against" => t.against = t.against.saturating_add(n),
|
||||||
"against" => t.against = 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
|
// 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).
|
// 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?;
|
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 (electorate, electorate_source) = electorate(state, &group).await?;
|
||||||
let measure = participation::stored(state, &group.slug).await?;
|
let measure = participation::stored(state, &group.slug).await?;
|
||||||
let participation = Participation::of(
|
let participation = Participation::of(
|
||||||
@@ -411,6 +466,11 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
|||||||
module_kinds,
|
module_kinds,
|
||||||
proposals,
|
proposals,
|
||||||
tallies,
|
tallies,
|
||||||
|
recorded,
|
||||||
|
shown,
|
||||||
|
pending,
|
||||||
|
simulated,
|
||||||
|
rules,
|
||||||
blocked,
|
blocked,
|
||||||
signal_counts,
|
signal_counts,
|
||||||
electorate,
|
electorate,
|
||||||
@@ -451,13 +511,34 @@ impl DocState {
|
|||||||
self.tallies.get(id).copied().unwrap_or_default()
|
self.tallies.get(id).copied().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The totals shown to people: today the live count, as `tally`. The one
|
/// The totals shown to people: the live count, intentions included (ADR
|
||||||
/// place where a publication by steps would plug in — on the chain, who
|
/// 0012: « je vois ce que j'ai fait sans attendre »). The one place where a
|
||||||
/// voted and when is public, and live totals tell what (the secret of the
|
/// publication by steps would plug in — on the chain, who voted and when is
|
||||||
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
|
/// public, and live totals tell what (the secret of the vote, review of
|
||||||
/// `tally`; the published texts (`publish.rs`) would follow this one.
|
/// 74f96be). Adoption reads `tally`, never an intention.
|
||||||
pub fn shown_tally(&self, id: &str) -> Tally {
|
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`.
|
/// How far the threshold is, as shown: on `shown_tally`.
|
||||||
@@ -709,6 +790,7 @@ fn proposal_json(
|
|||||||
me: &str,
|
me: &str,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let (mine, ballot) = mine;
|
let (mine, ballot) = mine;
|
||||||
|
let seen = ballots::seen(p.revision, mine, ballot);
|
||||||
let eval = st.shown_evaluation(unit, p);
|
let eval = st.shown_evaluation(unit, p);
|
||||||
let (kind, target) = match p.kind() {
|
let (kind, target) = match p.kind() {
|
||||||
// A module's version says what it holds (ADR 0009).
|
// A module's version says what it holds (ADR 0009).
|
||||||
@@ -729,14 +811,17 @@ fn proposal_json(
|
|||||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||||
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
|
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
|
||||||
"tally": st.shown_tally(&p.id),
|
"tally": st.shown_tally(&p.id),
|
||||||
|
"tallyPending": st.pending_tally(&p.id),
|
||||||
|
"tallySimulated": st.simulated_tally(&p.id),
|
||||||
"evaluation": eval,
|
"evaluation": eval,
|
||||||
"votedAtInertia": voted_at,
|
"votedAtInertia": voted_at,
|
||||||
"blocked": st.blocked.contains(&p.id),
|
"blocked": st.blocked.contains(&p.id),
|
||||||
"votable": st.votable(p),
|
"votable": st.votable(p),
|
||||||
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||||
"myVote": mine.map(|v| v.choice.as_str()),
|
"myVote": seen.choice,
|
||||||
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
|
"myVoteState": seen.state,
|
||||||
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
|
"myVoteStale": seen.stale,
|
||||||
|
"myVoteChain": seen.chain,
|
||||||
"myBallot": ballot,
|
"myBallot": ballot,
|
||||||
"translations": translations.cloned().unwrap_or_default(),
|
"translations": translations.cloned().unwrap_or_default(),
|
||||||
})
|
})
|
||||||
@@ -873,15 +958,17 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<ser
|
|||||||
.bind(doc_id)
|
.bind(doc_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
// Waiting, not failing: no article in force, nothing goes out (ADR 0011).
|
// Waiting, not failing: no article in force (ADR 0011), or something in effect that
|
||||||
let suspended = match &last {
|
// does not hold on the proven votes yet (ADR 0012) — nothing goes out.
|
||||||
Some(r) if r.done_at.is_none() => crate::publish::waits_for_a_text(state, doc_id).await?,
|
let waits = match &last {
|
||||||
_ => false,
|
Some(r) if r.done_at.is_none() => crate::publish::waits(state, doc_id).await?,
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
Ok(match last {
|
Ok(match last {
|
||||||
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
|
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
|
||||||
Some(r) => json!({
|
Some(r) => json!({
|
||||||
"state": if r.done_at.is_some() { "done" } else if suspended { "suspended" } else { "pending" },
|
"state": if r.done_at.is_some() { "done" } else if waits.is_some() { "suspended" } else { "pending" },
|
||||||
|
"waitsFor": waits.map(crate::publish::Wait::as_str),
|
||||||
"configured": state.cfg.publish.is_some(),
|
"configured": state.cfg.publish.is_some(),
|
||||||
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
|
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
|
||||||
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
|
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
//! and runs four background workers: the electorate refresher (size of the Ğ1
|
//! 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
|
//! 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,
|
//! 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).
|
//! repository that client applications read).
|
||||||
|
|
||||||
pub mod access;
|
pub mod access;
|
||||||
@@ -33,6 +34,7 @@ pub mod quorum_amend;
|
|||||||
pub mod seed;
|
pub mod seed;
|
||||||
pub mod seed_amend;
|
pub mod seed_amend;
|
||||||
pub mod seed_proposal_amend;
|
pub mod seed_proposal_amend;
|
||||||
|
pub mod simulation;
|
||||||
pub mod team;
|
pub mod team;
|
||||||
pub mod typos;
|
pub mod typos;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
@@ -49,6 +51,7 @@ use sqlx::SqlitePool;
|
|||||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
@@ -65,6 +68,9 @@ pub struct AppState {
|
|||||||
pub upcoming: Arc<overview::Upcoming>,
|
pub upcoming: Arc<overview::Upcoming>,
|
||||||
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
|
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
|
||||||
pub clock: clock::Clock,
|
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 {
|
impl AppState {
|
||||||
@@ -81,8 +87,20 @@ impl AppState {
|
|||||||
publish_wake: Arc::new(Notify::new()),
|
publish_wake: Arc::new(Notify::new()),
|
||||||
upcoming,
|
upcoming,
|
||||||
clock: clock::Clock::default(),
|
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.
|
/// Open (or create) the database and apply migrations.
|
||||||
@@ -162,6 +180,7 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/messages/read", post(messages::read_all))
|
.route("/messages/read", post(messages::read_all))
|
||||||
.route("/messages/{id}/read", post(messages::read))
|
.route("/messages/{id}/read", post(messages::read))
|
||||||
.route("/dev/clock", post(deadlines::dev_clock))
|
.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}/inertia", post(proposals::propose_inertia))
|
||||||
.route("/units/{id}/quorum", put(quorum::set))
|
.route("/units/{id}/quorum", put(quorum::set))
|
||||||
.route("/units/{id}/order", post(order::propose))
|
.route("/units/{id}/order", post(order::propose))
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
//! libreDecision server — entry point. Everything else lives in the library.
|
//! 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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
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
|
//! 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.
|
//! 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,
|
//! gestures that subject asks of its recipient now — lift or reiterate,
|
||||||
//! validate or refuse — and its deadline. A message dealt with offers no
|
//! 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
|
//! gesture any more. Its text is told by the interface, in the language of the
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::auth::{IdentityRow, User};
|
use crate::auth::{IdentityRow, User};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::{docs, objections, typos, util};
|
use crate::{ballots, docs, objections, typos, util};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -24,6 +25,9 @@ const SHOWN: i64 = 100;
|
|||||||
pub enum Topic {
|
pub enum Topic {
|
||||||
Objection,
|
Objection,
|
||||||
Typo,
|
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 {
|
impl Topic {
|
||||||
@@ -31,6 +35,7 @@ impl Topic {
|
|||||||
match self {
|
match self {
|
||||||
Self::Objection => "objection",
|
Self::Objection => "objection",
|
||||||
Self::Typo => "typo",
|
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),
|
Some((v, g)) => (json!({ "typo": v }), g),
|
||||||
None => return Ok(None),
|
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),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
let mut out = json!({
|
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,
|
"SELECT p.id AS id, u.id AS unit_id,
|
||||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
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,
|
(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
|
WHERE v.proposal_id = p.id AND v.revision = p.revision
|
||||||
AND (v.identity_id = ?1 OR v.ballot_id IN
|
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
|
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')
|
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",
|
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 units u ON u.id = p.unit_id
|
||||||
JOIN documents d ON d.id = u.doc_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
|
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
|
GROUP BY v.proposal_id
|
||||||
HAVING MAX(v.cast_at) >= ?",
|
HAVING MAX(v.cast_at) >= ?",
|
||||||
)
|
)
|
||||||
.bind(group)
|
.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)
|
.bind(since)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -588,6 +588,7 @@ pub async fn detail(
|
|||||||
|
|
||||||
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
|
let my_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 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()) {
|
let diff = match (&in_force, p.kind.as_str()) {
|
||||||
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -604,11 +605,11 @@ pub async fn detail(
|
|||||||
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
||||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||||
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
|
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
|
||||||
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
"tally": st.shown_tally(&p.id), "tallyPending": st.pending_tally(&p.id),
|
||||||
|
"tallySimulated": st.simulated_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
||||||
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
||||||
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
"myVote": seen.choice, "myVoteState": seen.state, "myVoteStale": seen.stale,
|
||||||
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
|
"myVoteChain": seen.chain, "myBallot": my_ballot,
|
||||||
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
|
|
||||||
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
|
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
|
||||||
},
|
},
|
||||||
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
|
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
|
||||||
@@ -635,8 +636,9 @@ pub struct VoteBody {
|
|||||||
choice: Option<String>,
|
choice: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One vote on the server, in a managed group. Where the vote goes through the
|
/// One vote written by the server: in a managed group, or in simulation while
|
||||||
/// chain (Monnaie libre), it is a ballot (`ballots::create`), never this.
|
/// 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(
|
pub async fn vote(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
User(user): User,
|
User(user): User,
|
||||||
@@ -644,9 +646,7 @@ pub async fn vote(
|
|||||||
Json(b): Json<VoteBody>,
|
Json(b): Json<VoteBody>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let (p, st) = guarded(&state, &user, &id).await?;
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
if trustin_access::vote_way(&st.group.to_group()) == trustin_access::VoteWay::Chain {
|
let way = ballots::server_way(&state, &st.group)?;
|
||||||
return Err(AppError::Conflict("chain_only"));
|
|
||||||
}
|
|
||||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
return Err(AppError::Conflict("not_votable"));
|
return Err(AppError::Conflict("not_votable"));
|
||||||
}
|
}
|
||||||
@@ -661,13 +661,12 @@ pub async fn vote(
|
|||||||
if !st.admits(&p, choice) {
|
if !st.admits(&p, choice) {
|
||||||
return Err(AppError::Conflict("objection"));
|
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"));
|
return Err(AppError::Conflict("members_only"));
|
||||||
}
|
}
|
||||||
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
|
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
|
||||||
ballots::write_server_votes(&state, &user.id, &[cast]).await?;
|
let entered = ballots::write(&state, &user, &st, way, &[cast]).await?;
|
||||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
Ok(Json(json!({ "entered": entered, "simulated": way == ballots::ServerWay::Simulated })))
|
||||||
Ok(Json(json!({ "entered": entered })))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -83,18 +83,29 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> {
|
|||||||
let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect();
|
let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect();
|
||||||
let last = rows.iter().map(|d| d.id).max().unwrap_or(0);
|
let last = rows.iter().map(|d| d.id).max().unwrap_or(0);
|
||||||
let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0);
|
let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0);
|
||||||
if waits_for_a_text(state, &doc_id).await? {
|
if let Some(wait) = waits(state, &doc_id).await? {
|
||||||
// Not a failure: no attempt counted, no error. The queue stays; the first
|
// Not a failure: no attempt counted, no error. The queue stays; the first
|
||||||
// text that enters queues a row due now, and everything goes out with it.
|
// text that enters queues a row due now, and everything goes out with it —
|
||||||
|
// or, waiting for proven votes, it is asked again a while later.
|
||||||
|
let again = match wait {
|
||||||
|
Wait::Text => MAX_BACKOFF_SECS,
|
||||||
|
Wait::Proven => PROVEN_AGAIN_SECS,
|
||||||
|
};
|
||||||
sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL")
|
sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL")
|
||||||
.bind(util::in_seconds(MAX_BACKOFF_SECS))
|
.bind(util::in_seconds(again))
|
||||||
.bind(&doc_id)
|
.bind(&doc_id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
tracing::info!(
|
match wait {
|
||||||
doc = %doc_id, waiting = rows.len(),
|
Wait::Text => tracing::info!(
|
||||||
"publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)"
|
doc = %doc_id, waiting = rows.len(),
|
||||||
);
|
"publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)"
|
||||||
|
),
|
||||||
|
Wait::Proven => tracing::info!(
|
||||||
|
doc = %doc_id, waiting = rows.len(),
|
||||||
|
"publication suspended: something in effect entered by simulated votes does not hold on the proven votes (LD_PUBLISH_SIMULATED=1 publishes anyway)"
|
||||||
|
),
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match publish_doc(state, &doc_id).await {
|
match publish_doc(state, &doc_id).await {
|
||||||
@@ -123,6 +134,58 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a document waits before it is published.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Wait {
|
||||||
|
/// None of its articles is in force (ADR 0011).
|
||||||
|
Text,
|
||||||
|
/// Something in effect entered by simulated votes and does not hold on the
|
||||||
|
/// proven votes alone (ADR 0012).
|
||||||
|
Proven,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Wait {
|
||||||
|
/// As the API says it (`publication.waitsFor`).
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Text => "text",
|
||||||
|
Self::Proven => "proven",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waiting for proven votes, the queue is asked again this often: a proven vote that
|
||||||
|
/// carries an entry already in force queues nothing by itself.
|
||||||
|
const PROVEN_AGAIN_SECS: i64 = 600;
|
||||||
|
|
||||||
|
/// Does the document wait before it is published, and for what: a first text
|
||||||
|
/// (`waits_for_a_text`), then proven votes (`waits_for_proven_votes`).
|
||||||
|
pub async fn waits(state: &AppState, doc_id: &str) -> anyhow::Result<Option<Wait>> {
|
||||||
|
if waits_for_a_text(state, doc_id).await? {
|
||||||
|
return Ok(Some(Wait::Text));
|
||||||
|
}
|
||||||
|
if waits_for_proven_votes(state, doc_id).await? {
|
||||||
|
return Ok(Some(Wait::Proven));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the document wait for proven votes before it is published? Yvv, 2026-11-03:
|
||||||
|
/// « Pas avant des votes prouvés ». A text — an order, a title, a level of inertia, a
|
||||||
|
/// removal — that entered by simulated votes is in force on the site, and sends
|
||||||
|
/// nothing to the repository of the texts while anything in effect that entered by
|
||||||
|
/// simulated votes does not hold on the proven votes alone, by the re-examination of
|
||||||
|
/// the act « supprime » (`simulation::unproven`, told only). The chain being closed,
|
||||||
|
/// nothing goes out of Monnaie libre before it opens. The one place of this rule;
|
||||||
|
/// `LD_PUBLISH_SIMULATED=1` publishes anyway (`Publish::simulated`).
|
||||||
|
pub async fn waits_for_proven_votes(state: &AppState, doc_id: &str) -> anyhow::Result<bool> {
|
||||||
|
if state.cfg.publish.as_ref().is_none_or(|p| p.simulated) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let unproven = crate::simulation::unproven(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
Ok(!unproven.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Does the document wait for a text before it is published? Yes when none of its
|
/// Does the document wait for a text before it is published? Yes when none of its
|
||||||
/// articles is in force — the seed proposals are not (ADR 0011) — unless the
|
/// articles is in force — the seed proposals are not (ADR 0011) — unless the
|
||||||
/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default is
|
/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default is
|
||||||
@@ -421,7 +484,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
|||||||
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let tally = st.shown_tally(&p.id);
|
let tally = st.recorded_tally(&p.id);
|
||||||
json!({
|
json!({
|
||||||
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
||||||
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
||||||
@@ -440,7 +503,7 @@ async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_j
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
let tally = st.shown_tally(&p.id);
|
let tally = st.recorded_tally(&p.id);
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
||||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||||
|
|||||||
@@ -27,21 +27,32 @@ use std::collections::{HashMap, HashSet};
|
|||||||
///
|
///
|
||||||
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
||||||
/// of its inertia; any revision) holds
|
/// of its inertia; any revision) holds
|
||||||
/// - a vote of the server (`votes`: a vote of a managed group, or the line of a ballot
|
/// - a vote that counts (`votes`): a vote of a managed group, a vote proven by the
|
||||||
/// of the chain once confirmed, written there by the confirmation);
|
/// chain, a **simulated** vote (ADR 0012);
|
||||||
/// - a line of a ballot of the chain, **even pending** — signed, not yet read on the
|
/// - a line of a ballot — the buffer of the votes keeps their history (ADR 0012) —
|
||||||
/// chain (Yvv, 2026-11-03) — or confirmed; a refused or expired ballot is no vote;
|
/// of the chain or of the simulation: **an intention still pending** (signed, not yet
|
||||||
|
/// read on the chain: Yvv, 2026-11-03), or a ballot proven, **whatever became of its
|
||||||
|
/// line**: the voice that counts, a vote overtaken by a more recent one, a *for* set
|
||||||
|
/// aside by an objection, **a withdrawal** (`none`). A vote withdrawn on the chain or
|
||||||
|
/// in simulation, before any adoption, thus still says that someone voted. A ballot
|
||||||
|
/// the wallet never signed (let go at once), never seen on the chain in time, or
|
||||||
|
/// refused by the chain, is no vote;
|
||||||
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
||||||
/// its votes are withdrawn.
|
/// its votes are withdrawn.
|
||||||
///
|
///
|
||||||
/// A vote of the server withdrawn before any adoption leaves no row (`votes` keeps the
|
/// Two limits, known. In a managed group there is no buffer: a vote of the server
|
||||||
/// last choice only): from then on, no vote exists (a known limit, ADR 0013).
|
/// withdrawn before any adoption leaves no row (`votes` keeps the last choice only),
|
||||||
|
/// and from then on no vote exists (ADR 0013). The act « supprime » that ends the
|
||||||
|
/// simulation (`simulation::discard`) takes the simulated ballots away with their
|
||||||
|
/// trace: an article voted in simulation only has no vote any more, unless an adoption
|
||||||
|
/// that holds on the proven votes stays.
|
||||||
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
||||||
let ids: Vec<String> = sqlx::query_scalar(
|
let ids: Vec<String> = sqlx::query_scalar(
|
||||||
"SELECT p.unit_id FROM votes v
|
"SELECT p.unit_id FROM votes v
|
||||||
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
||||||
WHERE u.doc_id = ?1
|
WHERE u.doc_id = ?1
|
||||||
UNION
|
UNION
|
||||||
|
-- Every ballot of the buffer, chain or simulated, pending or proven: its history.
|
||||||
SELECT p.unit_id FROM ballot_lines l
|
SELECT p.unit_id FROM ballot_lines l
|
||||||
JOIN ballots b ON b.id = l.ballot_id
|
JOIN ballots b ON b.id = l.ballot_id
|
||||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! The end of the simulation (ADR 0012).
|
||||||
|
//!
|
||||||
|
//! Yvv, 2026-11-02: « Il compte, puis s'efface ». A simulated vote counts like a
|
||||||
|
//! proven one, marked as simulated, until an act « supprime » of Yvv sets every
|
||||||
|
//! one of them aside; what they brought into force is then examined again, with
|
||||||
|
//! the proven votes only. Opening the chain does none of this by itself: the act
|
||||||
|
//! calls `discard` — `ld-server discard-simulation` tells what it would do,
|
||||||
|
//! `ld-server discard-simulation --apply` does it. Never run on its own.
|
||||||
|
//!
|
||||||
|
//! - **The sort**: the simulated ballots, their lines, their votes and the
|
||||||
|
//! messages about them are deleted; each identity that voted in simulation is
|
||||||
|
//! reconciled again, so that a proven vote of its own, if any, stands.
|
||||||
|
//! - **The re-examination**: every entry into force that counted simulated votes
|
||||||
|
//! (`adoptions.params.simulated`), the most recent first, still in effect, is
|
||||||
|
//! judged again on the proven votes alone, at the level it was voted at. Below
|
||||||
|
//! its threshold, it **returns among the proposals** and the version it had
|
||||||
|
//! displaced comes back into force (a removed text comes back, a level of
|
||||||
|
//! inertia goes back, the order and the title of before apply again); its
|
||||||
|
//! adoption is void. At or above, it stays, and its adoption says so
|
||||||
|
//! (`params.reexamined`): the act run again examines it no more.
|
||||||
|
//!
|
||||||
|
//! Told only (no `--apply`), the re-examination sets the simulated votes apart
|
||||||
|
//! without removing them: an entry that would return only once a more recent one
|
||||||
|
//! returned before it is told as no longer in effect.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::adoption;
|
||||||
|
use crate::ballots;
|
||||||
|
use crate::docs::{self, DocState, ProposalRow, UnitRow};
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::modules;
|
||||||
|
use crate::proposals;
|
||||||
|
use crate::util;
|
||||||
|
use ld_engine::inertia::InertiaLevel;
|
||||||
|
use ld_engine::threshold::Tally;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// One entry into force that counted simulated votes, judged again.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Reexamined {
|
||||||
|
pub doc_id: String,
|
||||||
|
pub proposal_id: String,
|
||||||
|
pub unit_id: String,
|
||||||
|
pub code: String,
|
||||||
|
/// `text`, `remove`, `inertia`, `order` or `title`.
|
||||||
|
pub kind: String,
|
||||||
|
pub at: String,
|
||||||
|
/// The votes it entered with, and the simulated share of them.
|
||||||
|
pub entered_with: Tally,
|
||||||
|
pub simulated: Tally,
|
||||||
|
/// The proven votes it holds now, and the *for* votes its level asks at them.
|
||||||
|
pub proven: Tally,
|
||||||
|
pub required: u32,
|
||||||
|
/// Still in force (or still applied): something else may have taken its place since.
|
||||||
|
pub in_effect: bool,
|
||||||
|
/// It holds on the proven votes alone: it stays.
|
||||||
|
pub holds: bool,
|
||||||
|
/// Returned among the proposals (`--apply` only).
|
||||||
|
pub returned: bool,
|
||||||
|
/// The version it had displaced, back in force when it returns.
|
||||||
|
pub displaced: Option<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.
|
||||||
|
for (a, params, simulated) in to_examine(state, None).await? {
|
||||||
|
if let Some(r) = reexamine(state, &a, ¶ms, simulated, apply).await? {
|
||||||
|
out.reexamined.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apply {
|
||||||
|
// What waited for the proven votes may go out now (`publish::waits_for_proven_votes`).
|
||||||
|
sqlx::query("UPDATE outbox SET next_at = ? WHERE done_at IS NULL").bind(util::now()).execute(&state.db).await?;
|
||||||
|
state.publish_wake.notify_one();
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entries into force still to examine, the most recent first: those that
|
||||||
|
/// counted simulated votes, not yet examined by an act — of one document, or all.
|
||||||
|
async fn to_examine(state: &AppState, doc_id: Option<&str>) -> AppResult<Vec<(AdoptionRow, Value, Tally)>> {
|
||||||
|
let adoptions = sqlx::query_as::<_, AdoptionRow>(
|
||||||
|
"SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions
|
||||||
|
WHERE ?1 IS NULL OR doc_id = ?1
|
||||||
|
ORDER BY at DESC, id DESC",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(adoptions
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|a| {
|
||||||
|
let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null);
|
||||||
|
let simulated = tally_of(¶ms["simulated"]);
|
||||||
|
// Without simulated votes, or examined by an act already: nothing to examine.
|
||||||
|
(simulated.cast() > 0 && params["reexamined"].is_null()).then_some((a, params, simulated))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What, in effect in a document, entered by simulated votes and does not hold on
|
||||||
|
/// the proven votes alone: the re-examination of the act, told only — nothing is
|
||||||
|
/// written. Empty: everything in effect holds on the proven votes. The publication
|
||||||
|
/// waits on it (Yvv, 2026-11-03: « Pas avant des votes prouvés »,
|
||||||
|
/// `publish::waits_for_proven_votes`).
|
||||||
|
pub async fn unproven(state: &AppState, doc_id: &str) -> AppResult<Vec<Reexamined>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (a, params, simulated) in to_examine(state, Some(doc_id)).await? {
|
||||||
|
if let Some(r) = reexamine(state, &a, ¶ms, simulated, false).await?
|
||||||
|
&& r.in_effect
|
||||||
|
&& !r.holds
|
||||||
|
{
|
||||||
|
out.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reexamine(
|
||||||
|
state: &AppState,
|
||||||
|
a: &AdoptionRow,
|
||||||
|
params: &Value,
|
||||||
|
simulated: Tally,
|
||||||
|
apply: bool,
|
||||||
|
) -> AppResult<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)) };
|
||||||
|
// At the level and on the quorum it was voted at (ADR 0013; an adoption of before
|
||||||
|
// holds the C of before, `constant`, which is no quorum: the article's quorum now).
|
||||||
|
let level =
|
||||||
|
serde_json::from_value::<InertiaLevel>(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p));
|
||||||
|
let quorum = params["quorum"].as_u64().and_then(|q| u32::try_from(q).ok()).unwrap_or_else(|| st.quorum_of(unit));
|
||||||
|
let eval = st.scale().evaluate(proven, level, quorum).ok();
|
||||||
|
let holds = eval.is_some_and(|e| e.reached);
|
||||||
|
let displaced = params["displaced"].as_str().map(str::to_owned);
|
||||||
|
let mut r = Reexamined {
|
||||||
|
doc_id: a.doc_id.clone(),
|
||||||
|
proposal_id: p.id.clone(),
|
||||||
|
unit_id: unit.id.clone(),
|
||||||
|
code: unit.code.clone(),
|
||||||
|
kind: a.kind.clone(),
|
||||||
|
at: a.at.clone(),
|
||||||
|
entered_with: Tally {
|
||||||
|
for_votes: u32::try_from(a.votes_for).unwrap_or(0),
|
||||||
|
against: u32::try_from(a.votes_against).unwrap_or(0),
|
||||||
|
},
|
||||||
|
simulated,
|
||||||
|
proven,
|
||||||
|
required: eval.map_or(0, |e| e.required),
|
||||||
|
in_effect,
|
||||||
|
holds,
|
||||||
|
returned: false,
|
||||||
|
displaced: displaced.clone(),
|
||||||
|
};
|
||||||
|
if apply && in_effect && !holds {
|
||||||
|
give_back(state, &st, unit, &p, a, params, displaced.as_deref()).await?;
|
||||||
|
r.returned = true;
|
||||||
|
} else if apply {
|
||||||
|
// It stays (or something else took its place since): examined once, never again.
|
||||||
|
let mut kept = params.clone();
|
||||||
|
kept["reexamined"] = serde_json::json!({ "at": util::now(), "proven": r.proven, "required": r.required });
|
||||||
|
sqlx::query("UPDATE adoptions SET params = ? WHERE id = ?")
|
||||||
|
.bind(kept.to_string())
|
||||||
|
.bind(a.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(Some(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An entry that does not hold on the proven votes: the version returns among the
|
||||||
|
/// proposals, the one it displaced comes back, and its adoption is void.
|
||||||
|
async fn give_back(
|
||||||
|
state: &AppState,
|
||||||
|
st: &DocState,
|
||||||
|
unit: &UnitRow,
|
||||||
|
p: &ProposalRow,
|
||||||
|
a: &AdoptionRow,
|
||||||
|
params: &Value,
|
||||||
|
displaced: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let now = util::now();
|
||||||
|
let back = match displaced {
|
||||||
|
Some(d) => proposals::load_proposal(state, d).await.ok().filter(|v| v.status == "open"),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'open', entered_at = NULL, left_at = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&p.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if a.kind == "inertia" {
|
||||||
|
let from = serde_json::from_value::<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(())
|
||||||
|
}
|
||||||
@@ -730,7 +730,7 @@ async fn a_team_link_makes_one_identity_per_opening() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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;
|
let (mut admin, _) = Client::new().await;
|
||||||
admin.login("Yvv", true).await;
|
admin.login("Yvv", true).await;
|
||||||
let (_, v) = admin
|
let (_, v) = admin
|
||||||
@@ -740,17 +740,21 @@ async fn in_monnaie_libre_an_invitation_never_votes_on_the_server() {
|
|||||||
let mut invited = admin.with(None);
|
let mut invited = admin.with(None);
|
||||||
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||||
assert_eq!(s, StatusCode::OK);
|
assert_eq!(s, StatusCode::OK);
|
||||||
// She reads the text and may propose; her vote would go through the chain, for the Ğ1
|
// She reads the text and may propose; her vote goes through the chain, for the Ğ1
|
||||||
// identity of her wallet (tests/ballots.rs) — off here, without a vote account.
|
// 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 id = doc_id(&mut invited, "monnaie-libre").await;
|
||||||
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
assert_eq!(s, StatusCode::OK);
|
assert_eq!(s, StatusCode::OK);
|
||||||
assert_eq!(doc["canVote"], false);
|
assert_eq!(doc["canVote"], true);
|
||||||
assert_eq!((doc["vote"]["way"].as_str(), doc["vote"]["reason"].as_str()), (Some("chain"), Some("chain_off")));
|
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 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;
|
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
assert_eq!(v["error"], "chain_only");
|
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.
|
// In the test group, every member votes.
|
||||||
let essai = doc_id(&mut admin, "essai").await;
|
let essai = doc_id(&mut admin, "essai").await;
|
||||||
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
|
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||||
@@ -794,6 +798,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
|
|||||||
author_name: "libreDecision".into(),
|
author_name: "libreDecision".into(),
|
||||||
author_email: "decision@example.org".into(),
|
author_email: "decision@example.org".into(),
|
||||||
without_articles: false,
|
without_articles: false,
|
||||||
|
simulated: false,
|
||||||
});
|
});
|
||||||
let db = open_db("sqlite::memory:").await.expect("db");
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
let state = AppState::new(cfg, db).await.expect("state");
|
let state = AppState::new(cfg, db).await.expect("state");
|
||||||
@@ -868,6 +873,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
|
|||||||
author_name: "libreDecision".into(),
|
author_name: "libreDecision".into(),
|
||||||
author_email: "decision@example.org".into(),
|
author_email: "decision@example.org".into(),
|
||||||
without_articles: false,
|
without_articles: false,
|
||||||
|
simulated: false,
|
||||||
});
|
});
|
||||||
cfg
|
cfg
|
||||||
};
|
};
|
||||||
@@ -1063,6 +1069,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
|
|||||||
author_name: "libreDecision".into(),
|
author_name: "libreDecision".into(),
|
||||||
author_email: "decision@example.org".into(),
|
author_email: "decision@example.org".into(),
|
||||||
without_articles: false,
|
without_articles: false,
|
||||||
|
simulated: false,
|
||||||
});
|
});
|
||||||
let git = |args: &[&str]| {
|
let git = |args: &[&str]| {
|
||||||
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
||||||
|
|||||||
@@ -284,6 +284,23 @@ async fn two_voices(state: &AppState, group: &str, code: &str) {
|
|||||||
assert_eq!(n, 1, "{group} {code}");
|
assert_eq!(n, 1, "{group} {code}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A web of two members and the quorum 2 at every level of a document, for the test
|
||||||
|
/// only (ADR 0013): two voices pass, one never does.
|
||||||
|
async fn small_web(state: &AppState, doc_id: &str) {
|
||||||
|
sqlx::query("UPDATE documents SET vote_floor = 1, quorums = '[2,2,2,2,2]' WHERE id = ?")
|
||||||
|
.bind(doc_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("quorums");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES ('wot_size', '2', '2026-11-02T00:00:00Z')
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = '2'",
|
||||||
|
)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("web");
|
||||||
|
}
|
||||||
|
|
||||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||||
}
|
}
|
||||||
@@ -307,10 +324,43 @@ fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn tally(doc: &Value, id: &str) -> (u64, u64) {
|
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"))
|
(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) {
|
async fn rewind_check(state: &AppState) {
|
||||||
// The page asks often, the indexer is read at most every few seconds: tests rewind that clock.
|
// 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");
|
sqlx::query("UPDATE ballots SET checked_at = NULL").execute(&state.db).await.expect("rewind");
|
||||||
@@ -355,10 +405,14 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
|
|||||||
];
|
];
|
||||||
assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment);
|
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");
|
assert_eq!(c.status(&b).await["status"], "pending");
|
||||||
let doc = c.doc("monnaie-libre").await;
|
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"]["status"], "pending");
|
||||||
assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for");
|
assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for");
|
||||||
|
|
||||||
@@ -370,10 +424,11 @@ async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
|
|||||||
assert_eq!(s["voter"], "Alice");
|
assert_eq!(s["voter"], "Alice");
|
||||||
assert_eq!(s["block"], 3_400_000);
|
assert_eq!(s["block"], 3_400_000);
|
||||||
let doc = c.doc("monnaie-libre").await;
|
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!(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.
|
// 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_eq!(find(&doc, &i1)["myVoteChain"], true);
|
||||||
assert!(find(&doc, &i1)["myBallot"].is_null());
|
assert!(find(&doc, &i1)["myBallot"].is_null());
|
||||||
assert_eq!(find(&doc, &e3)["myVote"], "against");
|
assert_eq!(find(&doc, &e3)["myVote"], "against");
|
||||||
@@ -487,6 +542,17 @@ async fn a_signer_who_is_not_a_member_is_refused_and_told() {
|
|||||||
.await
|
.await
|
||||||
.expect("n");
|
.expect("n");
|
||||||
assert_eq!(n, 0, "no identity is created for a refused ballot");
|
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]
|
#[tokio::test]
|
||||||
@@ -512,7 +578,10 @@ async fn a_wrong_amount_or_recipient_is_refused() {
|
|||||||
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
|
||||||
}
|
}
|
||||||
assert_eq!(c.status(&gecko).await["status"], "pending");
|
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]
|
#[tokio::test]
|
||||||
@@ -747,8 +816,22 @@ async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds
|
|||||||
let needed = u8::try_from(needed).expect("n");
|
let needed = u8::try_from(needed).expect("n");
|
||||||
let keys: Vec<String> = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
let keys: Vec<String> = (1..=needed).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
|
||||||
let mut last = Value::Null;
|
let mut last = Value::Null;
|
||||||
|
let mut luna = c.other();
|
||||||
|
luna.login("Luna").await;
|
||||||
for (n, account) in keys.iter().enumerate() {
|
for (n, account) in keys.iter().enumerate() {
|
||||||
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
|
// The last voice comes from another session: its intention is seen at once.
|
||||||
|
let last_one = n + 1 == keys.len();
|
||||||
|
let asker = if last_one { &mut luna } else { &mut c };
|
||||||
|
let b = asker.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
|
||||||
|
if last_one {
|
||||||
|
// Thirty-nine voices shown, thirty-eight proven: nothing enters before the proof.
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &e4), pending(&d, &e4)), ((39, 0), (1, 0)));
|
||||||
|
assert_eq!(counted(&state, &d, &e4).await, (38, 0));
|
||||||
|
let id = d["doc"]["id"].as_str().expect("id");
|
||||||
|
assert!(ld_server::adoption::run(&state, id).await.expect("run").is_empty());
|
||||||
|
assert_ne!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
|
||||||
|
}
|
||||||
let index = 10_000 + u32::try_from(n).expect("n");
|
let index = 10_000 + u32::try_from(n).expect("n");
|
||||||
indexer.pay(
|
indexer.pay(
|
||||||
1_000 + index,
|
1_000 + index,
|
||||||
@@ -790,16 +873,29 @@ async fn thirty_nine_voices_on_the_chain_make_a_text_pass_and_an_objection_holds
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ballots::sweep(&state).await.expect("sweep");
|
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["status"], "confirmed");
|
||||||
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
|
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
|
||||||
let d = c.doc("monnaie-libre").await;
|
let d = c.doc("monnaie-libre").await;
|
||||||
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
|
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
|
||||||
// A ballot sealed before the objection counts, as a vote already cast: kept, but
|
// Sealed before the objection, proven after it: its *for* on E5 is set aside
|
||||||
// the version held back cannot enter into force.
|
// (ADR 0012, revising 0010) — its *for* on E4 counts. Luna is told.
|
||||||
assert_eq!(tally(&d, &e5), (39, 0));
|
assert_eq!(tally(&d, &e5), (38, 0));
|
||||||
assert_eq!(find(&d, &e5)["votable"], false);
|
assert_eq!(find(&d, &e5)["votable"], false);
|
||||||
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
|
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
|
/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a
|
||||||
@@ -1076,7 +1172,10 @@ async fn a_comment_counts_once_its_block_is_final() {
|
|||||||
ballots::sweep(&state).await.expect("sweep");
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
assert_eq!(db_status(&state, id_of(&b)).await.0, "pending");
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "pending");
|
||||||
assert_eq!(db_status(&state, id_of(&r)).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));
|
indexer.finalized(Some(100));
|
||||||
ballots::sweep(&state).await.expect("sweep");
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
||||||
@@ -1325,15 +1424,17 @@ async fn voices_on_the_chain_bring_an_order_then_a_title_into_force() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[tokio::test]
|
#[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;
|
let (mut c, _) = Client::new(Config::for_tests()).await;
|
||||||
c.login("Chiara").await;
|
c.login("Chiara").await;
|
||||||
let doc = c.doc("monnaie-libre").await;
|
let doc = c.doc("monnaie-libre").await;
|
||||||
assert_eq!(doc["vote"]["way"], "chain");
|
// The chain closed: the vote is simulated, written by the server (ADR 0012).
|
||||||
assert_eq!(doc["vote"]["open"], false);
|
assert_eq!(doc["vote"]["way"], "server");
|
||||||
assert_eq!(doc["vote"]["reason"], "chain_off");
|
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!(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 doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
let i1 = version(&doc, "I1", "0");
|
let i1 = version(&doc, "I1", "0");
|
||||||
let (s, v) = c
|
let (s, v) = c
|
||||||
@@ -1346,8 +1447,11 @@ async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_v
|
|||||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
|
||||||
let essai = c.doc("essai").await;
|
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"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true)));
|
||||||
|
assert_eq!(essai["vote"]["simulated"], false);
|
||||||
let p = version(&essai, "I1", "0");
|
let p = version(&essai, "I1", "0");
|
||||||
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
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]
|
#[tokio::test]
|
||||||
@@ -1471,3 +1575,731 @@ async fn a_grouped_vote_in_a_managed_group_is_one_write() {
|
|||||||
assert_eq!(find(&doc, &e5)["myVote"], "against");
|
assert_eq!(find(&doc, &e5)["myVote"], "against");
|
||||||
assert_eq!(find(&doc, &e5)["myVoteChain"], false);
|
assert_eq!(find(&doc, &e5)["myVoteChain"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The buffer of the votes (ADR 0012): the intention at once, the proof of the
|
||||||
|
// chain, the reconciliation; the simulation while the chain is closed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_simulated_vote_in_monnaie_libre_is_seen_at_once_in_my_vote_and_the_totals() {
|
||||||
|
let (mut c, state) = Client::new(Config::for_tests()).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let (i1, e3, e4) = (version(&doc, "I1", "0"), version(&doc, "E3", "0"), version(&doc, "E4", "0"));
|
||||||
|
// One vote, without a wallet: written by the server, simulated.
|
||||||
|
let v = c.ok("POST", &format!("/api/proposals/{i1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!((v["simulated"].as_bool(), v["entered"].as_array().map(Vec::len)), (Some(true), Some(0)));
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!(tally(&d, &i1), (1, 0));
|
||||||
|
assert_eq!(pending(&d, &i1), (0, 0), "nothing waits for the chain: the simulated confirmer proved it");
|
||||||
|
assert_eq!(pair(&find(&d, &i1)["tallySimulated"]), (1, 0));
|
||||||
|
assert_eq!(mine(&d, &i1), (Some("for"), Some("simulated")));
|
||||||
|
assert_eq!(find(&d, &i1)["myVoteChain"], false);
|
||||||
|
// By default a simulated vote counts like a proven one, adoption included.
|
||||||
|
assert_eq!(counted(&state, &d, &i1).await, (1, 0));
|
||||||
|
// Through the same buffer: a sealed ballot, simulated, proven at block 0, for the session.
|
||||||
|
let row: (String, String, Option<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();
|
||||||
|
small_web(&state, &doc_id).await;
|
||||||
|
let e4 = version(&doc, "E4", "0");
|
||||||
|
let mut b = a.other();
|
||||||
|
b.login("Bruno").await;
|
||||||
|
a.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
let v = b.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
let entered = !v["entered"].as_array().expect("entered").is_empty();
|
||||||
|
let d = a.doc("monnaie-libre").await;
|
||||||
|
assert_eq!(pair(&find(&d, &e4)["tallySimulated"]), (2, 0), "shown either way");
|
||||||
|
assert_eq!(counted(&state, &d, &e4).await, if adopts { (2, 0) } else { (0, 0) });
|
||||||
|
assert_eq!(entered, adopts, "adopts = {adopts}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yvv, 2026-11-02: « Il compte, puis s'efface ». Opening the chain sets nothing
|
||||||
|
/// aside; the act « supprime » does, and examines again what the simulated votes
|
||||||
|
/// brought into force, with the proven votes only (`simulation::discard`).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opening_the_chain_sets_nothing_aside_the_act_does_and_examines_again_what_entered() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
small_web(&state, &doc_id).await;
|
||||||
|
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
|
||||||
|
// The seed proposal of I1 (ADR 0011), and a rival written before the votes.
|
||||||
|
let seed_i1 = version(&doc, "I1", "0");
|
||||||
|
let i1_unit = unit(&doc, "I1")["id"].as_str().expect("unit").to_owned();
|
||||||
|
let rival = c
|
||||||
|
.ok(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/docs/{doc_id}/proposals"),
|
||||||
|
Some(json!({ "unitId": i1_unit, "title": "Raison d'être", "body": "Un autre texte pour I1, voté en simulation.", "submit": true })),
|
||||||
|
)
|
||||||
|
.await["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("rival")
|
||||||
|
.to_owned();
|
||||||
|
// The chain open: one proven voice on E5, one on E6 — not enough to enter; two on
|
||||||
|
// the seed proposal of I1, which enters on proven votes alone.
|
||||||
|
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
|
||||||
|
for (p, block, signer) in [(&e5, 100, alice()), (&e6, 101, charlie)] {
|
||||||
|
let b = c.ballot(&doc, &[(p, Some("for")), (&seed_i1, Some("for"))], None).await;
|
||||||
|
indexer.pay(block, comment_of(&b), signer);
|
||||||
|
}
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str());
|
||||||
|
assert!(unit(&d, "E5")["inForce"].is_null() && unit(&d, "E6")["inForce"].is_null());
|
||||||
|
// The chain closed: Luna, Bruno and Marc vote in simulation; E4, E5, E6 enter, and
|
||||||
|
// the rival of I1, three voices against two, takes the place of the seed proposal.
|
||||||
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||||
|
let mut luna = c.other();
|
||||||
|
luna.login("Luna").await;
|
||||||
|
let mut bruno = c.other();
|
||||||
|
bruno.login("Bruno").await;
|
||||||
|
let mut marc = c.other();
|
||||||
|
marc.login("Marc").await;
|
||||||
|
let lines = json!([
|
||||||
|
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" },
|
||||||
|
{ "proposalId": e6, "choice": "for" }, { "proposalId": rival, "choice": "for" },
|
||||||
|
]);
|
||||||
|
luna.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
|
||||||
|
let lines = json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": rival, "choice": "for" }]);
|
||||||
|
bruno.ok("POST", &format!("/api/docs/{doc_id}/votes"), Some(json!({ "lines": lines }))).await;
|
||||||
|
marc.ok("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
for (code, id) in [("E4", &e4), ("E5", &e5), ("E6", &e6), ("I1", &rival)] {
|
||||||
|
assert_eq!(unit(&d, code)["inForce"]["id"], id.as_str(), "{code}");
|
||||||
|
}
|
||||||
|
// The chain opens again: nothing is set aside by itself — the simulated votes still count, marked.
|
||||||
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
|
||||||
|
let d = luna.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &e5), pair(&find(&d, &e5)["tallySimulated"])), ((2, 0), (1, 0)));
|
||||||
|
assert_eq!(mine(&d, &e5), (Some("for"), Some("simulated")));
|
||||||
|
let (s, v) = luna.call("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
|
||||||
|
// A second proven voice on E6, after it entered.
|
||||||
|
let b = c.ballot(&d, &[(&e6, Some("for"))], None).await;
|
||||||
|
indexer.pay(200, comment_of(&b), Signer { account: OTHER, identity: Some((9004, "Eve", true)) });
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
// Told first, nothing changed.
|
||||||
|
let told = ld_server::simulation::discard(&state, false).await.expect("dry run");
|
||||||
|
assert_eq!((told.applied, told.ballots, told.votes), (false, 3, 7));
|
||||||
|
let verdicts = |list: &[ld_server::simulation::Reexamined]| {
|
||||||
|
let mut v: Vec<(String, bool, bool)> = list.iter().map(|r| (r.code.clone(), r.holds, r.returned)).collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let expected_told: Vec<(String, bool, bool)> =
|
||||||
|
[("E4", false, false), ("E5", false, false), ("E6", true, false), ("I1", false, false)]
|
||||||
|
.iter()
|
||||||
|
.map(|(c, h, r)| ((*c).to_owned(), *h, *r))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(verdicts(&told.reexamined), expected_told);
|
||||||
|
assert_eq!(unit(&c.doc("monnaie-libre").await, "E5")["inForce"]["id"], e5.as_str());
|
||||||
|
// Done: the simulated votes are gone; what does not hold on the proven votes returns.
|
||||||
|
let done = ld_server::simulation::discard(&state, true).await.expect("act");
|
||||||
|
let expected_done: Vec<(String, bool, bool)> =
|
||||||
|
[("E4", false, true), ("E5", false, true), ("E6", true, false), ("I1", false, true)]
|
||||||
|
.iter()
|
||||||
|
.map(|(c, h, r)| ((*c).to_owned(), *h, *r))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(verdicts(&done.reexamined), expected_done);
|
||||||
|
let d = luna.doc("monnaie-libre").await;
|
||||||
|
assert!(unit(&d, "E4")["inForce"].is_null() && unit(&d, "E5")["inForce"].is_null());
|
||||||
|
assert_eq!(unit(&d, "E6")["inForce"]["id"], e6.as_str(), "two proven voices: it stays");
|
||||||
|
assert_eq!(unit(&d, "I1")["inForce"]["id"], seed_i1.as_str(), "the text it had displaced comes back");
|
||||||
|
assert_eq!(find(&d, &rival)["status"], "open");
|
||||||
|
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (None, None)));
|
||||||
|
let left: (i64, i64) = sqlx::query_as(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM votes WHERE source = 'simulated'), (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated')",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("left");
|
||||||
|
assert_eq!(left, (0, 0));
|
||||||
|
let mut adopted: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT proposal_id FROM adoptions").fetch_all(&state.db).await.expect("adoptions");
|
||||||
|
adopted.sort();
|
||||||
|
let mut kept = vec![seed_i1.clone(), e6.clone()];
|
||||||
|
kept.sort();
|
||||||
|
assert_eq!(adopted, kept, "the adoptions that do not hold are void; those on proven votes alone stay");
|
||||||
|
// Once is enough.
|
||||||
|
let again = ld_server::simulation::discard(&state, true).await.expect("again");
|
||||||
|
assert_eq!((again.ballots, again.reexamined.len()), (0, 0));
|
||||||
|
// Never without the dev login.
|
||||||
|
let mut cfg = chain_config(&indexer);
|
||||||
|
cfg.dev_login = false;
|
||||||
|
let (mut other, _) = Client::new(cfg).await;
|
||||||
|
let (s, _) = other.call("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_intention_never_confirmed_is_set_aside_and_told() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "0");
|
||||||
|
// A vote proven first: « contre ».
|
||||||
|
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
indexer.pay(100, comment_of(&first), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
// Then « pour », never paid: it takes the place of « contre » in the totals shown, for a while.
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
|
||||||
|
assert_eq!(mine(&d, &i1), (Some("for"), Some("pending")));
|
||||||
|
// Someone else sees the totals shown, intention included.
|
||||||
|
let mut yvv = c.other();
|
||||||
|
yvv.login("Yvv").await;
|
||||||
|
assert_eq!(tally(&yvv.doc("monnaie-libre").await, &i1), (1, 0));
|
||||||
|
// Its window passes: set aside, and said — under the article, and in her messages.
|
||||||
|
age(&state, "expires_at", -5, id_of(&b)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "expired");
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &i1), pending(&d, &i1)), ((0, 1), (0, 0)));
|
||||||
|
assert_eq!(mine(&d, &i1), (Some("against"), Some("recorded")), "the vote of before stays");
|
||||||
|
assert_eq!(find(&d, &i1)["myBallot"]["status"], "expired");
|
||||||
|
assert_eq!(ballot_messages(&mut c).await, [("ballot_expired".to_owned(), id_of(&b).to_owned())]);
|
||||||
|
let m = c.ok("GET", "/api/messages", None).await;
|
||||||
|
let message = &m["messages"][0];
|
||||||
|
assert_eq!((message["done"].as_bool(), message["gestures"].as_array().map(Vec::len)), (Some(true), Some(0)));
|
||||||
|
assert_eq!(message["ballot"]["lines"][0]["code"], "I1");
|
||||||
|
assert_eq!(message["ballot"]["lines"][0]["choice"], "for");
|
||||||
|
assert_eq!(m["unread"], 1);
|
||||||
|
// A ballot the wallet never signed is let go at once: nothing to tell.
|
||||||
|
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
|
||||||
|
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
|
||||||
|
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 1));
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(ballot_messages(&mut c).await.len(), 1);
|
||||||
|
// Purged a week later, its message goes with it.
|
||||||
|
age(&state, "settled_at", -8 * 24 * 3600, id_of(&b)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(ballot_messages(&mut c).await.len(), 0);
|
||||||
|
assert_eq!(c.ok("GET", "/api/messages/unread", None).await["unread"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_votes_of_one_identity_from_two_sessions_the_chain_keeps_the_last() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut chiara, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
chiara.login("Chiara").await;
|
||||||
|
let mut other = chiara.other();
|
||||||
|
other.login("Yvv").await;
|
||||||
|
let doc = chiara.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "0");
|
||||||
|
// One person, one wallet, two sessions: « pour » here, « contre » there.
|
||||||
|
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
let b = other.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
// Before the proofs, each intention counts for its session (ADR 0012, the cost).
|
||||||
|
assert_eq!(tally(&chiara.doc("monnaie-libre").await, &i1), (1, 1));
|
||||||
|
// On the chain, « pour » comes last; the indexer reads it first.
|
||||||
|
indexer.pay(200, comment_of(&a), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
indexer.pay(100, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let both = (db_status(&state, id_of(&a)).await.0, db_status(&state, id_of(&b)).await.0);
|
||||||
|
assert_eq!(both, ("confirmed".into(), "confirmed".into()));
|
||||||
|
// One voice: the last in the order of the chain.
|
||||||
|
let d = chiara.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (0, 0), (1, 0)));
|
||||||
|
let outcomes: Vec<(String, Option<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", "0"), version(&doc, "E5", "0"));
|
||||||
|
// Proven first: « contre » on E5.
|
||||||
|
let before = c.ballot(&doc, &[(&e5, Some("against"))], None).await;
|
||||||
|
indexer.pay(100, comment_of(&before), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
// « Pour », sealed: seen at once in place of « contre ».
|
||||||
|
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((1, 0), (Some("for"), Some("pending"))));
|
||||||
|
// An objection holds E5 back: the *for* still waiting weighs nothing.
|
||||||
|
let mut luna = c.other();
|
||||||
|
luna.login("Luna").await;
|
||||||
|
luna.ok(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{e5}/objections"),
|
||||||
|
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &e5), pending(&d, &e5)), ((0, 1), (0, 0)));
|
||||||
|
// The chain proves it now: set aside; « contre » stays; she is told.
|
||||||
|
indexer.pay(200, comment_of(&b), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&b)).await.0, "confirmed");
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &e5), counted(&state, &d, &e5).await), ((0, 1), (0, 1)));
|
||||||
|
assert_eq!(mine(&d, &e5), (Some("against"), Some("recorded")));
|
||||||
|
let held = &find(&d, &e5)["myBallot"];
|
||||||
|
assert_eq!((held["status"].as_str(), held["reason"].as_str()), (Some("refused"), Some("objection")));
|
||||||
|
assert_eq!(ballot_messages(&mut c).await, [("ballot_objection".to_owned(), id_of(&b).to_owned())]);
|
||||||
|
// A withdrawal, proven while held, counts.
|
||||||
|
let w = c.ballot(&doc, &[(&e5, None)], None).await;
|
||||||
|
indexer.pay(300, comment_of(&w), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &e5), mine(&d, &e5)), ((0, 0), (None, None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_waiting_intention_counts_as_my_vote_in_decisions() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, _) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let without = |d: &Value| d["docs"][0]["withoutMyVote"].as_u64();
|
||||||
|
let before = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
|
||||||
|
let i1 = version(&doc, "I1", "0");
|
||||||
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
let after = without(&c.ok("GET", "/api/groups/monnaie-libre/decisions", None).await);
|
||||||
|
assert_eq!(after, before.map(|n| n - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The evaluation as the engine gives it for these totals, on this version's letters.
|
||||||
|
async fn judged(state: &AppState, doc: &Value, id: &str, shown: (u32, u32)) -> Value {
|
||||||
|
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
||||||
|
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
|
||||||
|
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
|
||||||
|
let tally = ld_engine::threshold::Tally { for_votes: shown.0, against: shown.1 };
|
||||||
|
let e = ld_engine::threshold::evaluate(tally, st.electorate, &st.params(u, p)).expect("evaluation");
|
||||||
|
serde_json::to_value(e).expect("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What adoption judges: never an intention, simulated votes by default (ADR 0012).
|
||||||
|
async fn adopted_evaluation(state: &AppState, doc: &Value, id: &str) -> Value {
|
||||||
|
let st = ld_server::docs::load_state(state, doc["doc"]["id"].as_str().expect("doc")).await.expect("state");
|
||||||
|
let p = st.proposals.iter().find(|p| p.id == id).expect("version");
|
||||||
|
let u = st.units.iter().find(|u| u.id == p.unit_id).expect("unit");
|
||||||
|
serde_json::to_value(st.evaluation(u, p).expect("evaluation")).expect("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quorum (ADR 0013) is judged on the totals shown — votes recorded, pending and
|
||||||
|
/// simulated, the vote one sees as one's own replaced by one's intention: `quorum`,
|
||||||
|
/// `yesNeeded`, `noNeeded` and `missing` are those of the totals shown. Adoption counts
|
||||||
|
/// the proven votes and the simulated ones (`SIMULATED_ADOPTS`), never an intention.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_quorum_is_judged_on_the_totals_shown_and_adoption_never_on_an_intention() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
// I1, a seed proposal at level 2: the quorum 12, 9 « pour » at no vote.
|
||||||
|
let i1 = version(&doc, "I1", "0");
|
||||||
|
assert_eq!(find(&doc, &i1)["evaluation"], judged(&state, &doc, &i1, (0, 0)).await);
|
||||||
|
assert_eq!(find(&doc, &i1)["evaluation"]["yesNeeded"], 9);
|
||||||
|
// Proven: « contre ». Then « pour », sealed, not yet proven: it takes the place of
|
||||||
|
// « contre » in the totals shown, and in the evaluation shown.
|
||||||
|
let first = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
|
||||||
|
indexer.pay(100, comment_of(&first), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((1, 0), (1, 0), (0, 1)));
|
||||||
|
let shown = find(&d, &i1)["evaluation"].clone();
|
||||||
|
assert_eq!(shown, judged(&state, &d, &i1, (1, 0)).await, "on the totals shown");
|
||||||
|
assert_eq!((shown["cast"].as_u64(), shown["missing"].as_u64()), (Some(1), Some(8)));
|
||||||
|
let adoption = adopted_evaluation(&state, &d, &i1).await;
|
||||||
|
assert_eq!(adoption, judged(&state, &d, &i1, (0, 1)).await, "adoption: the proven « contre » only");
|
||||||
|
assert!(adoption["missing"].as_u64() > Some(8), "the intention never brings adoption closer: {adoption}");
|
||||||
|
// The detail says the same, and its five levels are asked at the votes shown.
|
||||||
|
let detail = c.ok("GET", &format!("/api/proposals/{i1}"), None).await;
|
||||||
|
assert_eq!(detail["proposal"]["evaluation"], shown);
|
||||||
|
assert!(detail["levels"].as_array().expect("levels").iter().all(|l| l["cast"] == 1), "{detail}");
|
||||||
|
// The chain closed: a simulated « pour » counts in both, like a proven vote.
|
||||||
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||||
|
let mut luna = c.other();
|
||||||
|
luna.login("Luna").await;
|
||||||
|
luna.ok(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/docs/{doc_id}/votes"),
|
||||||
|
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((tally(&d, &i1), pending(&d, &i1), counted(&state, &d, &i1).await), ((2, 0), (1, 0), (1, 1)));
|
||||||
|
assert_eq!(find(&d, &i1)["evaluation"], judged(&state, &d, &i1, (2, 0)).await);
|
||||||
|
assert_eq!(adopted_evaluation(&state, &d, &i1).await, judged(&state, &d, &i1, (1, 1)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « Un vote existe » (ADR 0013, `quorum::voted_units`), for every state of the buffer
|
||||||
|
/// (ADR 0012): an intention still pending, a proven line whatever became of it (a
|
||||||
|
/// withdrawal, a vote overtaken, a *for* set aside by an objection), a simulated vote,
|
||||||
|
/// a simulated withdrawal — the history of the ballots keeps their trace. A ballot the
|
||||||
|
/// wallet never signed, one never seen on the chain in time, one the chain refused, is
|
||||||
|
/// no vote.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_vote_exists_for_every_state_of_the_buffer() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let unit_id = |code: &str| unit(&doc, code)["id"].as_str().expect("unit").to_owned();
|
||||||
|
let exists = |u: String| {
|
||||||
|
let state = state.clone();
|
||||||
|
async move { ld_server::quorum::a_vote_exists(&state, &u).await.expect("query") }
|
||||||
|
};
|
||||||
|
let (i1, e1, e2, e3, e4) = (
|
||||||
|
version(&doc, "I1", "0"),
|
||||||
|
version(&doc, "E1", "0"),
|
||||||
|
version(&doc, "E2", "0"),
|
||||||
|
version(&doc, "E3", "0"),
|
||||||
|
version(&doc, "E4", "0"),
|
||||||
|
);
|
||||||
|
for code in ["I1", "E1", "E2", "E3", "E4"] {
|
||||||
|
assert!(!exists(unit_id(code)).await, "{code}: nobody voted yet");
|
||||||
|
}
|
||||||
|
// A ballot the wallet never signed: nothing.
|
||||||
|
let dropped = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
|
||||||
|
assert!(exists(unit_id("I1")).await, "an intention pending is a vote already");
|
||||||
|
c.ok("DELETE", &format!("/api/ballots/{}", id_of(&dropped)), None).await;
|
||||||
|
assert!(!exists(unit_id("I1")).await, "let go at once: no vote");
|
||||||
|
// Never seen on the chain in time: no vote.
|
||||||
|
let late = c.ballot(&doc, &[(&e2, Some("for"))], None).await;
|
||||||
|
age(&state, "expires_at", -2 * 3600, id_of(&late)).await;
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(db_status(&state, id_of(&late)).await.0, "expired");
|
||||||
|
assert!(!exists(unit_id("E2")).await);
|
||||||
|
// Proven, then withdrawn on the chain: no row in `votes`, the trace stays.
|
||||||
|
let a = c.ballot(&doc, &[(&e1, Some("for"))], None).await;
|
||||||
|
indexer.pay(100, comment_of(&a), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let w = c.ballot(&doc, &[(&e1, None)], None).await;
|
||||||
|
indexer.pay(101, comment_of(&w), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e1).await, (0, 0));
|
||||||
|
assert!(exists(unit_id("E1")).await, "a vote withdrawn leaves its trace");
|
||||||
|
// The chain closed: a simulated vote; a simulated vote, then withdrawn.
|
||||||
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||||
|
c.ok("POST", &format!("/api/proposals/{e3}/vote"), Some(json!({ "choice": "against" }))).await;
|
||||||
|
assert!(exists(unit_id("E3")).await, "a simulated vote");
|
||||||
|
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
c.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": null }))).await;
|
||||||
|
assert_eq!(counted(&state, &c.doc("monnaie-libre").await, &e4).await, (0, 0));
|
||||||
|
assert!(exists(unit_id("E4")).await, "a simulated vote withdrawn leaves its trace");
|
||||||
|
// The quorum is held: the author (an administrator, for a text of the seed) may not set it.
|
||||||
|
let mut admin = c.other();
|
||||||
|
admin.login_admin("Yvv").await;
|
||||||
|
for code in ["E1", "E3", "E4"] {
|
||||||
|
let (s, v) =
|
||||||
|
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "{code}");
|
||||||
|
}
|
||||||
|
let (s, _) =
|
||||||
|
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E2")), Some(json!({ "quorum": 20 }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "E2: no vote");
|
||||||
|
// The act « supprime » sets every simulated vote aside: with them, their trace.
|
||||||
|
ld_server::simulation::discard(&state, true).await.expect("act");
|
||||||
|
assert!(!exists(unit_id("E3")).await && !exists(unit_id("E4")).await);
|
||||||
|
assert!(exists(unit_id("E1")).await, "a proven trace stays");
|
||||||
|
let _ = doc_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare repository of the texts, for the publication (ADR 0011, 0012).
|
||||||
|
struct Texts {
|
||||||
|
dir: std::path::PathBuf,
|
||||||
|
bare: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Texts {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ld-v-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("dir");
|
||||||
|
let bare = dir.join("textes.git");
|
||||||
|
let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git");
|
||||||
|
assert!(ok.success());
|
||||||
|
Self { dir, bare }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configuration that publishes there; `simulated`: `LD_PUBLISH_SIMULATED`.
|
||||||
|
fn config(&self, mut cfg: Config, simulated: bool) -> Config {
|
||||||
|
cfg.data_dir = self.dir.join("data");
|
||||||
|
cfg.publish = Some(ld_server::config::Publish {
|
||||||
|
remote: self.bare.display().to_string(),
|
||||||
|
branch: "main".into(),
|
||||||
|
key: self.dir.join("data/keys/publish_ed25519"),
|
||||||
|
author_name: "libreDecision".into(),
|
||||||
|
author_email: "decision@example.org".into(),
|
||||||
|
without_articles: false,
|
||||||
|
simulated,
|
||||||
|
});
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The French text of the Acte as the repository has it; `None`: nothing published.
|
||||||
|
fn french(&self) -> Option<String> {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&self.bare)
|
||||||
|
.args(["show", "main:monnaie-libre/acte-engagement-certification/fr.md"])
|
||||||
|
.output()
|
||||||
|
.expect("git show");
|
||||||
|
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Texts {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A body of a version, as the document shows it.
|
||||||
|
fn body_of(doc: &Value, id: &str) -> String {
|
||||||
|
find(doc, id)["body"].as_str().expect("body").to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yvv, 2026-11-03: « Pas avant des votes prouvés ». A text that enters by simulated
|
||||||
|
/// votes is in force on the site, but sends nothing to the repository of the texts:
|
||||||
|
/// the publication waits until everything in effect holds on the proven votes alone —
|
||||||
|
/// the re-examination of the act « supprime » (`simulation`), told only.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_text_entered_by_simulated_votes_is_in_force_but_never_published_before_proven_votes() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let texts = Texts::new("proven");
|
||||||
|
let (mut c, state) = Client::new(texts.config(chain_config(&indexer), false)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
small_web(&state, &doc_id).await;
|
||||||
|
let (e4, e5) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"));
|
||||||
|
// The chain closed: two simulated « pour », E4 enters — in force on the site.
|
||||||
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
|
||||||
|
for name in ["Luna", "Bruno"] {
|
||||||
|
let mut s = c.other();
|
||||||
|
s.login(name).await;
|
||||||
|
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
}
|
||||||
|
assert_eq!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
assert!(texts.french().is_none(), "not before proven votes");
|
||||||
|
let publication = &c.doc("monnaie-libre").await["doc"]["publication"];
|
||||||
|
assert_eq!((publication["state"].as_str(), publication["waitsFor"].as_str()), (Some("suspended"), Some("proven")));
|
||||||
|
// The chain open: E5 enters on two proven votes — E4 still does not hold on them: nothing goes out.
|
||||||
|
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
|
||||||
|
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
|
||||||
|
for (block, signer) in [(100, alice()), (101, charlie)] {
|
||||||
|
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
|
||||||
|
indexer.pay(block, comment_of(&b), signer);
|
||||||
|
}
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let d = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!(unit(&d, "E5")["inForce"]["id"], e5.as_str());
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
assert!(texts.french().is_none(), "E4 does not hold on the proven votes yet");
|
||||||
|
// The act « supprime »: E4 returns among the proposals; what is in force holds on the
|
||||||
|
// proven votes alone — it goes out.
|
||||||
|
ld_server::simulation::discard(&state, true).await.expect("act");
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
let fr = texts.french().expect("published");
|
||||||
|
let (b4, b5) = (body_of(&d, &e4), body_of(&d, &e5));
|
||||||
|
assert!(fr.contains(b5.lines().next().expect("line")), "{fr}");
|
||||||
|
assert!(!fr.contains(b4.lines().next().expect("line")), "{fr}");
|
||||||
|
assert_eq!(c.doc("monnaie-libre").await["doc"]["publication"]["state"], "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yvv may choose the other way (`LD_PUBLISH_SIMULATED=1`): published anyway.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn with_the_switch_a_text_entered_by_simulated_votes_is_published() {
|
||||||
|
let texts = Texts::new("switch");
|
||||||
|
let (mut c, state) = Client::new(texts.config(Config::for_tests(), true)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
small_web(&state, &doc_id).await;
|
||||||
|
let e4 = version(&doc, "E4", "0");
|
||||||
|
for name in ["Luna", "Bruno"] {
|
||||||
|
let mut s = c.other();
|
||||||
|
s.login(name).await;
|
||||||
|
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
}
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
let fr = texts.french().expect("published");
|
||||||
|
assert!(fr.contains(body_of(&doc, &e4).lines().next().expect("line")), "{fr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production runs the migrations up to 0008 (M5, `3f10a0e`), its vote on the chain off.
|
||||||
|
/// The image of now applies 0009, 0010 and 0011, in this order; 0010 rewrites no row —
|
||||||
|
/// a ballot of before stays a ballot of the chain, its lines without outcome, the votes
|
||||||
|
/// as they were — and the first start writes nothing of the buffer.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_a_base_like_production_the_three_migrations_pass_in_order_and_0010_rewrites_nothing() {
|
||||||
|
use sqlx::sqlite::SqliteConnectOptions;
|
||||||
|
use std::str::FromStr;
|
||||||
|
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
|
||||||
|
let mut before = sqlx::migrate!("./migrations");
|
||||||
|
before.migrations = before.migrations.iter().filter(|m| m.version <= 8).cloned().collect::<Vec<_>>().into();
|
||||||
|
before.run(&pool).await.expect("up to 0008");
|
||||||
|
let tables = |pool: sqlx::SqlitePool| async move {
|
||||||
|
let ballots: Vec<(String, String, Option<i64>)> =
|
||||||
|
sqlx::query_as("SELECT id, status, block FROM ballots ORDER BY id")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("ballots");
|
||||||
|
let lines: Vec<(String, String, String)> =
|
||||||
|
sqlx::query_as("SELECT ballot_id, proposal_id, choice FROM ballot_lines ORDER BY ballot_id")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("lines");
|
||||||
|
let votes: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT proposal_id, identity_id, choice, source, ballot_id FROM votes ORDER BY proposal_id, identity_id",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("votes");
|
||||||
|
(ballots, lines, votes)
|
||||||
|
};
|
||||||
|
// The rows the image of before writes: a document, a ballot of the chain confirmed
|
||||||
|
// with its line and its vote, and one pending.
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO groups (slug, name, policy, hidden, position) VALUES ('monnaie-libre', 'Monnaie libre', 'g1-members', 0, 0)",
|
||||||
|
"INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, created_at)
|
||||||
|
VALUES ('d1', 'monnaie-libre', 'acte', '{\"fr\":\"Acte\"}', '{}', 'fr', 50, '2026-10-21T00:00:00Z')",
|
||||||
|
"INSERT INTO units (id, doc_id, code, section, position, inertia, created_at) VALUES ('u1', 'd1', 'E4', 'fondamental', 1, '3', '2026-10-21T00:00:00Z')",
|
||||||
|
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, revision, created_at, updated_at)
|
||||||
|
VALUES ('p1', 'u1', 'text', 'E4', 'Un texte.', 'open', 'seed', 1, '2026-10-21T00:00:00Z', '2026-10-21T00:00:00Z')",
|
||||||
|
"INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-21T00:00:00Z')",
|
||||||
|
"INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES ('idty:9001', 'g1', 'Alice', 1, '2026-10-21T00:00:00Z')",
|
||||||
|
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, voter_id, block,
|
||||||
|
event_index, created_at, expires_at, settled_at)
|
||||||
|
VALUES ('b1', 'd1', 'dev:chiara', 's1', 'h1', '0xr1', 'g1vote', 10, 'confirmed', 'idty:9001', 100, 1,
|
||||||
|
'2026-11-01T00:00:00Z', '2026-11-02T00:00:00Z', '2026-11-01T00:01:00Z')",
|
||||||
|
"INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b1', 'p1', 1, 'for')",
|
||||||
|
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id)
|
||||||
|
VALUES ('p1', 'idty:9001', 'for', 1, '2026-11-01T00:01:00Z', 'chain', 100, 1, 'b1')",
|
||||||
|
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, created_at, expires_at)
|
||||||
|
VALUES ('b2', 'd1', 'dev:chiara', 's2', 'h2', '0xr2', 'g1vote', 10, 'pending', '2026-11-03T00:00:00Z', '2099-01-01T00:00:00Z')",
|
||||||
|
"INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', 'p1', 1, 'against')",
|
||||||
|
] {
|
||||||
|
sqlx::query(sql).execute(&pool).await.unwrap_or_else(|e| panic!("{sql}: {e}"));
|
||||||
|
}
|
||||||
|
let written = tables(pool.clone()).await;
|
||||||
|
// The image of now.
|
||||||
|
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
|
||||||
|
let mut all = sqlx::migrate!("./migrations");
|
||||||
|
all.set_ignore_missing(true);
|
||||||
|
all.run(&pool).await.expect("0009 to 0011");
|
||||||
|
let order: Vec<i64> = sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY rowid")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("order");
|
||||||
|
assert_eq!(order, (1..=11).collect::<Vec<i64>>(), "0009, then 0010, then 0011");
|
||||||
|
assert_eq!(tables(pool.clone()).await, written, "no row rewritten");
|
||||||
|
let added: Vec<(String, String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT b.id, b.mode, l.outcome FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id ORDER BY b.id",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("columns");
|
||||||
|
assert_eq!(added, [("b1".into(), "chain".into(), None), ("b2".into(), "chain".into(), None)]);
|
||||||
|
let index: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'ballots_doc'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("index");
|
||||||
|
assert_eq!(index, 1);
|
||||||
|
// The first start, the chain closed: the watcher passes, nothing of the buffer is written.
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
assert_eq!(tables(pool.clone()).await, written);
|
||||||
|
let simulated: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated') + (SELECT COUNT(*) FROM votes WHERE source = 'simulated')
|
||||||
|
+ (SELECT COUNT(*) FROM messages WHERE topic = 'ballot')",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("simulated");
|
||||||
|
assert_eq!(simulated, 0);
|
||||||
|
// The ballots of before read as they did: a vote of the chain, recorded; the one
|
||||||
|
// pending, an intention that takes its place in the totals shown only.
|
||||||
|
let mine = ballots::my_votes(&state, "dev:chiara", "d1").await.expect("mine");
|
||||||
|
assert_eq!(mine.get("p1").map(|v| (v.choice.as_str(), v.source.as_str())), Some(("for", "chain")));
|
||||||
|
let st = ld_server::docs::load_state(&state, "d1").await.expect("state");
|
||||||
|
let pair = |t: ld_engine::threshold::Tally| (t.for_votes, t.against);
|
||||||
|
assert_eq!(
|
||||||
|
(pair(st.tally("p1")), pair(st.shown_tally("p1")), pair(st.pending_tally("p1"))),
|
||||||
|
((1, 0), (0, 1), (0, 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -263,15 +263,16 @@ async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
|
async fn while_the_chain_is_closed_a_guest_votes_in_simulation_and_has_a_count_of_ones_own() {
|
||||||
let mut admin = Client::new().await;
|
let mut admin = Client::new().await;
|
||||||
admin.login("Yvv", true).await;
|
admin.login("Yvv", true).await;
|
||||||
let mut guest = admin.other();
|
let mut guest = admin.other();
|
||||||
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
|
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
|
||||||
|
// No vote account: Monnaie libre votes in simulation, for whoever opens it (ADR 0012).
|
||||||
let d = decisions(&mut guest, "monnaie-libre").await;
|
let d = decisions(&mut guest, "monnaie-libre").await;
|
||||||
assert_eq!(d["canVote"], false);
|
assert_eq!(d["canVote"], true);
|
||||||
assert_eq!(d["docs"][0]["toVote"], 41);
|
assert_eq!(d["docs"][0]["toVote"], 41);
|
||||||
assert!(d["docs"][0]["withoutMyVote"].is_null());
|
assert_eq!(d["docs"][0]["withoutMyVote"], 41);
|
||||||
// In the test group, the same person votes.
|
// In the test group, the same person votes.
|
||||||
let d = decisions(&mut guest, "essai").await;
|
let d = decisions(&mut guest, "essai").await;
|
||||||
assert_eq!(d["canVote"], true);
|
assert_eq!(d["canVote"], true);
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ impl Remote {
|
|||||||
author_name: "libreDecision".into(),
|
author_name: "libreDecision".into(),
|
||||||
author_email: "decision@example.org".into(),
|
author_email: "decision@example.org".into(),
|
||||||
without_articles,
|
without_articles,
|
||||||
|
simulated: false,
|
||||||
});
|
});
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
//! `lifts_objection` / `reiterates_objection`;
|
//! `lifts_objection` / `reiterates_objection`;
|
||||||
//! - each person reads their own messages only — `reads_message`
|
//! - each person reads their own messages only — `reads_message`
|
||||||
//! (2026-10-31, lot F, ADR 0010);
|
//! (2026-10-31, lot F, ADR 0010);
|
||||||
|
//! - while the chain is closed, the vote is simulated: whoever may ask for a
|
||||||
|
//! ballot votes on the server, marked as simulated — `casts_simulated`
|
||||||
|
//! (2026-11-02, lot V, ADR 0012).
|
||||||
//! - the quorum of an article is set by the author of its first version — for a
|
//! - the quorum of an article is set by the author of its first version — for a
|
||||||
//! text of the seed, by an administrator — and only before the first vote —
|
//! text of the seed, by an administrator — and only before the first vote —
|
||||||
//! `sets_quorum` (2026-11-03, ADR 0013).
|
//! `sets_quorum` (2026-11-03, ADR 0013).
|
||||||
@@ -155,6 +158,17 @@ pub fn chain_vote_counts(member: bool) -> bool {
|
|||||||
member
|
member
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// While the chain is closed (no vote account yet), a group that votes on the
|
||||||
|
/// chain votes in simulation (Yvv, 2026-11-02: « Simule le vote à tout endroit »,
|
||||||
|
/// ADR 0012): whoever may ask for a ballot casts a simulated vote, written by the
|
||||||
|
/// server, marked as such, for the identity of the session — there is no wallet.
|
||||||
|
/// One person signed in two ways may then hold two voices: accepted while the
|
||||||
|
/// simulation lasts — Yvv, 2026-11-02: « Il compte, puis s'efface »: every
|
||||||
|
/// simulated vote counts until an act « supprime » sets them all aside.
|
||||||
|
pub fn casts_simulated(identity: &Identity, group: &Group, granted: bool, chain_open: bool) -> bool {
|
||||||
|
!chain_open && asks_ballot(identity, group, granted)
|
||||||
|
}
|
||||||
|
|
||||||
/// Access management stays with trustinWallet's administrators.
|
/// Access management stays with trustinWallet's administrators.
|
||||||
pub fn manages_access(identity: &Identity) -> bool {
|
pub fn manages_access(identity: &Identity) -> bool {
|
||||||
identity.admin
|
identity.admin
|
||||||
@@ -297,6 +311,20 @@ mod tests {
|
|||||||
assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote");
|
assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn while_the_chain_is_closed_whoever_may_ask_a_ballot_votes_in_simulation() {
|
||||||
|
let ml = group("monnaie-libre");
|
||||||
|
let invited = who(IdentityKind::Invite, false, false);
|
||||||
|
let member = who(IdentityKind::G1, true, false);
|
||||||
|
assert!(casts_simulated(&invited, &ml, true, false));
|
||||||
|
assert!(casts_simulated(&member, &ml, false, false));
|
||||||
|
assert!(!casts_simulated(&invited, &ml, false, false), "not without opening the group");
|
||||||
|
// The chain open: a ballot to sign, never a simulated vote.
|
||||||
|
assert!(!casts_simulated(&member, &ml, false, true));
|
||||||
|
// A managed group votes on the server, never in simulation.
|
||||||
|
assert!(!casts_simulated(&invited, &group("essai"), true, false));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn in_a_managed_group_every_member_votes_on_the_server() {
|
fn in_a_managed_group_every_member_votes_on_the_server() {
|
||||||
let essai = group("essai");
|
let essai = group("essai");
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
> changés sans toucher au reste. Corrigé le 2026-10-31 après la relecture indépendante de
|
> changés sans toucher au reste. Corrigé le 2026-10-31 après la relecture indépendante de
|
||||||
> `74f96be` (lot B2) : finalité, recherche filtrée et paginée, tâche de fond, plafond, bulletin
|
> `74f96be` (lot B2) : finalité, recherche filtrée et paginée, tâche de fond, plafond, bulletin
|
||||||
> Ğecko, bulletins lâchés, reçu.
|
> Ğecko, bulletins lâchés, reçu.
|
||||||
|
> **Complétée par [0012](0012-tampon-des-votes.md)** (2026-11-02, lot V) : le bulletin en attente
|
||||||
|
> est une intention, visible aussitôt pour la personne et dans les totaux montrés (jamais dans
|
||||||
|
> l'adoption) ; la réconciliation remplace « le dernier fait foi » ligne à ligne ; sans
|
||||||
|
> `LD_VOTE_ACCOUNT`, le vote est simulé au lieu d'être fermé.
|
||||||
|
|
||||||
**Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée.
|
**Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée.
|
||||||
Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1
|
Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
> coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch.
|
> coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch.
|
||||||
> Lot F, 2026-10-31. **Révisée le 2026-11-01** (décision d'Yvv, lot G) : sur une version
|
> Lot F, 2026-10-31. **Révisée le 2026-11-01** (décision d'Yvv, lot G) : sur une version
|
||||||
> retenue, on peut encore retirer son vote ou voter « contre », jamais ajouter un « pour ».
|
> retenue, on peut encore retirer son vote ou voter « contre », jamais ajouter un « pour ».
|
||||||
|
> **Révisée par [0012](0012-tampon-des-votes.md)** (2026-11-02, lot V) : un « pour » dont la
|
||||||
|
> preuve arrive sur la chaîne pendant qu'une objection retient la version est écarté ; il ne
|
||||||
|
> compte plus « tel qu'il est signé ».
|
||||||
|
|
||||||
**Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à
|
**Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à
|
||||||
qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte
|
qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte
|
||||||
@@ -37,7 +40,8 @@ d'un autre sans lui demander. Yvv, 2026-10-30 :
|
|||||||
direct, vote groupé, bulletin de la chaîne : `409 objection`, et une ligne refusée refuse tout le
|
direct, vote groupé, bulletin de la chaîne : `409 objection`, et une ligne refusée refuse tout le
|
||||||
vote). La version ne peut que perdre des voix tant qu'elle est retenue ; à la levée, elle
|
vote). La version ne peut que perdre des voix tant qu'elle est retenue ; à la levée, elle
|
||||||
n'entre qu'avec les voix encore là. Les votes déjà faits restent ; un bulletin scellé avant
|
n'entre qu'avec les voix encore là. Les votes déjà faits restent ; un bulletin scellé avant
|
||||||
l'objection et confirmé après compte tel qu'il est signé, sans la faire entrer. Règle :
|
l'objection et confirmé après compte tel qu'il est signé pour « contre » et le retrait ; son
|
||||||
|
« pour » est écarté (0012). Règle :
|
||||||
`ld_engine::objection::may_cast`, appliquée par `DocState::admits`.
|
`ld_engine::objection::may_cast`, appliquée par `DocState::admits`.
|
||||||
- Une personne n'a qu'une objection debout sur une même paire (`objection_exists`).
|
- Une personne n'a qu'une objection debout sur une même paire (`objection_exists`).
|
||||||
- **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé**
|
- **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé**
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# 0012 — Le tampon des votes : l'intention tout de suite, la preuve de la chaîne, la réconciliation
|
||||||
|
|
||||||
|
> Complète [0008](0008-vote-par-transaction.md) (vote par la chaîne Ğ1) et révise une phrase de
|
||||||
|
> [0010](0010-objections-et-coquilles-validees.md) (un bulletin scellé avant une objection et
|
||||||
|
> confirmé après). Demande d'Yvv du 2026-11-02. **Deux décisions d'Yvv font foi** : le poids
|
||||||
|
> d'un vote simulé, « Il compte, puis s'efface » (2026-11-02, 06 h 26), et la publication d'un
|
||||||
|
> texte entré par des votes simulés, « Pas avant des votes prouvés » (2026-11-03, formulaire d'orch). La
|
||||||
|
> façon de compter une intention et ce qu'on dit quand elle est écartée restent les choix du lot
|
||||||
|
> V, isolés dans `ld_engine::buffer` pour être changés seuls. Se lit avec
|
||||||
|
> [0013](0013-quorum.md) (le quorum, jugé sur les totaux montrés). Lot V, 2026-11-02 et 03.
|
||||||
|
|
||||||
|
**Contexte.** Yvv, 2026-11-02 : « Simule le vote à tout endroit. […] du point de vue de
|
||||||
|
l'architecture, il faut prévoir le "tampon" entre la base des votes et la mise à jour par les
|
||||||
|
indexeurs de la chaîne. Il faut prévoir le bon process pour une ux moderne "je vois ce que j'ai
|
||||||
|
fait sans attendre", avec un contrôle a posteriori de l'intégrité du vote (bien unique, bien le
|
||||||
|
dernier qui vaut). » Et ses décisions déjà prises : une personne, une voix, autant de fois
|
||||||
|
qu'elle veut, le dernier vote fait foi ; dans Monnaie libre l'intégrité vient de la chaîne Ğ1
|
||||||
|
(0,1 Ğ1 par vote, un seul geste, un bulletin scellé) ; les totaux se voient en direct et
|
||||||
|
**chacun voit son propre vote en direct** (« tout le monde a maintenant cette habitude,
|
||||||
|
difficile de régresser ») ; rien n'est dit à l'écran du recoupement entre la chaîne et les
|
||||||
|
totaux ; sur une version retenue par une objection, « contre » et retrait, jamais « pour ».
|
||||||
|
|
||||||
|
Jusqu'ici, dans Monnaie libre, un bulletin restait « en attente » sans rien compter jusqu'à ce
|
||||||
|
que l'indexeur le confirme (6 à 30 secondes, une journée au plus par Ğecko), et sans
|
||||||
|
`LD_VOTE_ACCOUNT` le vote était éteint (« pas encore ouvert »). Un bulletin scellé avant une
|
||||||
|
objection et payé après comptait « pour » (relevé par une relecture).
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
### Le cheminement d'un vote : trois temps
|
||||||
|
|
||||||
|
```
|
||||||
|
geste ──► INTENTION ──────────► PREUVE ─────────────► RÉCONCILIATION ──► votes (ce qui compte)
|
||||||
|
ballots (pending) la chaîne, lue par ld_engine::buffer
|
||||||
|
visible aussitôt l'indexeur ; ou le ::reconcile, par
|
||||||
|
(ton vote, totaux) confirmateur simulé identité et version
|
||||||
|
│
|
||||||
|
└── jamais prouvée dans sa fenêtre, refusée par la chaîne,
|
||||||
|
dépassée, « pour » prouvé sur une version retenue : ÉCARTÉE
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **L'intention** est le geste lui-même : un bulletin scellé (`ballots`, `ballot_lines`,
|
||||||
|
0008), un seul pour tout un panier. Elle est écrite **avant** que le portefeuille signe et se
|
||||||
|
voit **aussitôt** : pour la personne (« ton vote », état `pending`) et dans les totaux
|
||||||
|
montrés (`DocState::shown_tally`). Rien n'est parti (refus du portefeuille, nœud muet,
|
||||||
|
fenêtre fermée avant la signature) : la page la lâche aussitôt (`DELETE`, 0008) et elle
|
||||||
|
disparaît des totaux. Un code Ğecko montré est gardé, comme avant.
|
||||||
|
2. **La preuve** vient de la chaîne, lue par l'indexeur, sans rien changer à 0008 : la
|
||||||
|
remarque exacte, le virement du signataire vers le compte des votes, une identité Ğ1 membre
|
||||||
|
au moment de la confirmation, un bloc définitif. Pendant la simulation (plus bas), un
|
||||||
|
**confirmateur simulé** tient la place de l'indexeur.
|
||||||
|
3. **La réconciliation** a lieu à chaque preuve, pour chaque version du bulletin, et pour
|
||||||
|
l'identité qui a signé : elle relit **toutes** les lignes prouvées de cette identité sur
|
||||||
|
cette version et en tire la voix qui tient (`ld_engine::buffer::reconcile`, pur, testé) :
|
||||||
|
- **une seule voix par identité Ğ1** et par version ;
|
||||||
|
- **la dernière dans l'ordre de la chaîne** (le bloc, puis le rang de l'évènement), quel que
|
||||||
|
soit l'ordre dans lequel l'indexeur les a lues : le résultat ne dépend pas de l'ordre des
|
||||||
|
confirmations ; « retirer mon vote » (`none`) qui tient efface la voix ;
|
||||||
|
- elle **écarte** : ce que la chaîne ne confirme jamais dans la fenêtre de validité (24
|
||||||
|
heures, plus une heure de grâce si l'indexeur se tait ou si le bloc n'est pas encore
|
||||||
|
définitif : `expired`) ; ce que la chaîne refuse (`refused` : pas d'identité, pas membre,
|
||||||
|
virement manquant) ; ce qu'un vote plus récent de la même identité a dépassé
|
||||||
|
(`superseded`) ; et **un « pour » dont la preuve arrive pendant qu'une objection retient la
|
||||||
|
version** (`objection`, voir plus bas).
|
||||||
|
|
||||||
|
Chaque ligne d'un bulletin prouvé porte son issue (`ballot_lines.outcome` : `counted`,
|
||||||
|
`superseded`, `objection`), recalculée à chaque réconciliation sauf `objection`, fixée au
|
||||||
|
moment de la preuve. La table `votes` reste ce qui compte : une ligne par identité et par
|
||||||
|
version, écrite par la réconciliation (sources `chain`, `simulated`) ou par le serveur dans
|
||||||
|
un groupe géré (`server`). Chaque vote compté se vérifie toujours contre la chaîne en
|
||||||
|
recalculant H depuis les lignes et le sel (0008).
|
||||||
|
|
||||||
|
**Dans un groupe géré** (essai, Axiom…), il n'y a pas de tampon : le serveur est l'autorité, le
|
||||||
|
vote est écrit d'un coup et il est enregistré (`recorded`) dès l'écriture, comme avant.
|
||||||
|
|
||||||
|
### Les états d'un vote, et ce que chacun pèse
|
||||||
|
|
||||||
|
| État | Ce que c'est | `myVoteState` | Totaux montrés (`shown_tally`) | Adoption (`tally`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| enregistré | prouvé par la chaîne, ou vote du serveur d'un groupe géré | `recorded` | oui | oui |
|
||||||
|
| en attente | une intention sans preuve, dans sa fenêtre | `pending` | oui, **à la place** du vote que la personne voit comme le sien ; jamais un « pour » sur une version retenue | **jamais** |
|
||||||
|
| simulé | un vote de la simulation, chaîne fermée | `simulated` | oui, **jusqu'à l'acte « supprime »** | oui, **jusqu'à l'acte « supprime »** (décision d'Yvv, « Il compte, puis s'efface ») |
|
||||||
|
| écarté | expiré, refusé, dépassé, « pour » sur une version retenue | — | non | non |
|
||||||
|
|
||||||
|
- **L'adoption ne compte jamais une intention non confirmée.** Une entrée en vigueur ne repose
|
||||||
|
que sur des votes prouvés, sur les votes du serveur dans un groupe géré, et sur les votes
|
||||||
|
simulés jusqu'à l'acte qui les écarte (plus bas). Le balayage d'adoption et la mesure de la
|
||||||
|
participation p̃ lisent les mêmes poids (`ld_engine::buffer::Rules`).
|
||||||
|
- **Le quorum se juge sur les totaux montrés** (ADR 0013) : l'évaluation qu'on montre
|
||||||
|
(`evaluation` : `quorum`, `yesNeeded`, `noNeeded`, `missing`) et les cinq crans du détail se
|
||||||
|
calculent sur `shown_tally` — votes enregistrés, en attente et simulés, l'intention à la place
|
||||||
|
du vote vu de la personne (`DocState::shown_evaluation`) ; l'adoption, sur `tally`
|
||||||
|
(`DocState::evaluation`).
|
||||||
|
- **Une intention compte pour la session qui l'a demandée** : elle remplace, dans les totaux
|
||||||
|
montrés, le vote que cette session voit comme le sien (`my_votes`), et la dernière intention
|
||||||
|
de la session sur une version remplace les précédentes. Une intention sur une révision
|
||||||
|
dépassée ne compte pas (elle serait « à reconfirmer ») mais retire bien le vote d'avant ; un
|
||||||
|
« pour » en attente sur une version retenue ne pèse rien tant qu'elle est retenue (il serait
|
||||||
|
écarté) : `ld_engine::buffer::weigh_intent`.
|
||||||
|
- `tallyPending` dit la part des totaux montrés qui attend encore sa preuve ; `tallySimulated`
|
||||||
|
la part qui vient de la simulation. Les textes publiés (`publish.rs`) ne publient jamais une
|
||||||
|
intention : ils lisent `recorded_tally` (ce qui est enregistré, simulation comprise tant
|
||||||
|
qu'elle compte), ou `tally` pour les articles en vigueur, comme avant ; et rien ne part tant
|
||||||
|
qu'une entrée due à des votes simulés ne tient pas sur les seuls votes prouvés (« La
|
||||||
|
publication », plus bas).
|
||||||
|
|
||||||
|
### Ce que la personne voit, à chaque étape
|
||||||
|
|
||||||
|
| Étape | Sous l'article (`myVote`, `myVoteState`, `myBallot`) | Totaux | Boîte « Messages » |
|
||||||
|
|---|---|---|---|
|
||||||
|
| elle vote (groupe géré) | son choix, `recorded` | aussitôt | — |
|
||||||
|
| elle vote, la chaîne ouverte | son choix, `pending` ; « En attente de la chaîne Ğ1 : … » | aussitôt, avec son choix | — |
|
||||||
|
| la chaîne le confirme | son choix, `recorded` ; « inscrit sur la chaîne Ğ1 » | inchangés (l'intention devient preuve) | — |
|
||||||
|
| rien n'est parti (portefeuille, nœud, fenêtre fermée) | son vote d'avant | son intention retirée | — |
|
||||||
|
| jamais vu sur la chaîne en 24 h | son vote d'avant ; « Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps » | son intention retirée | « Ton vote … n'a pas été vu sur la chaîne Ğ1 en 24 heures : il est écarté. Tu peux voter à nouveau. » |
|
||||||
|
| refusé par la chaîne | son vote d'avant ; « Ton dernier vote n'a pas été compté : … » | son intention retirée | « Ton vote … n'a pas été compté : … » |
|
||||||
|
| un « pour » prouvé sur une version retenue | son vote d'avant ; « Ton dernier vote n'a pas été compté : une objection retenait cette version… » | inchangés | « Ton « pour » sur … est écarté : une objection retenait cette version quand la chaîne a confirmé ton vote. Ton vote d'avant reste. » |
|
||||||
|
| dépassé par un vote plus récent de la même identité | le plus récent | le plus récent | — (c'est son propre geste) |
|
||||||
|
| elle vote, la chaîne fermée (simulation) | son choix, `simulated` | aussitôt | — |
|
||||||
|
|
||||||
|
Rien n'est dit à l'écran sur le recoupement entre la chaîne et les totaux (décision d'Yvv).
|
||||||
|
Un message n'offre aucun geste : il dit ce qui s'est passé et mène au texte, où l'on revote.
|
||||||
|
|
||||||
|
### La simulation, tant que la chaîne est fermée
|
||||||
|
|
||||||
|
Sans `LD_VOTE_ACCOUNT`, on vote **quand même, partout, dans Monnaie libre**, sans portefeuille :
|
||||||
|
le bouton de vote s'affiche (l'accès dit `open: true`, `simulated: true`, `way: "server"`), le
|
||||||
|
vote part par l'écriture du serveur (`POST /api/proposals/{id}/vote`, `POST
|
||||||
|
/api/docs/{id}/votes`) et passe **par le même tampon** : un bulletin scellé (`ballots.mode =
|
||||||
|
'simulated'`, son sel et son empreinte, sans compte ni montant), puis le **confirmateur simulé**
|
||||||
|
le prouve aussitôt, dans la même requête (et le balayage reprend un bulletin simulé resté en
|
||||||
|
attente) : la voix est celle de **l'identité de la session** (il n'y a pas de portefeuille), sa
|
||||||
|
position est « bloc 0 », au rang de son arrivée ; puis la même réconciliation, le même
|
||||||
|
balayage d'adoption, les mêmes messages. L'architecture est ainsi éprouvée de bout en bout, en
|
||||||
|
production, sans la chaîne. Qui peut voter en simulation : la règle de trustinWallet,
|
||||||
|
`trustin_access::casts_simulated` (qui ouvre le groupe, la chaîne fermée) — une personne
|
||||||
|
invitée comprise.
|
||||||
|
|
||||||
|
**La décision d'Yvv, 2026-11-02 à 06 h 26 : « Il compte, puis s'efface ».**
|
||||||
|
|
||||||
|
- **Il compte.** Un vote simulé compte dans les totaux et **pour l'entrée en vigueur**, comme un
|
||||||
|
vote prouvé, avec la marque « simulé » (`myVoteState`, `tallySimulated`, `mode` du bulletin,
|
||||||
|
« Tes bulletins »). Avec les quorums de l'ADR 0013, neuf « pour » simulés suffisent à faire
|
||||||
|
entrer I1, E1 ou E2 au cran 2 (quorum 12) : le texte est alors en vigueur sur le site, et ne
|
||||||
|
se publie pas (« La publication », plus bas). La règle : `ld_engine::buffer::SIMULATED_ADOPTS
|
||||||
|
= true`, reprise par `Config::simulated_adopts` ; à `false`, « simulé = affiché, jamais compté
|
||||||
|
pour l'adoption » (les totaux montrés ne changent pas, l'adoption et p̃ les ignorent). Les deux
|
||||||
|
branches sont testées.
|
||||||
|
- **L'ouverture de la chaîne n'efface rien d'elle-même.** Dès que `LD_VOTE_ACCOUNT` est posé
|
||||||
|
(un acte « déploie »), on vote par la chaîne : un nouveau vote simulé est refusé (`409
|
||||||
|
chain_only`). Les votes simulés déjà là **continuent de compter**, marqués, jusqu'à l'acte qui
|
||||||
|
suit. Une identité Ğ1 (une session ouverte par la clé Ğ1, `idty:<index>`) qui vote ensuite
|
||||||
|
par la chaîne passe devant son vote simulé (bloc 0, avant tout bloc réel).
|
||||||
|
- **Puis il s'efface, par un acte « supprime » d'Yvv**, jamais déclenché par le code : ni le
|
||||||
|
serveur, ni l'ouverture de la chaîne, ni un balayage ne l'appellent. Il est préparé et testé
|
||||||
|
ici : `simulation::discard`, en ligne de commande dans l'image :
|
||||||
|
```sh
|
||||||
|
ld-server discard-simulation # à blanc : dit ce qu'il ferait, n'écrit rien
|
||||||
|
ld-server discard-simulation --apply # l'acte
|
||||||
|
```
|
||||||
|
Il lit la base du volume (`LD_DATA_DIR`, `libredecision.db`) et imprime un JSON : les
|
||||||
|
bulletins, lignes, votes et messages simulés, et chaque entrée réexaminée (article, sorte,
|
||||||
|
votes d'entrée et leur part simulée, votes prouvés, « pour » exigés, tient ou non, rendue ou
|
||||||
|
non, la version déplacée). Avec `--apply` :
|
||||||
|
1. **le tri** : il efface les messages des bulletins simulés, les votes simulés
|
||||||
|
(`votes.source = 'simulated'`), leurs lignes et les bulletins simulés, puis réconcilie de
|
||||||
|
nouveau chaque identité qui avait voté en simulation (son vote prouvé, s'il y en a un,
|
||||||
|
tient) ;
|
||||||
|
2. **le réexamen** : chaque entrée en vigueur qui a compté des votes simulés
|
||||||
|
(`adoptions.params.simulated`), la plus récente d'abord, encore en vigueur, est jugée de
|
||||||
|
nouveau sur les seuls votes prouvés, au cran et sur le quorum où elle a été votée
|
||||||
|
(`params.level`, `params.quorum`). **Sous son seuil, elle retourne parmi les
|
||||||
|
propositions** : la version qu'elle avait déplacée revient en vigueur (un texte retiré
|
||||||
|
revient, un cran d'inertie redescend, l'ordre et le titre d'avant s'appliquent de
|
||||||
|
nouveau), son adoption est effacée, une publication est mise en file. **À son seuil ou
|
||||||
|
au-dessus, elle reste**, et son adoption le dit (`params.reexamined`) ;
|
||||||
|
3. les envois en attente de la publication repartent.
|
||||||
|
|
||||||
|
Répété, l'acte ne trouve plus rien (aucun bulletin simulé, aucune entrée à réexaminer).
|
||||||
|
**À blanc**, le réexamen met les votes simulés de côté sans les effacer : une entrée qui ne
|
||||||
|
reviendrait qu'après le retour d'une plus récente est dite « plus en vigueur ».
|
||||||
|
- Pour les tests seulement, et seulement avec la connexion de développement, `POST
|
||||||
|
/api/dev/vote-chain {"open": false}` ferme la chaîne d'un serveur qui en a une (le contrôle
|
||||||
|
e2e de la simulation tourne sur le serveur jetable des e2e, qui a sa chaîne simulée).
|
||||||
|
|
||||||
|
### La publication : « Pas avant des votes prouvés »
|
||||||
|
|
||||||
|
**La décision d'Yvv, 2026-11-03 (formulaire d'orch) : « Pas avant des votes prouvés ».** Un texte qui
|
||||||
|
entre en vigueur grâce à des votes simulés est **en vigueur sur le site**, mais **ne déclenche
|
||||||
|
pas la publication** vers le dépôt des textes, que lisent les applis clientes. La publication
|
||||||
|
d'un document attend que tout ce qui y est en vigueur et y est entré par des votes simulés —
|
||||||
|
texte, retrait, cran d'inertie, ordre, titre — **tienne sur les seuls votes prouvés**, selon le
|
||||||
|
réexamen même de l'acte « supprime », fait à blanc (`simulation::unproven`). La chaîne étant
|
||||||
|
fermée, **rien ne se publie dans Monnaie libre avant son ouverture** ; après elle, la file part
|
||||||
|
dès que l'acte a rendu ce qui ne tient pas, ou dès que des votes prouvés portent ce qui est
|
||||||
|
entré.
|
||||||
|
|
||||||
|
- Un seul endroit : `publish::waits_for_proven_votes`, après `publish::waits_for_a_text` (ADR
|
||||||
|
0011), tous deux lus par `publish::waits`. Comme pour l'attente du premier texte, ce n'est pas
|
||||||
|
un échec : aucune tentative comptée, la file attend et se relit toutes les dix minutes ; l'API
|
||||||
|
dit `publication.state = "suspended"` et `publication.waitsFor = "proven"` (`"text"` pour
|
||||||
|
l'attente du premier texte).
|
||||||
|
- La bascule, pour publier quand même si Yvv revenait sur ce choix : `LD_PUBLISH_SIMULATED=1`
|
||||||
|
(`Publish::simulated`). Testé dans les deux sens.
|
||||||
|
- Plus strict qu'« un texte prouvé suffit » : tant qu'une entrée simulée ne tient pas, même un
|
||||||
|
texte prouvé du même document attend, pour que le dépôt ne porte jamais une entrée que les
|
||||||
|
votes prouvés ne portent pas.
|
||||||
|
|
||||||
|
### Un « pour » prouvé pendant une objection (révise 0010)
|
||||||
|
|
||||||
|
0010 disait : « un bulletin scellé avant l'objection et confirmé après compte tel qu'il est
|
||||||
|
signé, sans la faire entrer ». Une relecture a relevé qu'un « pour » entrait ainsi sur une
|
||||||
|
version qui ne doit plus en prendre. Désormais, **un « pour » dont la preuve arrive pendant
|
||||||
|
qu'une objection retient sa version est écarté** (`objection`) : il ne compte pas, comme s'il
|
||||||
|
n'avait pas été émis ; le vote d'avant de la même identité, s'il y en a un, reste ; la personne
|
||||||
|
qui l'a demandé reçoit un message. « Contre » et retrait, prouvés pendant l'objection, comptent
|
||||||
|
(Yvv, 01/11 : « Retirer ou "contre" »). « Arrive » se lit à l'heure du serveur qui lit la
|
||||||
|
preuve : la chaîne ne donne que des blocs, et l'objection une heure. Une objection levée avant
|
||||||
|
la preuve laisse le « pour » compter.
|
||||||
|
|
||||||
|
### « Un vote existe » (ADR 0013)
|
||||||
|
|
||||||
|
Le quorum d'un article se fixe avant le premier vote (`quorum::voted_units`, la seule
|
||||||
|
requête). Le tampon gardant l'historique des bulletins, elle compte : un vote qui compte
|
||||||
|
(serveur, chaîne, **simulé**) ; toute ligne d'un bulletin **en attente** ou **prouvé**, de la
|
||||||
|
chaîne ou de la simulation, **quel que soit son sort** (la voix qui compte, un vote dépassé, un
|
||||||
|
« pour » écarté par une objection, **un retrait**) ; une adoption. Un vote retiré sur la chaîne
|
||||||
|
ou en simulation laisse donc sa trace. Un bulletin lâché sans signature, jamais vu à temps ou
|
||||||
|
refusé par la chaîne n'est pas un vote. Deux limites : dans un groupe géré, sans tampon, un vote
|
||||||
|
retiré avant toute adoption ne laisse rien (ADR 0013) ; l'acte « supprime » emporte la trace des
|
||||||
|
votes simulés avec eux.
|
||||||
|
|
||||||
|
### L'API, contrat partagé avec les lots U et S
|
||||||
|
|
||||||
|
Dans le JSON d'une proposition (`GET /api/docs/{id}`, `GET /api/proposals/{id}`) :
|
||||||
|
|
||||||
|
- `tally: {for, against}` — les totaux montrés, intentions comprises ;
|
||||||
|
- `tallyPending: {for, against}` — la part encore sans preuve ;
|
||||||
|
- `tallySimulated: {for, against}` — la part simulée (ajouté) ;
|
||||||
|
- `evaluation` — sur les totaux montrés (ADR 0013 : `quorum`, `yesNeeded`, `noNeeded`) ;
|
||||||
|
- `myVote: 'for' | 'against' | null` — ce que la personne voit comme son vote maintenant : son
|
||||||
|
dernier geste, une intention en attente comprise (un retrait en attente : `null`) ;
|
||||||
|
- `myVoteState: 'recorded' | 'pending' | 'simulated' | null` ;
|
||||||
|
- inchangés : `myVoteStale`, `myVoteChain`, `myBallot` (qui peut dire `status: 'refused'`,
|
||||||
|
`reason: 'objection'` pour un « pour » écarté).
|
||||||
|
|
||||||
|
Dans l'accès (`vote` du JSON du document) : `{ way, open, simulated, reason, chain }` ;
|
||||||
|
pendant la simulation, `way: "server"`, `open: true`, `simulated: true`, `chain: null`. Un
|
||||||
|
vote écrit par le serveur répond `{ entered, simulated }` (et `count` pour un vote groupé).
|
||||||
|
Les bulletins (`GET /api/ballots/{id}`, `GET /api/ballots`, le reçu) gagnent `mode: 'chain' |
|
||||||
|
'simulated'` (le bloc d'un bulletin simulé n'est jamais montré). Les messages gagnent le sujet
|
||||||
|
`ballot`, de sortes `ballot_expired`, `ballot_refused`, `ballot_objection`, avec `ballot: {id,
|
||||||
|
status, reason, mode, createdAt, settledAt, lines: [{proposalId, unitId, code, title, choice,
|
||||||
|
outcome}]}`. La publication d'un document (`doc.publication`) gagne `waitsFor: 'text' |
|
||||||
|
'proven' | null`.
|
||||||
|
|
||||||
|
## Les données
|
||||||
|
|
||||||
|
Migration `0010_vote_buffer.sql`, additive (S a pris `0009` et `0011` ; les trois passent dans
|
||||||
|
l'ordre sur une base neuve comme sur une base arrêtée à `0008`, la production) :
|
||||||
|
|
||||||
|
- `ballots.mode TEXT NOT NULL DEFAULT 'chain'` (`chain` | `simulated`) : tous les bulletins
|
||||||
|
d'avant restent des bulletins de la chaîne ;
|
||||||
|
- `ballot_lines.outcome TEXT` (`counted` | `superseded` | `objection`, `NULL` tant que le
|
||||||
|
bulletin n'est pas prouvé ; les lignes d'avant restent à `NULL` et se lisent comme « pas
|
||||||
|
écartée par une objection » : la prochaine réconciliation de leur identité les qualifie) ;
|
||||||
|
- un index `ballots(doc_id, status)` pour lire les intentions d'un document ;
|
||||||
|
- `votes.source` prend une valeur de plus, `simulated` (colonne texte, rien à changer) ;
|
||||||
|
`messages.topic` aussi, `ballot` ; `adoptions.params` (JSON) garde de plus, pour l'acte, la
|
||||||
|
part simulée des votes d'entrée (`simulated`), la version déplacée (`displaced`), le cran
|
||||||
|
d'avant (`from_inertia`), et après l'acte `reexamined`.
|
||||||
|
|
||||||
|
Aucune ligne existante n'est réécrite, et le premier démarrage n'écrit rien du tampon (la
|
||||||
|
chaîne fermée en production : aucun bulletin). La purge (0008) efface avec un bulletin lâché
|
||||||
|
les messages qui en parlent.
|
||||||
|
|
||||||
|
## Coût
|
||||||
|
|
||||||
|
- **Les totaux montrés sont provisoires** le temps de la preuve : une intention compte pour la
|
||||||
|
session qui l'a demandée. Si une même identité vote depuis deux sessions à la fois, ou si une
|
||||||
|
session signe avec un autre portefeuille que celui de son vote précédent, les totaux montrés
|
||||||
|
peuvent s'écarter d'une voix jusqu'à la preuve ; la réconciliation remet le compte juste. Une
|
||||||
|
personne qui ouvre Monnaie libre peut faire paraître une voix dans les totaux montrés pendant
|
||||||
|
24 heures sans payer (une par session et par version, 20 bulletins au plus) : elle ne compte
|
||||||
|
jamais pour l'adoption.
|
||||||
|
- L'intention compte dès que le bulletin est scellé, quelques secondes avant la signature ; si
|
||||||
|
rien ne part, la page la lâche aussitôt.
|
||||||
|
- Un « pour » payé juste avant une objection mais lu juste après (l'indexeur suit à quelques
|
||||||
|
secondes) est écarté : la personne revote à la levée.
|
||||||
|
- **La simulation rend possible deux voix pour une personne** (une session par invitation, une
|
||||||
|
session Ğ1), ce que la chaîne empêche : accepté tant qu'elle dure, et effacé par l'acte
|
||||||
|
« supprime ». Chaque geste simulé laisse un bulletin en base jusqu'à l'acte.
|
||||||
|
- **Un texte entré par des votes simulés se lit en vigueur sur le site sans être publié** ; le
|
||||||
|
dépôt des textes garde la dernière publication jusqu'aux votes prouvés. L'acte peut faire
|
||||||
|
retourner parmi les propositions un texte que des personnes ont lu en vigueur.
|
||||||
|
- Un message sur un bulletin lâché disparaît avec lui à la purge (une semaine).
|
||||||
@@ -16,4 +16,5 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan
|
|||||||
| [0009](0009-modules-ordre-et-titre.md) | Les modules : l'ordre des articles de chaque section (inertie 2), le titre du document (inertie 4) | 2026-10-31 |
|
| [0009](0009-modules-ordre-et-titre.md) | Les modules : l'ordre des articles de chaque section (inertie 2), le titre du document (inertie 4) | 2026-10-31 |
|
||||||
| [0010](0010-objections-et-coquilles-validees.md) | Objections « redit » / « contredit », coquilles validées, messagerie (remplace 0004, complète 0003) | 2026-10-31 |
|
| [0010](0010-objections-et-coquilles-validees.md) | Objections « redit » / « contredit », coquilles validées, messagerie (remplace 0004, complète 0003) | 2026-10-31 |
|
||||||
| [0011](0011-propositions-seed.md) | Les Propositions seed : rien en vigueur au départ, trois textes mis en avant au cran 2, la publication attend le premier texte (complète 0002) | 2026-11-02 |
|
| [0011](0011-propositions-seed.md) | Les Propositions seed : rien en vigueur au départ, trois textes mis en avant au cran 2, la publication attend le premier texte (complète 0002) | 2026-11-02 |
|
||||||
|
| [0012](0012-tampon-des-votes.md) | Le tampon des votes : l'intention visible tout de suite, la preuve de la chaîne, la réconciliation a posteriori ; la simulation tant que la chaîne est fermée (complète 0008, révise 0010) | 2026-11-02 |
|
||||||
| [0013](0013-quorum.md) | Le quorum par cran (12 · 12 · 50 · 50 · 50), fixé par l'auteur avant le premier vote ; sous le quorum, les votants qui manquent comptent comme « non » (remplace le C de 0002 et 0007) | 2026-11-03 |
|
| [0013](0013-quorum.md) | Le quorum par cran (12 · 12 · 50 · 50 · 50), fixé par l'auteur avant le premier vote ; sous le quorum, les votants qui manquent comptent comme « non » (remplace le C de 0002 et 0007) | 2026-11-03 |
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
|
|||||||
| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » |
|
| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » |
|
||||||
| C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page |
|
| C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page |
|
||||||
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
|
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
|
||||||
|
| C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « Tes bulletins » | « Voter en lot » est là ; aucune fenêtre de portefeuille ; « Ton vote : contre » aussitôt, et un « contre » de plus dans les totaux ; dans l'API, `myVoteState` « simulated » et `tallySimulated` ; dans « Tes bulletins » : « Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1 », sans bloc |
|
||||||
|
|
||||||
## Au-delà de la liste
|
## Au-delà de la liste
|
||||||
|
|
||||||
|
|||||||
+56
-2
@@ -176,8 +176,9 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur
|
|||||||
|
|
||||||
test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => {
|
test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
// The e2e server has its vote account: the page is shown what a server without one answers
|
// The e2e server has its vote account: the page is shown a vote closed, as a server without
|
||||||
// (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`).
|
// one answered before lot V. Since lot V (ADR 0012) a server without one votes in simulation
|
||||||
|
// instead (the server: `without_a_vote_account_monnaie_libre_votes_in_simulation_…`; here, C34).
|
||||||
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
|
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
|
||||||
const res = await route.fetch();
|
const res = await route.fetch();
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -213,3 +214,56 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
|
|||||||
await unit.getByRole('button', { name: 'Le vote' }).click();
|
await unit.getByRole('button', { name: 'Le vote' }).click();
|
||||||
await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The version of an article of Monnaie libre, by its title, as the API tells it to `page`. */
|
||||||
|
async function version(page: import('@playwright/test').Page, title: string) {
|
||||||
|
const g = await (await page.request.get('/api/groups/monnaie-libre')).json();
|
||||||
|
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
|
||||||
|
type P = { title: string; tally: { against: number }; tallySimulated: { against: number }; myVoteState: string | null };
|
||||||
|
const all: P[] = d.units.flatMap((u: { proposals: P[] }) => u.proposals);
|
||||||
|
const p = all.find((x) => x.title === title);
|
||||||
|
if (!p) throw new Error(`no version « ${title} »`);
|
||||||
|
return { vote: d.vote as { way: string; open: boolean; simulated: boolean }, p };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simulé compte aussitôt, marqué « simulé » dans tes bulletins', async ({ page }) => {
|
||||||
|
// Lot V (ADR 0012, Yvv 2026-11-02: « Il compte, puis s'efface »). The e2e server has its
|
||||||
|
// chain: it is closed for this control only, then opened again. The vote goes to a
|
||||||
|
// proposal no other control reads, and only « contre »: nothing enters into force.
|
||||||
|
await signIn(page, 'Sacha');
|
||||||
|
const chainOpen = async (open: boolean) =>
|
||||||
|
(await (await page.request.post('/api/dev/vote-chain', { data: { open }, headers: { 'x-requested-with': 'libredecision' } })).json()).open;
|
||||||
|
const title = 'Logiciel auditable';
|
||||||
|
expect(await chainOpen(false)).toBe(false);
|
||||||
|
try {
|
||||||
|
const before = await version(page, title);
|
||||||
|
expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true });
|
||||||
|
await page.goto(DOC);
|
||||||
|
// The basket is there, without a wallet: the vote leaves by the server.
|
||||||
|
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
|
||||||
|
await deepRow(page, title).click();
|
||||||
|
const fold = page.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||||
|
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||||
|
await expect(page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' })).toHaveCount(0);
|
||||||
|
await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/);
|
||||||
|
await expect(fold.getByText(`0 pour · ${before.p.tally.against + 1} contre`)).toBeVisible();
|
||||||
|
// Under the article: marked « simulé », never « enregistré »; the note, once, by the buttons.
|
||||||
|
await expect(fold.locator('.voted')).toHaveText('Ton vote : contre · simulé');
|
||||||
|
await expect(fold.getByText('enregistré', { exact: false })).toHaveCount(0);
|
||||||
|
await expect(fold.getByText("La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé.", { exact: false })).toHaveCount(1);
|
||||||
|
const n = before.p.tallySimulated.against + 1;
|
||||||
|
await expect(fold.getByText(n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`, { exact: true })).toBeVisible();
|
||||||
|
// Counted at once, marked as simulated.
|
||||||
|
const after = await version(page, title);
|
||||||
|
expect(after.p.myVoteState).toBe('simulated');
|
||||||
|
expect(after.p.tallySimulated.against).toBe(before.p.tallySimulated.against + 1);
|
||||||
|
// In her ballots: simulated, counted for her session, never a block of the chain.
|
||||||
|
await page.goto('/bulletins');
|
||||||
|
const ballot = page.locator('li.ballot').first();
|
||||||
|
await expect(ballot.locator('.state')).toHaveText("Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1");
|
||||||
|
await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
// The next controls need the chain.
|
||||||
|
expect(await chainOpen(true)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
<div class="level">
|
<div class="level">
|
||||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
||||||
<div class="numbers">
|
<div class="numbers">
|
||||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
|
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
|
||||||
{#if p.evaluation}
|
{#if p.evaluation}
|
||||||
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
|
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
|
||||||
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||||
@@ -186,8 +186,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if p.myVoteStale}
|
{#if p.myVoteStale}
|
||||||
<p class="warn">{t('vote.stale')}</p>
|
<p class="warn">{t('vote.stale')}</p>
|
||||||
{:else if p.myVote}
|
{:else if p.myVote && p.myVoteState !== 'pending'}
|
||||||
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}</span></p>
|
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : p.myVoteState === 'simulated' ? 'vote.mine.simulated' : 'vote.mine.saved')}</span></p>
|
||||||
{:else if ballot?.status !== 'pending'}
|
{:else if ballot?.status !== 'pending'}
|
||||||
<p class="muted">{t('vote.mine.none')}</p>
|
<p class="muted">{t('vote.mine.none')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -215,6 +215,8 @@
|
|||||||
{#if chain && access.chain}
|
{#if chain && access.chain}
|
||||||
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
|
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
|
||||||
<p class="faint small">{t('vote.chain.counts')}</p>
|
<p class="faint small">{t('vote.chain.counts')}</p>
|
||||||
|
{:else if access.simulated}
|
||||||
|
<p class="faint small">{t('vote.simulated.note')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<p class="faint small">{t('vote.private')}</p>
|
<p class="faint small">{t('vote.private')}</p>
|
||||||
|
|||||||
+11
-1
@@ -255,6 +255,10 @@ export const en: Messages = {
|
|||||||
'vote.mine.chain': 'recorded on the Ğ1 chain',
|
'vote.mine.chain': 'recorded on the Ğ1 chain',
|
||||||
'vote.mine.saved': 'saved',
|
'vote.mine.saved': 'saved',
|
||||||
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
||||||
|
'vote.mine.simulated': 'simulated',
|
||||||
|
'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until it is erased when the chain opens.',
|
||||||
|
'vote.tally.simulated.one': 'of which {n} simulated',
|
||||||
|
'vote.tally.simulated.other': 'of which {n} simulated',
|
||||||
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
||||||
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
|
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
|
||||||
'vote.mine.receipts': 'Your ballots and receipts',
|
'vote.mine.receipts': 'Your ballots and receipts',
|
||||||
@@ -269,6 +273,7 @@ export const en: Messages = {
|
|||||||
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
|
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
|
||||||
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
|
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
|
||||||
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
|
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
|
||||||
|
'ballot.reason.objection': 'an objection was holding this version back when the Ğ1 chain confirmed your vote; your earlier vote stands.',
|
||||||
'basket.title': 'Voting basket',
|
'basket.title': 'Voting basket',
|
||||||
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
|
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
|
||||||
'basket.doc': 'The whole document',
|
'basket.doc': 'The whole document',
|
||||||
@@ -336,6 +341,7 @@ export const en: Messages = {
|
|||||||
'ballots.lines.other': '{n} votes',
|
'ballots.lines.other': '{n} votes',
|
||||||
'ballots.status.pending': 'Waiting for the Ğ1 chain',
|
'ballots.status.pending': 'Waiting for the Ğ1 chain',
|
||||||
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
|
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
|
||||||
|
'ballots.status.simulated': 'Simulated: counted for “{name}” until the Ğ1 chain opens',
|
||||||
'ballots.status.refused': 'Not counted: {reason}',
|
'ballots.status.refused': 'Not counted: {reason}',
|
||||||
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
|
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
|
||||||
'ballots.block': 'block {n}',
|
'ballots.block': 'block {n}',
|
||||||
@@ -475,7 +481,7 @@ export const en: Messages = {
|
|||||||
'objection.respond.contredit': 'Reword',
|
'objection.respond.contredit': 'Reword',
|
||||||
'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.',
|
'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.',
|
||||||
'messages.title': 'Messages',
|
'messages.title': 'Messages',
|
||||||
'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.',
|
'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more. You also read here what became of a vote that was not counted.',
|
||||||
'messages.none': 'No messages for now.',
|
'messages.none': 'No messages for now.',
|
||||||
'messages.readAll': 'Mark all as read',
|
'messages.readAll': 'Mark all as read',
|
||||||
'messages.read': 'Mark as read',
|
'messages.read': 'Mark as read',
|
||||||
@@ -484,12 +490,16 @@ export const en: Messages = {
|
|||||||
'messages.open': 'See it in the text',
|
'messages.open': 'See it in the text',
|
||||||
'messages.topic.objection': 'Objection',
|
'messages.topic.objection': 'Objection',
|
||||||
'messages.topic.typo': 'Typo',
|
'messages.topic.typo': 'Typo',
|
||||||
|
'messages.topic.ballot': 'Vote',
|
||||||
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.',
|
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.',
|
||||||
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.',
|
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.',
|
||||||
'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. Answer again.',
|
'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. Answer again.',
|
||||||
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
|
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
|
||||||
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
|
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
|
||||||
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
|
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
|
||||||
|
'messages.ballot_expired': 'Your vote of {date} ({articles}) was not seen on the Ğ1 chain within 24 hours: it is set aside. You can vote again.',
|
||||||
|
'messages.ballot_refused': 'Your vote of {date} ({articles}) was not counted: {reason} You can vote again.',
|
||||||
|
'messages.ballot_objection': 'Your “for” on {articles} is set aside: an objection was holding this version back when the Ğ1 chain confirmed your vote. Your earlier vote stands.',
|
||||||
'messages.respond': 'Answer',
|
'messages.respond': 'Answer',
|
||||||
'messages.outcome.answered': 'answered',
|
'messages.outcome.answered': 'answered',
|
||||||
'messages.outcome.reiterated': 'objection reiterated',
|
'messages.outcome.reiterated': 'objection reiterated',
|
||||||
|
|||||||
+11
-1
@@ -255,6 +255,10 @@ export const es: Messages = {
|
|||||||
'vote.mine.chain': 'inscrito en la cadena Ğ1',
|
'vote.mine.chain': 'inscrito en la cadena Ğ1',
|
||||||
'vote.mine.saved': 'registrado',
|
'vote.mine.saved': 'registrado',
|
||||||
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
||||||
|
'vote.mine.simulated': 'simulado',
|
||||||
|
'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», hasta que se borre al abrirse la cadena.',
|
||||||
|
'vote.tally.simulated.one': 'de ellos {n} simulado',
|
||||||
|
'vote.tally.simulated.other': 'de ellos {n} simulados',
|
||||||
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
||||||
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
|
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
|
||||||
'vote.mine.receipts': 'Tus papeletas y recibos',
|
'vote.mine.receipts': 'Tus papeletas y recibos',
|
||||||
@@ -269,6 +273,7 @@ export const es: Messages = {
|
|||||||
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
|
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
|
||||||
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
|
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
|
||||||
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
|
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
|
||||||
|
'ballot.reason.objection': 'una objeción retenía esta versión cuando la cadena Ğ1 confirmó tu voto; tu voto anterior se mantiene.',
|
||||||
'basket.title': 'Cesta de votación',
|
'basket.title': 'Cesta de votación',
|
||||||
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
|
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
|
||||||
'basket.doc': 'Todo el documento',
|
'basket.doc': 'Todo el documento',
|
||||||
@@ -336,6 +341,7 @@ export const es: Messages = {
|
|||||||
'ballots.lines.other': '{n} votos',
|
'ballots.lines.other': '{n} votos',
|
||||||
'ballots.status.pending': 'Esperando a la cadena Ğ1',
|
'ballots.status.pending': 'Esperando a la cadena Ğ1',
|
||||||
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
|
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
|
||||||
|
'ballots.status.simulated': 'Simulado: contado para «{name}» hasta que se abra la cadena Ğ1',
|
||||||
'ballots.status.refused': 'No contado: {reason}',
|
'ballots.status.refused': 'No contado: {reason}',
|
||||||
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
|
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
|
||||||
'ballots.block': 'bloque {n}',
|
'ballots.block': 'bloque {n}',
|
||||||
@@ -475,7 +481,7 @@ export const es: Messages = {
|
|||||||
'objection.respond.contredit': 'Reformular',
|
'objection.respond.contredit': 'Reformular',
|
||||||
'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.',
|
'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.',
|
||||||
'messages.title': 'Mensajes',
|
'messages.title': 'Mensajes',
|
||||||
'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.',
|
'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada. Aquí lees también qué fue de un voto que no se contó.',
|
||||||
'messages.none': 'Ningún mensaje por ahora.',
|
'messages.none': 'Ningún mensaje por ahora.',
|
||||||
'messages.readAll': 'Marcar todo como leído',
|
'messages.readAll': 'Marcar todo como leído',
|
||||||
'messages.read': 'Marcar como leído',
|
'messages.read': 'Marcar como leído',
|
||||||
@@ -484,12 +490,16 @@ export const es: Messages = {
|
|||||||
'messages.open': 'Verlo en el texto',
|
'messages.open': 'Verlo en el texto',
|
||||||
'messages.topic.objection': 'Objeción',
|
'messages.topic.objection': 'Objeción',
|
||||||
'messages.topic.typo': 'Errata',
|
'messages.topic.typo': 'Errata',
|
||||||
|
'messages.topic.ballot': 'Voto',
|
||||||
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.',
|
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.',
|
||||||
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.',
|
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.',
|
||||||
'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». Responde de nuevo.',
|
'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». Responde de nuevo.',
|
||||||
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
|
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
|
||||||
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
|
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
|
||||||
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
|
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
|
||||||
|
'messages.ballot_expired': 'Tu voto del {date} ({articles}) no se vio en la cadena Ğ1 en 24 horas: queda descartado. Puedes votar de nuevo.',
|
||||||
|
'messages.ballot_refused': 'Tu voto del {date} ({articles}) no se contó: {reason} Puedes votar de nuevo.',
|
||||||
|
'messages.ballot_objection': 'Tu «a favor» sobre {articles} queda descartado: una objeción retenía esta versión cuando la cadena Ğ1 confirmó tu voto. Tu voto anterior se mantiene.',
|
||||||
'messages.respond': 'Responder',
|
'messages.respond': 'Responder',
|
||||||
'messages.outcome.answered': 'respuesta dada',
|
'messages.outcome.answered': 'respuesta dada',
|
||||||
'messages.outcome.reiterated': 'objeción reiterada',
|
'messages.outcome.reiterated': 'objeción reiterada',
|
||||||
|
|||||||
+11
-1
@@ -259,6 +259,10 @@ export const fr = {
|
|||||||
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
|
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
|
||||||
'vote.mine.saved': 'enregistré',
|
'vote.mine.saved': 'enregistré',
|
||||||
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
||||||
|
'vote.mine.simulated': 'simulé',
|
||||||
|
'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », jusqu'à son effacement à l'ouverture de la chaîne.",
|
||||||
|
'vote.tally.simulated.one': 'dont {n} simulé',
|
||||||
|
'vote.tally.simulated.other': 'dont {n} simulés',
|
||||||
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
||||||
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
|
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
|
||||||
'vote.mine.receipts': 'Tes bulletins et reçus',
|
'vote.mine.receipts': 'Tes bulletins et reçus',
|
||||||
@@ -273,6 +277,7 @@ export const fr = {
|
|||||||
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
|
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
|
||||||
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
|
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
|
||||||
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
|
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
|
||||||
|
'ballot.reason.objection': "une objection retenait cette version quand la chaîne Ğ1 a confirmé ton vote ; ton vote d'avant reste.",
|
||||||
'basket.title': 'Panier de vote',
|
'basket.title': 'Panier de vote',
|
||||||
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
|
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
|
||||||
'basket.doc': 'Tout le document',
|
'basket.doc': 'Tout le document',
|
||||||
@@ -340,6 +345,7 @@ export const fr = {
|
|||||||
'ballots.lines.other': '{n} votes',
|
'ballots.lines.other': '{n} votes',
|
||||||
'ballots.status.pending': 'En attente de la chaîne Ğ1',
|
'ballots.status.pending': 'En attente de la chaîne Ğ1',
|
||||||
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
|
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
|
||||||
|
'ballots.status.simulated': "Simulé : compté pour « {name} » jusqu'à l'ouverture de la chaîne Ğ1",
|
||||||
'ballots.status.refused': 'Pas compté : {reason}',
|
'ballots.status.refused': 'Pas compté : {reason}',
|
||||||
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
|
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
|
||||||
'ballots.block': 'bloc {n}',
|
'ballots.block': 'bloc {n}',
|
||||||
@@ -479,7 +485,7 @@ export const fr = {
|
|||||||
'objection.respond.contredit': 'Reformuler',
|
'objection.respond.contredit': 'Reformuler',
|
||||||
'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.",
|
'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.",
|
||||||
'messages.title': 'Messages',
|
'messages.title': 'Messages',
|
||||||
'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.',
|
'messages.intro': "Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien. Tu y lis aussi ce qu'il advient d'un vote qui n'a pas été compté.",
|
||||||
'messages.none': "Aucun message pour l'instant.",
|
'messages.none': "Aucun message pour l'instant.",
|
||||||
'messages.readAll': 'Tout marquer comme lu',
|
'messages.readAll': 'Tout marquer comme lu',
|
||||||
'messages.read': 'Marquer comme lu',
|
'messages.read': 'Marquer comme lu',
|
||||||
@@ -488,12 +494,16 @@ export const fr = {
|
|||||||
'messages.open': 'Voir dans le texte',
|
'messages.open': 'Voir dans le texte',
|
||||||
'messages.topic.objection': 'Objection',
|
'messages.topic.objection': 'Objection',
|
||||||
'messages.topic.typo': 'Coquille',
|
'messages.topic.typo': 'Coquille',
|
||||||
|
'messages.topic.ballot': 'Vote',
|
||||||
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.",
|
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.",
|
||||||
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.",
|
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.",
|
||||||
'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». Réponds à nouveau.',
|
'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». Réponds à nouveau.',
|
||||||
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
|
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
|
||||||
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
|
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
|
||||||
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
|
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
|
||||||
|
'messages.ballot_expired': "Ton vote du {date} ({articles}) n'a pas été vu sur la chaîne Ğ1 en 24 heures : il est écarté. Tu peux voter à nouveau.",
|
||||||
|
'messages.ballot_refused': "Ton vote du {date} ({articles}) n'a pas été compté : {reason} Tu peux voter à nouveau.",
|
||||||
|
'messages.ballot_objection': "Ton « pour » sur {articles} est écarté : une objection retenait cette version quand la chaîne Ğ1 a confirmé ton vote. Ton vote d'avant reste.",
|
||||||
'messages.respond': 'Répondre',
|
'messages.respond': 'Répondre',
|
||||||
'messages.outcome.answered': 'réponse donnée',
|
'messages.outcome.answered': 'réponse donnée',
|
||||||
'messages.outcome.reiterated': 'objection réitérée',
|
'messages.outcome.reiterated': 'objection réitérée',
|
||||||
|
|||||||
+47
-5
@@ -117,7 +117,14 @@ export interface Proposal {
|
|||||||
seedMinimal: boolean;
|
seedMinimal: boolean;
|
||||||
/** A seed proposal (ADR 0011): open, put forward by the seed to start the vote, not in force. */
|
/** A seed proposal (ADR 0011): open, put forward by the seed to start the vote, not in force. */
|
||||||
seedProposal?: boolean;
|
seedProposal?: boolean;
|
||||||
|
/** The totals shown: recorded, pending and simulated votes, each intention in place of
|
||||||
|
* the vote its session sees as its own (ADR 0012). */
|
||||||
tally: Tally;
|
tally: Tally;
|
||||||
|
/** The part of `tally` still without proof (an intention pending on the chain). */
|
||||||
|
tallyPending: Tally;
|
||||||
|
/** The part of `tally` that comes from the simulation (ADR 0012). */
|
||||||
|
tallySimulated: Tally;
|
||||||
|
/** On the totals shown (`tally`), the quorum included (ADR 0013). */
|
||||||
evaluation: Evaluation | null;
|
evaluation: Evaluation | null;
|
||||||
votedAtInertia: Inertia;
|
votedAtInertia: Inertia;
|
||||||
/** Held back by a standing objection: the latest arrival of the two (never a text in force). */
|
/** Held back by a standing objection: the latest arrival of the two (never a text in force). */
|
||||||
@@ -126,11 +133,17 @@ export interface Proposal {
|
|||||||
votable: boolean;
|
votable: boolean;
|
||||||
/** Standing objections around this version. */
|
/** Standing objections around this version. */
|
||||||
objections: number;
|
objections: number;
|
||||||
|
/** What the person sees as her vote now: her latest gesture, an intention pending included. */
|
||||||
myVote: 'for' | 'against' | null;
|
myVote: 'for' | 'against' | null;
|
||||||
|
/** Where « ton vote » stands (ADR 0012): `recorded` — proven by the chain, or a vote of
|
||||||
|
* the server in a managed group; `pending` — an intention waiting for the chain;
|
||||||
|
* `simulated` — a vote of the simulation, while the chain is closed. */
|
||||||
|
myVoteState: 'recorded' | 'pending' | 'simulated' | null;
|
||||||
myVoteStale: boolean;
|
myVoteStale: boolean;
|
||||||
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
|
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
|
||||||
myVoteChain: boolean;
|
myVoteChain: boolean;
|
||||||
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */
|
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go —
|
||||||
|
* or its *for* set aside by an objection (`refused`, reason `objection`, ADR 0012). */
|
||||||
myBallot: MyBallot | null;
|
myBallot: MyBallot | null;
|
||||||
translations: Partial<Record<Lang, Translation>>;
|
translations: Partial<Record<Lang, Translation>>;
|
||||||
/** An order version: the order it gives its section now. */
|
/** An order version: the order it gives its section now. */
|
||||||
@@ -142,7 +155,7 @@ export type Choice = 'for' | 'against' | 'none';
|
|||||||
export interface MyBallot {
|
export interface MyBallot {
|
||||||
id: string;
|
id: string;
|
||||||
status: 'pending' | 'refused' | 'expired';
|
status: 'pending' | 'refused' | 'expired';
|
||||||
reason: 'not_member' | 'no_identity' | 'transfer' | null;
|
reason: 'not_member' | 'no_identity' | 'transfer' | 'objection' | null;
|
||||||
choice: Choice;
|
choice: Choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,8 +175,12 @@ export interface ChainSettings {
|
|||||||
|
|
||||||
/** How this session votes in the group of the document, and whether it may now. */
|
/** How this session votes in the group of the document, and whether it may now. */
|
||||||
export interface VoteAccess {
|
export interface VoteAccess {
|
||||||
|
/** `server` too while the chain is closed: the vote is then simulated (`simulated`). */
|
||||||
way: 'server' | 'chain';
|
way: 'server' | 'chain';
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
/** The chain is closed: the vote leaves by a write of the server, marked as simulated,
|
||||||
|
* and counts until an act « supprime » sets it aside (ADR 0012). */
|
||||||
|
simulated: boolean;
|
||||||
reason: 'members_only' | 'chain_off' | null;
|
reason: 'members_only' | 'chain_off' | null;
|
||||||
chain: ChainSettings | null;
|
chain: ChainSettings | null;
|
||||||
}
|
}
|
||||||
@@ -181,6 +198,8 @@ export interface Ballot {
|
|||||||
voter: string | null;
|
voter: string | null;
|
||||||
entered: Entered[];
|
entered: Entered[];
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
/** `simulated`: a ballot of the simulation, proven by the server, never on the chain. */
|
||||||
|
mode: 'chain' | 'simulated';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */
|
/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */
|
||||||
@@ -195,6 +214,7 @@ export interface BallotEntry {
|
|||||||
lines: number;
|
lines: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
settledAt: string | null;
|
settledAt: string | null;
|
||||||
|
mode: Ballot['mode'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
@@ -234,6 +254,9 @@ export interface Publication {
|
|||||||
doneAt?: string | null;
|
doneAt?: string | null;
|
||||||
commit?: string | null;
|
commit?: string | null;
|
||||||
attempts?: number;
|
attempts?: number;
|
||||||
|
/** Why it is suspended: no article in force yet (`text`, ADR 0011), or something in force
|
||||||
|
* entered by simulated votes that the proven votes do not carry yet (`proven`, ADR 0012). */
|
||||||
|
waitsFor?: 'text' | 'proven' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocView {
|
export interface DocView {
|
||||||
@@ -345,11 +368,29 @@ export interface TypoFix {
|
|||||||
version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null };
|
version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A message of one's box: about an objection or a typo fix, with its gestures now. */
|
/** A ballot set aside, as a message tells it (ADR 0012): line by line, what became of it. */
|
||||||
|
export interface BallotNotice {
|
||||||
|
id: string;
|
||||||
|
status: Ballot['status'];
|
||||||
|
reason: MyBallot['reason'];
|
||||||
|
mode: Ballot['mode'];
|
||||||
|
createdAt: string;
|
||||||
|
settledAt: string | null;
|
||||||
|
lines: { proposalId: string; unitId: string; code: string; title: string; choice: Choice; outcome: 'counted' | 'superseded' | 'objection' | null }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message of one's box: about an objection, a typo fix or a ballot set aside, with its gestures now. */
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
topic: 'objection' | 'typo';
|
topic: 'objection' | 'typo' | 'ballot';
|
||||||
kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending';
|
kind:
|
||||||
|
| 'objection_raised'
|
||||||
|
| 'objection_answered'
|
||||||
|
| 'objection_reiterated'
|
||||||
|
| 'typo_pending'
|
||||||
|
| 'ballot_expired'
|
||||||
|
| 'ballot_refused'
|
||||||
|
| 'ballot_objection';
|
||||||
cycle: number;
|
cycle: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
dueAt: string | null;
|
dueAt: string | null;
|
||||||
@@ -361,4 +402,5 @@ export interface Message {
|
|||||||
doc: { id: string; groupSlug: string; slug: string; title: LangMap };
|
doc: { id: string; groupSlug: string; slug: string; title: LangMap };
|
||||||
objection?: Objection;
|
objection?: Objection;
|
||||||
typo?: TypoFix;
|
typo?: TypoFix;
|
||||||
|
ballot?: BallotNotice;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
|
|
||||||
function standing(b: BallotEntry): string {
|
function standing(b: BallotEntry): string {
|
||||||
if (b.status === 'pending') return t('ballots.status.pending');
|
if (b.status === 'pending') return t('ballots.status.pending');
|
||||||
|
// A vote of the simulation (ADR 0012): counted for the session, never on the chain.
|
||||||
|
if (b.status === 'confirmed' && b.mode === 'simulated') return t('ballots.status.simulated', { name: b.voter ?? '' });
|
||||||
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
||||||
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||||
return t('ballots.status.expired');
|
return t('ballots.status.expired');
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
{#each data.value.ballots as b (b.id)}
|
{#each data.value.ballots as b (b.id)}
|
||||||
<li class="card block ballot">
|
<li class="card block ballot">
|
||||||
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
||||||
<p class="state {b.status}">
|
<p class="state {b.status}" class:simulated={b.mode === 'simulated'}>
|
||||||
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
||||||
</p>
|
</p>
|
||||||
<code class="comment">{b.comment}</code>
|
<code class="comment">{b.comment}</code>
|
||||||
@@ -159,7 +161,8 @@
|
|||||||
.state.confirmed {
|
.state.confirmed {
|
||||||
color: var(--moss);
|
color: var(--moss);
|
||||||
}
|
}
|
||||||
.state.pending {
|
.state.pending,
|
||||||
|
.state.confirmed.simulated {
|
||||||
color: var(--water);
|
color: var(--water);
|
||||||
}
|
}
|
||||||
.state.refused,
|
.state.refused,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
|
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
|
||||||
// ADR 0010): what waits for a gesture of the person — an objection to answer,
|
// ADR 0010): what waits for a gesture of the person — an objection to answer,
|
||||||
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
|
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
|
||||||
// deadline. A message dealt with offers no gesture any more. No mail.
|
// deadline. A message dealt with offers no gesture any more. No mail. A ballot set
|
||||||
|
// aside (ADR 0012) is told here too, without gesture: one votes again in the text.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
@@ -44,10 +45,27 @@
|
|||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The lines of a ballot a message is about: those set aside by an objection, or all. */
|
||||||
|
function linesOf(m: Message) {
|
||||||
|
const lines = m.ballot?.lines ?? [];
|
||||||
|
const held = lines.filter((l) => l.outcome === 'objection');
|
||||||
|
return m.kind === 'ballot_objection' && held.length ? held : lines;
|
||||||
|
}
|
||||||
|
|
||||||
/** The text of a message, in the language of the reader, from its subject. */
|
/** The text of a message, in the language of the reader, from its subject. */
|
||||||
function textOf(m: Message): string {
|
function textOf(m: Message): string {
|
||||||
const o = m.objection;
|
const o = m.objection;
|
||||||
const fix = m.typo;
|
const fix = m.typo;
|
||||||
|
const b = m.ballot;
|
||||||
|
if (b) {
|
||||||
|
const articles = linesOf(m)
|
||||||
|
.map((l) => l.code)
|
||||||
|
.join(', ');
|
||||||
|
const date = i18n.date(b.createdAt);
|
||||||
|
if (m.kind === 'ballot_expired') return t('messages.ballot_expired', { date, articles });
|
||||||
|
if (m.kind === 'ballot_refused') return t('messages.ballot_refused', { date, articles, reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||||
|
return t('messages.ballot_objection', { articles });
|
||||||
|
}
|
||||||
if (o) {
|
if (o) {
|
||||||
const held = o.heldVersion?.title ?? '';
|
const held = o.heldVersion?.title ?? '';
|
||||||
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
|
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
|
||||||
@@ -73,6 +91,8 @@
|
|||||||
function hrefOf(m: Message): string {
|
function hrefOf(m: Message): string {
|
||||||
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
|
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
|
||||||
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
|
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
|
||||||
|
const line = linesOf(m)[0];
|
||||||
|
if (line) return `${base}?${panelSearch({ tab: 'details', unitId: line.unitId, proposalId: line.proposalId }, null)}`;
|
||||||
if (!v) return base;
|
if (!v) return base;
|
||||||
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
|
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
|
||||||
}
|
}
|
||||||
@@ -149,7 +169,7 @@
|
|||||||
<p class="text">{textOf(m)}</p>
|
<p class="text">{textOf(m)}</p>
|
||||||
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
|
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
|
||||||
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
|
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
|
||||||
{#if m.done}
|
{#if m.done && m.topic !== 'ballot'}
|
||||||
<p class="outcome">{outcomeOf(m)}</p>
|
<p class="outcome">{outcomeOf(m)}</p>
|
||||||
{:else if m.dueAt}
|
{:else if m.dueAt}
|
||||||
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
|
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
|
|||||||
return {
|
return {
|
||||||
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
||||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
|
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, tallyPending: { for: 0, against: 0 }, tallySimulated: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
|
||||||
blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
blocked: false, votable: true, objections: 0, myVote: null, myVoteState: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
|
|||||||
return {
|
return {
|
||||||
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
||||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, tallyPending: { for: 0, against: 0 }, tallySimulated: { for: 0, against: 0 },
|
||||||
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
||||||
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteState: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user