//! Inertia amendments — a starting level of the seed raised in an existing base. //! //! Yvv, 2026-10-30: « Tu mets par défaut au milieu (3) pour toutes les //! propositions, (4) pour les 2 engagements structurels » — Unicité du compte //! (E1) and Réciprocité (E2). New bases get level 4 from the seed. An existing //! base, where both are in force at the starting level (`standard`, now 3), //! gets it through an amendment listed here, and **only if no vote ever //! changed the article's inertia**: //! //! - no adoption of a change of inertia on the article (`adoptions`, and the //! journal `inertia_changes`); //! - its level is still the one the amendment raises (the seed's 3). //! //! An open proposal to change the inertia does not stop it (orch, 2026-10-30: //! two such proposals exist in production, I1 → 5 and E1 → 5, at zero votes): //! it stays open, with its target, and is voted at the article's new level, //! since a change of inertia inherits it (ADR 0007). //! //! Like the seed amendments (`seed_amend`), each document is decided once, at //! the first start that knows the amendment; the decision is kept in //! `inertia_amendments` (applied, refused and why, or present) and in the //! logs. Applied, the level is written in one transaction with a line in //! `inertia_changes` (the article says so) and, where the document publishes, //! a row in the publication queue (`document.json` carries the levels). //! //! Since 2026-11-02 the seed gives E1 and E2 another starting level: put forward //! as seed proposals, they start low (ADR 0011, `seed_proposal_amend`). The two //! amendments of 30/10 are then superseded: decided in production, they decide //! nothing more, and say so only in the debug logs. use crate::AppState; use crate::seed::SeedDoc; use crate::util; use ld_engine::inertia::InertiaLevel; use sqlx::SqliteConnection; /// One starting level to raise, in every document instantiated from a seed. pub struct InertiaAmendment { /// 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 level it must still hold. pub from: u8, /// The level it gets: the seed's today, which must say so. pub to: u8, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, /// The later decision whose level the seed gives instead, if any: this one then /// decides nothing more, without an error. pub superseded_by: Option<&'static str>, } pub const AMENDMENTS: &[InertiaAmendment] = &[ InertiaAmendment { id: "2026-10-30-e1-inertie-4", doc: "acte-engagement-certification", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée : E1, 3 → 4", superseded_by: Some("2026-11-02-e1-proposition-seed"), }, InertiaAmendment { id: "2026-10-30-e2-inertie-4", doc: "acte-engagement-certification", unit: "E2", from: 3, to: 4, reason: "inertie de départ relevée : E2, 3 → 4", superseded_by: Some("2026-11-02-e2-proposition-seed"), }, ]; /// What the database says of the article. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Facts { /// `units.inertia` as written. pub raw: String, /// Changes of its inertia that a vote brought into force. pub voted_changes: i64, /// Open proposals to change its inertia (they stay open; told in the logs). pub open_proposals: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { Apply, /// The level is already there: nothing to do. Present, Refuse(&'static str), } /// The rule, apart from the database: a vote on the inertia keeps it. pub fn verdict(facts: Option<&Facts>, a: &InertiaAmendment) -> Verdict { 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() == a.to { return Verdict::Present; } if f.voted_changes > 0 { Verdict::Refuse("changed_by_vote") } else if level.get() != a.from { 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 verdict: Verdict, } /// Decides every amendment not yet decided, document by document. pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment]) -> anyhow::Result> { let mut out = Vec::new(); 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((v, open)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue }; match v { Verdict::Apply => tracing::info!( amendment = a.id, %group, unit = a.unit, from = a.from, to = a.to, open_inertia_proposals = open, "inertia amendment applied" ), Verdict::Present => { tracing::info!(amendment = a.id, %group, unit = a.unit, "inertia amendment: level already there") } Verdict::Refuse(why) => tracing::warn!( amendment = a.id, %group, unit = a.unit, reason = why, "inertia amendment refused: the article keeps its inertia" ), } 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 must hold the level the amendment gives: a seed edited later never /// reaches a base by this way. fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool { let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit)); match level { Some(u) if u.level().get() == a.to => true, Some(u) if a.superseded_by.is_some() => { tracing::debug!( amendment = a.id, unit = a.unit, seed_level = u.level().get(), superseded_by = a.superseded_by, "inertia amendment superseded: nothing decided" ); false } _ => { tracing::error!( amendment = a.id, doc = a.doc, unit = a.unit, "inertia amendment: the seed does not give this level, nothing decided" ); false } } } async fn facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result { 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?; let open_proposals: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE unit_id = ? AND kind = 'inertia' AND status = 'open'") .bind(unit_id) .fetch_one(&mut *conn) .await?; Ok(Facts { raw, voted_changes, open_proposals }) } /// One document, in one transaction: `None` when it was decided at an earlier start. async fn decide( state: &AppState, a: &InertiaAmendment, doc_id: &str, publishes: bool, ) -> anyhow::Result> { let mut tx = state.db.begin().await?; let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inertia_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)> = sqlx::query_as("SELECT id, inertia FROM units WHERE doc_id = ? AND code = ?") .bind(doc_id) .bind(a.unit) .fetch_optional(&mut *tx) .await?; let found = match &unit { Some((id, raw)) => Some(facts(&mut tx, id, raw.clone()).await?), None => None, }; let mut v = verdict(found.as_ref(), a); let now = util::now(); let to = InertiaLevel::new(a.to).map(InertiaLevel::as_db).unwrap_or_default(); if let (Verdict::Apply, Some((unit_id, raw))) = (v, &unit) { // Written only over the level that was judged. let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?") .bind(&to) .bind(unit_id) .bind(raw) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { 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(a.from.to_string()) .bind(&to) .bind(a.id) .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 inertia_amendments (amendment_id, doc_id, unit_code, from_level, to_level, outcome, reason, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(a.id) .bind(doc_id) .bind(a.unit) .bind(unit.as_ref().map(|(_, raw)| raw.clone())) .bind(&to) .bind(outcome) .bind(reason) .bind(&now) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Some((v, found.map_or(0, |f| f.open_proposals)))) } #[cfg(test)] mod tests { use super::*; const A: InertiaAmendment = InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée", superseded_by: None, }; fn at(raw: &str) -> Facts { Facts { raw: raw.into(), ..Facts::default() } } #[test] fn the_starting_level_is_raised() { assert_eq!(verdict(Some(&at("3")), &A), Verdict::Apply); // The word of before, if an older image wrote it. assert_eq!(verdict(Some(&at("standard")), &A), Verdict::Apply); } #[test] fn an_open_change_of_inertia_does_not_stop_it() { let f = Facts { open_proposals: 1, ..at("3") }; assert_eq!(verdict(Some(&f), &A), Verdict::Apply); } #[test] fn the_level_already_there_is_left_as_it_is() { assert_eq!(verdict(Some(&at("4")), &A), Verdict::Present); assert_eq!(verdict(Some(&at("high")), &A), Verdict::Present); } #[test] fn a_vote_on_the_inertia_keeps_it() { let cases = [ (None, "no_article"), (Some(Facts { voted_changes: 1, ..at("3") }), "changed_by_vote"), (Some(at("5")), "level_differs"), (Some(at("2")), "level_differs"), (Some(at("haute")), "level_unreadable"), ]; for (facts, reason) in cases { assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}"); } } }