forked from yvv/decision
Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//! The quorum of an article (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition par défaut, variable
|
||||
//! modifiable par l'auteur initial du texte » — « L'auteur, avant le 1er vote ». Each
|
||||
//! level has its quorum (`documents.quorums`); an article judged on another one holds
|
||||
//! it in `units.quorum`, set here by the author of its first version (an administrator
|
||||
//! for a text of the seed, `trustin_access::sets_quorum`), and only while no vote
|
||||
//! exists on it (`voted_units`). From 2 to W; from 1 in the test group
|
||||
//! (`ld_engine::inertia::quorum_bounds`). Each change is journaled (`quorum_changes`).
|
||||
//! An article's own quorum is not published: `document.json` carries the quorums by level.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::docs::{self, DocState, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::inertia::quorum_bounds;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::SqliteConnection;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// **« Un vote existe »** — the units of a document on which a vote exists: the one
|
||||
/// place this is asked (ADR 0013; lot V extends it here, nowhere else).
|
||||
///
|
||||
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
||||
/// of its inertia; any revision) holds
|
||||
/// - a vote of the server (`votes`: a vote of a managed group, or the line of a ballot
|
||||
/// of the chain once confirmed, written there by the confirmation);
|
||||
/// - a line of a ballot of the chain, **even pending** — signed, not yet read on the
|
||||
/// chain (Yvv, 2026-11-03) — or confirmed; a refused or expired ballot is no vote;
|
||||
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
||||
/// its votes are withdrawn.
|
||||
///
|
||||
/// A vote of the server withdrawn before any adoption leaves no row (`votes` keeps the
|
||||
/// last choice only): from then on, no vote exists (a known limit, ADR 0013).
|
||||
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
||||
let ids: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT p.unit_id FROM votes v
|
||||
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1
|
||||
UNION
|
||||
SELECT p.unit_id FROM ballot_lines l
|
||||
JOIN ballots b ON b.id = l.ballot_id
|
||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1 AND b.status IN ('pending', 'confirmed')
|
||||
UNION
|
||||
SELECT a.unit_id FROM adoptions a WHERE a.doc_id = ?1",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
Ok(ids.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Does a vote exist on this unit (`voted_units`)?
|
||||
pub async fn a_vote_exists(state: &AppState, unit_id: &str) -> AppResult<bool> {
|
||||
let doc_id = doc_of_unit(state, unit_id).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
Ok(voted_units(&mut conn, &doc_id).await?.contains(unit_id))
|
||||
}
|
||||
|
||||
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// The author of each unit's first version (`None`: a text of the seed, without author).
|
||||
/// A first version is a text — of an article or a module —, never a removal or a change
|
||||
/// of inertia, whatever became of it since.
|
||||
pub async fn first_authors(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Option<String>>> {
|
||||
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT p.unit_id, p.author_id FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ? AND p.kind NOT IN ('remove', 'inertia') ORDER BY p.created_at, p.rowid",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
for (unit, author) in rows {
|
||||
out.entry(unit).or_insert(author);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a person may do with the quorums of a document, read once for its view.
|
||||
pub struct Rights {
|
||||
first_authors: HashMap<String, Option<String>>,
|
||||
voted: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Rights {
|
||||
pub async fn of(state: &AppState, doc_id: &str) -> AppResult<Self> {
|
||||
let first_authors = first_authors(state, doc_id).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let voted = voted_units(&mut conn, doc_id).await?;
|
||||
Ok(Self { first_authors, voted })
|
||||
}
|
||||
|
||||
fn first_author(&self, unit: &UnitRow) -> Option<&str> {
|
||||
self.first_authors.get(&unit.id).and_then(|a| a.as_deref())
|
||||
}
|
||||
|
||||
/// May `user` set the quorum of `unit` now (`trustin_access::sets_quorum`)?
|
||||
pub fn sets(&self, user: &IdentityRow, unit: &UnitRow) -> bool {
|
||||
trustin_access::sets_quorum(&user.to_identity(), self.first_author(unit), self.voted.contains(&unit.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quorum of a unit, as its view says it: its own (`null`: its level's), its level's,
|
||||
/// and whether `user` may set it.
|
||||
pub fn unit_json(st: &DocState, rights: &Rights, user: &IdentityRow, unit: &UnitRow) -> serde_json::Value {
|
||||
json!({
|
||||
"quorum": unit.own_quorum(), "quorumDefault": st.quorum_default(unit), "canSetQuorum": rights.sets(user, unit),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QuorumBody {
|
||||
/// A whole number of voters; `null`: back to the quorum of its level.
|
||||
quorum: Option<i64>,
|
||||
}
|
||||
|
||||
/// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`.
|
||||
pub async fn set(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<QuorumBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let doc_id = doc_of_unit(&state, &unit_id).await?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
let authors = first_authors(&state, &doc_id).await?;
|
||||
let first = authors.get(&unit.id).and_then(|a| a.as_deref());
|
||||
let identity = user.to_identity();
|
||||
if !trustin_access::sets_quorum(&identity, first, false) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let (lowest, highest) = quorum_bounds(st.electorate, st.group.slug == trustin_access::TEST_GROUP);
|
||||
let to = match b.quorum {
|
||||
None => None,
|
||||
Some(q) if q >= i64::from(lowest) && q <= i64::from(highest) => Some(q),
|
||||
Some(_) => return Err(AppError::Invalid("quorum")),
|
||||
};
|
||||
let mut tx = state.db.begin().await?;
|
||||
// Asked within the writing transaction: a vote that arrives first wins.
|
||||
let voted = voted_units(&mut tx, &doc_id).await?.contains(&unit.id);
|
||||
if !trustin_access::sets_quorum(&identity, first, voted) {
|
||||
return Err(AppError::Conflict("voted"));
|
||||
}
|
||||
if unit.quorum != to {
|
||||
let now = util::now();
|
||||
sqlx::query("UPDATE units SET quorum = ? WHERE id = ?").bind(to).bind(&unit.id).execute(&mut *tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_changes (unit_id, doc_id, from_quorum, to_quorum, identity_id, at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&unit.id)
|
||||
.bind(&doc_id)
|
||||
.bind(unit.quorum)
|
||||
.bind(to)
|
||||
.bind(&user.id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tracing::info!(doc = %doc_id, unit = %unit.code, from = ?unit.quorum, to = ?to, by = %user.id, "quorum set");
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({ "quorum": to, "quorumDefault": st.quorum_default(unit), "canSetQuorum": true })))
|
||||
}
|
||||
Reference in New Issue
Block a user