//! Seed amendments — a starting text replaced by a newer text of the seed. //! //! A seeded document is never instantiated again: once in the database, it is //! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30: //! the Préambule becomes « Raison d'être »), new bases get it from the seed; //! an existing base gets it through an amendment listed here, and **only if //! nobody has touched the article**: //! //! - its version in force is the seed's own, a *seed a minima* (never voted //! into force, never confirmed), at revision 1; //! - nobody ever voted on it, corrected it, edited one of its translations or //! named it in a contradiction signal; //! - its text is exactly the one the amendment replaces (fingerprint). //! //! Each document is decided once, at the first start that knows the //! amendment, and the decision is kept in `seed_amendments`: applied, refused //! (and why — also in the logs), or present (the base was born with the new //! text). Applied, the text and its seed translations are replaced in one //! transaction, the article's history gets a line of its own (kind //! `amendment`, with its diff) and, where the document publishes, the //! publication queue a row. use crate::AppState; use crate::seed::{SeedDoc, SeedVersion}; use crate::util; use sqlx::SqliteConnection; /// One starting text to replace, in every document instantiated from a seed. pub struct Amendment { /// Stable identifier: each document's decision is kept under it. pub id: &'static str, /// Slug of the seed document. pub doc: &'static str, /// Code of the article. pub unit: &'static str, /// Fingerprint of the starting text expected in force (source language). pub from: &'static str, /// Fingerprint of the new text: the version in force of the seed today, which /// must hash to it (a seed edited later never reaches a base by this way). pub to: &'static str, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, } pub const AMENDMENTS: &[Amendment] = &[Amendment { id: "2026-10-30-i1-raison-d-etre", doc: "acte-engagement-certification", unit: "I1", from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375", to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", reason: "texte de départ remplacé : I1", }]; /// The fingerprint of a text: the hash translations are pinned to. pub fn fingerprint(title: &str, body: &str) -> String { util::source_hash(title, body) } /// What the database says of the version in force of the article. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Facts { pub origin: String, /// Voted into force (`entered_at`). pub entered: bool, /// A seed a minima that later reached its threshold (`confirmed_at`). pub confirmed: bool, pub revision: i64, /// Votes cast on it, whatever their revision. pub votes: i64, /// Kinds of its revisions, in order. pub revisions: Vec, /// Translations written or corrected by a member. pub member_translations: i64, /// Contradiction signals naming it, standing or lifted. pub signals: i64, pub fingerprint: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { Apply, /// The new text is already in force: nothing to do. Present, Refuse(&'static str), } /// The rule, apart from the database: any trace of a person keeps the text. pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict { let Some(f) = facts else { return Verdict::Refuse("no_text_in_force") }; if f.fingerprint == a.to { return Verdict::Present; } let refusal = if f.origin != "seed" { "not_a_seed" } else if f.entered { "voted_into_force" } else if f.confirmed { "confirmed" } else if f.revision != 1 { "revised" } else if f.votes > 0 { "votes" } else if f.revisions != ["initial"] { "corrected" } else if f.member_translations > 0 { "translation_edited" } else if f.signals > 0 { "signalled" } else if f.fingerprint != a.from { "text_differs" } else { return Verdict::Apply; }; Verdict::Refuse(refusal) } /// A decision taken at this start. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Outcome { pub amendment: &'static str, pub group: String, pub verdict: Verdict, } /// Decides every amendment not yet decided, document by document. pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Amendment]) -> anyhow::Result> { let mut out = Vec::new(); for a in list { let Some((seed, target)) = target_of(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) = decide(state, a, seed, target, &doc_id, publish_path.is_some()).await? else { continue }; match v { Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"), Verdict::Present => { tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment: text already there") } Verdict::Refuse(why) => { tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "seed amendment refused: the article was touched, it keeps its text") } } if v == Verdict::Apply && publish_path.is_some() { state.publish_wake.notify_one(); } out.push(Outcome { amendment: a.id, group, verdict: v }); } } Ok(out) } /// The seed document and its version in force for the article, checked against `to`. fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'a SeedVersion)> { let Some(seed) = seeds.iter().find(|s| s.slug == a.doc) else { tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided"); return None; }; let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| u.versions.iter().find(|v| v.status == "in_force")); let src = seed.source_lang.as_str(); let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?))); match (version, text) { (Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)), _ => { tracing::error!( amendment = a.id, doc = a.doc, unit = a.unit, "seed amendment: the seed text is not the one it names, nothing decided" ); None } } } async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result> { #[derive(sqlx::FromRow)] struct Row { origin: String, entered_at: Option, confirmed_at: Option, revision: i64, title: String, body: String, } let Some(p) = sqlx::query_as::<_, Row>( "SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = 'in_force'", ) .bind(proposal_id) .fetch_optional(&mut *conn) .await? else { return Ok(None); }; let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE proposal_id = ?") .bind(proposal_id) .fetch_one(&mut *conn) .await?; let revisions: Vec = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id") .bind(proposal_id) .fetch_all(&mut *conn) .await?; let member_translations: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM translations WHERE proposal_id = ? AND (origin <> 'seed' OR updated_by IS NOT NULL)", ) .bind(proposal_id) .fetch_one(&mut *conn) .await?; let signals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE proposal_id = ?1 OR target_proposal_id = ?1") .bind(proposal_id) .fetch_one(&mut *conn) .await?; Ok(Some(Facts { origin: p.origin, entered: p.entered_at.is_some(), confirmed: p.confirmed_at.is_some(), revision: p.revision, votes, revisions, member_translations, signals, fingerprint: fingerprint(&p.title, &p.body), })) } /// One document, in one transaction: `None` when it was decided at an earlier start. async fn decide( state: &AppState, a: &Amendment, seed: &SeedDoc, target: &SeedVersion, doc_id: &str, publishes: bool, ) -> anyhow::Result> { let mut tx = state.db.begin().await?; let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seed_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 in_force: Option = sqlx::query_scalar::<_, Option>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") .bind(doc_id) .bind(a.unit) .fetch_optional(&mut *tx) .await? .flatten(); let found = match &in_force { Some(id) => facts(&mut tx, id).await?, None => None, }; let mut v = verdict(found.as_ref(), a); let now = util::now(); if let (Verdict::Apply, Some(pid)) = (v, &in_force) { let src = seed.source_lang.as_str(); let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default(); let (title, body) = (pick(&target.title), pick(&target.body)); // Written only over the text that was judged, still untouched. let done = sqlx::query( "UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL AND confirmed_at IS NULL AND revision = 1", ) .bind(&title) .bind(&body) .bind(&now) .bind(pid) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { sqlx::query( "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 2, 'amendment', ?, ?, NULL, ?)", ) .bind(pid) .bind(&title) .bind(&body) .bind(&now) .execute(&mut *tx) .await?; let hash = fingerprint(&title, &body); for lang in util::LANGS.iter().filter(|l| **l != src) { let (Some(t), Some(b)) = (target.title.get(*lang), target.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, ?) ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body, source_hash = excluded.source_hash, origin = 'seed', updated_by = NULL, updated_at = excluded.updated_at", ) .bind(pid) .bind(lang) .bind(t) .bind(b) .bind(&hash) .bind(&now) .execute(&mut *tx) .await?; } if 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?; } } else { v = Verdict::Refuse("changed"); } } let (outcome, reason) = match v { Verdict::Apply => ("applied", None), Verdict::Present => ("present", None), Verdict::Refuse(why) => ("refused", Some(why)), }; sqlx::query( "INSERT INTO seed_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, at) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(a.id) .bind(doc_id) .bind(a.unit) .bind(&in_force) .bind(outcome) .bind(reason) .bind(&now) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Some(v)) } #[cfg(test)] mod tests { use super::*; const A: Amendment = Amendment { id: "test", doc: "doc", unit: "I1", from: "old", to: "new", reason: "texte de départ remplacé : I1", }; fn intact() -> Facts { Facts { origin: "seed".into(), revision: 1, revisions: vec!["initial".into()], fingerprint: "old".into(), ..Facts::default() } } #[test] fn an_untouched_starting_text_is_replaced() { assert_eq!(verdict(Some(&intact()), &A), Verdict::Apply); } #[test] fn the_new_text_already_there_is_left_as_it_is() { let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() }; assert_eq!(verdict(Some(&f), &A), Verdict::Present); } #[test] fn any_trace_of_a_person_keeps_the_text() { let cases = [ (None, "no_text_in_force"), (Some(Facts { origin: "member".into(), ..intact() }), "not_a_seed"), (Some(Facts { entered: true, ..intact() }), "voted_into_force"), (Some(Facts { confirmed: true, ..intact() }), "confirmed"), (Some(Facts { revision: 2, ..intact() }), "revised"), (Some(Facts { votes: 1, ..intact() }), "votes"), (Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"), (Some(Facts { revisions: vec![], ..intact() }), "corrected"), (Some(Facts { member_translations: 1, ..intact() }), "translation_edited"), (Some(Facts { signals: 1, ..intact() }), "signalled"), (Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"), ]; for (facts, reason) in cases { assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}"); } } }