//! Seed amendments — a starting text replaced by a newer text of the seed. //! //! The text replaced is an article's version in force (a seed a minima), or, since //! 2026-11-02, one of its open proposals from the seed, named by its label: the //! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv). //! The guards are the same. Since the same day, a starting text may be a seed //! proposal instead of a text in force (ADR 0011): where an article has no text //! in force, `Version::InForce` examines its seed proposal (a new base: present). //! //! 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; /// Which version of the article an amendment replaces. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Version { /// Its starting text: in force (a seed a minima), or its seed proposal (ADR 0011). InForce, /// An open proposal of the seed, at zero votes, by its label. Open(&'static str), } /// 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, /// The version replaced. pub version: Version, /// 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", version: Version::InForce, from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375", to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", reason: "texte de départ remplacé : I1", }, // The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02). Amendment { id: "2026-11-02-f1-2-regles", doc: "acte-engagement-certification", unit: "F1.2", version: Version::Open("1.0.0"), from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6", to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60", reason: "proposition de départ réécrite : F1.2", }, Amendment { id: "2026-11-02-n1-regles", doc: "acte-engagement-certification", unit: "N1", version: Version::Open("1.0.0"), from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142", to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e", reason: "proposition de départ réécrite : N1", }, Amendment { id: "2026-11-02-n1-1-regles", doc: "acte-engagement-certification", unit: "N1.1", version: Version::Open("1.0.0"), from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee", to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0", reason: "proposition de départ réécrite : N1.1", }, Amendment { id: "2026-11-02-o1-regles", doc: "acte-engagement-certification", unit: "O1", version: Version::Open("1.0.0"), from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642", to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537", reason: "proposition de départ réécrite : O1", }, ]; /// 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 (objections since 2026-10-31) naming it, standing or lifted. pub signals: i64, /// Typo fixes someone else wrote on it, whatever became of them (2026-10-31). pub typos: 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(match a.version { Version::InForce => "no_text_in_force", Version::Open(_) => "no_open_version", }); }; 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"] || f.typos > 0 { "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| match a.version { // The starting text: in force, or since 2026-11-02 put forward (ADR 0011). Version::InForce => v.status == "in_force" || v.seed_proposal, Version::Open(label) => v.status == "open" && v.label.as_deref() == Some(label), }) }); 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 } } } /// What the database says of a version holding `status` (`None`: it does not). pub(crate) async fn facts( conn: &mut SqliteConnection, proposal_id: &str, status: &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 = ?", ) .bind(proposal_id) .bind(status) .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?; let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?") .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, typos, 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 = || { sqlx::query_scalar::<_, Option>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") .bind(doc_id) .bind(a.unit) }; let open = |label: Option<&'static str>| { sqlx::query_scalar::<_, String>( "SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND (p.label = ?3 OR (?3 IS NULL AND p.seed_featured = 1)) ORDER BY p.rowid LIMIT 1", ) .bind(doc_id) .bind(a.unit) .bind(label) }; // The version examined, and the status it holds. let examined: Option<(String, &'static str)> = match a.version { Version::InForce => match in_force().fetch_optional(&mut *tx).await?.flatten() { Some(id) => Some((id, "in_force")), // No text in force: the starting text may be a seed proposal (ADR 0011). None => open(None).fetch_optional(&mut *tx).await?.map(|id| (id, "open")), }, Version::Open(label) => open(Some(label)).fetch_optional(&mut *tx).await?.map(|id| (id, "open")), }; let target_id = examined.as_ref().map(|(id, _)| id.clone()); let found = match &examined { Some((id, status)) => facts(&mut tx, id, status).await?, None => None, }; let mut v = verdict(found.as_ref(), a); let now = util::now(); if let (Verdict::Apply, Some((pid, status))) = (v, &examined) { 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 = ? AND origin = 'seed' AND entered_at IS NULL AND confirmed_at IS NULL AND revision = 1", ) .bind(&title) .bind(&body) .bind(&now) .bind(pid) .bind(*status) .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(&target_id) .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", version: Version::InForce, 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 { typos: 1, ..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:?}"); } // An open proposal of the seed that is no longer there (withdrawn, never seeded). let open = Amendment { version: Version::Open("1.0.0"), ..A }; assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version")); assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply); } }