//! 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). //! //! Since 2026-11-03 (lot T), a text may be amended twice: F1.1, F1.2 and N1.1 say //! the quorum by level (ADR 0013) instead of the floor C, and F1.2 and N1.1 had //! been rewritten by the amendments of 02/11. An amendment that `follows` an //! earlier one replaces the text that one wrote. The same day (lot T2, after the //! review of M6b), three phrases of those texts are written true by amendments that //! follow lot T's: F1.1 is then at its revision 2, F1.2 and N1.1 at their revision 3. //! Still the same day (lot T3, Yvv: no dated figure in a voted text), F1.2 and N1.1 are //! written without a figure that depends on the web of trust, and true for every quorum, //! by amendments that follow lot T2's: they are then at their revision 4; F1.1 is left. //! So an amendment admits, besides the seed's own revision 1, the revision written //! by the amendments it follows (`earlier`: the one it follows, the one that one //! follows…), applied in that order, the last being the one it follows, and nothing //! else between them: a revision n + 1 whose history is « initial » then n times //! « amendment », on which those n amendments, and they alone, were applied //! (`seed_amendments`), its text the one the amendment replaces (fingerprint). //! The earlier one is then `superseded_by` it: the seed no longer gives its text, //! so it decides nothing more, without an error (the decisions already taken stay). //! //! 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 — or further, written by the //! amendments it follows; //! - no trace of a vote is on it — a vote that counts (`votes`: a vote of the server, //! proven by the chain, or simulated), or a line of a ballot of the buffer, pending //! or proven, whatever became of it (ADR 0012) — and nobody corrected it, edited //! one of its translations or named it in a contradiction signal (an objection). //! Two votes leave no trace, and so do not keep the text: in a group without the //! buffer (the test group), a vote of the server withdrawn (`votes` keeps the last //! choice only); and a simulated vote once the act « supprime » took it away //! (`simulation::discard`); //! - 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, /// The earlier amendment of the same text whose text this one replaces, if any: /// its revision 2 is then admitted (see the module). pub follows: Option<&'static str>, /// The later amendment whose text the seed gives instead, if any: this one then /// decides nothing more, without an error. pub superseded_by: Option<&'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", follows: None, superseded_by: None, }, // 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", follows: None, superseded_by: Some("2026-11-03-f1-2-quorum"), }, 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", follows: None, superseded_by: None, }, 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", follows: None, superseded_by: Some("2026-11-03-n1-1-quorum"), }, 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", follows: None, superseded_by: None, }, // The articles that still described the floor C, after the quorum by level (ADR 0013; // Yvv, 2026-11-02 and 03; lot T). F1.1 was never amended; F1.2 and N1.1 replace the // text the amendments of 02/11 wrote. Amendment { id: "2026-11-03-f1-1-quorum", doc: "acte-engagement-certification", unit: "F1.1", version: Version::Open("1.0.0"), from: "258980be190192fe3c7b32f6319584b682621d01329d880b7056c28c84e9db0a", to: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa", reason: "proposition de départ réécrite : F1.1, le quorum par cran", follows: None, superseded_by: Some("2026-11-03-f1-1-phrases"), }, Amendment { id: "2026-11-03-f1-2-quorum", doc: "acte-engagement-certification", unit: "F1.2", version: Version::Open("1.0.0"), from: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60", to: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9", reason: "proposition de départ réécrite : F1.2, le quorum par cran", follows: Some("2026-11-02-f1-2-regles"), superseded_by: Some("2026-11-03-f1-2-phrases"), }, Amendment { id: "2026-11-03-n1-1-quorum", doc: "acte-engagement-certification", unit: "N1.1", version: Version::Open("1.0.0"), from: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0", to: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48", reason: "proposition de départ réécrite : N1.1, le quorum par cran", follows: Some("2026-11-02-n1-1-regles"), superseded_by: Some("2026-11-03-n1-1-phrases"), }, // The three phrases the review of M6b left inexact (lot T2, 2026-11-03): the share of the // level is highest at the quorum (F1.1); the author sets the quorum while the article keeps // no trace of a vote (F1.2); the quorum is no minimum of voters (N1.1). Each replaces the // text lot T wrote: F1.1 at its revision 2, F1.2 and N1.1 at their revision 3. Amendment { id: "2026-11-03-f1-1-phrases", doc: "acte-engagement-certification", unit: "F1.1", version: Version::Open("1.0.0"), from: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa", to: "8375b78af825e5ce5f978442519c078010ac9ad87f5cbc9358b407aa3668c5e5", reason: "proposition de départ réécrite : F1.1, la part du cran au-delà du quorum", follows: Some("2026-11-03-f1-1-quorum"), superseded_by: None, }, Amendment { id: "2026-11-03-f1-2-phrases", doc: "acte-engagement-certification", unit: "F1.2", version: Version::Open("1.0.0"), from: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9", to: "96caf353b33805f2894593d72c5b70fce2e751039cd7f4651d8842c986729a14", reason: "proposition de départ réécrite : F1.2, le quorum fixé tant qu'aucun vote n'a laissé de trace", follows: Some("2026-11-03-f1-2-quorum"), superseded_by: Some("2026-11-03-f1-2-chiffres"), }, Amendment { id: "2026-11-03-n1-1-phrases", doc: "acte-engagement-certification", unit: "N1.1", version: Version::Open("1.0.0"), from: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48", to: "7fbedcb8750c208f8790c4f87dc21d8bd1096a27a1a0044af6b9b8f2057f2aa8", reason: "proposition de départ réécrite : N1.1, le quorum n'est pas un minimum de votants", follows: Some("2026-11-03-n1-1-quorum"), superseded_by: Some("2026-11-03-n1-1-chiffres"), }, // No dated figure in a voted text (lot T3, Yvv, 2026-11-03: « pas de chiffre datés dans un // texte voté »), every sentence true for every W and every quorum: N1.1 loses its example // (7 224 members, 120 voters) and says that under the quorum it takes as many « pour » as if // the quorum had voted; N1.1 and F1.2 ask the share of a level « comme d'habitude » beyond the // quorum. Each replaces the text lot T2 wrote, at its revision 4. F1.1 is not touched. Amendment { id: "2026-11-03-f1-2-chiffres", doc: "acte-engagement-certification", unit: "F1.2", version: Version::Open("1.0.0"), from: "96caf353b33805f2894593d72c5b70fce2e751039cd7f4651d8842c986729a14", to: "ddce5aa5fcd13dfc3e374c37509a1bb8cfafddefb6e6add5a06b130b43216d62", reason: "proposition de départ réécrite : F1.2, la part du cran par défaut au-delà du quorum", follows: Some("2026-11-03-f1-2-phrases"), superseded_by: None, }, Amendment { id: "2026-11-03-n1-1-chiffres", doc: "acte-engagement-certification", unit: "N1.1", version: Version::Open("1.0.0"), from: "7fbedcb8750c208f8790c4f87dc21d8bd1096a27a1a0044af6b9b8f2057f2aa8", to: "4289e3579ee747f6023162339ed60601b810d33462617b42a9f8b5b3735c3238", reason: "proposition de départ réécrite : N1.1, sans chiffre daté ; sous le quorum, autant de voix pour que si le quorum avait voté", follows: Some("2026-11-03-n1-1-phrases"), superseded_by: None, }, ]; /// 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, /// Lines of ballots of the buffer naming it, pending or proven, whatever became of them. pub ballots: i64, /// The seed amendments applied on it, in order. pub amendments: Vec, /// 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 amendments whose text `a` replaces, oldest first: the one it follows, the one that /// one follows, and so on, through `list`. Empty when it follows none. pub fn earlier(list: &[Amendment], a: &Amendment) -> Vec<&'static str> { let mut out = Vec::new(); let mut at = a.follows; while let Some(id) = at { if out.contains(&id) { break; } out.insert(0, id); at = list.iter().find(|e| e.id == id).and_then(|e| e.follows); } out } /// The rule, apart from the database: any trace of a person keeps the text. `earlier`: /// the amendments `a` follows, oldest first (`earlier`). pub fn verdict(facts: Option<&Facts>, a: &Amendment, earlier: &[&str]) -> 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; } // The text the amendments it follows wrote: by them alone, in their order, the last // being the one it follows (a base born later holds the last ones only), one revision // each over the seed's. let n = f.amendments.len(); let after_earlier = n > 0 && f.revision == 1 + n as i64 && earlier.len() >= n && earlier[earlier.len() - n..].iter().zip(&f.amendments).all(|(e, applied)| e == applied); let history: Vec<&str> = std::iter::once("initial").chain(std::iter::repeat_n("amendment", if after_earlier { n } else { 0 })).collect(); 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 && !after_earlier { "revised" } else if f.votes > 0 { "votes" } else if f.ballots > 0 { "ballots" } else if f.revisions != history || 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 before = earlier(list, a); 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, &before, 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)), _ if a.superseded_by.is_some() => { tracing::debug!( amendment = a.id, unit = a.unit, superseded_by = a.superseded_by, "seed amendment superseded: nothing decided" ); None } _ => { 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?; // Every line of a ballot, chain or simulated, whatever its fate: a vote was cast. let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?") .bind(proposal_id) .fetch_one(&mut *conn) .await?; let amendments: Vec = sqlx::query_scalar( "SELECT amendment_id FROM seed_amendments WHERE proposal_id = ? AND outcome = 'applied' ORDER BY at, rowid", ) .bind(proposal_id) .fetch_all(&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, ballots, amendments, 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, earlier: &[&str], 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, earlier); let now = util::now(); let judged = found.as_ref().map_or(1, |f| f.revision); 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 = ?", ) .bind(&title) .bind(&body) .bind(&now) .bind(pid) .bind(*status) .bind(judged) .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 (?, ?, 'amendment', ?, ?, NULL, ?)", ) .bind(pid) .bind(judged + 1) .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", follows: None, superseded_by: None, }; 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); // A ballot of the buffer names it, pending or proven, whatever became of it (ADR 0012). assert_eq!(verdict(Some(&Facts { ballots: 1, ..intact() }), &A, &[]), Verdict::Refuse("ballots")); } /// The text an earlier amendment wrote, as production holds F1.2 and N1.1 since M5. fn amended_by(earlier: &str) -> Facts { Facts { revision: 2, revisions: vec!["initial".into(), "amendment".into()], amendments: vec![earlier.into()], ..intact() } } #[test] fn a_second_amendment_admits_the_text_the_first_wrote_and_nothing_else() { // Lot T (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2); the quorum // texts replace them, after that amendment only. let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A }; assert_eq!(verdict(Some(&amended_by("m5")), &second, &["m5"]), Verdict::Apply); // A base born with the text of M5: its revision 1, as the seed made it. assert_eq!(verdict(Some(&intact()), &second, &["m5"]), Verdict::Apply); // Not the amendment it follows, or none: a revision 2 of something else. assert_eq!(verdict(Some(&amended_by("other")), &second, &["m5"]), Verdict::Refuse("revised")); let typo = Facts { revisions: vec!["initial".into(), "typo".into()], amendments: vec![], ..amended_by("m5") }; assert_eq!(verdict(Some(&typo), &second, &["m5"]), Verdict::Refuse("revised")); // A history that is not « initial then amendment ». let odd = Facts { revisions: vec!["initial".into(), "substantive".into()], ..amended_by("m5") }; assert_eq!(verdict(Some(&odd), &second, &["m5"]), Verdict::Refuse("corrected")); // Further on, nothing is admitted. let third = Facts { revision: 3, revisions: vec!["initial".into(), "amendment".into(), "typo".into()], ..amended_by("m5") }; assert_eq!(verdict(Some(&third), &second, &["m5"]), Verdict::Refuse("revised")); // The same traces of a person keep it. for (f, why) in [ (Facts { votes: 1, ..amended_by("m5") }, "votes"), (Facts { ballots: 2, ..amended_by("m5") }, "ballots"), (Facts { typos: 1, ..amended_by("m5") }, "corrected"), (Facts { member_translations: 1, ..amended_by("m5") }, "translation_edited"), (Facts { signals: 1, ..amended_by("m5") }, "signalled"), (Facts { fingerprint: "other".into(), ..amended_by("m5") }, "text_differs"), ] { assert_eq!(verdict(Some(&f), &second, &["m5"]), Verdict::Refuse(why), "{f:?}"); } // An amendment that follows none never admits a revision 2. assert_eq!(verdict(Some(&amended_by("m5")), &A, &[]), Verdict::Refuse("revised")); } /// The text written by `applied`, in order, one revision each over the seed's. fn written_by(applied: &[&str]) -> Facts { let mut revisions = vec!["initial".to_owned()]; revisions.extend(applied.iter().map(|_| "amendment".to_owned())); Facts { revision: 1 + applied.len() as i64, revisions, amendments: applied.iter().map(|a| (*a).to_owned()).collect(), ..intact() } } #[test] fn a_third_amendment_admits_the_texts_the_two_before_wrote_and_nothing_else() { // Lot T2 (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2), then by M6b // (revision 3); the true phrases replace the text of M6b, after it only. let third = Amendment { version: Version::Open("1.0.0"), follows: Some("t"), ..A }; let before: &[&str] = &["m5", "t"]; // Production: M5 then M6b, each its revision. assert_eq!(verdict(Some(&written_by(&["m5", "t"])), &third, before), Verdict::Apply); // A base born with the text of M5 (02/11): M6b alone wrote its revision 2. assert_eq!(verdict(Some(&written_by(&["t"])), &third, before), Verdict::Apply); // A base born with the text of M6b: its revision 1. assert_eq!(verdict(Some(&intact()), &third, before), Verdict::Apply); // The last revision is not M6b's, or not M6b's alone, or out of order: refused. for applied in [&["m5"][..], &["t", "m5"], &["m5", "other"], &["other", "t"], &["m5", "t", "t"]] { assert_eq!(verdict(Some(&written_by(applied)), &third, before), Verdict::Refuse("revised"), "{applied:?}"); } // A revision that no amendment wrote: a typo after M6b, a revision count off by one. let typo = Facts { revision: 4, revisions: vec!["initial".into(), "amendment".into(), "amendment".into(), "typo".into()], ..written_by(&["m5", "t"]) }; assert_eq!(verdict(Some(&typo), &third, before), Verdict::Refuse("revised")); let short = Facts { revision: 2, ..written_by(&["m5", "t"]) }; assert_eq!(verdict(Some(&short), &third, before), Verdict::Refuse("revised")); // The history is not « initial » then twice « amendment ». let odd = Facts { revisions: vec!["initial".into(), "amendment".into(), "substantive".into()], ..written_by(&["m5", "t"]) }; assert_eq!(verdict(Some(&odd), &third, before), Verdict::Refuse("corrected")); // Any trace of a person keeps the text, with its reason. for (f, why) in [ (Facts { votes: 1, ..written_by(&["m5", "t"]) }, "votes"), (Facts { ballots: 1, ..written_by(&["m5", "t"]) }, "ballots"), (Facts { typos: 1, ..written_by(&["m5", "t"]) }, "corrected"), (Facts { member_translations: 1, ..written_by(&["m5", "t"]) }, "translation_edited"), (Facts { signals: 1, ..written_by(&["m5", "t"]) }, "signalled"), (Facts { fingerprint: "other".into(), ..written_by(&["m5", "t"]) }, "text_differs"), (Facts { entered: true, ..written_by(&["m5", "t"]) }, "voted_into_force"), ] { assert_eq!(verdict(Some(&f), &third, before), Verdict::Refuse(why), "{f:?}"); } // Lot T's own amendment, which follows M5 alone, never admits a revision 3. let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A }; assert_eq!(verdict(Some(&written_by(&["m5", "t"])), &second, &["m5"]), Verdict::Refuse("revised")); } #[test] fn a_fourth_amendment_admits_the_texts_the_three_before_wrote_and_nothing_else() { // Lot T3 (2026-11-03): F1.2 and N1.1 were amended by M5, M6b and M6c (revision 4 in // production); the texts without dated figures replace the text of M6c, after it only. let fourth = Amendment { version: Version::Open("1.0.0"), follows: Some("t2"), ..A }; let before: &[&str] = &["m5", "t", "t2"]; // Production: M5, M6b, M6c, each its revision. assert_eq!(verdict(Some(&written_by(&["m5", "t", "t2"])), &fourth, before), Verdict::Apply); // A base born with the text of M5 (02/11): M6b and M6c wrote its revisions 2 and 3. assert_eq!(verdict(Some(&written_by(&["t", "t2"])), &fourth, before), Verdict::Apply); // A base born with the text of M6b, then of M6c: its revision 2, its revision 1. assert_eq!(verdict(Some(&written_by(&["t2"])), &fourth, before), Verdict::Apply); assert_eq!(verdict(Some(&intact()), &fourth, before), Verdict::Apply); // The last revision is not M6c's, or not by them alone, or out of order: refused. for applied in [ &["m5", "t"][..], &["t"], &["m5"], &["t2", "t"], &["m5", "t2", "t"], &["other", "t", "t2"], &["m5", "t", "t2", "t2"], ] { assert_eq!(verdict(Some(&written_by(applied)), &fourth, before), Verdict::Refuse("revised"), "{applied:?}"); } // A typo after M6c: a revision no amendment wrote. let typo = Facts { revision: 5, revisions: vec![ "initial".into(), "amendment".into(), "amendment".into(), "amendment".into(), "typo".into(), ], ..written_by(&["m5", "t", "t2"]) }; assert_eq!(verdict(Some(&typo), &fourth, before), Verdict::Refuse("revised")); // Any trace of a person keeps the text, with its reason. for (f, why) in [ (Facts { votes: 1, ..written_by(&["m5", "t", "t2"]) }, "votes"), (Facts { ballots: 1, ..written_by(&["m5", "t", "t2"]) }, "ballots"), (Facts { typos: 1, ..written_by(&["m5", "t", "t2"]) }, "corrected"), (Facts { member_translations: 1, ..written_by(&["m5", "t", "t2"]) }, "translation_edited"), (Facts { signals: 1, ..written_by(&["m5", "t", "t2"]) }, "signalled"), (Facts { fingerprint: "other".into(), ..written_by(&["m5", "t", "t2"]) }, "text_differs"), (Facts { confirmed: true, ..written_by(&["m5", "t", "t2"]) }, "confirmed"), ] { assert_eq!(verdict(Some(&f), &fourth, before), Verdict::Refuse(why), "{f:?}"); } // Lot T2's own amendment never admits a revision 4. let third = Amendment { version: Version::Open("1.0.0"), follows: Some("t"), ..A }; assert_eq!(verdict(Some(&written_by(&["m5", "t", "t2"])), &third, &["m5", "t"]), Verdict::Refuse("revised")); } #[test] fn the_amendments_followed_are_read_through_the_list_oldest_first() { let by_id = |id: &str| AMENDMENTS.iter().find(|a| a.id == id).expect("amendment"); assert_eq!( earlier(AMENDMENTS, by_id("2026-11-03-f1-2-phrases")), ["2026-11-02-f1-2-regles", "2026-11-03-f1-2-quorum"] ); assert_eq!( earlier(AMENDMENTS, by_id("2026-11-03-n1-1-phrases")), ["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum"] ); assert_eq!(earlier(AMENDMENTS, by_id("2026-11-03-f1-1-phrases")), ["2026-11-03-f1-1-quorum"]); assert_eq!( earlier(AMENDMENTS, by_id("2026-11-03-f1-2-chiffres")), ["2026-11-02-f1-2-regles", "2026-11-03-f1-2-quorum", "2026-11-03-f1-2-phrases"] ); assert_eq!( earlier(AMENDMENTS, by_id("2026-11-03-n1-1-chiffres")), ["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum", "2026-11-03-n1-1-phrases"] ); assert!(earlier(AMENDMENTS, by_id("2026-10-30-i1-raison-d-etre")).is_empty()); // Each one followed wrote the text the next replaces, and is superseded by it. for a in AMENDMENTS.iter().filter(|a| a.follows.is_some()) { let e = by_id(a.follows.expect("follows")); assert_eq!((e.unit, e.doc, e.to, e.superseded_by), (a.unit, a.doc, a.from, Some(a.id)), "{}", a.id); } // A list that loops never hangs. let looped = [Amendment { id: "x", follows: Some("y"), ..A }, Amendment { id: "y", follows: Some("x"), ..A }]; assert_eq!(earlier(&looped, &looped[0]), ["x", "y"]); } }