Files
decision/crates/ld-server/src/inertia_amend.rs
T
YvvandClaude Opus 5.5 6228d3913a WIP lot S : Propositions seed (I1, E1, E2 ouvertes, cran 2), amendement gardé, publication suspendue sans article en vigueur
Travail en cours, sauvegardé à la demande d'orch : le moteur, le serveur et
tests/seed_proposals.rs (verts) ; les autres tests et les e2e ne sont pas
encore repris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:50:31 +01:00

337 lines
12 KiB
Rust

//! 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<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
if !seed_says(seeds, a) {
continue;
}
let docs: Vec<(String, String, Option<String>)> =
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<Facts> {
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<Option<(Verdict, i64)>> {
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:?}");
}
}
}