v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+618
View File
@@ -0,0 +1,618 @@
//! 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::error::{AppError, AppResult};
use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, Query, State};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate};
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(())
}
#[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.in_force_id IS NOT NULL) AS in_force,
(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,
pub vote_floor: f64,
pub publish_path: Option<String>,
}
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct UnitRow {
pub id: String,
pub code: String,
pub position: f64,
pub inertia: String,
pub in_force_id: Option<String>,
}
#[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>,
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>,
}
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, p.revision, p.created_at, p.updated_at, p.submitted_at,
p.entered_at, p.confirmed_at
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
impl ProposalRow {
pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() {
"remove" => ProposalKind::Remove,
"inertia" => ProposalKind::Inertia(
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard),
),
_ => ProposalKind::Text,
}
}
}
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset {
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard)
}
pub struct DocState {
pub doc: DocRow,
pub group: GroupRow,
pub units: Vec<UnitRow>,
pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>,
pub blocked: HashSet<String>,
pub signal_counts: HashMap<String, i64>,
pub electorate: u32,
pub electorate_source: &'static str,
}
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 = ?",
)
.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, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
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.id"
))
.bind(doc_id)
.fetch_all(&state.db)
.await?;
#[derive(sqlx::FromRow)]
struct TallyRow {
proposal_id: String,
choice: String,
n: i64,
}
let rows = sqlx::query_as::<_, TallyRow>(
"SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id
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",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut tallies: HashMap<String, Tally> = HashMap::new();
for r in rows {
let t = tallies.entry(r.proposal_id).or_default();
let n = u32::try_from(r.n).unwrap_or(0);
match r.choice.as_str() {
"for" => t.for_votes = n,
"against" => t.against = n,
_ => {}
}
}
#[derive(sqlx::FromRow)]
struct SignalRow {
proposal_id: String,
target_proposal_id: String,
}
let signals = sqlx::query_as::<_, SignalRow>(
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
let mut blocked = HashSet::new();
let mut signal_counts: HashMap<String, i64> = HashMap::new();
for s in &signals {
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
// Two texts that contradict each other never stand in force together.
if in_force.contains(s.target_proposal_id.as_str()) {
blocked.insert(s.proposal_id.clone());
}
if in_force.contains(s.proposal_id.as_str()) {
blocked.insert(s.target_proposal_id.clone());
}
}
let (electorate, electorate_source) = electorate(state, &group).await?;
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source })
}
impl DocState {
pub fn tally(&self, id: &str) -> Tally {
self.tallies.get(id).copied().unwrap_or_default()
}
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
self.units
.iter()
.map(|u| UnitState {
id: u.id.clone(),
position: u.position,
inertia: inertia_of(u),
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| 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.
pub fn proposal_states(&self) -> Vec<ProposalState> {
self.proposals
.iter()
.filter(|p| p.status == "open")
.map(|p| 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),
})
.collect()
}
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
}
}
// ---------------------------------------------------------------------------
// 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)
}
#[derive(sqlx::FromRow)]
struct MyVote {
proposal_id: String,
choice: String,
revision: i64,
}
fn proposal_json(
st: &DocState,
unit: &UnitRow,
p: &ProposalRow,
mine: Option<&MyVote>,
translations: Option<&BTreeMap<String, serde_json::Value>>,
me: &str,
) -> serde_json::Value {
let eval = st.evaluation(unit, p);
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())),
};
let voted_at = match p.kind() {
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
_ => inertia_of(unit),
};
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 })),
"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.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at.as_str(),
"blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
"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: HashMap<String, MyVote> = sqlx::query_as::<_, MyVote>(
"SELECT v.proposal_id, v.choice, v.revision 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.identity_id = ?",
)
.bind(&id)
.bind(&user.id)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|v| (v.proposal_id.clone(), v))
.collect();
let units: Vec<_> = st
.units
.iter()
.map(|u| {
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id))
.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();
json!({
"id": u.id, "code": u.code, "position": u.position, "inertia": u.inertia,
"inForce": in_force, "proposals": proposals,
})
})
.collect();
let publication = publication_status(&state, &id).await?;
Ok(Json(json!({
"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,
"electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"publication": publication,
},
"units": units,
"inertia": InertiaPreset::ALL.iter().map(|p| json!({
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(),
})).collect::<Vec<_>>(),
})))
}
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?;
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 { "pending" },
"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,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, 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()
}