forked from yvv/decision
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>
337 lines
12 KiB
Rust
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:?}");
|
|
}
|
|
}
|
|
}
|