Files
decision/crates/ld-server/src/module_amend.rs
T
YvvandClaude Opus 5.5 3831ddb1bf v3 : modules d'ordre et de titre — moteur, données, serveur (L11)
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>
2026-10-31 02:08:44 +01:00

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"));
}
}