//! Seed proposal amendments — a starting text in force made a seed proposal in //! an existing base (ADR 0011). //! //! Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les //! 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition //! seed" » — then: « 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 ». New bases get I1, E1 and E2 from the seed as open proposals put //! forward, at `seed::SEED_PROPOSAL_LEVEL`. An existing base — production holds //! them in force, seeds a minima, in Monnaie libre and in the test group — gets //! them through an amendment listed here, decided once per document and article. //! //! **The status**, only if nobody has touched the text in force: //! //! - it is the seed's own (origin `seed`), never voted into force, never //! confirmed (`entered_at`, `confirmed_at` empty); //! - nobody ever voted on it, on the server or in a ballot of the chain; //! - no typo fix (applied, waiting or refused), no member translation, no //! objection names it; its history is the seed's alone: its initial text, and //! at most the seed amendment that replaced it (I1, the « Raison d'être »); //! - its text is the seed proposal's of today (fingerprint). //! //! Applied, the same version stays — its text, translations, history and votes //! (none) — and becomes open and marked; the article has no text in force any //! more; its history gets a line (kind `seed_proposal`). //! //! **The level**, with the status only, and only if nobody has touched it: no //! change of inertia proposed by a member that is open, applied or voted on, no //! vote that changed it, and a level the seed gave the article before (I1: 3; //! E1 and E2: 3, then 4 since 30/10). Applied, the article says so //! (`inertia_changes`, « réglage de départ du texte »). Refused, it keeps its //! level and the status still changes. //! //! Each decision is kept in `seed_proposal_amendments` (status and level: //! applied, refused and why, or present) and in the logs. Where the document //! publishes, the publication queue gets a row — and waits while no article is //! in force (`publish`, ADR 0011). use crate::AppState; use crate::seed::{self, SeedDoc}; use crate::seed_amend::{self, fingerprint}; use crate::util; use ld_engine::inertia::InertiaLevel; use sqlx::SqliteConnection; /// One starting text in force to put forward, in every document instantiated from a seed. pub struct SeedProposalAmendment { /// 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 text (source language): the seed proposal of the seed /// today, which must hash to it, and the text in force it expects. pub text: &'static str, /// The levels the article may still hold for its level to go low: those the seed /// gave it before. pub levels: &'static [u8], /// Reason given to the publication queue, and so to the texts repository. pub reason: &'static str, } pub const AMENDMENTS: &[SeedProposalAmendment] = &[ SeedProposalAmendment { id: "2026-11-02-i1-proposition-seed", doc: "acte-engagement-certification", unit: "I1", text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", levels: &[3], reason: "Proposition seed : I1 sort de la vigueur", }, SeedProposalAmendment { id: "2026-11-02-e1-proposition-seed", doc: "acte-engagement-certification", unit: "E1", text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd", levels: &[3, 4], reason: "Proposition seed : E1 sort de la vigueur", }, SeedProposalAmendment { id: "2026-11-02-e2-proposition-seed", doc: "acte-engagement-certification", unit: "E2", text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0", levels: &[3, 4], reason: "Proposition seed : E2 sort de la vigueur", }, ]; /// What the database says of the version in force. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Facts { /// As a seed amendment reads it. pub text: seed_amend::Facts, /// Lines of ballots of the chain naming it, whatever became of them. pub ballots: i64, } /// What the article holds. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Found { /// A version in force. InForce(Facts), /// No text in force, and its seed proposal: born so, or already amended. SeedProposal, /// Neither. Nothing, } /// What the database says of the article's level. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct LevelFacts { /// `units.inertia` as written. pub raw: String, /// Changes of inertia proposed on it that are open or applied, or hold a vote. pub inertia_proposals: i64, /// Changes of its inertia that a vote brought into force. pub voted_changes: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { Apply, /// Already so: nothing to do. Present, Refuse(&'static str), } impl Verdict { fn outcome(self) -> (&'static str, Option<&'static str>) { match self { Verdict::Apply => ("applied", None), Verdict::Present => ("present", None), Verdict::Refuse(why) => ("refused", Some(why)), } } } /// The status, apart from the database: any trace of a person keeps the text in force. pub fn verdict(found: &Found, a: &SeedProposalAmendment) -> Verdict { let f = match found { Found::Nothing => return Verdict::Refuse("no_text_in_force"), Found::SeedProposal => return Verdict::Present, Found::InForce(f) => f, }; let t = &f.text; let kinds: Vec<&str> = t.revisions.iter().map(String::as_str).collect(); let seed_history = matches!((kinds.as_slice(), t.revision), (["initial"], 1) | (["initial", "amendment"], 2)); let refusal = if t.origin != "seed" { "not_a_seed" } else if t.entered { "voted_into_force" } else if t.confirmed { "confirmed" } else if t.votes > 0 { "votes" } else if f.ballots > 0 { "ballots" } else if t.typos > 0 || kinds.iter().any(|k| matches!(*k, "typo" | "revert")) { "corrected" } else if !seed_history { "revised" } else if t.member_translations > 0 { "translation_edited" } else if t.signals > 0 { "signalled" } else if t.fingerprint != a.text { "text_differs" } else { return Verdict::Apply; }; Verdict::Refuse(refusal) } /// The level, apart from the database: it goes low with the status only, and any /// change of inertia a person proposed or voted keeps it. pub fn level_verdict(status: Verdict, facts: Option<&LevelFacts>, a: &SeedProposalAmendment, to: u8) -> Verdict { if let Verdict::Refuse(_) = status { return Verdict::Refuse("not_a_seed_proposal"); } let Some(f) = facts else { return Verdict::Refuse("no_article") }; let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") }; if level.get() == to { Verdict::Present } else if f.voted_changes > 0 { Verdict::Refuse("changed_by_vote") } else if f.inertia_proposals > 0 { Verdict::Refuse("inertia_proposal") } else if !a.levels.contains(&level.get()) { Verdict::Refuse("level_differs") } 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 status: Verdict, pub level: Verdict, } /// Decides every amendment not yet decided, document by document. pub async fn apply( state: &AppState, seeds: &[SeedDoc], list: &[SeedProposalAmendment], ) -> anyhow::Result> { let mut out = Vec::new(); let to = seed::SEED_PROPOSAL_LEVEL; for a in list { if !seed_says(seeds, a) { 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((status, level)) = decide(state, a, to, &doc_id, publish_path.is_some()).await? else { continue; }; let (s, sr) = status.outcome(); let (l, lr) = level.outcome(); if matches!(status, Verdict::Refuse(_)) || matches!(level, Verdict::Refuse(_)) { tracing::warn!( amendment = a.id, %group, unit = a.unit, status = s, reason = sr, level = l, level_reason = lr, "seed proposal amendment: refused where the article was touched, it keeps what it had" ); } else { tracing::info!( amendment = a.id, %group, unit = a.unit, status = s, level = l, to_level = to, "seed proposal amendment" ); } if (status == Verdict::Apply || level == Verdict::Apply) && publish_path.is_some() { state.publish_wake.notify_one(); } out.push(Outcome { amendment: a.id, group, status, level }); } } Ok(out) } /// The seed must put the article forward, with the text the amendment names and /// no level of its own: a seed edited later never reaches a base by this way. fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool { let unit = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| { let src = s.source_lang.as_str(); let u = s.units.iter().find(|u| u.code == a.unit)?; let v = u.versions.iter().find(|v| v.seed_proposal)?; Some((u, fingerprint(v.title.get(src)?, v.body.get(src)?))) }); match unit { Some((u, print)) if print == a.text && u.inertia.is_none() => true, _ => { tracing::error!( amendment = a.id, doc = a.doc, unit = a.unit, "seed proposal amendment: the seed does not put this text forward, nothing decided" ); false } } } /// What the article holds, and the version examined: in force, or its seed proposal. async fn found( conn: &mut SqliteConnection, unit_id: &str, in_force: Option<&str>, ) -> anyhow::Result<(Found, Option)> { if let Some(id) = in_force { let Some(text) = seed_amend::facts(&mut *conn, id, "in_force").await? else { return Ok((Found::Nothing, Some(id.to_owned()))); }; let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?") .bind(id) .fetch_one(&mut *conn) .await?; return Ok((Found::InForce(Facts { text, ballots }), Some(id.to_owned()))); } let featured: Option = sqlx::query_scalar( "SELECT id FROM proposals WHERE unit_id = ? AND status = 'open' AND origin = 'seed' AND seed_featured = 1 ORDER BY rowid LIMIT 1", ) .bind(unit_id) .fetch_optional(&mut *conn) .await?; Ok(match featured { Some(id) => (Found::SeedProposal, Some(id)), None => (Found::Nothing, None), }) } async fn level_facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result { let inertia_proposals: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM proposals p WHERE p.unit_id = ? AND p.kind = 'inertia' AND (p.status IN ('open', 'applied') OR EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id))", ) .bind(unit_id) .fetch_one(&mut *conn) .await?; let voted_changes: i64 = sqlx::query_scalar( "SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia') + (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')", ) .bind(unit_id) .fetch_one(&mut *conn) .await?; Ok(LevelFacts { raw, inertia_proposals, voted_changes }) } /// One document, in one transaction: `None` when it was decided at an earlier start. async fn decide( state: &AppState, a: &SeedProposalAmendment, to: u8, doc_id: &str, publishes: bool, ) -> anyhow::Result> { let mut tx = state.db.begin().await?; let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seed_proposal_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 unit: Option<(String, String, Option)> = sqlx::query_as("SELECT id, inertia, in_force_id FROM units WHERE doc_id = ? AND code = ?") .bind(doc_id) .bind(a.unit) .fetch_optional(&mut *tx) .await?; let ((status_found, examined), level_found) = match &unit { Some((id, raw, in_force)) => { (found(&mut tx, id, in_force.as_deref()).await?, Some(level_facts(&mut tx, id, raw.clone()).await?)) } None => ((Found::Nothing, None), None), }; let mut status = verdict(&status_found, a); let mut level = level_verdict(status, level_found.as_ref(), a, to); let now = util::now(); let to_db = InertiaLevel::new(to).map(InertiaLevel::as_db).unwrap_or_default(); if let (Verdict::Apply, Some((unit_id, _, Some(pid)))) = (status, &unit) { // Written only over the version that was judged, still untouched and in force. let revision = match &status_found { Found::InForce(f) => f.text.revision, _ => 0, }; let left = sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ? AND in_force_id = ?") .bind(unit_id) .bind(pid) .execute(&mut *tx) .await?; let opened = sqlx::query( "UPDATE proposals SET status = 'open', seed_featured = 1, left_at = ?, updated_at = ? WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL AND confirmed_at IS NULL AND revision = ?", ) .bind(&now) .bind(&now) .bind(pid) .bind(revision) .execute(&mut *tx) .await?; if left.rows_affected() == 1 && opened.rows_affected() == 1 { // The history says it: the same text, at the same revision, now a seed proposal. sqlx::query( "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) SELECT id, revision, 'seed_proposal', title, body, NULL, ? FROM proposals WHERE id = ?", ) .bind(&now) .bind(pid) .execute(&mut *tx) .await?; } else { // Nothing is written: what was examined changed under us. tx.rollback().await?; tx = state.db.begin().await?; status = Verdict::Refuse("changed"); level = level_verdict(status, level_found.as_ref(), a, to); } } if let (Verdict::Apply, Some((unit_id, raw, _))) = (level, &unit) { let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?") .bind(&to_db) .bind(unit_id) .bind(raw) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { let from = InertiaLevel::parse(raw).map_or_else(|| raw.clone(), InertiaLevel::as_db); sqlx::query( "INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at) VALUES (?, ?, ?, ?, 'amendment', ?, ?)", ) .bind(unit_id) .bind(doc_id) .bind(from) .bind(&to_db) .bind(a.id) .bind(&now) .execute(&mut *tx) .await?; } else { level = Verdict::Refuse("changed"); } } if publishes && (status == Verdict::Apply || level == Verdict::Apply) { 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) = status.outcome(); let (level_outcome, level_reason) = level.outcome(); sqlx::query( "INSERT INTO seed_proposal_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, from_level, to_level, level_outcome, level_reason, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(a.id) .bind(doc_id) .bind(a.unit) .bind(examined) .bind(outcome) .bind(reason) .bind(unit.as_ref().map(|(_, raw, _)| raw.clone())) .bind(&to_db) .bind(level_outcome) .bind(level_reason) .bind(&now) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Some((status, level))) } #[cfg(test)] mod tests { use super::*; const A: SeedProposalAmendment = SeedProposalAmendment { id: "test", doc: "doc", unit: "E1", text: "seed", levels: &[3, 4], reason: "Proposition seed : E1 sort de la vigueur", }; fn intact() -> Facts { Facts { text: seed_amend::Facts { origin: "seed".into(), revision: 1, revisions: vec!["initial".into()], fingerprint: "seed".into(), ..seed_amend::Facts::default() }, ballots: 0, } } fn with(f: impl FnOnce(&mut seed_amend::Facts)) -> Found { let mut facts = intact(); f(&mut facts.text); Found::InForce(facts) } fn at(raw: &str) -> LevelFacts { LevelFacts { raw: raw.into(), ..LevelFacts::default() } } #[test] fn an_untouched_text_in_force_is_put_forward() { assert_eq!(verdict(&Found::InForce(intact()), &A), Verdict::Apply); // I1: its initial text, then the seed amendment of 30/10. let amended = with(|t| { t.revision = 2; t.revisions = vec!["initial".into(), "amendment".into()]; }); assert_eq!(verdict(&amended, &A), Verdict::Apply); } #[test] fn a_seed_proposal_already_there_is_left_as_it_is() { assert_eq!(verdict(&Found::SeedProposal, &A), Verdict::Present); } #[test] fn any_trace_of_a_person_keeps_the_text_in_force() { let cases = [ (Found::Nothing, "no_text_in_force"), (with(|t| t.origin = "member".into()), "not_a_seed"), (with(|t| t.entered = true), "voted_into_force"), (with(|t| t.confirmed = true), "confirmed"), (with(|t| t.votes = 1), "votes"), (Found::InForce(Facts { ballots: 1, ..intact() }), "ballots"), (with(|t| t.typos = 1), "corrected"), ( with(|t| { t.revision = 2; t.revisions = vec!["initial".into(), "typo".into()]; }), "corrected", ), ( with(|t| { t.revision = 2; t.revisions = vec!["initial".into(), "substantive".into()]; }), "revised", ), (with(|t| t.revision = 2), "revised"), (with(|t| t.revisions = vec![]), "revised"), (with(|t| t.member_translations = 1), "translation_edited"), (with(|t| t.signals = 1), "signalled"), (with(|t| t.fingerprint = "other".into()), "text_differs"), ]; for (found, reason) in cases { assert_eq!(verdict(&found, &A), Verdict::Refuse(reason), "{found:?}"); } } #[test] fn the_level_goes_low_with_the_status_where_nobody_touched_it() { assert_eq!(level_verdict(Verdict::Apply, Some(&at("4")), &A, 2), Verdict::Apply); assert_eq!(level_verdict(Verdict::Apply, Some(&at("3")), &A, 2), Verdict::Apply); // The word of before, if an older image wrote it. assert_eq!(level_verdict(Verdict::Apply, Some(&at("standard")), &A, 2), Verdict::Apply); assert_eq!(level_verdict(Verdict::Present, Some(&at("2")), &A, 2), Verdict::Present); assert_eq!(level_verdict(Verdict::Apply, Some(&at("1")), &A, 1), Verdict::Present, "level 1, if Yvv says so"); } #[test] fn a_change_of_inertia_a_person_proposed_or_voted_keeps_the_level() { let cases = [ (Verdict::Refuse("votes"), Some(at("4")), "not_a_seed_proposal"), (Verdict::Apply, None, "no_article"), (Verdict::Apply, Some(at("haute")), "level_unreadable"), (Verdict::Apply, Some(LevelFacts { voted_changes: 1, ..at("4") }), "changed_by_vote"), (Verdict::Apply, Some(LevelFacts { inertia_proposals: 1, ..at("4") }), "inertia_proposal"), (Verdict::Apply, Some(at("5")), "level_differs"), (Verdict::Apply, Some(at("1")), "level_differs"), ]; for (status, facts, reason) in cases { assert_eq!(level_verdict(status, facts.as_ref(), &A, 2), Verdict::Refuse(reason), "{facts:?}"); } } #[test] fn the_shipped_amendments_name_the_three_first_modules_and_their_levels_of_before() { let units: Vec<&str> = AMENDMENTS.iter().map(|a| a.unit).collect(); assert_eq!(units, ["I1", "E1", "E2"]); assert!(AMENDMENTS.iter().all(|a| !a.levels.contains(&seed::SEED_PROPOSAL_LEVEL))); assert!(InertiaLevel::new(seed::SEED_PROPOSAL_LEVEL).is_some_and(|l| l.get() <= 2), "a low level"); } }