//! 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; an //! article the seed now holds, an article amendment (`article_amend`), placed where the //! seed puts it while the order of its section was never voted on; the units the seed no //! longer holds — the information on how the vote works (ADR 0014) —, a retirement //! (`retire`), where no person left a trace on them: they stay in the database, out of //! the document. use crate::AppState; use crate::article_amend; use crate::inertia_amend; use crate::module_amend; use crate::modules; use crate::quorum_amend; use crate::retire; 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 sqlx::SqliteConnection; 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, /// The modules of the document (ADR 0009): its title, the order of each section. #[serde(default)] pub modules: Option, pub units: Vec, } #[derive(Deserialize)] pub struct SeedModules { pub title: Option, pub order: Option, } #[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, /// 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, 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, pub versions: Vec, } 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, /// `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, /// module, seed proposal, quorum, article amendments and the retirements (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?; article_amend::apply(state, &seeds, article_amend::AMENDMENTS).await?; retire::apply(state, &seeds, retire::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> { 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 { 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 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 { write_unit(&mut tx, &doc_id, src, unit, unit.position, &now).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(()) } /// Writes one article of the seed into a document, at `position`: its unit (at its level), /// its versions — in force, open, put forward —, their first revision and their /// translations. A new base (`instantiate`), or an article a seed adds later /// (`article_amend`). Returns the unit's id. pub(crate) async fn write_unit( conn: &mut SqliteConnection, doc_id: &str, src: &str, unit: &SeedUnit, position: f64, now: &str, ) -> sqlx::Result { let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default(); 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(position) .bind(unit.level().as_db()) .bind(now) .execute(&mut *conn) .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 *conn) .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 *conn) .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 *conn).await?; } if status == "in_force" { sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") .bind(&pid) .bind(&unit_id) .execute(&mut *conn) .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 *conn) .await?; } } Ok(unit_id) } /// 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()) }