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:
@@ -190,14 +190,15 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
||||
Ok(entered)
|
||||
}
|
||||
|
||||
/// What an adoption keeps of the formula it was judged by. The four letters
|
||||
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
|
||||
/// `constant`: read as they were written); the level and the scale join them.
|
||||
/// 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
|
||||
/// 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.
|
||||
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
|
||||
let level = st.voting_level(unit, p);
|
||||
let f = st.scale().params(level);
|
||||
let f = st.params(unit, p);
|
||||
serde_json::json!({
|
||||
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant,
|
||||
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
|
||||
"level": level, "share_pct": level.share_pct(),
|
||||
"participation": st.participation.ratio, "participation_source": st.participation.source,
|
||||
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
|
||||
|
||||
@@ -15,7 +15,7 @@ use axum::extract::{Path, Query, State};
|
||||
use ld_engine::ballot::Choice;
|
||||
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
|
||||
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
|
||||
use ld_engine::threshold::{Evaluation, Tally};
|
||||
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -196,8 +196,21 @@ pub struct DocRow {
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub source_lang: String,
|
||||
/// The reference quorum Q₀ (C before 2026-11-03): read for the reference
|
||||
/// participation p0 = 2Q₀/W only, and as every level's quorum while `quorums` is NULL.
|
||||
pub vote_floor: f64,
|
||||
pub publish_path: Option<String>,
|
||||
/// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum
|
||||
/// amendment (`quorum_amend`).
|
||||
pub quorums: Option<String>,
|
||||
}
|
||||
|
||||
impl DocRow {
|
||||
/// The quorum of each level, 1 to 5: the document's, or, in a base the quorum
|
||||
/// amendment has not reached yet, its reference quorum at every level — the C of before.
|
||||
pub fn quorums(&self) -> [u32; 5] {
|
||||
self.quorums.as_deref().and_then(|raw| serde_json::from_str(raw).ok()).unwrap_or([whole(self.vote_floor); 5])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
@@ -208,6 +221,16 @@ pub struct UnitRow {
|
||||
pub position: f64,
|
||||
pub inertia: String,
|
||||
pub in_force_id: Option<String>,
|
||||
/// Its own quorum, set by the author of its first version before the first vote
|
||||
/// (`quorum`); NULL: the quorum of its level.
|
||||
pub quorum: Option<i64>,
|
||||
}
|
||||
|
||||
impl UnitRow {
|
||||
/// Its own quorum, if its author set one.
|
||||
pub fn own_quorum(&self) -> Option<u32> {
|
||||
self.quorum.and_then(|q| u32::try_from(q).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
@@ -294,7 +317,8 @@ pub struct DocState {
|
||||
|
||||
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
|
||||
sqlx::query_as::<_, DocRow>(
|
||||
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?",
|
||||
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, quorums
|
||||
FROM documents WHERE id = ?",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -321,7 +345,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
let doc = load_doc(state, doc_id).await?;
|
||||
let group = load_group(state, &doc.group_slug).await?;
|
||||
let units = sqlx::query_as::<_, UnitRow>(
|
||||
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
"SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -397,9 +421,25 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// What this document's votes are judged by: W, C and p̃.
|
||||
/// What this document's votes are judged by: W, p̃ and the quorum of each level.
|
||||
pub fn scale(&self) -> Scale {
|
||||
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
|
||||
Scale { electorate: self.electorate, participation: self.participation.ratio, quorums: self.doc.quorums() }
|
||||
}
|
||||
|
||||
/// The quorum an article is judged on, and its versions with it: its own, else its
|
||||
/// level's (`Scale::quorum`).
|
||||
pub fn quorum_of(&self, unit: &UnitRow) -> u32 {
|
||||
self.scale().quorum(inertia_of(unit), unit.own_quorum())
|
||||
}
|
||||
|
||||
/// The quorum of the article's level: what it is judged on without one of its own.
|
||||
pub fn quorum_default(&self, unit: &UnitRow) -> u32 {
|
||||
self.scale().quorum(inertia_of(unit), None)
|
||||
}
|
||||
|
||||
/// The letters a proposal is judged by: its level and its article's quorum.
|
||||
pub fn params(&self, unit: &UnitRow, p: &ProposalRow) -> FormulaParams {
|
||||
compose::params_for(&bare(unit), p.kind(), &self.scale())
|
||||
}
|
||||
|
||||
/// p̃ · W, as a whole number of voters.
|
||||
@@ -422,7 +462,7 @@ impl DocState {
|
||||
|
||||
/// How far the threshold is, as shown: on `shown_tally`.
|
||||
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
threshold::evaluate(self.shown_tally(&p.id), self.electorate, &self.params(unit, p)).ok()
|
||||
}
|
||||
|
||||
/// Open to a vote: open or in force, and not held back by an objection.
|
||||
@@ -511,6 +551,7 @@ impl DocState {
|
||||
id: u.id.clone(),
|
||||
position: u.position,
|
||||
inertia: inertia_of(u),
|
||||
quorum: u.own_quorum(),
|
||||
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) {
|
||||
Some(order) => VersionText {
|
||||
id: p.id.clone(),
|
||||
@@ -563,13 +604,12 @@ impl DocState {
|
||||
}
|
||||
|
||||
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
threshold::evaluate(self.tally(&p.id), self.electorate, &self.params(unit, p)).ok()
|
||||
}
|
||||
|
||||
/// The level a proposal is voted at — a change of inertia inherits its article's.
|
||||
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
|
||||
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
|
||||
compose::voting_level(&us, p.kind())
|
||||
compose::voting_level(&bare(unit), p.kind())
|
||||
}
|
||||
|
||||
/// The usual participation, as the screen tells it.
|
||||
@@ -583,22 +623,36 @@ impl DocState {
|
||||
})
|
||||
}
|
||||
|
||||
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
|
||||
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
|
||||
/// The five levels on this scale: M, G, s_k, the quorum, and the *for* votes asked at
|
||||
/// `cast` votes — on an article's own quorum if it has one (`own`), else each level's.
|
||||
pub fn levels_json(&self, cast: u32, own: Option<u32>) -> Vec<serde_json::Value> {
|
||||
let scale = self.scale();
|
||||
InertiaLevel::ALL
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let params = scale.params(*k);
|
||||
let quorum = scale.quorum(*k, own);
|
||||
let params = scale.params(*k, quorum);
|
||||
json!({
|
||||
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
|
||||
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
|
||||
"sharePct": k.share_pct(), "quorum": quorum, "cast": cast,
|
||||
"required": scale.required(cast, *k, quorum).ok(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit as the engine sees it, without its text: its level and its own quorum.
|
||||
fn bare(unit: &UnitRow) -> UnitState {
|
||||
UnitState {
|
||||
id: unit.id.clone(),
|
||||
position: unit.position,
|
||||
inertia: inertia_of(unit),
|
||||
quorum: unit.own_quorum(),
|
||||
in_force: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-negative count, rounded to the nearest whole.
|
||||
fn whole(x: f64) -> u32 {
|
||||
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
|
||||
@@ -705,6 +759,7 @@ pub async fn document(
|
||||
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
|
||||
|
||||
let changes = inertia_changes(&state, &id).await?;
|
||||
let rights = crate::quorum::Rights::of(&state, &id).await?;
|
||||
let unit_json = |u: &UnitRow| {
|
||||
let of_unit: Vec<_> = visible
|
||||
.iter()
|
||||
@@ -723,11 +778,18 @@ pub async fn document(
|
||||
.as_ref()
|
||||
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
|
||||
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
|
||||
json!({
|
||||
let mut j = json!({
|
||||
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
|
||||
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
})
|
||||
});
|
||||
// Its quorum (ADR 0013): its own or `null`, its level's, and who may set it.
|
||||
if let (Some(o), serde_json::Value::Object(q)) =
|
||||
(j.as_object_mut(), crate::quorum::unit_json(&st, &rights, &user, u))
|
||||
{
|
||||
o.extend(q);
|
||||
}
|
||||
j
|
||||
};
|
||||
let units: Vec<_> = st.articles().map(unit_json).collect();
|
||||
// The modules (ADR 0009): the title of the document, the order of each section.
|
||||
@@ -757,7 +819,7 @@ pub async fn document(
|
||||
"doc": {
|
||||
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
|
||||
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(),
|
||||
"electorate": st.electorate, "electorateSource": st.electorate_source,
|
||||
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
|
||||
"participation": st.participation_json(),
|
||||
@@ -766,7 +828,7 @@ pub async fn document(
|
||||
"units": units,
|
||||
"modules": { "title": title, "orders": orders },
|
||||
// The five levels, with the *for* votes asked at the usual participation.
|
||||
"levels": st.levels_json(st.usual_voters()),
|
||||
"levels": st.levels_json(st.usual_voters(), None),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ pub mod overview;
|
||||
pub mod participation;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod quorum;
|
||||
pub mod quorum_amend;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod seed_proposal_amend;
|
||||
@@ -161,6 +163,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/messages/{id}/read", post(messages::read))
|
||||
.route("/dev/clock", post(deadlines::dev_clock))
|
||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||
.route("/units/{id}/quorum", put(quorum::set))
|
||||
.route("/units/{id}/order", post(order::propose))
|
||||
.route("/units/{id}/title", post(doc_title::propose))
|
||||
.route("/feedback", post(access::send_feedback))
|
||||
|
||||
@@ -622,7 +622,7 @@ pub async fn detail(
|
||||
"voteFloor": st.doc.vote_floor,
|
||||
"participation": st.participation_json(),
|
||||
// The five levels, with the *for* votes each would ask at this version's participation.
|
||||
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
|
||||
"levels": st.levels_json(st.shown_tally(&p.id).cast(), unit.own_quorum()),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
|
||||
let json = json!({
|
||||
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
|
||||
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
|
||||
"voteFloor": st.doc.vote_floor, "units": units, "modules": modules,
|
||||
"voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(), "units": units, "modules": modules,
|
||||
});
|
||||
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
|
||||
Ok(files)
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Quorum amendments — the quorums by level a seed now gives a document, written into
|
||||
//! a document born without them (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, the test group at 1
|
||||
//! everywhere. A new base gets them from the seed (`quorums` of each instance). An
|
||||
//! existing base — production holds two instances of the certification act — reads
|
||||
//! `vote_floor` at every level until then (the C of before: 50 and 1), and gets them
|
||||
//! through an amendment listed here: written **only where the document holds none**;
|
||||
//! nothing else is touched (no article's own quorum, no vote, no text).
|
||||
//!
|
||||
//! Like the other amendments of the seed, each document is decided once, at the first
|
||||
//! start that knows the amendment, and the decision is kept in `quorum_amendments`
|
||||
//! (applied, refused and why, or present) and in the logs. Applied where the document
|
||||
//! publishes, the publication queue gets a row (`document.json` carries the quorums).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, quorums_json};
|
||||
use crate::util;
|
||||
|
||||
/// The quorums by level to write into every document instantiated from a seed.
|
||||
pub struct QuorumAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The quorums of each group's instance: the seed's today, which must say so.
|
||||
pub quorums: &'static [(&'static str, [u32; 5])],
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[QuorumAmendment] = &[QuorumAmendment {
|
||||
id: "2026-11-03-quorums-par-cran",
|
||||
doc: "acte-engagement-certification",
|
||||
quorums: &[("monnaie-libre", [12, 12, 50, 50, 50]), ("essai", [1, 1, 1, 1, 1])],
|
||||
reason: "quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013)",
|
||||
}];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with these quorums: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: `found` is what the document holds.
|
||||
pub fn verdict(found: Option<&str>, wanted: &[u32; 5]) -> Verdict {
|
||||
match found {
|
||||
None => Verdict::Apply,
|
||||
Some(raw) if serde_json::from_str::<[u32; 5]>(raw).ok().as_ref() == Some(wanted) => Verdict::Present,
|
||||
// Written otherwise: kept as it is.
|
||||
Some(_) => Verdict::Refuse("quorums_set"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[QuorumAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some(wanted) = seed_says(seeds, a, &group) else { continue };
|
||||
let Some(v) = decide(state, a, &wanted, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, quorums = ?wanted, "quorums written"),
|
||||
Verdict::Present => tracing::info!(amendment = a.id, %group, "quorums: already there"),
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, reason = why, "quorums refused: the document keeps its own")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must give the group's instance the quorums the amendment writes: a seed
|
||||
/// edited later never reaches a base by this way, and a seed without them decides nothing.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &QuorumAmendment, group: &str) -> Option<[u32; 5]> {
|
||||
let wanted = a.quorums.iter().find(|(g, _)| *g == group).map(|(_, q)| *q)?;
|
||||
let declared = seeds
|
||||
.iter()
|
||||
.find(|s| s.slug == a.doc)
|
||||
.and_then(|s| s.instances.iter().find(|i| i.group == group))
|
||||
.and_then(|i| i.quorums);
|
||||
if declared == Some(wanted) {
|
||||
Some(wanted)
|
||||
} else {
|
||||
tracing::warn!(amendment = a.id, doc = a.doc, %group, "quorum amendment: the seed does not give these quorums, nothing decided");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &QuorumAmendment,
|
||||
wanted: &[u32; 5],
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<Verdict>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM quorum_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let found: Option<String> =
|
||||
sqlx::query_scalar("SELECT quorums FROM documents WHERE id = ?").bind(doc_id).fetch_one(&mut *tx).await?;
|
||||
let v = verdict(found.as_deref(), wanted);
|
||||
let now = util::now();
|
||||
let to = quorums_json(wanted);
|
||||
if v == Verdict::Apply {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ? AND quorums IS NULL")
|
||||
.bind(&to)
|
||||
.bind(doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_amendments (amendment_id, doc_id, from_quorums, to_quorums, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(&found)
|
||||
.bind(&to)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ML: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
#[test]
|
||||
fn written_where_none_kept_where_other() {
|
||||
assert_eq!(verdict(None, &ML), Verdict::Apply);
|
||||
assert_eq!(verdict(Some("[12,12,50,50,50]"), &ML), Verdict::Present);
|
||||
assert_eq!(verdict(Some("[12, 12, 50, 50, 50]"), &ML), Verdict::Present, "the same numbers");
|
||||
assert_eq!(verdict(Some("[3,3,3,3,3]"), &ML), Verdict::Refuse("quorums_set"));
|
||||
assert_eq!(verdict(Some("not json"), &ML), Verdict::Refuse("quorums_set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amendment_says_what_the_seed_says() {
|
||||
let seed: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../seeds/acte-engagement-certification.json")).expect("seed");
|
||||
for (group, quorums) in AMENDMENTS[0].quorums {
|
||||
let inst = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == *group);
|
||||
assert_eq!(inst.map(|i| i["quorums"].clone()), Some(serde_json::json!(quorums)), "{group}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,18 @@
|
||||
//! section, ADR 0009), a module amendment (`module_amend`), built from what the
|
||||
//! document holds; a starting text in force the seed now puts forward as a seed
|
||||
//! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an
|
||||
//! article nobody has touched.
|
||||
//! article nobody has touched; the quorums by level the seed now gives a document
|
||||
//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::inertia_amend;
|
||||
use crate::module_amend;
|
||||
use crate::modules;
|
||||
use crate::quorum_amend;
|
||||
use crate::seed_amend;
|
||||
use crate::seed_proposal_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use ld_engine::inertia::{InertiaLevel, quorum_bounds};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -63,8 +65,13 @@ pub struct SeedModule {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeedInstance {
|
||||
pub group: String,
|
||||
/// The reference quorum Q₀ (the C of before): p0 = 2Q₀/W while too few proposals are
|
||||
/// voted on; every level's quorum where `quorums` is missing.
|
||||
pub vote_floor: f64,
|
||||
pub publish_path: Option<String>,
|
||||
/// The quorum of each level, 1 to 5 (ADR 0013): the default of an article at that level.
|
||||
#[serde(default)]
|
||||
pub quorums: Option<[u32; 5]>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -134,6 +141,7 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?;
|
||||
// After the text amendments: a starting text replaced first, then put forward.
|
||||
seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?;
|
||||
quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -191,8 +199,15 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
}
|
||||
|
||||
/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL`
|
||||
/// alone; every other article says its level.
|
||||
/// alone; every other article says its level. A quorum is never under the lowest an
|
||||
/// author may set (`quorum_bounds`): 2, or 1 in the test group.
|
||||
fn check(seed: &SeedDoc) -> anyhow::Result<()> {
|
||||
for inst in &seed.instances {
|
||||
let (lowest, _) = quorum_bounds(1, inst.group == trustin_access::TEST_GROUP);
|
||||
if inst.quorums.is_some_and(|q| q.iter().any(|q| *q < lowest)) {
|
||||
anyhow::bail!("{}: a quorum under {lowest}", inst.group);
|
||||
}
|
||||
}
|
||||
for u in &seed.units {
|
||||
if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") {
|
||||
anyhow::bail!("{}: a seed proposal is open, never in force", u.code);
|
||||
@@ -229,6 +244,14 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// The quorums by level, written apart: only where the seed gives them (ADR 0013).
|
||||
if let Some(q) = &inst.quorums {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ?")
|
||||
.bind(quorums_json(q))
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for unit in &seed.units {
|
||||
let unit_id = util::id();
|
||||
sqlx::query(
|
||||
@@ -319,3 +342,8 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The quorums by level as `documents.quorums` holds them: `[12,12,50,50,50]`.
|
||||
pub fn quorums_json(quorums: &[u32; 5]) -> String {
|
||||
serde_json::to_string(quorums).unwrap_or_else(|_| "null".into())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user