forked from yvv/decision
v3 : la Raison d'être remplace le Préambule (L7), amendement de seed gardé
- seed : I1 devient « Raison d'être », texte d'Yvv (fr), traduit en « Purpose » et « Razón de ser » ; apostrophes droites comme le reste du seed, insécables avant « ; » et dans les guillemets. - seed_amend.rs : un amendement de seed remplace un texte de départ dans une base existante, décidé une fois par document et seulement si personne n'a touché l'article (seed a minima d'origine, révision 1, aucun vote, aucune correction, aucune traduction de membre, aucun signalement, empreinte attendue). Décision consignée (migration 0004, table seed_amendments), ligne d'historique « texte de départ remplacé » avec son diff, republication mise en file là où le document publie. - Une coquille se mesure désormais aussi contre le texte posé par l'amendement. - Tests : règle pure (3), scénarios d'API (base neuve, amendement appliqué et republié une seule fois, cinq refus), e2e C13 ; C3, C6 et l'API suivent le nouveau texte (C6 : les apostrophes typographiques d'Unicité du compte, « latransmission » ayant disparu). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -203,12 +203,13 @@ async fn editing_is_not_voting() {
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may fix the glued word of the preamble in force, without a vote.
|
||||
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||
let pid = e1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may give the text in force its typographic apostrophes, without a vote.
|
||||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
assert!(fixed.contains("m\u{2019}engage"));
|
||||
let (s, v) =
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await;
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["edit"], "typo");
|
||||
// A change of meaning in force is refused: it goes through a proposal.
|
||||
@@ -216,7 +217,7 @@ async fn editing_is_not_voting() {
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })),
|
||||
Some(json!({ "title": e1["title"], "body": "Tout autre chose." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
@@ -759,7 +760,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||||
};
|
||||
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
|
||||
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
|
||||
assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||
assert!(fr.contains("## Raison d'être") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
|
||||
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
|
||||
let json: Value =
|
||||
@@ -862,3 +863,307 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
|
||||
assert_eq!(pending(&db).await, 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I1 « Raison d'être » (Yvv's review, 2026-10-30): the seed and its amendment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Préambule every base seeded before 2026-10-30 holds (the seed file had not
|
||||
/// changed since 67ddee2, the first commit of the v3).
|
||||
const PREAMBULE: [(&str, &str, &str); 3] = [
|
||||
(
|
||||
"fr",
|
||||
"Préambule",
|
||||
"Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
|
||||
),
|
||||
(
|
||||
"en",
|
||||
"Preamble",
|
||||
"This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
|
||||
),
|
||||
(
|
||||
"es",
|
||||
"Preámbulo",
|
||||
"La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado.",
|
||||
),
|
||||
];
|
||||
|
||||
const RAISON_D_ETRE: &str = "*«\u{a0}Derrière une clé il n'y a qu'un seul être humain vivant\u{a0}; derrière un être humain, il n'y a qu'une seule clé certifiante.\u{a0}»*\n\nC'est le seul et unique propos de la certification";
|
||||
|
||||
fn today_seed() -> Value {
|
||||
let path = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||||
}
|
||||
|
||||
/// The seed as it was before 2026-10-30: the Préambule in force.
|
||||
fn first_seed_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ld-first-seed-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let mut seed = today_seed();
|
||||
let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1");
|
||||
for (lang, title, body) in PREAMBULE {
|
||||
i1["versions"][0]["title"][lang] = json!(title);
|
||||
i1["versions"][0]["body"][lang] = json!(body);
|
||||
}
|
||||
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
/// A base seeded with the Préambule, as production was: groups and documents, by the
|
||||
/// code of the time (no amendment).
|
||||
async fn base_with_the_preambule(tag: &str, mut cfg: Config) -> (Client, AppState) {
|
||||
cfg.seed_dir = first_seed_dir(tag);
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(cfg, db).await.expect("state");
|
||||
seed::documents(&state).await.expect("first seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// The same base, started again with today's seed (and its amendments).
|
||||
async fn restart(before: &AppState, cfg: Config) -> (Client, AppState) {
|
||||
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// Each document's decision on the amendment: (group, outcome, reason, at).
|
||||
async fn decisions(state: &AppState) -> Vec<(String, String, Option<String>, String)> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, s.outcome, s.reason, s.at FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
|
||||
ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions")
|
||||
}
|
||||
|
||||
fn outcomes(rows: &[(String, String, Option<String>, String)]) -> Vec<(&str, &str, Option<&str>)> {
|
||||
rows.iter().map(|(g, o, r, _)| (g.as_str(), o.as_str(), r.as_deref())).collect()
|
||||
}
|
||||
|
||||
async fn i1_of(c: &mut Client, group: &str) -> Value {
|
||||
let id = doc_id(c, group).await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
unit(&doc, "I1")["inForce"].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_base_starts_with_the_raison_d_etre() {
|
||||
let (mut c, state) = Client::new().await;
|
||||
c.login("Chiara", false).await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}");
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(1), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
|
||||
}
|
||||
}
|
||||
// Born with the text: nothing to replace, and it is said once.
|
||||
assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
|
||||
let a = ld_server::seed_amend::AMENDMENTS.iter().find(|a| a.unit == "I1").expect("I1 amendment");
|
||||
assert_eq!(a.doc, "acte-engagement-certification");
|
||||
let (_, title, body) = PREAMBULE[0];
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.from, "the Préambule bases hold");
|
||||
let seed = today_seed();
|
||||
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
|
||||
let v = &i1["versions"][0];
|
||||
assert_eq!(v["status"], "in_force");
|
||||
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_it() {
|
||||
use std::process::Command;
|
||||
let dir = std::env::temp_dir().join(format!("ld-amend-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let remote = dir.join("textes.git");
|
||||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.data_dir = dir.join("data");
|
||||
cfg.publish = Some(ld_server::config::Publish {
|
||||
remote: remote.display().to_string(),
|
||||
branch: "main".into(),
|
||||
key: dir.join("data/keys/publish_ed25519"),
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
});
|
||||
let git = |args: &[&str]| {
|
||||
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
||||
String::from_utf8(out.stdout).expect("utf8")
|
||||
};
|
||||
let show = |file: &str| git(&["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")]);
|
||||
|
||||
// Production as it is: the Préambule, published.
|
||||
let (_, before) = base_with_the_preambule("applied", cfg.clone()).await;
|
||||
ld_server::publish::pass(&before).await.expect("pass");
|
||||
assert!(show("fr.md").contains("## Préambule"));
|
||||
|
||||
// The new version starts: the untouched Préambule becomes the Raison d'être, in both groups.
|
||||
let (mut c, state) = restart(&before, cfg.clone()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(outcomes(&first), [("essai", "applied", None), ("monnaie-libre", "applied", None)]);
|
||||
c.login("Chiara", false).await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être", "{group}");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE));
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(2), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!(
|
||||
(tr["title"].as_str(), tr["stale"].as_bool(), tr["origin"].as_str()),
|
||||
(Some(title), Some(false), Some("seed")),
|
||||
"{group} {lang}"
|
||||
);
|
||||
}
|
||||
// The article's history says so, with what changed.
|
||||
let (_, d) = c.call("GET", &format!("/api/proposals/{}", i1["id"].as_str().expect("id")), None).await;
|
||||
let history = d["history"].as_array().expect("history");
|
||||
assert_eq!(history.len(), 2, "{d}");
|
||||
assert_eq!((history[1]["kind"].as_str(), history[1]["author"].as_str()), (Some("amendment"), None));
|
||||
let parts = history[1]["diff"].as_array().expect("diff");
|
||||
let said =
|
||||
|op: &str| parts.iter().filter(|p| p["op"] == op).filter_map(|p| p["text"].as_str()).collect::<String>();
|
||||
assert!(said("removed").contains("Préambule") && said("added").contains("Raison"), "{parts:?}");
|
||||
}
|
||||
// Republished where the document publishes — never from the test group.
|
||||
let queued: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.done_at IS NULL",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(queued, [("monnaie-libre".to_owned(), "texte de départ remplacé : I1".to_owned())]);
|
||||
ld_server::publish::pass(&state).await.expect("pass");
|
||||
let fr = show("fr.md");
|
||||
assert!(fr.contains("## Raison d'être") && !fr.contains("Préambule"), "{fr}");
|
||||
assert!(show("en.md").contains("## Purpose") && show("es.md").contains("## Razón de ser"));
|
||||
assert!(git(&["log", "-1", "--format=%B", "main"]).contains("texte de départ remplacé : I1"));
|
||||
|
||||
// Once only: the next start changes nothing.
|
||||
let (mut c, again) = restart(&state, cfg.clone()).await;
|
||||
assert_eq!(decisions(&again).await, first);
|
||||
c.login("Chiara", false).await;
|
||||
let i1 = i1_of(&mut c, "monnaie-libre").await;
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let revisions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revisions WHERE proposal_id = ?")
|
||||
.bind(&pid)
|
||||
.fetch_one(&again.db)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
||||
|
||||
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
|
||||
let body = i1["body"].as_str().expect("body");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": PREAMBULE[0].1, "body": PREAMBULE[0].2 })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Touch {
|
||||
Vote,
|
||||
Typo,
|
||||
Translation,
|
||||
Signal,
|
||||
Text,
|
||||
}
|
||||
for (touch, reason) in [
|
||||
(Touch::Vote, "votes"),
|
||||
(Touch::Typo, "corrected"),
|
||||
(Touch::Translation, "translation_edited"),
|
||||
(Touch::Signal, "signalled"),
|
||||
(Touch::Text, "text_differs"),
|
||||
] {
|
||||
let tag = format!("{touch:?}").to_lowercase();
|
||||
let (mut c, before) = base_with_the_preambule(&tag, Config::for_tests()).await;
|
||||
c.login("Yvv", false).await;
|
||||
// Touched in the test group only: Monnaie libre stays intact.
|
||||
let id = doc_id(&mut c, "essai").await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) = match touch {
|
||||
Touch::Vote => {
|
||||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||||
}
|
||||
Touch::Typo => {
|
||||
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
||||
.await
|
||||
}
|
||||
Touch::Translation => {
|
||||
let en = &i1["translations"]["en"];
|
||||
let body =
|
||||
en["body"].as_str().expect("en").replace("This commitment act", "The present commitment act");
|
||||
c.call(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{pid}/translations/en"),
|
||||
Some(json!({ "title": en["title"], "body": body })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Touch::Signal => {
|
||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||
c.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{k1}/signals"),
|
||||
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Touch::Text => {
|
||||
sqlx::query("UPDATE proposals SET body = body || ' Fin.' WHERE id = ?")
|
||||
.bind(&pid)
|
||||
.execute(&before.db)
|
||||
.await
|
||||
.expect("another text");
|
||||
(StatusCode::OK, Value::Null)
|
||||
}
|
||||
};
|
||||
assert_eq!(s, StatusCode::OK, "{touch:?}: {v}");
|
||||
|
||||
let (mut c, state) = restart(&before, Config::for_tests()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(
|
||||
outcomes(&first),
|
||||
[("essai", "refused", Some(reason)), ("monnaie-libre", "applied", None)],
|
||||
"{touch:?}"
|
||||
);
|
||||
c.login("Yvv", false).await;
|
||||
let i1 = i1_of(&mut c, "essai").await;
|
||||
assert_eq!(i1["title"], "Préambule", "{touch:?}");
|
||||
assert_eq!(i1["revision"], 1, "{touch:?}");
|
||||
let (_, d) = c.call("GET", &format!("/api/proposals/{pid}"), None).await;
|
||||
assert!(d["history"].as_array().expect("h").iter().all(|h| h["kind"] != "amendment"), "{touch:?}");
|
||||
// Decided once: a later start does not look again.
|
||||
let (_, again) = restart(&state, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again).await, first, "{touch:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user