forked from yvv/decision
1033 lines
40 KiB
Rust
1033 lines
40 KiB
Rust
//! Reading: groups, tools, documents, previews.
|
|
//!
|
|
//! The state of a document is loaded once (`DocState`) and turned into the
|
|
//! engine's pure types; every number shown on screen comes from the engine.
|
|
|
|
use crate::AppState;
|
|
use crate::auth::{IdentityRow, User};
|
|
use crate::ballots::{self, MyBallot, MyVote};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::modules::ModuleKind;
|
|
use crate::participation;
|
|
use crate::util::{self, LangMap};
|
|
use axum::Json;
|
|
use axum::extract::{Path, Query, State};
|
|
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::inertia::{InertiaLevel, Participation, Scale};
|
|
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use trustin_access::{Group, GroupPolicy};
|
|
|
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
|
pub struct GroupRow {
|
|
pub slug: String,
|
|
pub name: String,
|
|
pub parent: Option<String>,
|
|
pub policy: String,
|
|
pub hidden: bool,
|
|
pub position: i64,
|
|
}
|
|
|
|
impl GroupRow {
|
|
pub fn to_group(&self) -> Group {
|
|
Group {
|
|
slug: self.slug.clone(),
|
|
name: self.name.clone(),
|
|
parent: self.parent.clone(),
|
|
policy: GroupPolicy::parse(&self.policy).unwrap_or(GroupPolicy::Managed),
|
|
hidden: self.hidden,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn granted(state: &AppState, group: &str, identity: &str) -> AppResult<bool> {
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ? AND identity_id = ?")
|
|
.bind(group)
|
|
.bind(identity)
|
|
.fetch_one(&state.db)
|
|
.await?;
|
|
Ok(n > 0)
|
|
}
|
|
|
|
pub async fn load_group(state: &AppState, slug: &str) -> AppResult<GroupRow> {
|
|
sqlx::query_as::<_, GroupRow>("SELECT slug, name, parent, policy, hidden, position FROM groups WHERE slug = ?")
|
|
.bind(slug)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
/// 404 for a group one cannot see, 403 for one seen but not open.
|
|
pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<()> {
|
|
let id = user.to_identity();
|
|
let g = group.to_group();
|
|
let has = granted(state, &group.slug, &user.id).await?;
|
|
if !trustin_access::sees(&id, &g, has) {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
if !trustin_access::opens(&id, &g, has) {
|
|
return Err(AppError::Forbidden);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Does this identity open the group now (see `trustin_access::opens`)?
|
|
pub async fn opens(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
|
let has = granted(state, &group.slug, &user.id).await?;
|
|
Ok(trustin_access::opens(&user.to_identity(), &group.to_group(), has))
|
|
}
|
|
|
|
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
|
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
|
let has = granted(state, &group.slug, &user.id).await?;
|
|
Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct GroupView {
|
|
slug: String,
|
|
name: String,
|
|
parent: Option<String>,
|
|
policy: String,
|
|
open: bool,
|
|
}
|
|
|
|
pub async fn groups(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
|
let rows = sqlx::query_as::<_, GroupRow>(
|
|
"SELECT slug, name, parent, policy, hidden, position FROM groups ORDER BY position",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
let id = user.to_identity();
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let has = granted(&state, &row.slug, &user.id).await?;
|
|
let g = row.to_group();
|
|
if trustin_access::sees(&id, &g, has) {
|
|
out.push(GroupView {
|
|
open: trustin_access::opens(&id, &g, has),
|
|
slug: row.slug,
|
|
name: row.name,
|
|
parent: row.parent,
|
|
policy: row.policy,
|
|
});
|
|
}
|
|
}
|
|
Ok(Json(json!({ "groups": out })))
|
|
}
|
|
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DocListRow {
|
|
id: String,
|
|
slug: String,
|
|
#[sqlx(rename = "title")]
|
|
#[serde(skip)]
|
|
title_raw: String,
|
|
#[sqlx(rename = "summary")]
|
|
#[serde(skip)]
|
|
summary_raw: String,
|
|
in_force: i64,
|
|
open: i64,
|
|
}
|
|
|
|
/// The tools of a group. Only "Docs de référence" is active for now.
|
|
pub const TOOLS: [(&str, bool); 4] = [("decisions", false), ("docs", true), ("mandats", false), ("protocoles", false)];
|
|
|
|
pub async fn group(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(slug): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let row = load_group(&state, &slug).await?;
|
|
let id = user.to_identity();
|
|
let has = granted(&state, &slug, &user.id).await?;
|
|
let g = row.to_group();
|
|
if !trustin_access::sees(&id, &g, has) {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
let open = trustin_access::opens(&id, &g, has);
|
|
let docs = if open {
|
|
sqlx::query_as::<_, DocListRow>(
|
|
"SELECT d.id, d.slug, d.title, d.summary,
|
|
(SELECT COUNT(*) FROM units u
|
|
WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force,
|
|
-- Articles in force; open proposals of articles and modules alike (ADR 0009).
|
|
(SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id
|
|
WHERE u.doc_id = d.id AND p.status = 'open') AS open
|
|
FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at",
|
|
)
|
|
.bind(&slug)
|
|
.fetch_all(&state.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|d| {
|
|
json!({
|
|
"id": d.id, "slug": d.slug,
|
|
"title": util::parse_lang(&d.title_raw), "summary": util::parse_lang(&d.summary_raw),
|
|
"inForce": d.in_force, "open": d.open,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let tools: Vec<_> = TOOLS.iter().map(|(key, active)| json!({ "key": key, "active": active })).collect();
|
|
Ok(Json(json!({
|
|
"group": { "slug": row.slug, "name": row.name, "parent": row.parent, "policy": row.policy, "open": open },
|
|
"tools": tools,
|
|
"docs": docs,
|
|
})))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Document state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
|
pub struct DocRow {
|
|
pub id: String,
|
|
pub group_slug: String,
|
|
pub slug: String,
|
|
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)]
|
|
pub struct UnitRow {
|
|
pub id: String,
|
|
pub code: String,
|
|
pub section: Option<String>,
|
|
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)]
|
|
pub struct ProposalRow {
|
|
pub id: String,
|
|
pub unit_id: String,
|
|
pub kind: String,
|
|
pub inertia_target: Option<String>,
|
|
pub title: String,
|
|
pub body: String,
|
|
pub status: String,
|
|
pub origin: String,
|
|
pub label: Option<String>,
|
|
pub author_id: Option<String>,
|
|
pub author_name: Option<String>,
|
|
/// The author came by invitation, not with a Ğ1 key: shown next to the name,
|
|
/// so a name taken ahead of its owner, or a look-alike, does not pass for them.
|
|
pub author_invited: Option<bool>,
|
|
pub revision: i64,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub submitted_at: Option<String>,
|
|
pub entered_at: Option<String>,
|
|
pub confirmed_at: Option<String>,
|
|
/// Put forward by the seed to start the vote (ADR 0011): see `seed_proposal`.
|
|
pub seed_featured: bool,
|
|
}
|
|
|
|
pub const PROPOSAL_SELECT: &str =
|
|
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin,
|
|
p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at,
|
|
p.entered_at, p.confirmed_at, p.seed_featured
|
|
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
|
|
|
|
impl ProposalRow {
|
|
/// A seed a minima: in force from the seed, never voted into force nor confirmed
|
|
/// (the title and the orders of a new base; a text a seed proposal amendment left).
|
|
pub fn seed_minimal(&self) -> bool {
|
|
self.status == "in_force" && self.entered_at.is_none() && self.confirmed_at.is_none() && self.origin == "seed"
|
|
}
|
|
|
|
/// A seed proposal (ADR 0011): open, from the seed, put forward to start the vote,
|
|
/// never voted into force. Not in force: it enters like any proposal.
|
|
pub fn seed_proposal(&self) -> bool {
|
|
self.seed_featured && self.status == "open" && self.origin == "seed" && self.entered_at.is_none()
|
|
}
|
|
|
|
pub fn kind(&self) -> ProposalKind {
|
|
match self.kind.as_str() {
|
|
"remove" => ProposalKind::Remove,
|
|
"inertia" => {
|
|
ProposalKind::Inertia(self.inertia_target.as_deref().and_then(InertiaLevel::parse).unwrap_or_default())
|
|
}
|
|
_ => ProposalKind::Text,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The level of a unit: a digit, or one of the four words of before.
|
|
pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
|
|
InertiaLevel::parse(&unit.inertia).unwrap_or_default()
|
|
}
|
|
|
|
pub struct DocState {
|
|
pub doc: DocRow,
|
|
pub group: GroupRow,
|
|
/// Every unit, articles and modules, by position.
|
|
pub units: Vec<UnitRow>,
|
|
/// The units that are modules (ADR 0009), by unit id; any other is an article.
|
|
pub module_kinds: HashMap<String, ModuleKind>,
|
|
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>,
|
|
/// 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
|
|
/// a new *for* (`admits`), and never entering into force.
|
|
pub blocked: HashSet<String>,
|
|
/// Standing objections touching each version, either side.
|
|
pub signal_counts: HashMap<String, i64>,
|
|
pub electorate: u32,
|
|
pub electorate_source: &'static str,
|
|
/// p̃, from the group's last measure (or the reference).
|
|
pub participation: Participation,
|
|
pub measured_at: Option<String>,
|
|
}
|
|
|
|
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, quorums
|
|
FROM documents WHERE id = ?",
|
|
)
|
|
.bind(doc_id)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
/// W: the Ğ1 web of trust for Monnaie libre, the members of the group otherwise.
|
|
pub async fn electorate(state: &AppState, group: &GroupRow) -> AppResult<(u32, &'static str)> {
|
|
if group.policy == GroupPolicy::G1Members.as_str() {
|
|
let cached: Option<String> =
|
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'wot_size'").fetch_optional(&state.db).await?;
|
|
let w = cached.and_then(|v| v.parse().ok()).unwrap_or(state.cfg.wot_fallback);
|
|
return Ok((w.max(1), "wot"));
|
|
}
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ?")
|
|
.bind(&group.slug)
|
|
.fetch_one(&state.db)
|
|
.await?;
|
|
Ok((u32::try_from(n).unwrap_or(u32::MAX).max(1), "group"))
|
|
}
|
|
|
|
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, quorum FROM units WHERE doc_id = ? ORDER BY position, id",
|
|
)
|
|
.bind(doc_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
let kinds: Vec<(String, String)> =
|
|
sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'")
|
|
.bind(doc_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
let module_kinds: HashMap<String, ModuleKind> =
|
|
kinds.into_iter().filter_map(|(id, kind)| Some((id, ModuleKind::parse(&kind)?))).collect();
|
|
// Same second (the variants of a seed): the order they were written in, not their
|
|
// random identifiers. The engine ranks rivals on its own; this order is what people see.
|
|
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
|
|
"{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?)
|
|
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.rowid"
|
|
))
|
|
.bind(doc_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct TallyRow {
|
|
proposal_id: String,
|
|
choice: String,
|
|
source: String,
|
|
n: i64,
|
|
}
|
|
let rows = sqlx::query_as::<_, TallyRow>(
|
|
"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
|
|
WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice, v.source",
|
|
)
|
|
.bind(doc_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
let rules = state.vote_rules();
|
|
let add = |map: &mut HashMap<String, Tally>, id: &str, choice: &str, n: u32| {
|
|
let t = map.entry(id.to_owned()).or_default();
|
|
match choice {
|
|
"for" => t.for_votes = t.for_votes.saturating_add(n),
|
|
"against" => t.against = t.against.saturating_add(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
|
|
// 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?;
|
|
|
|
// 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 measure = participation::stored(state, &group.slug).await?;
|
|
let participation = Participation::of(
|
|
electorate,
|
|
doc.vote_floor,
|
|
measure.as_ref().and_then(|m| m.median),
|
|
measure.as_ref().map_or(0, |m| m.sample),
|
|
);
|
|
let measured_at = measure.map(|m| m.measured_at);
|
|
Ok(DocState {
|
|
doc,
|
|
group,
|
|
units,
|
|
module_kinds,
|
|
proposals,
|
|
tallies,
|
|
recorded,
|
|
shown,
|
|
pending,
|
|
simulated,
|
|
rules,
|
|
blocked,
|
|
signal_counts,
|
|
electorate,
|
|
electorate_source,
|
|
participation,
|
|
measured_at,
|
|
})
|
|
}
|
|
|
|
impl DocState {
|
|
/// 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, 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.
|
|
pub fn usual_voters(&self) -> u32 {
|
|
whole(self.participation.voters(self.electorate))
|
|
}
|
|
|
|
pub fn tally(&self, id: &str) -> Tally {
|
|
self.tallies.get(id).copied().unwrap_or_default()
|
|
}
|
|
|
|
/// The totals shown to people: the live count, intentions included (ADR
|
|
/// 0012: « je vois ce que j'ai fait sans attendre »). The one place where a
|
|
/// publication by steps would plug in — on the chain, who voted and when is
|
|
/// public, and live totals tell what (the secret of the vote, review of
|
|
/// 74f96be). Adoption reads `tally`, never an intention.
|
|
pub fn shown_tally(&self, id: &str) -> Tally {
|
|
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`.
|
|
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
|
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.
|
|
pub fn votable(&self, p: &ProposalRow) -> bool {
|
|
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
|
|
}
|
|
|
|
/// May `choice` be cast on `p` now — by one vote, a grouped one or a ballot of the
|
|
/// chain? Open or in force; held back by an objection, against or a withdrawal
|
|
/// only, never a *for* (`ld_engine::objection::may_cast`, Yvv 2026-11-01).
|
|
pub fn admits(&self, p: &ProposalRow, choice: Choice) -> bool {
|
|
matches!(p.status.as_str(), "open" | "in_force")
|
|
&& ld_engine::objection::may_cast(self.blocked.contains(&p.id), choice)
|
|
}
|
|
|
|
/// The module a unit is, if it is one.
|
|
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
|
|
self.module_kinds.get(unit_id).copied()
|
|
}
|
|
|
|
/// The articles, by position: every unit that is no module.
|
|
pub fn articles(&self) -> impl Iterator<Item = &UnitRow> {
|
|
self.units.iter().filter(|u| !self.module_kinds.contains_key(&u.id))
|
|
}
|
|
|
|
/// The modules of a kind; the orders in the order of the document's sections.
|
|
pub fn modules(&self, kind: ModuleKind) -> Vec<&UnitRow> {
|
|
let mut out: Vec<&UnitRow> = self.units.iter().filter(|u| self.module_of(&u.id) == Some(kind)).collect();
|
|
let sections = self.sections();
|
|
let rank = |u: &UnitRow| u.section.as_ref().and_then(|s| sections.iter().position(|x| x == s));
|
|
out.sort_by_key(|u| (rank(u).unwrap_or(usize::MAX), u.code.clone()));
|
|
out
|
|
}
|
|
|
|
/// The sections of the document, in the order its articles read in.
|
|
pub fn sections(&self) -> Vec<String> {
|
|
let mut out: Vec<String> = Vec::new();
|
|
for u in self.articles() {
|
|
if let Some(s) = &u.section
|
|
&& !out.contains(s)
|
|
{
|
|
out.push(s.clone());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The articles in the order of `section` now: those one can see — a text in
|
|
/// force, or an open version — by position (`ld_engine::order::members`;
|
|
/// `order::sections_now` says the same in SQL).
|
|
pub fn members(&self, section: &str) -> Vec<String> {
|
|
let open: HashSet<&str> = self
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| p.status == "open" && matches!(p.kind.as_str(), "text" | "remove"))
|
|
.map(|p| p.unit_id.as_str())
|
|
.collect();
|
|
let articles: Vec<ld_engine::order::Article<'_>> = self
|
|
.articles()
|
|
.map(|u| ld_engine::order::Article {
|
|
id: &u.id,
|
|
section: u.section.as_deref(),
|
|
position: u.position,
|
|
visible: u.in_force_id.is_some() || open.contains(u.id.as_str()),
|
|
})
|
|
.collect();
|
|
ld_engine::order::members(&articles, section)
|
|
}
|
|
|
|
/// The order each order module's section reads in now, by unit id.
|
|
fn live_orders(&self) -> HashMap<String, Vec<String>> {
|
|
self.modules(ModuleKind::Order)
|
|
.into_iter()
|
|
.map(|u| (u.id.clone(), self.members(u.section.as_deref().unwrap_or_default())))
|
|
.collect()
|
|
}
|
|
|
|
/// The units as the engine sees them. The version in force of an order is
|
|
/// the order its section reads in now: the engine compares orders as texts.
|
|
pub fn unit_states(&self) -> Vec<UnitState> {
|
|
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
|
|
let live = self.live_orders();
|
|
self.units
|
|
.iter()
|
|
.map(|u| UnitState {
|
|
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(),
|
|
title: String::new(),
|
|
body: ld_engine::order::canonical(order),
|
|
tally: self.tally(&p.id),
|
|
},
|
|
None => VersionText {
|
|
id: p.id.clone(),
|
|
title: p.title.clone(),
|
|
body: p.body.clone(),
|
|
tally: self.tally(&p.id),
|
|
},
|
|
}),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Open proposals only — the in-force versions compete as incumbents. An
|
|
/// order proposal is read as it fits its section now (completed at the end);
|
|
/// one that holds no order is blocked.
|
|
pub fn proposal_states(&self) -> Vec<ProposalState> {
|
|
let live = self.live_orders();
|
|
self.proposals
|
|
.iter()
|
|
.filter(|p| p.status == "open")
|
|
.map(|p| {
|
|
let mut s = ProposalState {
|
|
id: p.id.clone(),
|
|
unit_id: p.unit_id.clone(),
|
|
kind: p.kind(),
|
|
title: p.title.clone(),
|
|
body: p.body.clone(),
|
|
tally: self.tally(&p.id),
|
|
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
|
|
blocked: self.blocked.contains(&p.id),
|
|
};
|
|
if let Some(members) = live.get(&p.unit_id).filter(|_| p.kind == ModuleKind::Order.as_str()) {
|
|
s.title = String::new();
|
|
match ld_engine::order::decode(&p.body) {
|
|
Some(order) => {
|
|
s.body = ld_engine::order::canonical(&ld_engine::order::fit(&order, members).order);
|
|
}
|
|
None => s.blocked = true,
|
|
}
|
|
}
|
|
s
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
|
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 {
|
|
compose::voting_level(&bare(unit), p.kind())
|
|
}
|
|
|
|
/// The usual participation, as the screen tells it.
|
|
pub fn participation_json(&self) -> serde_json::Value {
|
|
let p = &self.participation;
|
|
json!({
|
|
"ratio": p.ratio, "source": p.source, "sample": p.sample, "median": p.median, "bounded": p.bounded,
|
|
"voters": self.usual_voters(), "minSample": Participation::MIN_SAMPLE,
|
|
"windowDays": participation::WINDOW_DAYS, "everyHours": participation::EVERY_HOURS,
|
|
"bounds": [Participation::LOWEST, Participation::HIGHEST], "measuredAt": self.measured_at,
|
|
})
|
|
}
|
|
|
|
/// 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 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(), "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 }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Document view
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct TranslationRow {
|
|
proposal_id: String,
|
|
lang: String,
|
|
title: String,
|
|
body: String,
|
|
source_hash: String,
|
|
origin: String,
|
|
}
|
|
|
|
pub async fn translations_for(
|
|
state: &AppState,
|
|
ids: &[&ProposalRow],
|
|
) -> AppResult<HashMap<String, BTreeMap<String, serde_json::Value>>> {
|
|
let mut out: HashMap<String, BTreeMap<String, serde_json::Value>> = HashMap::new();
|
|
if ids.is_empty() {
|
|
return Ok(out);
|
|
}
|
|
let marks = vec!["?"; ids.len()].join(",");
|
|
let sql = format!(
|
|
"SELECT proposal_id, lang, title, body, source_hash, origin FROM translations WHERE proposal_id IN ({marks})"
|
|
);
|
|
let mut q = sqlx::query_as::<_, TranslationRow>(&sql);
|
|
for p in ids {
|
|
q = q.bind(&p.id);
|
|
}
|
|
let rows = q.fetch_all(&state.db).await?;
|
|
let current: HashMap<&str, String> =
|
|
ids.iter().map(|p| (p.id.as_str(), util::source_hash(&p.title, &p.body))).collect();
|
|
for r in rows {
|
|
let stale = current.get(r.proposal_id.as_str()).is_none_or(|h| *h != r.source_hash);
|
|
out.entry(r.proposal_id)
|
|
.or_default()
|
|
.insert(r.lang, json!({ "title": r.title, "body": r.body, "stale": stale, "origin": r.origin }));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn proposal_json(
|
|
st: &DocState,
|
|
unit: &UnitRow,
|
|
p: &ProposalRow,
|
|
mine: (Option<&MyVote>, Option<&MyBallot>),
|
|
translations: Option<&BTreeMap<String, serde_json::Value>>,
|
|
me: &str,
|
|
) -> serde_json::Value {
|
|
let (mine, ballot) = mine;
|
|
let seen = ballots::seen(p.revision, mine, ballot);
|
|
let eval = st.shown_evaluation(unit, p);
|
|
let (kind, target) = match p.kind() {
|
|
// A module's version says what it holds (ADR 0009).
|
|
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
|
|
ProposalKind::Text => ("text", None),
|
|
ProposalKind::Remove => ("remove", None),
|
|
ProposalKind::Inertia(t) => ("inertia", Some(t)),
|
|
};
|
|
let voted_at = st.voting_level(unit, p);
|
|
json!({
|
|
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
|
|
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
|
|
"author": p.author_id.as_ref().map(|id| json!({
|
|
"id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false),
|
|
})),
|
|
"mine": p.author_id.as_deref() == Some(me),
|
|
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
|
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
|
|
"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),
|
|
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
|
"myVote": seen.choice,
|
|
"myVoteState": seen.state,
|
|
"myVoteStale": seen.stale,
|
|
"myVoteChain": seen.chain,
|
|
"myBallot": ballot,
|
|
"translations": translations.cloned().unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DocQuery {}
|
|
|
|
pub async fn document(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let st = load_state(&state, &id).await?;
|
|
require_open(&state, &user, &st.group).await?;
|
|
let visible: Vec<&ProposalRow> =
|
|
st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect();
|
|
let translations = translations_for(&state, &visible).await?;
|
|
let mine = ballots::my_votes(&state, &user.id, &id).await?;
|
|
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()
|
|
.filter(|p| p.unit_id == u.id)
|
|
.map(|p| {
|
|
let of = (mine.get(&p.id), pending.get(&p.id));
|
|
let mut j = proposal_json(&st, u, p, of, translations.get(&p.id), &user.id);
|
|
if st.module_of(&u.id) == Some(ModuleKind::Order) {
|
|
j["order"] = crate::order::version_json(&st, u, p);
|
|
}
|
|
j
|
|
})
|
|
.collect();
|
|
let in_force = u
|
|
.in_force_id
|
|
.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();
|
|
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.
|
|
let title = st.modules(ModuleKind::Title).first().map(|u| {
|
|
let mut j = unit_json(u);
|
|
j["kind"] = json!("title");
|
|
j
|
|
});
|
|
let orders: Vec<_> = st
|
|
.modules(ModuleKind::Order)
|
|
.into_iter()
|
|
.map(|u| {
|
|
let members = st.members(u.section.as_deref().unwrap_or_default());
|
|
let mut j = unit_json(u);
|
|
j["kind"] = json!("order");
|
|
j["moot"] = json!(ld_engine::order::is_moot(&members));
|
|
j["members"] = json!(members);
|
|
j
|
|
})
|
|
.collect();
|
|
|
|
let publication = publication_status(&state, &id).await?;
|
|
let vote = ballots::access(&state, &user, &st.group).await?;
|
|
Ok(Json(json!({
|
|
"canVote": vote["open"],
|
|
"vote": vote,
|
|
"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, "quorums": st.doc.quorums(),
|
|
"electorate": st.electorate, "electorateSource": st.electorate_source,
|
|
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
|
|
"participation": st.participation_json(),
|
|
"publication": publication,
|
|
},
|
|
"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(), None),
|
|
})))
|
|
}
|
|
|
|
/// The past of each article's inertia (unit id → changes, oldest first).
|
|
async fn inertia_changes(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Vec<serde_json::Value>>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
unit_id: String,
|
|
from_level: String,
|
|
to_level: String,
|
|
cause: String,
|
|
at: String,
|
|
}
|
|
let rows = sqlx::query_as::<_, Row>(
|
|
"SELECT unit_id, from_level, to_level, cause, at FROM inertia_changes WHERE doc_id = ? ORDER BY id",
|
|
)
|
|
.bind(doc_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
let mut out: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
|
|
for r in rows {
|
|
out.entry(r.unit_id).or_default().push(json!({
|
|
"from": InertiaLevel::parse(&r.from_level), "to": InertiaLevel::parse(&r.to_level),
|
|
"cause": r.cause, "at": r.at,
|
|
}));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
created_at: String,
|
|
done_at: Option<String>,
|
|
commit_sha: Option<String>,
|
|
last_error: Option<String>,
|
|
attempts: i64,
|
|
}
|
|
let last = sqlx::query_as::<_, Row>(
|
|
"SELECT created_at, done_at, commit_sha, last_error, attempts FROM outbox WHERE doc_id = ? ORDER BY id DESC LIMIT 1",
|
|
)
|
|
.bind(doc_id)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
// Waiting, not failing: no article in force (ADR 0011), or something in effect that
|
|
// does not hold on the proven votes yet (ADR 0012) — nothing goes out.
|
|
let waits = match &last {
|
|
Some(r) if r.done_at.is_none() => crate::publish::waits(state, doc_id).await?,
|
|
_ => None,
|
|
};
|
|
Ok(match last {
|
|
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
|
|
Some(r) => json!({
|
|
"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(),
|
|
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
|
|
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
|
|
}),
|
|
})
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PreviewQuery {
|
|
mode: Option<String>,
|
|
}
|
|
|
|
pub async fn preview(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
Query(q): Query<PreviewQuery>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let st = load_state(&state, &id).await?;
|
|
require_open(&state, &user, &st.group).await?;
|
|
let mode = match q.mode.as_deref() {
|
|
Some("all") => PreviewMode::All,
|
|
Some("wellrated") => PreviewMode::WellRated,
|
|
_ => PreviewMode::InForce,
|
|
};
|
|
// The articles only: a module changes no text of the preview.
|
|
let articles: Vec<UnitState> = st.unit_states().into_iter().filter(|u| st.module_of(&u.id).is_none()).collect();
|
|
let units = compose::preview(&articles, &st.proposal_states(), &st.scale(), mode)
|
|
.map_err(|_| AppError::Invalid("electorate"))?;
|
|
// Translations of the versions shown.
|
|
let shown: HashSet<String> = units
|
|
.iter()
|
|
.map(|u| {
|
|
u.proposal_id.clone().unwrap_or_else(|| {
|
|
st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()).unwrap_or_default()
|
|
})
|
|
})
|
|
.collect();
|
|
let rows: Vec<&ProposalRow> = st.proposals.iter().filter(|p| shown.contains(&p.id)).collect();
|
|
let tr = translations_for(&state, &rows).await?;
|
|
let out: Vec<_> = units
|
|
.into_iter()
|
|
.map(|u| {
|
|
let vid = u
|
|
.proposal_id
|
|
.clone()
|
|
.or_else(|| st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()));
|
|
let translations = vid.as_ref().and_then(|v| tr.get(v)).cloned().unwrap_or_default();
|
|
json!({
|
|
"unitId": u.unit_id, "title": u.title, "body": u.body, "mark": u.mark,
|
|
"proposalId": u.proposal_id, "versionId": vid, "translations": translations,
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(Json(json!({ "mode": q.mode.unwrap_or_else(|| "inforce".into()), "units": out })))
|
|
}
|
|
|
|
/// Unused for now; keeps the language list close to the documents.
|
|
pub fn source_langs() -> LangMap {
|
|
util::LANGS.iter().map(|l| ((*l).to_owned(), String::new())).collect()
|
|
}
|