Files
decision/crates/ld-server/src/seed_proposal_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

572 lines
22 KiB
Rust

//! Seed proposal amendments — a starting text in force made a seed proposal in
//! an existing base (ADR 0011).
//!
//! Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les
//! 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition
//! seed" » — then: « on pouvait l'amorcer avec une inertie basse, pour que le
//! nombre de votes initiaux soit assez petit pour commencer l'expérience en mode
//! prod ». New bases get I1, E1 and E2 from the seed as open proposals put
//! forward, at `seed::SEED_PROPOSAL_LEVEL`. An existing base — production holds
//! them in force, seeds a minima, in Monnaie libre and in the test group — gets
//! them through an amendment listed here, decided once per document and article.
//!
//! **The status**, only if nobody has touched the text in force:
//!
//! - it is the seed's own (origin `seed`), never voted into force, never
//! confirmed (`entered_at`, `confirmed_at` empty);
//! - nobody ever voted on it, on the server or in a ballot of the chain;
//! - no typo fix (applied, waiting or refused), no member translation, no
//! objection names it; its history is the seed's alone: its initial text, and
//! at most the seed amendment that replaced it (I1, the « Raison d'être »);
//! - its text is the seed proposal's of today (fingerprint).
//!
//! Applied, the same version stays — its text, translations, history and votes
//! (none) — and becomes open and marked; the article has no text in force any
//! more; its history gets a line (kind `seed_proposal`).
//!
//! **The level**, with the status only, and only if nobody has touched it: no
//! change of inertia proposed by a member that is open, applied or voted on, no
//! vote that changed it, and a level the seed gave the article before (I1: 3;
//! E1 and E2: 3, then 4 since 30/10). Applied, the article says so
//! (`inertia_changes`, « réglage de départ du texte »). Refused, it keeps its
//! level and the status still changes.
//!
//! Each decision is kept in `seed_proposal_amendments` (status and level:
//! applied, refused and why, or present) and in the logs. Where the document
//! publishes, the publication queue gets a row — and waits while no article is
//! in force (`publish`, ADR 0011).
use crate::AppState;
use crate::seed::{self, SeedDoc};
use crate::seed_amend::{self, fingerprint};
use crate::util;
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// One starting text in force to put forward, in every document instantiated from a seed.
pub struct SeedProposalAmendment {
/// 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,
/// Fingerprint of the text (source language): the seed proposal of the seed
/// today, which must hash to it, and the text in force it expects.
pub text: &'static str,
/// The levels the article may still hold for its level to go low: those the seed
/// gave it before.
pub levels: &'static [u8],
/// Reason given to the publication queue, and so to the texts repository.
pub reason: &'static str,
}
pub const AMENDMENTS: &[SeedProposalAmendment] = &[
SeedProposalAmendment {
id: "2026-11-02-i1-proposition-seed",
doc: "acte-engagement-certification",
unit: "I1",
text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
levels: &[3],
reason: "Proposition seed : I1 sort de la vigueur",
},
SeedProposalAmendment {
id: "2026-11-02-e1-proposition-seed",
doc: "acte-engagement-certification",
unit: "E1",
text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd",
levels: &[3, 4],
reason: "Proposition seed : E1 sort de la vigueur",
},
SeedProposalAmendment {
id: "2026-11-02-e2-proposition-seed",
doc: "acte-engagement-certification",
unit: "E2",
text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0",
levels: &[3, 4],
reason: "Proposition seed : E2 sort de la vigueur",
},
];
/// What the database says of the version in force.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
/// As a seed amendment reads it.
pub text: seed_amend::Facts,
/// Lines of ballots of the chain naming it, whatever became of them.
pub ballots: i64,
}
/// What the article holds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Found {
/// A version in force.
InForce(Facts),
/// No text in force, and its seed proposal: born so, or already amended.
SeedProposal,
/// Neither.
Nothing,
}
/// What the database says of the article's level.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LevelFacts {
/// `units.inertia` as written.
pub raw: String,
/// Changes of inertia proposed on it that are open or applied, or hold a vote.
pub inertia_proposals: i64,
/// Changes of its inertia that a vote brought into force.
pub voted_changes: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// Already so: nothing to do.
Present,
Refuse(&'static str),
}
impl Verdict {
fn outcome(self) -> (&'static str, Option<&'static str>) {
match self {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
}
}
}
/// The status, apart from the database: any trace of a person keeps the text in force.
pub fn verdict(found: &Found, a: &SeedProposalAmendment) -> Verdict {
let f = match found {
Found::Nothing => return Verdict::Refuse("no_text_in_force"),
Found::SeedProposal => return Verdict::Present,
Found::InForce(f) => f,
};
let t = &f.text;
let kinds: Vec<&str> = t.revisions.iter().map(String::as_str).collect();
let seed_history = matches!((kinds.as_slice(), t.revision), (["initial"], 1) | (["initial", "amendment"], 2));
let refusal = if t.origin != "seed" {
"not_a_seed"
} else if t.entered {
"voted_into_force"
} else if t.confirmed {
"confirmed"
} else if t.votes > 0 {
"votes"
} else if f.ballots > 0 {
"ballots"
} else if t.typos > 0 || kinds.iter().any(|k| matches!(*k, "typo" | "revert")) {
"corrected"
} else if !seed_history {
"revised"
} else if t.member_translations > 0 {
"translation_edited"
} else if t.signals > 0 {
"signalled"
} else if t.fingerprint != a.text {
"text_differs"
} else {
return Verdict::Apply;
};
Verdict::Refuse(refusal)
}
/// The level, apart from the database: it goes low with the status only, and any
/// change of inertia a person proposed or voted keeps it.
pub fn level_verdict(status: Verdict, facts: Option<&LevelFacts>, a: &SeedProposalAmendment, to: u8) -> Verdict {
if let Verdict::Refuse(_) = status {
return Verdict::Refuse("not_a_seed_proposal");
}
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() == to {
Verdict::Present
} else if f.voted_changes > 0 {
Verdict::Refuse("changed_by_vote")
} else if f.inertia_proposals > 0 {
Verdict::Refuse("inertia_proposal")
} else if !a.levels.contains(&level.get()) {
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 status: Verdict,
pub level: Verdict,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(
state: &AppState,
seeds: &[SeedDoc],
list: &[SeedProposalAmendment],
) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
let to = seed::SEED_PROPOSAL_LEVEL;
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((status, level)) = decide(state, a, to, &doc_id, publish_path.is_some()).await? else {
continue;
};
let (s, sr) = status.outcome();
let (l, lr) = level.outcome();
if matches!(status, Verdict::Refuse(_)) || matches!(level, Verdict::Refuse(_)) {
tracing::warn!(
amendment = a.id, %group, unit = a.unit, status = s, reason = sr, level = l, level_reason = lr,
"seed proposal amendment: refused where the article was touched, it keeps what it had"
);
} else {
tracing::info!(
amendment = a.id, %group, unit = a.unit, status = s, level = l, to_level = to,
"seed proposal amendment"
);
}
if (status == Verdict::Apply || level == Verdict::Apply) && publish_path.is_some() {
state.publish_wake.notify_one();
}
out.push(Outcome { amendment: a.id, group, status, level });
}
}
Ok(out)
}
/// The seed must put the article forward, with the text the amendment names and
/// no level of its own: a seed edited later never reaches a base by this way.
fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool {
let unit = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| {
let src = s.source_lang.as_str();
let u = s.units.iter().find(|u| u.code == a.unit)?;
let v = u.versions.iter().find(|v| v.seed_proposal)?;
Some((u, fingerprint(v.title.get(src)?, v.body.get(src)?)))
});
match unit {
Some((u, print)) if print == a.text && u.inertia.is_none() => true,
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
unit = a.unit,
"seed proposal amendment: the seed does not put this text forward, nothing decided"
);
false
}
}
}
/// What the article holds, and the version examined: in force, or its seed proposal.
async fn found(
conn: &mut SqliteConnection,
unit_id: &str,
in_force: Option<&str>,
) -> anyhow::Result<(Found, Option<String>)> {
if let Some(id) = in_force {
let Some(text) = seed_amend::facts(&mut *conn, id, "in_force").await? else {
return Ok((Found::Nothing, Some(id.to_owned())));
};
let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?")
.bind(id)
.fetch_one(&mut *conn)
.await?;
return Ok((Found::InForce(Facts { text, ballots }), Some(id.to_owned())));
}
let featured: Option<String> = sqlx::query_scalar(
"SELECT id FROM proposals WHERE unit_id = ? AND status = 'open' AND origin = 'seed' AND seed_featured = 1
ORDER BY rowid LIMIT 1",
)
.bind(unit_id)
.fetch_optional(&mut *conn)
.await?;
Ok(match featured {
Some(id) => (Found::SeedProposal, Some(id)),
None => (Found::Nothing, None),
})
}
async fn level_facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<LevelFacts> {
let inertia_proposals: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM proposals p WHERE p.unit_id = ? AND p.kind = 'inertia'
AND (p.status IN ('open', 'applied') OR EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id))",
)
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
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?;
Ok(LevelFacts { raw, inertia_proposals, voted_changes })
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &SeedProposalAmendment,
to: u8,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<(Verdict, Verdict)>> {
let mut tx = state.db.begin().await?;
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM seed_proposal_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, Option<String>)> =
sqlx::query_as("SELECT id, inertia, in_force_id FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?;
let ((status_found, examined), level_found) = match &unit {
Some((id, raw, in_force)) => {
(found(&mut tx, id, in_force.as_deref()).await?, Some(level_facts(&mut tx, id, raw.clone()).await?))
}
None => ((Found::Nothing, None), None),
};
let mut status = verdict(&status_found, a);
let mut level = level_verdict(status, level_found.as_ref(), a, to);
let now = util::now();
let to_db = InertiaLevel::new(to).map(InertiaLevel::as_db).unwrap_or_default();
if let (Verdict::Apply, Some((unit_id, _, Some(pid)))) = (status, &unit) {
// Written only over the version that was judged, still untouched and in force.
let revision = match &status_found {
Found::InForce(f) => f.text.revision,
_ => 0,
};
let left = sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ? AND in_force_id = ?")
.bind(unit_id)
.bind(pid)
.execute(&mut *tx)
.await?;
let opened = sqlx::query(
"UPDATE proposals SET status = 'open', seed_featured = 1, left_at = ?, updated_at = ?
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
AND confirmed_at IS NULL AND revision = ?",
)
.bind(&now)
.bind(&now)
.bind(pid)
.bind(revision)
.execute(&mut *tx)
.await?;
if left.rows_affected() == 1 && opened.rows_affected() == 1 {
// The history says it: the same text, at the same revision, now a seed proposal.
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
SELECT id, revision, 'seed_proposal', title, body, NULL, ? FROM proposals WHERE id = ?",
)
.bind(&now)
.bind(pid)
.execute(&mut *tx)
.await?;
} else {
// Nothing is written: what was examined changed under us.
tx.rollback().await?;
tx = state.db.begin().await?;
status = Verdict::Refuse("changed");
level = level_verdict(status, level_found.as_ref(), a, to);
}
}
if let (Verdict::Apply, Some((unit_id, raw, _))) = (level, &unit) {
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
.bind(&to_db)
.bind(unit_id)
.bind(raw)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
let from = InertiaLevel::parse(raw).map_or_else(|| raw.clone(), InertiaLevel::as_db);
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(from)
.bind(&to_db)
.bind(a.id)
.bind(&now)
.execute(&mut *tx)
.await?;
} else {
level = Verdict::Refuse("changed");
}
}
if publishes && (status == Verdict::Apply || level == Verdict::Apply) {
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) = status.outcome();
let (level_outcome, level_reason) = level.outcome();
sqlx::query(
"INSERT INTO seed_proposal_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason,
from_level, to_level, level_outcome, level_reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(examined)
.bind(outcome)
.bind(reason)
.bind(unit.as_ref().map(|(_, raw, _)| raw.clone()))
.bind(&to_db)
.bind(level_outcome)
.bind(level_reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((status, level)))
}
#[cfg(test)]
mod tests {
use super::*;
const A: SeedProposalAmendment = SeedProposalAmendment {
id: "test",
doc: "doc",
unit: "E1",
text: "seed",
levels: &[3, 4],
reason: "Proposition seed : E1 sort de la vigueur",
};
fn intact() -> Facts {
Facts {
text: seed_amend::Facts {
origin: "seed".into(),
revision: 1,
revisions: vec!["initial".into()],
fingerprint: "seed".into(),
..seed_amend::Facts::default()
},
ballots: 0,
}
}
fn with(f: impl FnOnce(&mut seed_amend::Facts)) -> Found {
let mut facts = intact();
f(&mut facts.text);
Found::InForce(facts)
}
fn at(raw: &str) -> LevelFacts {
LevelFacts { raw: raw.into(), ..LevelFacts::default() }
}
#[test]
fn an_untouched_text_in_force_is_put_forward() {
assert_eq!(verdict(&Found::InForce(intact()), &A), Verdict::Apply);
// I1: its initial text, then the seed amendment of 30/10.
let amended = with(|t| {
t.revision = 2;
t.revisions = vec!["initial".into(), "amendment".into()];
});
assert_eq!(verdict(&amended, &A), Verdict::Apply);
}
#[test]
fn a_seed_proposal_already_there_is_left_as_it_is() {
assert_eq!(verdict(&Found::SeedProposal, &A), Verdict::Present);
}
#[test]
fn any_trace_of_a_person_keeps_the_text_in_force() {
let cases = [
(Found::Nothing, "no_text_in_force"),
(with(|t| t.origin = "member".into()), "not_a_seed"),
(with(|t| t.entered = true), "voted_into_force"),
(with(|t| t.confirmed = true), "confirmed"),
(with(|t| t.votes = 1), "votes"),
(Found::InForce(Facts { ballots: 1, ..intact() }), "ballots"),
(with(|t| t.typos = 1), "corrected"),
(
with(|t| {
t.revision = 2;
t.revisions = vec!["initial".into(), "typo".into()];
}),
"corrected",
),
(
with(|t| {
t.revision = 2;
t.revisions = vec!["initial".into(), "substantive".into()];
}),
"revised",
),
(with(|t| t.revision = 2), "revised"),
(with(|t| t.revisions = vec![]), "revised"),
(with(|t| t.member_translations = 1), "translation_edited"),
(with(|t| t.signals = 1), "signalled"),
(with(|t| t.fingerprint = "other".into()), "text_differs"),
];
for (found, reason) in cases {
assert_eq!(verdict(&found, &A), Verdict::Refuse(reason), "{found:?}");
}
}
#[test]
fn the_level_goes_low_with_the_status_where_nobody_touched_it() {
assert_eq!(level_verdict(Verdict::Apply, Some(&at("4")), &A, 2), Verdict::Apply);
assert_eq!(level_verdict(Verdict::Apply, Some(&at("3")), &A, 2), Verdict::Apply);
// The word of before, if an older image wrote it.
assert_eq!(level_verdict(Verdict::Apply, Some(&at("standard")), &A, 2), Verdict::Apply);
assert_eq!(level_verdict(Verdict::Present, Some(&at("2")), &A, 2), Verdict::Present);
assert_eq!(level_verdict(Verdict::Apply, Some(&at("1")), &A, 1), Verdict::Present, "level 1, if Yvv says so");
}
#[test]
fn a_change_of_inertia_a_person_proposed_or_voted_keeps_the_level() {
let cases = [
(Verdict::Refuse("votes"), Some(at("4")), "not_a_seed_proposal"),
(Verdict::Apply, None, "no_article"),
(Verdict::Apply, Some(at("haute")), "level_unreadable"),
(Verdict::Apply, Some(LevelFacts { voted_changes: 1, ..at("4") }), "changed_by_vote"),
(Verdict::Apply, Some(LevelFacts { inertia_proposals: 1, ..at("4") }), "inertia_proposal"),
(Verdict::Apply, Some(at("5")), "level_differs"),
(Verdict::Apply, Some(at("1")), "level_differs"),
];
for (status, facts, reason) in cases {
assert_eq!(level_verdict(status, facts.as_ref(), &A, 2), Verdict::Refuse(reason), "{facts:?}");
}
}
#[test]
fn the_shipped_amendments_name_the_three_first_modules_and_their_levels_of_before() {
let units: Vec<&str> = AMENDMENTS.iter().map(|a| a.unit).collect();
assert_eq!(units, ["I1", "E1", "E2"]);
assert!(AMENDMENTS.iter().all(|a| !a.levels.contains(&seed::SEED_PROPOSAL_LEVEL)));
assert!(InertiaLevel::new(seed::SEED_PROPOSAL_LEVEL).is_some_and(|l| l.get() <= 2), "a low level");
}
}