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:
@@ -0,0 +1,15 @@
|
||||
-- Seed amendments (2026-10-30): a starting text replaced by a newer one of the
|
||||
-- seed, decided once per document and only on an article nobody has touched
|
||||
-- (src/seed_amend.rs). Each decision is kept here: applied, refused (and why),
|
||||
-- or already present. Additive only.
|
||||
|
||||
CREATE TABLE seed_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
proposal_id TEXT, -- the version in force that was examined
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -14,6 +14,7 @@ pub mod error;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod team;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
|
||||
@@ -71,10 +71,11 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text the votes were cast on: its last initial or substantive revision.
|
||||
/// The text the votes were cast on: its last initial or substantive revision, or the
|
||||
/// starting text a seed amendment put in place (`seed_amend`).
|
||||
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive', 'amendment')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(&p.id)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//!
|
||||
//! On start, missing groups are created and every `seeds/*.json` document is
|
||||
//! instantiated in each group it names, **only if that group does not hold it
|
||||
//! yet**. An existing document is never touched: from its first vote on, it
|
||||
//! is runtime data.
|
||||
//! yet**. An existing document is never instantiated again: from its first
|
||||
//! vote on, it is runtime data. The one way a newer seed text reaches an
|
||||
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
|
||||
//! and applied only to an article nobody has touched.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
@@ -64,14 +67,23 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On start: the groups, the documents missing from them, then the seed
|
||||
/// amendments (each decided once per document).
|
||||
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
let seeds = documents(state).await?;
|
||||
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The groups and every seed document missing from its groups; returns the seeds read.
|
||||
pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
|
||||
ensure_groups(state).await?;
|
||||
let dir = state.cfg.seed_dir.clone();
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
|
||||
return Ok(());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
@@ -82,13 +94,14 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut seeds = Vec::new();
|
||||
for path in files {
|
||||
load_file(state, &path).await?;
|
||||
seeds.push(load_file(state, &path).await?);
|
||||
}
|
||||
Ok(())
|
||||
Ok(seeds)
|
||||
}
|
||||
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
let raw = tokio::fs::read_to_string(path).await?;
|
||||
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||
for inst in &seed.instances {
|
||||
@@ -111,7 +124,7 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
instantiate(state, &seed, inst).await?;
|
||||
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
|
||||
}
|
||||
Ok(())
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Seed amendments — a starting text replaced by a newer text of the seed.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// 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,
|
||||
/// 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",
|
||||
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
|
||||
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
}];
|
||||
|
||||
/// 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 naming it, standing or lifted.
|
||||
pub signals: 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("no_text_in_force") };
|
||||
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"] {
|
||||
"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| v.status == "in_force"));
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, proposal_id: &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 = 'in_force'",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.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?;
|
||||
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,
|
||||
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: Option<String> =
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
let found = match &in_force {
|
||||
Some(id) => facts(&mut tx, id).await?,
|
||||
None => None,
|
||||
};
|
||||
let mut v = verdict(found.as_ref(), a);
|
||||
let now = util::now();
|
||||
if let (Verdict::Apply, Some(pid)) = (v, &in_force) {
|
||||
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 = 'in_force' AND origin = 'seed' AND entered_at IS NULL
|
||||
AND confirmed_at IS NULL AND revision = 1",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.bind(pid)
|
||||
.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(&in_force)
|
||||
.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",
|
||||
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 { 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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