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
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful

Boucle verte dans la copie du lot (98b4530 puis d57326d) : 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:
Yvv
2026-11-03 06:16:08 +01:00
co-authored by Claude Opus 5.5
37 changed files with 2939 additions and 235 deletions
+10 -5
View File
@@ -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
+387
View File
@@ -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));
}
}
+1
View File
@@ -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.
+5 -1
View File
@@ -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
View File
@@ -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 }))
} }
+13
View File
@@ -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
View File
@@ -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"),
+20 -1
View File
@@ -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))
+8 -1
View File
@@ -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,
}
} }
+11 -2
View File
@@ -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!({
+6 -2
View File
@@ -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",
+3
View File
@@ -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?;
+12 -13
View File
@@ -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 })))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+72 -9
View File
@@ -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 },
+17 -6
View File
@@ -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
+326
View File
@@ -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, &params, 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(&params["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, &params, 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(())
}
+14 -7
View File
@@ -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");
+849 -17
View File
@@ -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))
);
}
+4 -3
View File
@@ -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);
+1
View File
@@ -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
} }
+28
View File
@@ -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");
+4
View File
@@ -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é**
+296
View File
@@ -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).
+1
View File
@@ -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
View File
@@ -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);
}
});
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+5 -2
View File
@@ -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,
+22 -2
View File
@@ -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>
+2 -2
View File
@@ -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,
}; };
} }
+2 -2
View File
@@ -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,
}; };
} }