Files
decision/crates/ld-server/src/seed.rs
T
YvvandClaude Opus 5.5 01b7841472 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>
2026-11-03 05:00:41 +01:00

350 lines
13 KiB
Rust

//! Seeds — so as not to start from a blank page.
//!
//! On start, missing groups are created and every `seeds/*.json` document is
//! instantiated in each group it names, **only if that group does not hold it
//! yet**. An existing document is never instantiated again: from its first
//! vote on, it is runtime data. The one way a newer seed text reaches an
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
//! and applied only to an article nobody has touched; a newer starting level of
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
//! no vote changed; modules the seed declares (the title, the order of each
//! 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; 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, quorum_bounds};
use serde::Deserialize;
use std::path::Path;
/// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « on
/// pouvait l'amorcer avec une inertie basse, pour que le nombre de votes initiaux soit
/// assez petit pour commencer l'expérience en mode prod » — level 2 (« basse »),
/// confirmed by Yvv the same day (level 1, « très basse », was the other choice). The
/// one place to change:
/// the seed gives no level to an article it puts forward, and the seed proposal
/// amendment (`seed_proposal_amend`) writes this one.
pub const SEED_PROPOSAL_LEVEL: u8 = 2;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedDoc {
pub slug: String,
pub title: LangMap,
pub summary: LangMap,
pub source_lang: String,
pub instances: Vec<SeedInstance>,
/// The modules of the document (ADR 0009): its title, the order of each section.
#[serde(default)]
pub modules: Option<SeedModules>,
pub units: Vec<SeedUnit>,
}
#[derive(Deserialize)]
pub struct SeedModules {
pub title: Option<SeedModule>,
pub order: Option<SeedModule>,
}
#[derive(Deserialize)]
pub struct SeedModule {
/// The level it starts at, 1 to 5.
pub inertia: InertiaLevel,
}
#[derive(Deserialize)]
#[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)]
pub struct SeedUnit {
pub code: String,
pub section: Option<String>,
pub position: f64,
/// A level, 1 to 5 (a word of before is read as its level). None for an article
/// the seed puts forward: it starts at `SEED_PROPOSAL_LEVEL`.
#[serde(default)]
pub inertia: Option<InertiaLevel>,
pub versions: Vec<SeedVersion>,
}
impl SeedUnit {
/// Holds a seed proposal (ADR 0011).
pub fn puts_forward(&self) -> bool {
self.versions.iter().any(|v| v.seed_proposal)
}
/// The level it starts at: its own, or the one of seed proposals.
pub fn level(&self) -> InertiaLevel {
match self.inertia {
Some(level) => level,
None => InertiaLevel::new(SEED_PROPOSAL_LEVEL).unwrap_or_default(),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedVersion {
pub label: Option<String>,
/// `in_force` (seed a minima: the title and the orders) or `open` (a proposal at
/// zero votes).
pub status: String,
/// A seed proposal (ADR 0011): open, put forward to start the vote, not in force.
#[serde(default)]
pub seed_proposal: bool,
pub title: LangMap,
pub body: LangMap,
}
pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
for (position, g) in trustin_access::default_groups().into_iter().enumerate() {
sqlx::query(
"INSERT OR IGNORE INTO groups (slug, name, parent, policy, hidden, position) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&g.slug)
.bind(&g.name)
.bind(&g.parent)
.bind(g.policy.as_str())
.bind(g.hidden)
.bind(i64::try_from(position).unwrap_or(0))
.execute(&state.db)
.await?;
}
Ok(())
}
/// On start: the groups, the documents missing from them, then the seed,
/// inertia and module amendments (each decided once per document).
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
let seeds = documents(state).await?;
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
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(())
}
/// The groups and every seed document missing from its groups; returns the seeds read.
pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
ensure_groups(state).await?;
let dir = state.cfg.seed_dir.clone();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(e) => {
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
return Ok(Vec::new());
}
};
let mut files = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_some_and(|x| x == "json") {
files.push(path);
}
}
files.sort();
let mut seeds = Vec::new();
for path in files {
seeds.push(load_file(state, &path).await?);
}
Ok(seeds)
}
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
let raw = tokio::fs::read_to_string(path).await?;
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
check(&seed).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
for inst in &seed.instances {
let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?")
.bind(&inst.group)
.fetch_one(&state.db)
.await?;
if group_exists == 0 {
tracing::warn!(group = %inst.group, slug = %seed.slug, "seed names an unknown group");
continue;
}
let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE group_slug = ? AND slug = ?")
.bind(&inst.group)
.bind(&seed.slug)
.fetch_one(&state.db)
.await?;
if present > 0 {
continue;
}
instantiate(state, &seed, inst).await?;
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
}
Ok(seed)
}
/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_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);
}
match (u.puts_forward(), u.inertia.is_some()) {
(true, true) => {
anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code)
}
(false, false) => anyhow::bail!("{}: no level of inertia", u.code),
_ => {}
}
}
Ok(())
}
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
let src = seed.source_lang.as_str();
let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default();
let now = util::now();
let doc_id = util::id();
let mut tx = state.db.begin().await?;
sqlx::query(
"INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&doc_id)
.bind(&inst.group)
.bind(&seed.slug)
.bind(util::lang_json(&seed.title))
.bind(util::lang_json(&seed.summary))
.bind(src)
.bind(inst.vote_floor)
.bind(&inst.publish_path)
.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(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
)
.bind(&unit_id)
.bind(&doc_id)
.bind(&unit.code)
.bind(&unit.section)
.bind(unit.position)
.bind(unit.level().as_db())
.bind(&now)
.execute(&mut *tx)
.await?;
for v in &unit.versions {
let pid = util::id();
let (title, body) = (pick(&v.title), pick(&v.body));
let status = if v.status == "in_force" { "in_force" } else { "open" };
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)",
)
.bind(&pid)
.bind(&unit_id)
.bind(&title)
.bind(&body)
.bind(status)
.bind(&v.label)
.bind(&now)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)",
)
.bind(&pid)
.bind(&title)
.bind(&body)
.bind(&now)
.execute(&mut *tx)
.await?;
// The mark, written apart: only where the seed puts the version forward.
if v.seed_proposal && status == "open" {
sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?").bind(&pid).execute(&mut *tx).await?;
}
if status == "in_force" {
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
.bind(&pid)
.bind(&unit_id)
.execute(&mut *tx)
.await?;
}
let hash = util::source_hash(&title, &body);
for lang in util::LANGS.iter().filter(|l| **l != src) {
let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue };
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)",
)
.bind(&pid)
.bind(lang)
.bind(t)
.bind(b)
.bind(&hash)
.bind(&now)
.execute(&mut *tx)
.await?;
}
}
}
// The modules, from the document just written: its title, the order of each section.
if let Some(m) = &seed.modules {
modules::create(&mut tx, &doc_id, m.into(), &now).await?;
}
if inst.publish_path.is_some() {
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
)
.bind(&doc_id)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
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())
}