//! 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 that counts (`votes`): a vote of a managed group, a vote proven by the /// chain, a **simulated** vote (ADR 0012); /// - a line of a ballot — the buffer of the votes keeps their history (ADR 0012) — /// 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 /// its votes are withdrawn. /// /// Two limits, known. In a managed group there is no buffer: a vote of the server /// 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> { let ids: Vec = 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 -- Every ballot of the buffer, chain or simulated, pending or proven: its history. 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 { 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 { 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>> { let rows: Vec<(String, Option)> = 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>, voted: HashSet, } impl Rights { pub async fn of(state: &AppState, doc_id: &str) -> AppResult { 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, } /// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`. pub async fn set( State(state): State, User(user): User, Path(unit_id): Path, Json(b): Json, ) -> AppResult> { 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 }))) }