Files
decision/crates/ld-server/src/seed_amend.rs
T
YvvandClaude Opus 5.5 aaa653e1a7 Lot S : tests serveur repris sans Propositions seed en vigueur (modules, overview), rustfmt
modules.rs : 0 article en vigueur et 35 propositions en cours dans une base
neuve (I1, E1 et E2 sont des Propositions seed) ; l'ajout des modules à une
base existante se rejoue sur le seed de production du 02/11 (textes en
vigueur), comme inertia.rs. overview.rs : le vote porte sur la Proposition
seed I1 ; 41 modules à voter, inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:12:20 +01:00

502 lines
19 KiB
Rust

//! Seed amendments — a starting text replaced by a newer text of the seed.
//!
//! The text replaced is an article's version in force (a seed a minima), or, since
//! 2026-11-02, one of its open proposals from the seed, named by its label: the
//! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv).
//! The guards are the same. Since the same day, a starting text may be a seed
//! proposal instead of a text in force (ADR 0011): where an article has no text
//! in force, `Version::InForce` examines its seed proposal (a new base: present).
//!
//! A seeded document is never instantiated again: once in the database, it is
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
//! the Préambule becomes « Raison d'être »), new bases get it from the seed;
//! an existing base gets it through an amendment listed here, and **only if
//! nobody has touched the article**:
//!
//! - its version in force is the seed's own, a *seed a minima* (never voted
//! into force, never confirmed), at revision 1;
//! - nobody ever voted on it, corrected it, edited one of its translations or
//! named it in a contradiction signal;
//! - its text is exactly the one the amendment replaces (fingerprint).
//!
//! Each document is decided once, at the first start that knows the
//! amendment, and the decision is kept in `seed_amendments`: applied, refused
//! (and why — also in the logs), or present (the base was born with the new
//! text). Applied, the text and its seed translations are replaced in one
//! transaction, the article's history gets a line of its own (kind
//! `amendment`, with its diff) and, where the document publishes, the
//! publication queue a row.
use crate::AppState;
use crate::seed::{SeedDoc, SeedVersion};
use crate::util;
use sqlx::SqliteConnection;
/// Which version of the article an amendment replaces.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Version {
/// Its starting text: in force (a seed a minima), or its seed proposal (ADR 0011).
InForce,
/// An open proposal of the seed, at zero votes, by its label.
Open(&'static str),
}
/// One starting text to replace, in every document instantiated from a seed.
pub struct Amendment {
/// 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 version replaced.
pub version: Version,
/// Fingerprint of the starting text expected in force (source language).
pub from: &'static str,
/// Fingerprint of the new text: the version in force of the seed today, which
/// must hash to it (a seed edited later never reaches a base by this way).
pub to: &'static str,
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
}
pub const AMENDMENTS: &[Amendment] = &[
Amendment {
id: "2026-10-30-i1-raison-d-etre",
doc: "acte-engagement-certification",
unit: "I1",
version: Version::InForce,
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
reason: "texte de départ remplacé : I1",
},
// The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02).
Amendment {
id: "2026-11-02-f1-2-regles",
doc: "acte-engagement-certification",
unit: "F1.2",
version: Version::Open("1.0.0"),
from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6",
to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60",
reason: "proposition de départ réécrite : F1.2",
},
Amendment {
id: "2026-11-02-n1-regles",
doc: "acte-engagement-certification",
unit: "N1",
version: Version::Open("1.0.0"),
from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142",
to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e",
reason: "proposition de départ réécrite : N1",
},
Amendment {
id: "2026-11-02-n1-1-regles",
doc: "acte-engagement-certification",
unit: "N1.1",
version: Version::Open("1.0.0"),
from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee",
to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0",
reason: "proposition de départ réécrite : N1.1",
},
Amendment {
id: "2026-11-02-o1-regles",
doc: "acte-engagement-certification",
unit: "O1",
version: Version::Open("1.0.0"),
from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642",
to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537",
reason: "proposition de départ réécrite : O1",
},
];
/// The fingerprint of a text: the hash translations are pinned to.
pub fn fingerprint(title: &str, body: &str) -> String {
util::source_hash(title, body)
}
/// What the database says of the version in force of the article.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
pub origin: String,
/// Voted into force (`entered_at`).
pub entered: bool,
/// A seed a minima that later reached its threshold (`confirmed_at`).
pub confirmed: bool,
pub revision: i64,
/// Votes cast on it, whatever their revision.
pub votes: i64,
/// Kinds of its revisions, in order.
pub revisions: Vec<String>,
/// Translations written or corrected by a member.
pub member_translations: i64,
/// Contradiction signals (objections since 2026-10-31) naming it, standing or lifted.
pub signals: i64,
/// Typo fixes someone else wrote on it, whatever became of them (2026-10-31).
pub typos: i64,
pub fingerprint: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// The new text is already in force: nothing to do.
Present,
Refuse(&'static str),
}
/// The rule, apart from the database: any trace of a person keeps the text.
pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
let Some(f) = facts else {
return Verdict::Refuse(match a.version {
Version::InForce => "no_text_in_force",
Version::Open(_) => "no_open_version",
});
};
if f.fingerprint == a.to {
return Verdict::Present;
}
let refusal = if f.origin != "seed" {
"not_a_seed"
} else if f.entered {
"voted_into_force"
} else if f.confirmed {
"confirmed"
} else if f.revision != 1 {
"revised"
} else if f.votes > 0 {
"votes"
} else if f.revisions != ["initial"] || f.typos > 0 {
"corrected"
} else if f.member_translations > 0 {
"translation_edited"
} else if f.signals > 0 {
"signalled"
} else if f.fingerprint != a.from {
"text_differs"
} else {
return Verdict::Apply;
};
Verdict::Refuse(refusal)
}
/// 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: &[Amendment]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
let Some((seed, target)) = target_of(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) = decide(state, a, seed, target, &doc_id, publish_path.is_some()).await? else { continue };
match v {
Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"),
Verdict::Present => {
tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment: text already there")
}
Verdict::Refuse(why) => {
tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "seed amendment refused: the article was touched, it keeps its text")
}
}
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 document and its version in force for the article, checked against `to`.
fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'a SeedVersion)> {
let Some(seed) = seeds.iter().find(|s| s.slug == a.doc) else {
tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided");
return None;
};
let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| {
u.versions.iter().find(|v| match a.version {
// The starting text: in force, or since 2026-11-02 put forward (ADR 0011).
Version::InForce => v.status == "in_force" || v.seed_proposal,
Version::Open(label) => v.status == "open" && v.label.as_deref() == Some(label),
})
});
let src = seed.source_lang.as_str();
let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?)));
match (version, text) {
(Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)),
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
unit = a.unit,
"seed amendment: the seed text is not the one it names, nothing decided"
);
None
}
}
}
/// What the database says of a version holding `status` (`None`: it does not).
pub(crate) async fn facts(
conn: &mut SqliteConnection,
proposal_id: &str,
status: &str,
) -> anyhow::Result<Option<Facts>> {
#[derive(sqlx::FromRow)]
struct Row {
origin: String,
entered_at: Option<String>,
confirmed_at: Option<String>,
revision: i64,
title: String,
body: String,
}
let Some(p) = sqlx::query_as::<_, Row>(
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = ?",
)
.bind(proposal_id)
.bind(status)
.fetch_optional(&mut *conn)
.await?
else {
return Ok(None);
};
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE proposal_id = ?")
.bind(proposal_id)
.fetch_one(&mut *conn)
.await?;
let revisions: Vec<String> = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
.bind(proposal_id)
.fetch_all(&mut *conn)
.await?;
let member_translations: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM translations WHERE proposal_id = ? AND (origin <> 'seed' OR updated_by IS NOT NULL)",
)
.bind(proposal_id)
.fetch_one(&mut *conn)
.await?;
let signals: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE proposal_id = ?1 OR target_proposal_id = ?1")
.bind(proposal_id)
.fetch_one(&mut *conn)
.await?;
let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?")
.bind(proposal_id)
.fetch_one(&mut *conn)
.await?;
Ok(Some(Facts {
origin: p.origin,
entered: p.entered_at.is_some(),
confirmed: p.confirmed_at.is_some(),
revision: p.revision,
votes,
revisions,
member_translations,
signals,
typos,
fingerprint: fingerprint(&p.title, &p.body),
}))
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &Amendment,
seed: &SeedDoc,
target: &SeedVersion,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<Verdict>> {
let mut tx = state.db.begin().await?;
let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seed_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 in_force = || {
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
};
let open = |label: Option<&'static str>| {
sqlx::query_scalar::<_, String>(
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed'
AND (p.label = ?3 OR (?3 IS NULL AND p.seed_featured = 1))
ORDER BY p.rowid LIMIT 1",
)
.bind(doc_id)
.bind(a.unit)
.bind(label)
};
// The version examined, and the status it holds.
let examined: Option<(String, &'static str)> = match a.version {
Version::InForce => match in_force().fetch_optional(&mut *tx).await?.flatten() {
Some(id) => Some((id, "in_force")),
// No text in force: the starting text may be a seed proposal (ADR 0011).
None => open(None).fetch_optional(&mut *tx).await?.map(|id| (id, "open")),
},
Version::Open(label) => open(Some(label)).fetch_optional(&mut *tx).await?.map(|id| (id, "open")),
};
let target_id = examined.as_ref().map(|(id, _)| id.clone());
let found = match &examined {
Some((id, status)) => facts(&mut tx, id, status).await?,
None => None,
};
let mut v = verdict(found.as_ref(), a);
let now = util::now();
if let (Verdict::Apply, Some((pid, status))) = (v, &examined) {
let src = seed.source_lang.as_str();
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
let (title, body) = (pick(&target.title), pick(&target.body));
// Written only over the text that was judged, still untouched.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ?
WHERE id = ? AND status = ? AND origin = 'seed' AND entered_at IS NULL
AND confirmed_at IS NULL AND revision = 1",
)
.bind(&title)
.bind(&body)
.bind(&now)
.bind(pid)
.bind(*status)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
VALUES (?, 2, 'amendment', ?, ?, NULL, ?)",
)
.bind(pid)
.bind(&title)
.bind(&body)
.bind(&now)
.execute(&mut *tx)
.await?;
let hash = fingerprint(&title, &body);
for lang in util::LANGS.iter().filter(|l| **l != src) {
let (Some(t), Some(b)) = (target.title.get(*lang), target.body.get(*lang)) else { continue };
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
source_hash = excluded.source_hash, origin = 'seed', updated_by = NULL, updated_at = excluded.updated_at",
)
.bind(pid)
.bind(lang)
.bind(t)
.bind(b)
.bind(&hash)
.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 seed_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(&target_id)
.bind(outcome)
.bind(reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(v))
}
#[cfg(test)]
mod tests {
use super::*;
const A: Amendment = Amendment {
id: "test",
doc: "doc",
unit: "I1",
version: Version::InForce,
from: "old",
to: "new",
reason: "texte de départ remplacé : I1",
};
fn intact() -> Facts {
Facts {
origin: "seed".into(),
revision: 1,
revisions: vec!["initial".into()],
fingerprint: "old".into(),
..Facts::default()
}
}
#[test]
fn an_untouched_starting_text_is_replaced() {
assert_eq!(verdict(Some(&intact()), &A), Verdict::Apply);
}
#[test]
fn the_new_text_already_there_is_left_as_it_is() {
let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() };
assert_eq!(verdict(Some(&f), &A), Verdict::Present);
}
#[test]
fn any_trace_of_a_person_keeps_the_text() {
let cases = [
(None, "no_text_in_force"),
(Some(Facts { origin: "member".into(), ..intact() }), "not_a_seed"),
(Some(Facts { entered: true, ..intact() }), "voted_into_force"),
(Some(Facts { confirmed: true, ..intact() }), "confirmed"),
(Some(Facts { revision: 2, ..intact() }), "revised"),
(Some(Facts { votes: 1, ..intact() }), "votes"),
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
(Some(Facts { typos: 1, ..intact() }), "corrected"),
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
(Some(Facts { signals: 1, ..intact() }), "signalled"),
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
];
for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
}
// An open proposal of the seed that is no longer there (withdrawn, never seeded).
let open = Amendment { version: Version::Open("1.0.0"), ..A };
assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version"));
assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply);
}
}