forked from yvv/decision
L'ordre des articles de chaque section (cran 2) et le titre du document (cran 4) deviennent des modules : des unités à part, dont les versions sont des propositions votées comme les articles (dépliées, panier, chaîne). - moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé, complété à la fin quand un article entre ou sort, places redistribuées, nouvel article en fin de section) ; pending : un ordre de moins de deux articles n'est jamais à voter ; - données : migration 0007 (units.kind, module_amendments) ; le seed déclare ses modules ; module_amend.rs les ajoute une fois, gardé et journalisé, aux documents nés sans eux, sans toucher articles ni votes ; - serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer, entrée en vigueur : les articles changent de place, le document change de titre, en et es passent « à revoir ») ; publication des modules ; - reste mineur : la médiane compte les statuts open, in_force, applied, et eux seuls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
8.1 KiB
Rust
222 lines
8.1 KiB
Rust
//! Module amendments — the modules a seed now declares, added to a document
|
|
//! that was born without them (ADR 0009).
|
|
//!
|
|
//! Yvv, 2026-10-30: the title of the document at level 4, the order of the
|
|
//! articles of each section at level 2. A new base gets them from the seed
|
|
//! (`modules` in the seed file). An existing base — production holds two
|
|
//! instances of the certification act, Monnaie libre and the test group — gets
|
|
//! them through an amendment listed here, built **from what the document holds
|
|
//! then**: its title as it reads, and the order each section reads in. Nothing
|
|
//! else is written: no article moves, no text changes, no vote is touched.
|
|
//!
|
|
//! Like the seed and inertia amendments, each document is decided once, at the
|
|
//! first start that knows the amendment, and the decision is kept in
|
|
//! `module_amendments` (applied with the codes created, refused and why, or
|
|
//! present) and in the logs. Applied where the document publishes, the
|
|
//! publication queue gets a row (`document.json` carries the modules).
|
|
|
|
use crate::AppState;
|
|
use crate::modules::{self, Levels};
|
|
use crate::seed::SeedDoc;
|
|
use crate::util;
|
|
use ld_engine::inertia::InertiaLevel;
|
|
use sqlx::SqliteConnection;
|
|
|
|
/// Modules to add to every document instantiated from a seed.
|
|
pub struct ModuleAmendment {
|
|
/// Stable identifier: each document's decision is kept under it.
|
|
pub id: &'static str,
|
|
/// Slug of the seed document.
|
|
pub doc: &'static str,
|
|
/// The level of the title module: the seed's today, which must say so.
|
|
pub title: u8,
|
|
/// The level of the order modules: the seed's today, which must say so.
|
|
pub order: u8,
|
|
/// Reason given to the publication queue, and so to the commit of the texts.
|
|
pub reason: &'static str,
|
|
}
|
|
|
|
pub const AMENDMENTS: &[ModuleAmendment] = &[ModuleAmendment {
|
|
id: "2026-10-31-modules-titre-ordre",
|
|
doc: "acte-engagement-certification",
|
|
title: 4,
|
|
order: 2,
|
|
reason: "modules ajoutés : le titre du document, l'ordre des articles de chaque section",
|
|
}];
|
|
|
|
/// What the database says of the document.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Facts {
|
|
/// Its title in its source language.
|
|
pub title: String,
|
|
/// Modules it holds already, of either kind.
|
|
pub modules: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Verdict {
|
|
Apply,
|
|
/// Born with its modules: nothing to do.
|
|
Present,
|
|
Refuse(&'static str),
|
|
}
|
|
|
|
/// The rule, apart from the database.
|
|
pub fn verdict(facts: Option<&Facts>) -> Verdict {
|
|
let Some(f) = facts else { return Verdict::Refuse("no_document") };
|
|
if f.modules > 0 {
|
|
Verdict::Present
|
|
} else if f.title.trim().is_empty() {
|
|
Verdict::Refuse("no_title")
|
|
} 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,
|
|
pub created: Vec<String>,
|
|
}
|
|
|
|
/// Decides every amendment not yet decided, document by document.
|
|
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ModuleAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
|
let mut out = Vec::new();
|
|
for a in list {
|
|
let Some(levels) = seed_says(seeds, a) else { 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, created)) = decide(state, a, levels, &doc_id, publish_path.is_some()).await? else {
|
|
continue;
|
|
};
|
|
match v {
|
|
Verdict::Apply => tracing::info!(amendment = a.id, %group, created = ?created, "modules added"),
|
|
Verdict::Present => tracing::info!(amendment = a.id, %group, "modules: already there"),
|
|
Verdict::Refuse(why) => {
|
|
tracing::warn!(amendment = a.id, %group, reason = why, "modules refused: the document stays as it is")
|
|
}
|
|
}
|
|
if v == Verdict::Apply && publish_path.is_some() {
|
|
state.publish_wake.notify_one();
|
|
}
|
|
out.push(Outcome { amendment: a.id, group, verdict: v, created });
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The seed must declare the modules at the levels the amendment gives: a seed
|
|
/// edited later never reaches a base by this way.
|
|
fn seed_says(seeds: &[SeedDoc], a: &ModuleAmendment) -> Option<Levels> {
|
|
let levels = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.modules.as_ref()).map(Levels::from);
|
|
let wanted = Levels { title: InertiaLevel::new(a.title), order: InertiaLevel::new(a.order) };
|
|
match levels {
|
|
Some(l) if l == wanted && l.title.is_some() && l.order.is_some() => Some(l),
|
|
_ => {
|
|
tracing::error!(
|
|
amendment = a.id,
|
|
doc = a.doc,
|
|
"module amendment: the seed does not declare these modules, nothing decided"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn facts(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Option<Facts>> {
|
|
let doc: Option<(String, String)> = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
|
|
.bind(doc_id)
|
|
.fetch_optional(&mut *conn)
|
|
.await?;
|
|
let Some((raw, src)) = doc else { return Ok(None) };
|
|
let modules: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind <> 'article'")
|
|
.bind(doc_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
Ok(Some(Facts { title: util::parse_lang(&raw).get(&src).cloned().unwrap_or_default(), modules }))
|
|
}
|
|
|
|
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
|
async fn decide(
|
|
state: &AppState,
|
|
a: &ModuleAmendment,
|
|
levels: Levels,
|
|
doc_id: &str,
|
|
publishes: bool,
|
|
) -> anyhow::Result<Option<(Verdict, Vec<String>)>> {
|
|
let mut tx = state.db.begin().await?;
|
|
let decided: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM module_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 found = facts(&mut tx, doc_id).await?;
|
|
let v = verdict(found.as_ref());
|
|
let now = util::now();
|
|
let created = if v == Verdict::Apply { modules::create(&mut tx, doc_id, levels, &now).await? } else { Vec::new() };
|
|
if v == Verdict::Apply && 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?;
|
|
}
|
|
let (outcome, reason) = match v {
|
|
Verdict::Apply => ("applied", None),
|
|
Verdict::Present => ("present", None),
|
|
Verdict::Refuse(why) => ("refused", Some(why)),
|
|
};
|
|
sqlx::query(
|
|
"INSERT INTO module_amendments (amendment_id, doc_id, outcome, reason, created, at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(a.id)
|
|
.bind(doc_id)
|
|
.bind(outcome)
|
|
.bind(reason)
|
|
.bind((v == Verdict::Apply).then(|| serde_json::to_string(&created).unwrap_or_else(|_| "[]".into())))
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(Some((v, created)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn doc(title: &str, modules: i64) -> Facts {
|
|
Facts { title: title.into(), modules }
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_without_modules_receives_them() {
|
|
assert_eq!(verdict(Some(&doc("Acte d'engagement Certification", 0))), Verdict::Apply);
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_born_with_its_modules_is_left_as_it_is() {
|
|
assert_eq!(verdict(Some(&doc("Acte", 9))), Verdict::Present);
|
|
assert_eq!(verdict(Some(&doc("Acte", 1))), Verdict::Present, "never a second set");
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_without_a_document_or_a_title() {
|
|
assert_eq!(verdict(None), Verdict::Refuse("no_document"));
|
|
assert_eq!(verdict(Some(&doc(" ", 0))), Verdict::Refuse("no_title"));
|
|
}
|
|
}
|