//! Module amendments — the modules a seed now declares, added to a document //! that was born without them (ADR 0009). //! //! Yvv, 2026-10-30: the title of the document at level 4, the order of the //! articles of each section at level 2. A new base gets them from the seed //! (`modules` in the seed file). An existing base — production holds two //! instances of the certification act, Monnaie libre and the test group — gets //! them through an amendment listed here, built **from what the document holds //! then**: its title as it reads, and the order each section reads in. Nothing //! else is written: no article moves, no text changes, no vote is touched. //! //! Like the seed and inertia amendments, each document is decided once, at the //! first start that knows the amendment, and the decision is kept in //! `module_amendments` (applied with the codes created, refused and why, or //! present) and in the logs. Applied where the document publishes, the //! publication queue gets a row (`document.json` carries the modules). use crate::AppState; use crate::modules::{self, Levels}; use crate::seed::SeedDoc; use crate::util; use ld_engine::inertia::InertiaLevel; use sqlx::SqliteConnection; /// Modules to add to every document instantiated from a seed. pub struct ModuleAmendment { /// Stable identifier: each document's decision is kept under it. pub id: &'static str, /// Slug of the seed document. pub doc: &'static str, /// The level of the title module: the seed's today, which must say so. pub title: u8, /// The level of the order modules: the seed's today, which must say so. pub order: u8, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, } pub const AMENDMENTS: &[ModuleAmendment] = &[ModuleAmendment { id: "2026-10-31-modules-titre-ordre", doc: "acte-engagement-certification", title: 4, order: 2, reason: "modules ajoutés : le titre du document, l'ordre des articles de chaque section", }]; /// What the database says of the document. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Facts { /// Its title in its source language. pub title: String, /// Modules it holds already, of either kind. pub modules: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { Apply, /// Born with its modules: nothing to do. Present, Refuse(&'static str), } /// The rule, apart from the database. pub fn verdict(facts: Option<&Facts>) -> Verdict { let Some(f) = facts else { return Verdict::Refuse("no_document") }; if f.modules > 0 { Verdict::Present } else if f.title.trim().is_empty() { Verdict::Refuse("no_title") } else { Verdict::Apply } } /// A decision taken at this start. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Outcome { pub amendment: &'static str, pub group: String, pub verdict: Verdict, pub created: Vec, } /// Decides every amendment not yet decided, document by document. pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ModuleAmendment]) -> anyhow::Result> { let mut out = Vec::new(); for a in list { let Some(levels) = seed_says(seeds, a) else { continue }; let docs: Vec<(String, String, Option)> = sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug") .bind(a.doc) .fetch_all(&state.db) .await?; for (doc_id, group, publish_path) in docs { let Some((v, created)) = decide(state, a, levels, &doc_id, publish_path.is_some()).await? else { continue; }; match v { Verdict::Apply => tracing::info!(amendment = a.id, %group, created = ?created, "modules added"), Verdict::Present => tracing::info!(amendment = a.id, %group, "modules: already there"), Verdict::Refuse(why) => { tracing::warn!(amendment = a.id, %group, reason = why, "modules refused: the document stays as it is") } } if v == Verdict::Apply && publish_path.is_some() { state.publish_wake.notify_one(); } out.push(Outcome { amendment: a.id, group, verdict: v, created }); } } Ok(out) } /// The seed must declare the modules at the levels the amendment gives: a seed /// edited later never reaches a base by this way. fn seed_says(seeds: &[SeedDoc], a: &ModuleAmendment) -> Option { let levels = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.modules.as_ref()).map(Levels::from); let wanted = Levels { title: InertiaLevel::new(a.title), order: InertiaLevel::new(a.order) }; match levels { Some(l) if l == wanted && l.title.is_some() && l.order.is_some() => Some(l), _ => { tracing::error!( amendment = a.id, doc = a.doc, "module amendment: the seed does not declare these modules, nothing decided" ); None } } } async fn facts(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result> { let doc: Option<(String, String)> = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?") .bind(doc_id) .fetch_optional(&mut *conn) .await?; let Some((raw, src)) = doc else { return Ok(None) }; let modules: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind <> 'article'") .bind(doc_id) .fetch_one(&mut *conn) .await?; Ok(Some(Facts { title: util::parse_lang(&raw).get(&src).cloned().unwrap_or_default(), modules })) } /// One document, in one transaction: `None` when it was decided at an earlier start. async fn decide( state: &AppState, a: &ModuleAmendment, levels: Levels, doc_id: &str, publishes: bool, ) -> anyhow::Result)>> { let mut tx = state.db.begin().await?; let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments WHERE amendment_id = ? AND doc_id = ?") .bind(a.id) .bind(doc_id) .fetch_one(&mut *tx) .await?; if decided > 0 { return Ok(None); } let found = facts(&mut tx, doc_id).await?; let v = verdict(found.as_ref()); let now = util::now(); let created = if v == Verdict::Apply { modules::create(&mut tx, doc_id, levels, &now).await? } else { Vec::new() }; if v == Verdict::Apply && publishes { sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") .bind(doc_id) .bind(a.reason) .bind(&now) .bind(&now) .execute(&mut *tx) .await?; } let (outcome, reason) = match v { Verdict::Apply => ("applied", None), Verdict::Present => ("present", None), Verdict::Refuse(why) => ("refused", Some(why)), }; sqlx::query( "INSERT INTO module_amendments (amendment_id, doc_id, outcome, reason, created, at) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(a.id) .bind(doc_id) .bind(outcome) .bind(reason) .bind((v == Verdict::Apply).then(|| serde_json::to_string(&created).unwrap_or_else(|_| "[]".into()))) .bind(&now) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Some((v, created))) } #[cfg(test)] mod tests { use super::*; fn doc(title: &str, modules: i64) -> Facts { Facts { title: title.into(), modules } } #[test] fn a_document_without_modules_receives_them() { assert_eq!(verdict(Some(&doc("Acte d'engagement Certification", 0))), Verdict::Apply); } #[test] fn a_document_born_with_its_modules_is_left_as_it_is() { assert_eq!(verdict(Some(&doc("Acte", 9))), Verdict::Present); assert_eq!(verdict(Some(&doc("Acte", 1))), Verdict::Present, "never a second set"); } #[test] fn nothing_without_a_document_or_a_title() { assert_eq!(verdict(None), Verdict::Refuse("no_document")); assert_eq!(verdict(Some(&doc(" ", 0))), Verdict::Refuse("no_title")); } }