diff --git a/crates/ld-server/migrations/0009_seed_proposals.sql b/crates/ld-server/migrations/0009_seed_proposals.sql new file mode 100644 index 0000000..e7abc5a --- /dev/null +++ b/crates/ld-server/migrations/0009_seed_proposals.sql @@ -0,0 +1,51 @@ +-- Propositions seed (2026-11-02, ADR 0011). Additive only: nothing is dropped, +-- nothing an older image needs is rewritten. +-- +-- 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" (intégré par un ou deux clients pour amorcer le mouvement de vote). » +-- +-- A seed proposal is an open proposal of the seed put forward to start the vote +-- (I1, E1, E2 of the certification act). It is not in force: it enters like any +-- proposal, by its threshold. The mark stays on the row once written; what the +-- API calls `seedProposal` is read from it while the version is open and was +-- never voted into force (src/docs.rs). An older image ignores the column and +-- reads the version as an open proposal of the seed. +ALTER TABLE proposals ADD COLUMN seed_featured INTEGER NOT NULL DEFAULT 0; + +-- The starting texts in force made seed proposals in an existing base +-- (src/seed_proposal_amend.rs): decided once per document and article, and +-- only where nobody has touched the text. With the status, the article's level +-- of inertia goes low (Yvv, 2026-11-02: « l'amorcer avec une inertie basse »), +-- only where nobody has touched it either. Each decision is kept here, for the +-- status and for the level: applied, refused (and why), or present (the base +-- was born with the seed proposal, at the low level). +CREATE TABLE seed_proposal_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + unit_code TEXT NOT NULL, + proposal_id TEXT, -- the version examined: in force, or the seed proposal found + outcome TEXT NOT NULL, -- applied | refused | present (the status) + reason TEXT, -- why it was refused + from_level TEXT, -- the level found + to_level TEXT NOT NULL, -- the level seed proposals start at + level_outcome TEXT NOT NULL, -- applied | refused | present (the level) + level_reason TEXT, -- why it was refused + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); + +-- A floor C changed in an existing document (src/floor_amend.rs), should Yvv +-- lower it to start (question asked on 2026-11-02; none listed yet): decided +-- once per document, only if the floor is still the one expected. Each decision +-- is kept here: applied, refused (and why), or present. +CREATE TABLE floor_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + from_floor REAL, -- the floor found + to_floor REAL NOT NULL, + outcome TEXT NOT NULL, -- applied | refused | present + reason TEXT, -- why it was refused + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index cfa1cf5..6915938 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -19,6 +19,10 @@ pub struct Publish { pub key: PathBuf, pub author_name: String, pub author_email: String, + /// Publish a document even when none of its articles is in force + /// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default (orch, 2026-11-02, while Yvv + /// decides): the queue waits, and the first text that enters sends it (ADR 0011). + pub without_articles: bool, } /// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how @@ -110,6 +114,7 @@ impl Config { key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")), author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()), author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()), + without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"), }); let dev_login = flag("LD_DEV_LOGIN"); let vote = var("LD_VOTE_ACCOUNT").and_then(|account| { diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index af2d971..707dcfa 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -232,15 +232,29 @@ pub struct ProposalRow { pub submitted_at: Option, pub entered_at: Option, pub confirmed_at: Option, + /// Put forward by the seed to start the vote (ADR 0011): see `seed_proposal`. + pub seed_featured: bool, } pub const PROPOSAL_SELECT: &str = "SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin, p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at, - p.entered_at, p.confirmed_at + p.entered_at, p.confirmed_at, p.seed_featured FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; impl ProposalRow { + /// A seed a minima: in force from the seed, never voted into force nor confirmed + /// (the title and the orders of a new base; a text a seed proposal amendment left). + pub fn seed_minimal(&self) -> bool { + self.status == "in_force" && self.entered_at.is_none() && self.confirmed_at.is_none() && self.origin == "seed" + } + + /// A seed proposal (ADR 0011): open, from the seed, put forward to start the vote, + /// never voted into force. Not in force: it enters like any proposal. + pub fn seed_proposal(&self) -> bool { + self.seed_featured && self.status == "open" && self.origin == "seed" && self.entered_at.is_none() + } + pub fn kind(&self) -> ProposalKind { match self.kind.as_str() { "remove" => ProposalKind::Remove, @@ -659,7 +673,7 @@ fn proposal_json( "mine": p.author_id.as_deref() == Some(me), "revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at, "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, - "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", + "seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(), "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, @@ -797,10 +811,15 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult crate::publish::waits_for_a_text(state, doc_id).await?, + _ => false, + }; Ok(match last { None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }), Some(r) => json!({ - "state": if r.done_at.is_some() { "done" } else { "pending" }, + "state": if r.done_at.is_some() { "done" } else if suspended { "suspended" } else { "pending" }, "configured": state.cfg.publish.is_some(), "since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha, "attempts": r.attempts, "error": r.last_error.map(|_| "retrying"), diff --git a/crates/ld-server/src/floor_amend.rs b/crates/ld-server/src/floor_amend.rs new file mode 100644 index 0000000..4080ec8 --- /dev/null +++ b/crates/ld-server/src/floor_amend.rs @@ -0,0 +1,199 @@ +//! Floor amendments — the floor C of an existing document changed (ADR 0011). +//! +//! C is the minimal number of *for* votes below which nothing passes (ADR 0002): +//! 50 in Monnaie libre, whatever the level of inertia. A low level for the seed +//! proposals does not make a start with five people possible; whether C is +//! lowered to start is Yvv's question (orch, 2026-11-02). **None is listed**: +//! the floor stays. Should he lower it, one entry here does it, with the seed's +//! `voteFloor` of the group set to the same value (new bases read the seed): +//! +//! - decided once per document, at the first start that knows the amendment; +//! - only if the floor is still the one expected (`from`); already `to`: present; +//! - kept in `floor_amendments` and in the logs; where the document publishes, +//! the publication queue gets a row (`document.json` carries `voteFloor`). +//! +//! Every threshold of the document follows at the next pass of the sweeper, and +//! so does the reference participation (2C/W) while fewer than five proposals +//! are voted (ADR 0007). + +use crate::AppState; +use crate::seed::SeedDoc; +use crate::util; + +/// One floor to change, in the document a seed instantiated in a group. +pub struct FloorAmendment { + /// Stable identifier: the decision is kept under it. + pub id: &'static str, + /// Slug of the seed document. + pub doc: &'static str, + /// The group whose document it changes. + pub group: &'static str, + /// The floor it must still hold. + pub from: f64, + /// The floor it gets: the seed's today for the group, which must say so. + pub to: f64, + /// Reason given to the publication queue, and so to the texts repository. + pub reason: &'static str, +} + +/// None while Yvv has not decided (2026-11-02). +pub const AMENDMENTS: &[FloorAmendment] = &[]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// The floor is already there: nothing to do. + Present, + Refuse(&'static str), +} + +fn same(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +/// The rule, apart from the database. +pub fn verdict(found: Option, a: &FloorAmendment) -> Verdict { + match found { + None => Verdict::Refuse("no_document"), + Some(c) if same(c, a.to) => Verdict::Present, + Some(c) if !same(c, a.from) => Verdict::Refuse("floor_differs"), + Some(_) => Verdict::Apply, + } +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq)] +pub struct Outcome { + pub amendment: &'static str, + pub verdict: Verdict, +} + +/// Decides every amendment not yet decided. +pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[FloorAmendment]) -> anyhow::Result> { + let mut out = Vec::new(); + for a in list { + if !seed_says(seeds, a) { + continue; + } + let doc: Option<(String, f64, Option)> = + sqlx::query_as("SELECT id, vote_floor, publish_path FROM documents WHERE slug = ? AND group_slug = ?") + .bind(a.doc) + .bind(a.group) + .fetch_optional(&state.db) + .await?; + let mut tx = state.db.begin().await?; + let doc_id = doc.as_ref().map(|(id, _, _)| id.clone()); + if let Some(id) = &doc_id { + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM floor_amendments WHERE amendment_id = ? AND doc_id = ?") + .bind(a.id) + .bind(id) + .fetch_one(&mut *tx) + .await?; + if decided > 0 { + continue; + } + } + let mut v = verdict(doc.as_ref().map(|(_, c, _)| *c), a); + let now = util::now(); + if let (Verdict::Apply, Some((id, c, publish_path))) = (v, &doc) { + // Written only over the floor that was judged. + let done = sqlx::query("UPDATE documents SET vote_floor = ? WHERE id = ? AND vote_floor = ?") + .bind(a.to) + .bind(id) + .bind(c) + .execute(&mut *tx) + .await?; + if done.rows_affected() != 1 { + v = Verdict::Refuse("changed"); + } else if publish_path.is_some() { + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(id) + .bind(a.reason) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } + let Some(id) = doc_id else { + tracing::warn!(amendment = a.id, group = a.group, "floor amendment: no such document, nothing decided"); + continue; + }; + let (outcome, reason) = match v { + Verdict::Apply => ("applied", None), + Verdict::Present => ("present", None), + Verdict::Refuse(why) => ("refused", Some(why)), + }; + sqlx::query( + "INSERT INTO floor_amendments (amendment_id, doc_id, from_floor, to_floor, outcome, reason, at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(&id) + .bind(doc.as_ref().map(|(_, c, _)| *c)) + .bind(a.to) + .bind(outcome) + .bind(reason) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + match v { + Verdict::Refuse(why) => { + tracing::warn!(amendment = a.id, group = a.group, reason = why, "floor amendment refused: the floor stays") + } + _ => tracing::info!(amendment = a.id, group = a.group, from = a.from, to = a.to, outcome, "floor amendment"), + } + if v == Verdict::Apply { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, verdict: v }); + } + Ok(out) +} + +/// The seed must give the group this floor: new bases read it there. +fn seed_says(seeds: &[SeedDoc], a: &FloorAmendment) -> bool { + let floor = seeds + .iter() + .find(|s| s.slug == a.doc) + .and_then(|s| s.instances.iter().find(|i| i.group == a.group)) + .map(|i| i.vote_floor); + if floor.is_some_and(|c| same(c, a.to)) { + return true; + } + tracing::error!( + amendment = a.id, + group = a.group, + "floor amendment: the seed does not give this floor, nothing decided" + ); + false +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: FloorAmendment = FloorAmendment { + id: "test", + doc: "doc", + group: "monnaie-libre", + from: 50.0, + to: 10.0, + reason: "plancher C abaissé : 50 → 10", + }; + + #[test] + fn none_is_listed_while_yvv_decides() { + assert!(AMENDMENTS.is_empty()); + } + + #[test] + fn the_floor_changes_only_from_the_one_expected() { + assert_eq!(verdict(Some(50.0), &A), Verdict::Apply); + assert_eq!(verdict(Some(10.0), &A), Verdict::Present); + assert_eq!(verdict(Some(40.0), &A), Verdict::Refuse("floor_differs")); + assert_eq!(verdict(None, &A), Verdict::Refuse("no_document")); + } +} diff --git a/crates/ld-server/src/inertia_amend.rs b/crates/ld-server/src/inertia_amend.rs index 3af65c6..5a4e50f 100644 --- a/crates/ld-server/src/inertia_amend.rs +++ b/crates/ld-server/src/inertia_amend.rs @@ -22,6 +22,11 @@ //! logs. Applied, the level is written in one transaction with a line in //! `inertia_changes` (the article says so) and, where the document publishes, //! a row in the publication queue (`document.json` carries the levels). +//! +//! Since 2026-11-02 the seed gives E1 and E2 another starting level: put forward +//! as seed proposals, they start low (ADR 0011, `seed_proposal_amend`). The two +//! amendments of 30/10 are then superseded: decided in production, they decide +//! nothing more, and say so only in the debug logs. use crate::AppState; use crate::seed::SeedDoc; @@ -43,6 +48,9 @@ pub struct InertiaAmendment { pub to: u8, /// Reason given to the publication queue, and so to the commit of the texts. pub reason: &'static str, + /// The later decision whose level the seed gives instead, if any: this one then + /// decides nothing more, without an error. + pub superseded_by: Option<&'static str>, } pub const AMENDMENTS: &[InertiaAmendment] = &[ @@ -53,6 +61,7 @@ pub const AMENDMENTS: &[InertiaAmendment] = &[ from: 3, to: 4, reason: "inertie de départ relevée : E1, 3 → 4", + superseded_by: Some("2026-11-02-e1-proposition-seed"), }, InertiaAmendment { id: "2026-10-30-e2-inertie-4", @@ -61,6 +70,7 @@ pub const AMENDMENTS: &[InertiaAmendment] = &[ from: 3, to: 4, reason: "inertie de départ relevée : E2, 3 → 4", + superseded_by: Some("2026-11-02-e2-proposition-seed"), }, ]; @@ -148,7 +158,17 @@ pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool { let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit)); match level { - Some(u) if u.inertia.get() == a.to => true, + Some(u) if u.level().get() == a.to => true, + Some(u) if a.superseded_by.is_some() => { + tracing::debug!( + amendment = a.id, + unit = a.unit, + seed_level = u.level().get(), + superseded_by = a.superseded_by, + "inertia amendment superseded: nothing decided" + ); + false + } _ => { tracing::error!( amendment = a.id, @@ -267,8 +287,15 @@ async fn decide( mod tests { use super::*; - const A: InertiaAmendment = - InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée" }; + const A: InertiaAmendment = InertiaAmendment { + id: "test", + doc: "doc", + unit: "E1", + from: 3, + to: 4, + reason: "inertie de départ relevée", + superseded_by: None, + }; fn at(raw: &str) -> Facts { Facts { raw: raw.into(), ..Facts::default() } diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index db55f58..dc5fbac 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -17,6 +17,7 @@ pub mod deadlines; pub mod doc_title; pub mod docs; pub mod error; +pub mod floor_amend; pub mod indexer; pub mod inertia_amend; pub mod messages; @@ -30,6 +31,7 @@ pub mod proposals; pub mod publish; pub mod seed; pub mod seed_amend; +pub mod seed_proposal_amend; pub mod team; pub mod typos; pub mod util; diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 62b24f6..75408ae 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -570,7 +570,9 @@ pub async fn detail( let mut history = Vec::new(); let mut previous: Option<&Rev> = None; for r in &revs { + // A change of status (a seed proposal, ADR 0011) changes no text: no diff. let diff = previous + .filter(|_| r.kind != "seed_proposal") .map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body))); let typo = r.typo_id.as_ref().map(|t| { json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") }) @@ -601,7 +603,7 @@ pub async fn detail( "author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false), "mine": p.author_id.as_deref() == Some(user.id.as_str()), "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, - "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", + "seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(), "tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), "votable": st.votable(&p), "myVote": my_vote.as_ref().map(|v| &v.choice), diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index c5e58c1..6a620ec 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -83,6 +83,20 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect(); let last = rows.iter().map(|d| d.id).max().unwrap_or(0); let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0); + if waits_for_a_text(state, &doc_id).await? { + // Not a failure: no attempt counted, no error. The queue stays; the first + // text that enters queues a row due now, and everything goes out with it. + sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL") + .bind(util::in_seconds(MAX_BACKOFF_SECS)) + .bind(&doc_id) + .execute(&state.db) + .await?; + tracing::info!( + doc = %doc_id, waiting = rows.len(), + "publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)" + ); + continue; + } match publish_doc(state, &doc_id).await { Ok(sha) => { sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?") @@ -109,6 +123,23 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> { Ok(()) } +/// Does the document wait for a text before it is published? Yes when none of its +/// articles is in force — the seed proposals are not (ADR 0011) — unless the +/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default +/// is the orch's (2026-11-02), while Yvv decides: never a document without a text. +pub async fn waits_for_a_text(state: &AppState, doc_id: &str) -> anyhow::Result { + if state.cfg.publish.as_ref().is_none_or(|p| p.without_articles) { + return Ok(false); + } + let in_force: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND in_force_id IS NOT NULL", + ) + .bind(doc_id) + .fetch_one(&state.db) + .await?; + Ok(in_force == 0) +} + async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result { let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?; let known_hosts = state.cfg.data_dir.join("keys/known_hosts"); diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index ef6d42d..b2ab103 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -9,18 +9,29 @@ //! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia //! no vote changed; modules the seed declares (the title, the order of each //! section, ADR 0009), a module amendment (`module_amend`), built from what the -//! document holds. +//! document holds; a starting text in force the seed now puts forward as a seed +//! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an +//! article nobody has touched. use crate::AppState; use crate::inertia_amend; use crate::module_amend; use crate::modules; use crate::seed_amend; +use crate::seed_proposal_amend; use crate::util::{self, LangMap}; use ld_engine::inertia::InertiaLevel; use serde::Deserialize; use std::path::Path; +/// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « 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 » — level 2 (« basse »), or +/// level 1 (« très basse »): his answer awaited, 2 by default. The one place to change: +/// the seed gives no level to an article it puts forward, and the seed proposal +/// amendment (`seed_proposal_amend`) writes this one. +pub const SEED_PROPOSAL_LEVEL: u8 = 2; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SeedDoc { @@ -60,16 +71,38 @@ pub struct SeedUnit { pub code: String, pub section: Option, pub position: f64, - /// A level, 1 to 5 (a word of before is read as its level). - pub inertia: InertiaLevel, + /// A level, 1 to 5 (a word of before is read as its level). None for an article + /// the seed puts forward: it starts at `SEED_PROPOSAL_LEVEL`. + #[serde(default)] + pub inertia: Option, pub versions: Vec, } +impl SeedUnit { + /// Holds a seed proposal (ADR 0011). + pub fn puts_forward(&self) -> bool { + self.versions.iter().any(|v| v.seed_proposal) + } + + /// The level it starts at: its own, or the one of seed proposals. + pub fn level(&self) -> InertiaLevel { + match self.inertia { + Some(level) => level, + None => InertiaLevel::new(SEED_PROPOSAL_LEVEL).unwrap_or_default(), + } + } +} + #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SeedVersion { pub label: Option, - /// `in_force` (seed a minima) or `open` (proposal at zero votes). + /// `in_force` (seed a minima: the title and the orders) or `open` (a proposal at + /// zero votes). pub status: String, + /// A seed proposal (ADR 0011): open, put forward to start the vote, not in force. + #[serde(default)] + pub seed_proposal: bool, pub title: LangMap, pub body: LangMap, } @@ -98,6 +131,9 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> { seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?; inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?; module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?; + // After the text amendments: a starting text replaced first, then put forward. + seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?; + crate::floor_amend::apply(state, &seeds, crate::floor_amend::AMENDMENTS).await?; Ok(()) } @@ -130,6 +166,7 @@ pub async fn documents(state: &AppState) -> anyhow::Result> { async fn load_file(state: &AppState, path: &Path) -> anyhow::Result { 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()))?; + check(&seed).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; for inst in &seed.instances { let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?") .bind(&inst.group) @@ -153,6 +190,22 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result { Ok(seed) } +/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL` +/// alone; every other article says its level. +fn check(seed: &SeedDoc) -> anyhow::Result<()> { + for u in &seed.units { + if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") { + anyhow::bail!("{}: a seed proposal is open, never in force", u.code); + } + match (u.puts_forward(), u.inertia.is_some()) { + (true, true) => anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code), + (false, false) => anyhow::bail!("{}: no level of inertia", u.code), + _ => {} + } + } + Ok(()) +} + async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> { let src = seed.source_lang.as_str(); let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default(); @@ -185,7 +238,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .bind(&unit.code) .bind(&unit.section) .bind(unit.position) - .bind(unit.inertia.as_db()) + .bind(unit.level().as_db()) .bind(&now) .execute(&mut *tx) .await?; @@ -218,6 +271,13 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .bind(&now) .execute(&mut *tx) .await?; + // The mark, written apart: only where the seed puts the version forward. + if v.seed_proposal && status == "open" { + sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?") + .bind(&pid) + .execute(&mut *tx) + .await?; + } if status == "in_force" { sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") .bind(&pid) diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index d8971b5..6bf0465 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -3,7 +3,9 @@ //! 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. +//! 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: @@ -33,21 +35,12 @@ use sqlx::SqliteConnection; /// Which version of the article an amendment replaces. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Version { - /// Its text in force, a seed a minima. + /// 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), } -impl Version { - fn status(self) -> &'static str { - match self { - Version::InForce => "in_force", - Version::Open(_) => "open", - } - } -} - /// 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. @@ -232,7 +225,8 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' }; let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| { u.versions.iter().find(|v| match a.version { - Version::InForce => v.status == "in_force", + // 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), }) }); @@ -252,7 +246,8 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' } } -async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result> { +/// 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> { #[derive(sqlx::FromRow)] struct Row { origin: String, @@ -327,35 +322,39 @@ async fn decide( if decided > 0 { return Ok(None); } - let target_id: Option = match a.version { - Version::InForce => { - sqlx::query_scalar::<_, Option>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") - .bind(doc_id) - .bind(a.unit) - .fetch_optional(&mut *tx) - .await? - .flatten() - } - Version::Open(label) => { - sqlx::query_scalar( - "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 = ? - ORDER BY p.rowid LIMIT 1", - ) + let in_force = || { + sqlx::query_scalar::<_, Option>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") .bind(doc_id) .bind(a.unit) - .bind(label) - .fetch_optional(&mut *tx) - .await? - } }; - let found = match &target_id { - Some(id) => facts(&mut tx, id, a.version.status()).await?, + 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)) = (v, &target_id) { + 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)); @@ -369,7 +368,7 @@ async fn decide( .bind(&body) .bind(&now) .bind(pid) - .bind(a.version.status()) + .bind(*status) .execute(&mut *tx) .await?; if done.rows_affected() == 1 { diff --git a/crates/ld-server/src/seed_proposal_amend.rs b/crates/ld-server/src/seed_proposal_amend.rs new file mode 100644 index 0000000..96a0420 --- /dev/null +++ b/crates/ld-server/src/seed_proposal_amend.rs @@ -0,0 +1,571 @@ +//! 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> { + 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)> = + 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)> { + 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 = 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 { + 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> { + 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)> = + 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"); + } +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index d6a2c64..49d63ae 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -749,6 +749,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() { key: dir.join("data/keys/publish_ed25519"), author_name: "libreDecision".into(), author_email: "decision@example.org".into(), + without_articles: false, }); let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(cfg, db).await.expect("state"); @@ -822,6 +823,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives key: dir.join("data/keys/publish_ed25519"), author_name: "libreDecision".into(), author_email: "decision@example.org".into(), + without_articles: false, }); cfg }; @@ -1005,6 +1007,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i key: dir.join("data/keys/publish_ed25519"), author_name: "libreDecision".into(), author_email: "decision@example.org".into(), + without_articles: false, }); let git = |args: &[&str]| { let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git"); diff --git a/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json b/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json new file mode 100644 index 0000000..171b38f --- /dev/null +++ b/crates/ld-server/tests/fixtures/seed-production-2026-11-02.json @@ -0,0 +1,791 @@ +{ + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02).", + "slug": "acte-engagement-certification", + "title": { + "fr": "Acte d'engagement Certification", + "en": "Certification Commitment Act", + "es": "Acta de compromiso de Certificación" + }, + "summary": { + "fr": "Ce que chaque membre de la toile de confiance Ğ1 s'engage à respecter quand il certifie. Texte modulaire sous vote permanent : chaque article entre en vigueur dès qu'il atteint son seuil.", + "en": "What every member of the Ğ1 web of trust commits to uphold when certifying. A modular text under permanent vote: each article comes into force as soon as it reaches its threshold.", + "es": "Lo que todo miembro de la red de confianza Ğ1 se compromete a respetar cuando certifica. Texto modular en votación permanente: cada artículo entra en vigor en cuanto alcanza su umbral." + }, + "sourceLang": "fr", + "instances": [ + { + "group": "monnaie-libre", + "voteFloor": 50, + "publishPath": "monnaie-libre/acte-engagement-certification" + }, + { + "group": "essai", + "voteFloor": 1, + "publishPath": null + } + ], + "modules": { + "title": { + "inertia": 4 + }, + "order": { + "inertia": 2 + } + }, + "units": [ + { + "code": "I1", + "section": "introduction", + "position": 1, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "in_force", + "title": { + "fr": "Raison d'être", + "en": "Purpose", + "es": "Razón de ser" + }, + "body": { + "fr": "*« Derrière une clé il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé certifiante. »*\n\nC'est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur. Nous pouvons nous fier à l'unicité et l'intégrité de tout geste signé par cette clé. C'est l'enjeu de la certification.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre.", + "en": "*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”*\n\nThis is the one and only purpose of certification, which gives our fiduciary web its value. We can rely on the uniqueness and integrity of every action signed with this key. That is what is at stake in certification.\n\nThis commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded.", + "es": "*«Detrás de una clave no hay más que un solo ser humano vivo; detrás de un ser humano, no hay más que una sola clave certificadora.»*\n\nEste es el propósito único y exclusivo de la certificación, que da su valor a nuestra red fiduciaria. Podemos fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. Eso es lo que está en juego en la certificación.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre." + } + } + ] + }, + { + "code": "I2", + "section": "introduction", + "position": 2, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Les deux garanties réciproques", + "en": "The two reciprocal guarantees", + "es": "Las dos garantías recíprocas" + }, + "body": { + "fr": "La Certification repose sur deux garanties réciproques :\n\n**1.** Derrière une clé publique créatrice de monnaie se trouve un **être humain vivant**.\n\n**2.** Derrière cet être humain se trouve **une seule et unique** clé publique créatrice de monnaie.\n\nLa Certification est un acte technique et fiduciaire, pas un acte d'adhésion morale ou de sympathie.", + "en": "Certification rests on two reciprocal guarantees:\n\n**1.** Behind a money-creating public key there is a **living human being**.\n\n**2.** Behind this human being there is **one single and unique** money-creating public key.\n\nCertification is a technical and fiduciary act, not an act of moral endorsement or of sympathy.", + "es": "La Certificación se basa en dos garantías recíprocas:\n\n**1.** Detrás de una clave pública creadora de moneda hay un **ser humano vivo**.\n\n**2.** Detrás de este ser humano hay **una sola y única** clave pública creadora de moneda.\n\nLa Certificación es un acto técnico y fiduciario, no un acto de adhesión moral ni de simpatía." + } + } + ] + }, + { + "code": "T1", + "section": "fondamental", + "position": 3, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Engagements fondamentaux", + "en": "Fundamental commitments", + "es": "Compromisos fundamentales" + }, + "body": { + "fr": "Les engagements fondamentaux ci-dessous constituent le socle irréductible de l'acte d'engagement. Ils sont pris sur l'honneur par tout membre de la toile de confiance.", + "en": "The fundamental commitments below form the irreducible foundation of the commitment act. They are made on their honour by every member of the web of trust.", + "es": "Los compromisos fundamentales que siguen constituyen la base irreductible del acta de compromiso. Todo miembro de la red de confianza los asume por su honor." + } + } + ] + }, + { + "code": "E1", + "section": "fondamental", + "position": 4, + "inertia": 4, + "versions": [ + { + "label": "1.0.0", + "status": "in_force", + "title": { + "fr": "Unicité du compte", + "en": "Uniqueness of the account", + "es": "Unicidad de la cuenta" + }, + "body": { + "fr": "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.", + "en": "I commit on my honour to have, now and ever, no more than one single and unique account co-creating G1 money.", + "es": "Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta cocreadora de moneda G1." + } + } + ] + }, + { + "code": "E2", + "section": "fondamental", + "position": 5, + "inertia": 4, + "versions": [ + { + "label": "1.0.0", + "status": "in_force", + "title": { + "fr": "Réciprocité", + "en": "Reciprocity", + "es": "Reciprocidad" + }, + "body": { + "fr": "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent scrupuleusement ces deux présents engagements fondamentaux.", + "en": "I commit on my honour to certify only natural persons who scrupulously respect these two fundamental commitments.", + "es": "Me comprometo por mi honor a certificar únicamente a personas físicas que respeten escrupulosamente estos dos compromisos fundamentales." + } + } + ] + }, + { + "code": "T2", + "section": "technique", + "position": 6, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Engagements techniques", + "en": "Technical commitments", + "es": "Compromisos técnicos" + }, + "body": { + "fr": "Les engagements techniques définissent les obligations pratiques et vérifiables du certificateur pour garantir la qualité de la toile de confiance.", + "en": "The technical commitments define the practical and verifiable obligations of the certifier to guarantee the quality of the web of trust.", + "es": "Los compromisos técnicos definen las obligaciones prácticas y verificables del certificador para garantizar la calidad de la red de confianza." + } + } + ] + }, + { + "code": "E3", + "section": "technique", + "position": 7, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Connaissance suffisante", + "en": "Sufficient knowledge", + "es": "Conocimiento suficiente" + }, + "body": { + "fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas « avoir vu » ; c'est assurer à la communauté G1 que je pourrai la contacter facilement et être en mesure de repérer un double-compte ou tout autre problème.", + "en": "I have made sure that I know the person who manages this public key well enough. Knowing well enough does not mean “having seen”; it means assuring the G1 community that I will be able to contact them easily and to detect a duplicate account or any other problem.", + "es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública. Conocer suficientemente no significa «haber visto»; significa asegurar a la comunidad G1 que podré contactarla fácilmente y estar en condiciones de detectar una cuenta duplicada o cualquier otro problema." + } + }, + { + "label": "0.4.0-draft", + "status": "open", + "title": { + "fr": "Connaissance suffisante", + "en": "Sufficient knowledge", + "es": "Conocimiento suficiente" + }, + "body": { + "fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter par au moins deux canaux indépendants, je suis en mesure de repérer un double-compte, et je m'engage à signaler tout doute aux experts de la communauté. « Avoir vu » quelqu'un ne suffit pas.", + "en": "I have made sure that I know the person who manages this public key well enough: I can get back in touch with them through at least two independent channels, I am able to detect a duplicate account, and I commit to reporting any doubt to the community experts. “Having seen” someone is not enough.", + "es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública: puedo volver a contactarla por al menos dos canales independientes, estoy en condiciones de detectar una cuenta duplicada y me comprometo a comunicar cualquier duda a los expertos de la comunidad. «Haber visto» a alguien no basta." + } + } + ] + }, + { + "code": "E4", + "section": "technique", + "position": 8, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Vérification personnelle de la clé", + "en": "Personal verification of the key", + "es": "Verificación personal de la clave" + }, + "body": { + "fr": "J'ai personnellement vérifié que c'est bien cette clé publique que je m'apprête à certifier, en la comparant avec la personne concernée et non par un intermédiaire.", + "en": "I have personally verified that this is indeed the public key I am about to certify, by comparing it with the person concerned and not through an intermediary.", + "es": "He verificado personalmente que esta es efectivamente la clave pública que me dispongo a certificar, comparándola con la persona interesada y no a través de un intermediario." + } + } + ] + }, + { + "code": "E5", + "section": "technique", + "position": 9, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Joignabilité réciproque", + "en": "Mutual reachability", + "es": "Contacto recíproco" + }, + "body": { + "fr": "Je suis joignable rapidement et facilement par mes certifieurs, et je peux joindre rapidement et facilement les personnes que je certifie, par plusieurs moyens de communication différents et indépendants (physique, téléphone, email, messagerie, etc.).", + "en": "I can be reached quickly and easily by my certifiers, and I can quickly and easily reach the people I certify, through several different and independent means of communication (in person, phone, email, messaging, etc.).", + "es": "Soy localizable de forma rápida y sencilla por mis certificadores, y puedo localizar de forma rápida y sencilla a las personas que certifico, por varios medios de comunicación diferentes e independientes (en persona, teléfono, correo electrónico, mensajería, etc.)." + } + } + ] + }, + { + "code": "E6", + "section": "technique", + "position": 10, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Document de révocation", + "en": "Revocation document", + "es": "Documento de revocación" + }, + "body": { + "fr": "J'ai vérifié avec la personne certifiée qu'elle a bien généré son document de révocation de compte et qu'elle le conserve en lieu sûr.", + "en": "I have checked with the certified person that they have indeed generated their account revocation document and that they keep it in a safe place.", + "es": "He verificado con la persona certificada que ha generado efectivamente su documento de revocación de cuenta y que lo conserva en un lugar seguro." + } + } + ] + }, + { + "code": "E7", + "section": "technique", + "position": 11, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Rencontre physique ou vérification multi-canaux", + "en": "In-person meeting or multi-channel verification", + "es": "Encuentro en persona o verificación multicanal" + }, + "body": { + "fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par plusieurs moyens de communication différents et indépendants : courrier + réseau social + forum + email + visio + téléphone.", + "en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through several different and independent means of communication: postal mail + social network + forum + email + video call + phone.", + "es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por varios medios de comunicación diferentes e independientes: correo postal + red social + foro + correo electrónico + videollamada + teléfono." + } + }, + { + "label": "0.4.0-draft", + "status": "open", + "title": { + "fr": "Rencontre physique ou vérification multi-canaux", + "en": "In-person meeting or multi-channel verification", + "es": "Encuentro en persona o verificación multicanal" + }, + "body": { + "fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par au moins trois moyens de communication différents et indépendants, dont un échange en visio en direct.", + "en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through at least three different and independent means of communication, including a live video call.", + "es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por al menos tres medios de comunicación diferentes e independientes, entre ellos una videollamada en directo." + } + } + ] + }, + { + "code": "T3", + "section": "technique", + "position": 12, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Conseils et bonnes pratiques", + "en": "Advice and good practices", + "es": "Consejos y buenas prácticas" + }, + "body": { + "fr": "Les pratiques suivantes sont fortement recommandées pour garantir la qualité et la sécurité de la toile de confiance.", + "en": "The following practices are strongly recommended to guarantee the quality and security of the web of trust.", + "es": "Las siguientes prácticas se recomiendan encarecidamente para garantizar la calidad y la seguridad de la red de confianza." + } + } + ] + }, + { + "code": "E8", + "section": "technique", + "position": 13, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Ne jamais certifier seul", + "en": "Never certify alone", + "es": "No certificar nunca en solitario" + }, + "body": { + "fr": "Ne certifiez jamais seul, mais accompagné d'au moins un autre membre de la toile de confiance G1, pour garantir un double contrôle.", + "en": "Never certify alone, but accompanied by at least one other member of the G1 web of trust, to guarantee a double check.", + "es": "No certifique nunca en solitario, sino junto con al menos otro miembro de la red de confianza G1, para garantizar un doble control." + } + } + ] + }, + { + "code": "E9", + "section": "technique", + "position": 14, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Vérification des certifications existantes", + "en": "Verification of existing certifications", + "es": "Verificación de las certificaciones existentes" + }, + "body": { + "fr": "Avant toute Certification, vérifiez si le compte a déjà reçu des certifications et de qui elles proviennent. Contactez les certifieurs existants en cas de doute. Si un certifieurs existant ne connaît pas la personne, alertez immédiatement les experts de la communauté.", + "en": "Before any Certification, check whether the account has already received certifications and who they come from. Contact the existing certifiers if in doubt. If an existing certifier does not know the person, alert the community experts immediately.", + "es": "Antes de cualquier Certificación, verifique si la cuenta ya ha recibido certificaciones y de quién proceden. En caso de duda, contacte con los certificadores existentes. Si un certificador existente no conoce a la persona, alerte de inmediato a los expertos de la comunidad." + } + } + ] + }, + { + "code": "E10", + "section": "technique", + "position": 15, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Vérification de maîtrise du compte", + "en": "Verification of account control", + "es": "Verificación del control de la cuenta" + }, + "body": { + "fr": "Vérifiez la maîtrise du compte par un transfert-test : envoyez quelques G1 et demandez le renvoi, afin de vous assurer que la personne contrôle bien sa clé privée.", + "en": "Verify control of the account with a test transfer: send a few G1 and ask for them to be sent back, to make sure that the person really controls their private key.", + "es": "Verifique el control de la cuenta mediante una transferencia de prueba: envíe unos pocos G1 y pida que se los devuelvan, para asegurarse de que la persona controla realmente su clave privada." + } + } + ] + }, + { + "code": "E11", + "section": "technique", + "position": 16, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Transmission et compréhension du document", + "en": "Transmission and understanding of the document", + "es": "Transmisión y comprensión del documento" + }, + "body": { + "fr": "Vérifiez que vos contacts ont bien étudié et compris le présent acte d'engagement dans sa version à jour avant de procéder à la Certification.", + "en": "Check that your contacts have indeed studied and understood the current version of this commitment act before proceeding with the Certification.", + "es": "Verifique que sus contactos han estudiado y comprendido efectivamente la versión vigente de la presente acta de compromiso antes de proceder a la Certificación." + } + } + ] + }, + { + "code": "K1", + "section": "conclusion", + "position": 17, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Règles abrégées de la toile de confiance", + "en": "Abridged rules of the web of trust", + "es": "Reglas abreviadas de la red de confianza" + }, + "body": { + "fr": "**Paramètres protocolaires en vigueur :**\n\n- Stock de **100 certifications** possibles\n- 1 certification émissible tous les **5 jours**\n- Nouveau compte valide si **>= 5 certifications** reçues en **2 mois**\n- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n- Sentinelle : membre ayant reçu et émis >= Y[N] certifs (Y = ceil(N^(1/5)))\n- Certifications actives valables **2 ans**\n- Renouvellement de l'accord tous les **12 mois**\n\n*Note : le vote porte sur l'inclusion de ces règles dans le document, pas sur les valeurs des variables protocolaires elles-mêmes, qui sont fixées par le protocole Duniter.*", + "en": "**Protocol parameters in force:**\n\n- Stock of **100 certifications** available\n- 1 certification can be issued every **5 days**\n- New account valid if **>= 5 certifications** received within **2 months**\n- Distance rule: **<= 5 steps** from **80% of the sentries**\n- Sentry: member who has received and issued >= Y[N] certifications (Y = ceil(N^(1/5)))\n- Active certifications valid for **2 years**\n- Renewal of the agreement every **12 months**\n\n*Note: the vote concerns the inclusion of these rules in the document, not the values of the protocol variables themselves, which are set by the Duniter protocol.*", + "es": "**Parámetros de protocolo vigentes:**\n\n- Reserva de **100 certificaciones** posibles\n- 1 certificación emitible cada **5 días**\n- Cuenta nueva válida si recibe **>= 5 certificaciones** en **2 meses**\n- Regla de distancia: **<= 5 pasos** del **80% de los centinelas**\n- Centinela: miembro que ha recibido y emitido >= Y[N] certificaciones (Y = ceil(N^(1/5)))\n- Certificaciones activas válidas durante **2 años**\n- Renovación del acuerdo cada **12 meses**\n\n*Nota: la votación se refiere a la inclusión de estas reglas en el documento, no a los valores de las variables del protocolo en sí, que fija el protocolo Duniter.*" + } + } + ] + }, + { + "code": "K2", + "section": "conclusion", + "position": 18, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Monnaie G1", + "en": "G1 currency", + "es": "Moneda G1" + }, + "body": { + "fr": "**Paramètres monétaires :**\n\n- 1 Dividende Universel (DU) par personne par jour\n- Réévaluation à chaque équinoxe : `DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note : le vote porte sur l'inclusion de ces paramètres dans le document, pas sur les valeurs monétaires elles-mêmes, qui découlent de la TRM et du bloc 0.*", + "en": "**Monetary parameters:**\n\n- 1 Universal Dividend (UD) per person per day\n- Revaluation at each equinox: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` with c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note: the vote concerns the inclusion of these parameters in the document, not the monetary values themselves, which derive from the RTM and block 0.*", + "es": "**Parámetros monetarios:**\n\n- 1 Dividendo Universal (DU) por persona y día\n- Revalorización en cada equinoccio: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` con c = 4.88%\n- DU(0) = 10.00 G1\n\n*Nota: la votación se refiere a la inclusión de estos parámetros en el documento, no a los valores monetarios en sí, que se derivan de la TRM y del bloque 0.*" + } + } + ] + }, + { + "code": "X1", + "section": "annexe", + "position": 19, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Annexe 1 : Intégration logicielle", + "en": "Annex 1: Software integration", + "es": "Anexo 1: Integración en el software" + }, + "body": { + "fr": "Les obligations pour les logiciels implémentant la Certification G1.", + "en": "The obligations for software implementing G1 Certification.", + "es": "Las obligaciones del software que implementa la Certificación G1." + } + } + ] + }, + { + "code": "X1.1", + "section": "annexe", + "position": 20, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Dépôt de référence", + "en": "Reference repository", + "es": "Repositorio de referencia" + }, + "body": { + "fr": "Le texte de référence de l'acte d'engagement Certification est hébergé dans le dépôt git officiel : https://git.duniter.org/documents/g1_monetary_license\n\nLes applications Cesium, Gecko et toute application de Certification doivent pointer vers ce dépôt pour afficher la version en vigueur.", + "en": "The reference text of the Certification commitment act is hosted in the official git repository: https://git.duniter.org/documents/g1_monetary_license\n\nThe Cesium and Gecko applications, and any Certification application, must point to this repository to display the version in force.", + "es": "El texto de referencia del acta de compromiso de Certificación está alojado en el repositorio git oficial: https://git.duniter.org/documents/g1_monetary_license\n\nLas aplicaciones Cesium, Gecko y cualquier aplicación de Certificación deben apuntar a este repositorio para mostrar la versión vigente." + } + } + ] + }, + { + "code": "X1.2", + "section": "annexe", + "position": 21, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Obligation de transmission", + "en": "Transmission obligation", + "es": "Obligación de transmisión" + }, + "body": { + "fr": "Tout logiciel G1 permettant la Certification doit transmettre le présent acte d'engagement au certifié et en afficher les paramètres du bloc 0 de la blockchain Duniter.", + "en": "Any G1 software that enables Certification must transmit this commitment act to the certified person and display the block 0 parameters of the Duniter blockchain.", + "es": "Todo software G1 que permita la Certificación debe transmitir la presente acta de compromiso a la persona certificada y mostrar los parámetros del bloque 0 de la blockchain Duniter." + } + } + ] + }, + { + "code": "X1.3", + "section": "annexe", + "position": 22, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Logiciel auditable", + "en": "Auditable software", + "es": "Software auditable" + }, + "body": { + "fr": "Tout logiciel utilisé pour la Certification ou la création monétaire doit être publié sous licence libre, afin de permettre son audit par la communauté.", + "en": "Any software used for Certification or money creation must be published under a free licence, so that it can be audited by the community.", + "es": "Todo software utilizado para la Certificación o la creación monetaria debe publicarse bajo licencia libre, para permitir su auditoría por parte de la comunidad." + } + } + ] + }, + { + "code": "X2", + "section": "annexe", + "position": 23, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Annexe 2 : Questions à la Certification", + "en": "Annex 2: Questions for Certification", + "es": "Anexo 2: Preguntas para la Certificación" + }, + "body": { + "fr": "Liste de questions à présenter dans les logiciels de Certification.Inspirée de la checklist de la Charte 1.0 et des discussions forum (topic 32412).", + "en": "List of questions to be presented in Certification software. Inspired by the checklist of Charter 1.0 and by forum discussions (topic 32412).", + "es": "Lista de preguntas que deben presentarse en el software de Certificación. Inspirada en la lista de verificación de la Carta 1.0 y en los debates del foro (tema 32412)." + } + } + ] + }, + { + "code": "X2.1", + "section": "annexe", + "position": 24, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Questions piège (réponse attendue : NON)", + "en": "Trap questions (expected answer: NO)", + "es": "Preguntas trampa (respuesta esperada: NO)" + }, + "body": { + "fr": "**Si OUI à l'une de ces questions, la Certification doit être refusée.**\n\n- La personne m'a contacté uniquement pour obtenir ma Certification\n- Je certifie sous la pression ou pour faire plaisir\n- Je n'ai aucun moyen de vérifier l'identité de la personne\n- La personne refuse de me fournir plusieurs moyens de contact\n- Je soupçonne que la personne possède déjà un compte membre\n- Je ne connais aucun de ses autres certifieurs\n- La personne ne comprend pas ce qu'est la monnaie libre\n- La personne n'a pas lu le présent acte d'engagement", + "en": "**If YES to any of these questions, the Certification must be refused.**\n\n- The person contacted me only to obtain my Certification\n- I am certifying under pressure or to please someone\n- I have no way of verifying the person's identity\n- The person refuses to give me several means of contact\n- I suspect that the person already has a member account\n- I do not know any of their other certifiers\n- The person does not understand what libre currency is\n- The person has not read this commitment act", + "es": "**Si la respuesta es SÍ a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- La persona me ha contactado únicamente para obtener mi Certificación\n- Certifico bajo presión o por complacer\n- No tengo ningún medio para verificar la identidad de la persona\n- La persona se niega a proporcionarme varios medios de contacto\n- Sospecho que la persona ya posee una cuenta de miembro\n- No conozco a ninguno de sus otros certificadores\n- La persona no entiende qué es la moneda libre\n- La persona no ha leído la presente acta de compromiso" + } + } + ] + }, + { + "code": "X2.2", + "section": "annexe", + "position": 25, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Questions identité (réponse attendue : OUI)", + "en": "Identity questions (expected answer: YES)", + "es": "Preguntas de identidad (respuesta esperada: SÍ)" + }, + "body": { + "fr": "**Si NON à l'une de ces questions, la Certification doit être refusée.**\n\n- Je connais cette personne suffisamment pour la recontacter\n- J'ai vérifié personnellement le lien personne / clé publique\n- La personne est une personne physique vivante (pas une entité morale)\n- Je pourrais reconnaître cette personne si je la croisais\n- J'ai au moins 2 moyens de contact différents pour cette personne", + "en": "**If NO to any of these questions, the Certification must be refused.**\n\n- I know this person well enough to get back in touch with them\n- I have personally verified the person / public key link\n- The person is a living natural person (not a legal entity)\n- I would recognise this person if I came across them\n- I have at least 2 different means of contact for this person", + "es": "**Si la respuesta es NO a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- Conozco a esta persona suficientemente para volver a contactarla\n- He verificado personalmente el vínculo persona / clave pública\n- La persona es una persona física viva (no una persona jurídica)\n- Reconocería a esta persona si me la cruzara\n- Tengo al menos 2 medios de contacto diferentes para esta persona" + } + } + ] + }, + { + "code": "X2.3", + "section": "annexe", + "position": 26, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Questions sécurité (réponse attendue : OUI)", + "en": "Security questions (expected answer: YES)", + "es": "Preguntas de seguridad (respuesta esperada: SÍ)" + }, + "body": { + "fr": "**Si NON, la Certification est déconseillée.**\n\n- La personne a généré son document de révocation\n- La personne maîtrise effectivement son compte (test de transfert effectué)\n- La personne sait où retrouver le présent acte d'engagement dans sa version à jour", + "en": "**If NO, the Certification is not recommended.**\n\n- The person has generated their revocation document\n- The person actually controls their account (test transfer carried out)\n- The person knows where to find the current version of this commitment act", + "es": "**Si la respuesta es NO, se desaconseja la Certificación.**\n\n- La persona ha generado su documento de revocación\n- La persona controla efectivamente su cuenta (transferencia de prueba realizada)\n- La persona sabe dónde encontrar la versión vigente de la presente acta de compromiso" + } + } + ] + }, + { + "code": "F1", + "section": "formule", + "position": 27, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Formule de vote", + "en": "Voting formula", + "es": "Fórmula de votación" + }, + "body": { + "fr": "La formule qui régit l'adoption ou le rejet de chaque modification du présent document. Référence : g1vote (https://g1vote-view-237903.pages.duniter.org/).", + "en": "The formula that governs the adoption or rejection of each amendment to this document. Reference: g1vote (https://g1vote-view-237903.pages.duniter.org/).", + "es": "La fórmula que rige la adopción o el rechazo de cada modificación del presente documento. Referencia: g1vote (https://g1vote-view-237903.pages.duniter.org/)." + } + } + ] + }, + { + "code": "F1.1", + "section": "formule", + "position": 28, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Formule du seuil WoT", + "en": "WoT threshold formula", + "es": "Fórmula del umbral WoT" + }, + "body": { + "fr": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nOù :\n- **C** = constante de base (plancher fixe)\n- **B** = exposant de base (B^W tend vers 0)\n- **W** = taille de la toile de confiance (électeurs éligibles)\n- **T** = total des votes exprimés (pour + contre)\n- **M** = ratio de majorité cible (M = majority_pct / 100)\n- **G** = exposant du gradient d'inertie\n\n**Comportement :** Faible participation → quasi-unanimité requise. Forte participation → majorité simple M suffit.", + "en": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nWhere:\n- **C** = base constant (fixed floor)\n- **B** = base exponent (B^W tends towards 0)\n- **W** = size of the web of trust (eligible voters)\n- **T** = total votes cast (for + against)\n- **M** = target majority ratio (M = majority_pct / 100)\n- **G** = inertia gradient exponent\n\n**Behaviour:** Low participation → near-unanimity required. High participation → simple majority M is enough.", + "es": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nDonde:\n- **C** = constante de base (mínimo fijo)\n- **B** = exponente de base (B^W tiende a 0)\n- **W** = tamaño de la red de confianza (votantes habilitados)\n- **T** = total de votos emitidos (a favor + en contra)\n- **M** = proporción de mayoría objetivo (M = majority_pct / 100)\n- **G** = exponente del gradiente de inercia\n\n**Comportamiento:** Baja participación → se requiere casi unanimidad. Alta participación → basta la mayoría simple M." + } + } + ] + }, + { + "code": "F1.2", + "section": "formule", + "position": 29, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Paramètres par défaut", + "en": "Default parameters", + "es": "Parámetros por defecto" + }, + "body": { + "fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.", + "en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.\n- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.", + "es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3." + } + } + ] + }, + { + "code": "F1.3", + "section": "formule", + "position": 30, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Processus de dépôt officiel", + "en": "Official filing process", + "es": "Proceso de depósito oficial" + }, + "body": { + "fr": "Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n1. Le texte de remplacement est intégré au document officiel\n2. Le hash IPFS du document mis à jour est calculé\n3. Le hash est ancré on-chain via `system.remark`\n4. Le dépôt git officiel est synchronisé\n5. Les applications (Cesium, Gecko) mettent à jour automatiquement", + "en": "When an alternative proposal reaches the adoption threshold:\n\n1. The replacement text is incorporated into the official document\n2. The IPFS hash of the updated document is computed\n3. The hash is anchored on-chain via `system.remark`\n4. The official git repository is synchronised\n5. The applications (Cesium, Gecko) update automatically", + "es": "Cuando una propuesta alternativa alcanza el umbral de adopción:\n\n1. El texto de sustitución se integra en el documento oficial\n2. Se calcula el hash IPFS del documento actualizado\n3. El hash se ancla on-chain mediante `system.remark`\n4. Se sincroniza el repositorio git oficial\n5. Las aplicaciones (Cesium, Gecko) se actualizan automáticamente" + } + } + ] + }, + { + "code": "N1", + "section": "inertie", + "position": 31, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Réglage de l'inertie", + "en": "Inertia setting", + "es": "Ajuste de la inercia" + }, + "body": { + "fr": "Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.\n\nTout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.", + "en": "Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.\n\nAny member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.", + "es": "Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.\n\nCualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4." + } + } + ] + }, + { + "code": "N1.1", + "section": "inertie", + "position": 32, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Niveaux d'inertie", + "en": "Inertia levels", + "es": "Niveles de inercia" + }, + "body": { + "fr": "À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T/W)^G)`\n\n**Les cinq crans** : la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.\n- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.", + "en": "At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T/W)^G)`\n\n**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.\n- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.\n- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.", + "es": "En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`\n\n**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.\n- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.\n- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103." + } + } + ] + }, + { + "code": "O1", + "section": "ordonnancement", + "position": 33, + "inertia": 3, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "title": { + "fr": "Ordonnancement du document", + "en": "Ordering of the document", + "es": "Ordenación del documento" + }, + "body": { + "fr": "L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.\n\nUn nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.\n\nLe titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.", + "en": "The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.\n\nA new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.\n\nThe title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.", + "es": "El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.\n\nUn artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.\n\nEl título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto." + } + } + ] + } + ] +} diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs new file mode 100644 index 0000000..22bf64b --- /dev/null +++ b/crates/ld-server/tests/seed_proposals.rs @@ -0,0 +1,698 @@ +//! Propositions seed (Yvv, 2026-11-02, ADR 0011): the three first modules of the +//! certification act — I1 « Raison d'être », E1 Unicité du compte, E2 Réciprocité — +//! keep a status of their own to start the vote, but they are not in force. A new +//! base is born with them open; an existing base gets them through a guarded +//! amendment, only where nobody has touched the text. Monnaie libre publishes no +//! document without any article in force: the queue waits for the first text. + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +const CODES: [&str; 3] = ["I1", "E1", "E2"]; +/// The level seed proposals start at (Yvv, 2026-11-02: « une inertie basse »). +const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL; +const GROUPS: [&str; 2] = ["essai", "monnaie-libre"]; + +struct Client { + router: axum::Router, + cookie: Option, +} + +impl Client { + fn of(state: &AppState) -> Self { + Self { router: app(state.clone()), cookie: None } + } + + fn other(&self) -> Self { + Self { router: self.router.clone(), cookie: None } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); + if let Some(c) = &self.cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .expect("request"); + let res = self.router.clone().oneshot(req).await.expect("response"); + let status = res.status(); + if let Some(set) = res.headers().get(header::SET_COOKIE) { + let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); + if raw.starts_with("ld_session=") { + self.cookie = Some(raw); + } + } + let bytes = res.into_body().collect().await.expect("body").to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + } + + async fn get(&mut self, uri: &str) -> Value { + let (s, v) = self.call("GET", uri, None).await; + assert_eq!(s, StatusCode::OK, "{uri}: {v}"); + v + } + + async fn login(&mut self, name: &str, admin: bool) { + let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + } + + async fn vote(&mut self, proposal: &str, choice: &str) -> Value { + let (s, v) = + self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v + } + + async fn doc(&mut self, group: &str) -> Value { + let id = self.get(&format!("/api/groups/{group}")).await["docs"][0]["id"].as_str().expect("doc").to_owned(); + self.get(&format!("/api/docs/{id}")).await + } +} + +fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { + doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +/// Every version shown of the document: in force, open, of articles and modules. +fn versions(doc: &Value) -> Vec<&Value> { + let m = &doc["modules"]; + doc["units"] + .as_array() + .expect("units") + .iter() + .chain(std::iter::once(&m["title"])) + .chain(m["orders"].as_array().expect("orders").iter()) + .flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter())) + .filter(|v| !v.is_null()) + .collect() +} + +async fn fresh() -> AppState { + fresh_with(Config::for_tests()).await +} + +async fn fresh_with(cfg: Config) -> AppState { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(cfg, db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +#[tokio::test] +async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() { + let state = fresh().await; + let mut c = Client::of(&state); + c.login("Chiara", false).await; + for group in GROUPS { + let doc = c.doc(group).await; + let in_force: Vec<&str> = doc["units"] + .as_array() + .expect("units") + .iter() + .filter(|u| !u["inForce"].is_null()) + .filter_map(|u| u["code"].as_str()) + .collect(); + assert!(in_force.is_empty(), "{group}: no article in force, {in_force:?}"); + for code in CODES { + let u = unit(&doc, code); + let p = &u["proposals"][0]; + assert_eq!( + (p["status"].as_str(), p["origin"].as_str(), p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()), + (Some("open"), Some("seed"), Some(true), Some(false)), + "{group} {code}" + ); + assert_eq!((p["revision"].as_i64(), p["tally"]["for"].as_u64()), (Some(1), Some(0)), "{group} {code}"); + assert_eq!(p["votable"], true, "{group} {code}"); + assert_eq!(u["inertia"], LOW, "{group} {code}: a low level to start"); + assert!(p["translations"]["en"]["title"].is_string(), "{group} {code}: translated"); + } + assert_eq!(unit(&doc, "I1")["proposals"][0]["title"], "Raison d'être"); + // Only those three; the title and the orders stay seeds a minima, in force. + let marked: Vec<&Value> = versions(&doc).into_iter().filter(|v| v["seedProposal"] == true).collect(); + assert_eq!(marked.len(), 3, "{group}"); + let title = &doc["modules"]["title"]["inForce"]; + assert_eq!((title["seedMinimal"].as_bool(), title["seedProposal"].as_bool()), (Some(true), Some(false))); + assert!(doc["modules"]["orders"].as_array().expect("orders").iter().all(|o| o["inForce"]["seedMinimal"] == true)); + // The counts: no article in force, three more open versions; still 41 modules to vote. + let g = c.get(&format!("/api/groups/{group}")).await; + assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35)), "{group}"); + let d = c.get(&format!("/api/groups/{group}/decisions")).await; + assert_eq!(d["docs"][0]["toVote"], 41, "{group}"); + // The preview of the text in force is empty; « if everything passes » shows them. + let id = doc["doc"]["id"].as_str().expect("id"); + let p = c.get(&format!("/api/docs/{id}/preview")).await; + assert_eq!(p["units"], json!([]), "{group}"); + let all = c.get(&format!("/api/docs/{id}/preview?mode=all")).await; + assert!(all["units"].as_array().expect("units").iter().any(|u| u["title"] == "Raison d'être")); + } + // Born with them: nothing to amend, and it is said once per article and group. + let decided: Vec<(String, String, String)> = sqlx::query_as( + "SELECT d.group_slug, s.unit_code, s.outcome FROM seed_proposal_amendments s + JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code", + ) + .fetch_all(&state.db) + .await + .expect("decisions"); + assert_eq!(decided.len(), 6, "{decided:?}"); + assert!(decided.iter().all(|(_, _, o)| o == "present"), "{decided:?}"); + let changes: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("changes"); + assert_eq!(changes, 0, "born at the low level: nothing changed"); + // The Raison d'être of a new base is still the text its own amendment names. + let i1: Vec = sqlx::query_scalar( + "SELECT outcome FROM seed_amendments WHERE amendment_id = '2026-10-30-i1-raison-d-etre' ORDER BY doc_id", + ) + .fetch_all(&state.db) + .await + .expect("i1"); + assert_eq!(i1, ["present", "present"]); +} + +// --------------------------------------------------------------------------- +// A base made like production (02/11): I1 at revision 2 (the Préambule, then the +// « Raison d'être » amendment of 30/10), E1 and E2 at revision 1, all three in +// force as seeds a minima, in both groups. +// --------------------------------------------------------------------------- + +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.", + ), +]; + +/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a +/// minima, I1 and E1 at level 3 and 4. +fn production_seed() -> Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json") +} + +/// The seed of production, with I1 the Préambule as before 2026-10-30. +fn seed_dir(tag: &str, preambule: bool) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ld-seed-proposals-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + let mut seed = production_seed(); + if preambule { + 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 +} + +/// Production as of 2026-11-02: born with the Préambule, then started with the seed it +/// runs (its amendments: I1 becomes the Raison d'être). +async fn production(tag: &str, cfg: Config) -> AppState { + let mut first = cfg.clone(); + first.seed_dir = seed_dir(&format!("{tag}-first"), true); + let db = open_db("sqlite::memory:").await.expect("db"); + let born = AppState::new(first, db).await.expect("state"); + seed::documents(&born).await.expect("first seed"); + let mut then = cfg; + then.seed_dir = seed_dir(&format!("{tag}-then"), false); + let state = AppState::new(then, born.db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed of production"); + state +} + +/// The same base, started again with today's seed (and its amendments). +async fn restart(before: &AppState, cfg: Config) -> AppState { + let state = AppState::new(cfg, before.db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +#[derive(Debug, PartialEq, sqlx::FromRow)] +struct Version { + id: String, + status: String, + revision: i64, + title: String, + body: String, + in_force: bool, +} + +async fn version(state: &AppState, group: &str, code: &str) -> Version { + sqlx::query_as( + "SELECT p.id, p.status, p.revision, p.title, p.body, IFNULL(u.in_force_id = p.id, 0) AS in_force + FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id + WHERE d.group_slug = ? AND u.code = ? AND p.origin = 'seed' AND p.kind = 'text'", + ) + .bind(group) + .bind(code) + .fetch_one(&state.db) + .await + .expect("version") +} + +async fn kinds(state: &AppState, id: &str) -> Vec { + sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id") + .bind(id) + .fetch_all(&state.db) + .await + .expect("revisions") +} + +/// Each decision on the seed proposals: (group, unit, outcome, reason, level outcome, +/// level reason, at). +type Decision = (String, String, String, Option, String, Option, String); + +async fn decisions(state: &AppState) -> Vec { + sqlx::query_as( + "SELECT d.group_slug, s.unit_code, s.outcome, s.reason, s.level_outcome, s.level_reason, s.at + FROM seed_proposal_amendments s JOIN documents d ON d.id = s.doc_id ORDER BY d.group_slug, s.unit_code", + ) + .fetch_all(&state.db) + .await + .expect("decisions") +} + +type Outcome<'a> = (&'a str, &'a str, &'a str, Option<&'a str>, &'a str, Option<&'a str>); + +fn outcomes(rows: &[Decision]) -> Vec> { + rows.iter() + .map(|(g, u, o, r, lo, lr, _)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref(), lo.as_str(), lr.as_deref())) + .collect() +} + +async fn unit_id(state: &AppState, group: &str, code: &str) -> String { + sqlx::query_scalar( + "SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?", + ) + .bind(group) + .bind(code) + .fetch_one(&state.db) + .await + .expect("unit") +} + +async fn level_of(state: &AppState, group: &str, code: &str) -> String { + sqlx::query_scalar( + "SELECT u.inertia FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?", + ) + .bind(group) + .bind(code) + .fetch_one(&state.db) + .await + .expect("level") +} + +/// Production on 2026-10-30 (orch): open changes of inertia at zero votes, I1 → 5 and +/// E1 → 5. Written here in Monnaie libre only, so both answers of the rule are seen. +async fn open_inertia_proposals(state: &AppState) { + let mut c = Client::of(state); + c.login("Oscar", false).await; + for code in ["I1", "E1"] { + let u = unit_id(state, "monnaie-libre", code).await; + let (s, v) = c.call("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 5 }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + } +} + +#[tokio::test] +async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once() { + let before = production("applied", Config::for_tests()).await; + open_inertia_proposals(&before).await; + // As production holds them. + let mut kept = Vec::new(); + for group in GROUPS { + for code in CODES { + let v = version(&before, group, code).await; + assert_eq!((v.status.as_str(), v.in_force), ("in_force", true), "{group} {code}"); + let expected: &[&str] = if code == "I1" { &["initial", "amendment"] } else { &["initial"] }; + assert_eq!(kinds(&before, &v.id).await, expected, "{group} {code}"); + assert_eq!(v.revision, if code == "I1" { 2 } else { 1 }, "{group} {code}"); + assert_eq!(level_of(&before, group, code).await, if code == "I1" { "3" } else { "4" }); + kept.push(v); + } + } + let translations = |state: AppState| async move { + sqlx::query_as::<_, (String, String, String, String, String)>( + "SELECT proposal_id, lang, title, body, source_hash FROM translations ORDER BY proposal_id, lang", + ) + .fetch_all(&state.db) + .await + .expect("translations") + }; + let translated = translations(before.clone()).await; + + let state = restart(&before, Config::for_tests()).await; + let first = decisions(&state).await; + // Every text becomes a seed proposal; the level goes low where nobody touched it: + // an open change of inertia by a member keeps it (I1 and E1 in Monnaie libre). + assert_eq!( + outcomes(&first), + [ + ("essai", "E1", "applied", None, "applied", None), + ("essai", "E2", "applied", None, "applied", None), + ("essai", "I1", "applied", None, "applied", None), + ("monnaie-libre", "E1", "applied", None, "refused", Some("inertia_proposal")), + ("monnaie-libre", "E2", "applied", None, "applied", None), + ("monnaie-libre", "I1", "applied", None, "refused", Some("inertia_proposal")), + ] + ); + for (i, group) in GROUPS.iter().enumerate() { + for (j, code) in CODES.iter().enumerate() { + let was = &kept[i * CODES.len() + j]; + let v = version(&state, group, code).await; + // The same version, its text and revision kept: open now, and not in force. + assert_eq!(v.id, was.id, "{group} {code}"); + assert_eq!((v.status.as_str(), v.in_force), ("open", false), "{group} {code}"); + assert_eq!((&v.title, &v.body, v.revision), (&was.title, &was.body, was.revision), "{group} {code}"); + let featured: (i64, Option, Option) = + sqlx::query_as("SELECT seed_featured, entered_at, confirmed_at FROM proposals WHERE id = ?") + .bind(&v.id) + .fetch_one(&state.db) + .await + .expect("mark"); + assert_eq!(featured, (1, None, None), "{group} {code}"); + // Its history says the change of status, after what it had. + let mut expected: Vec<&str> = if *code == "I1" { vec!["initial", "amendment"] } else { vec!["initial"] }; + expected.push("seed_proposal"); + assert_eq!(kinds(&state, &v.id).await, expected, "{group} {code}"); + let in_force: Option = sqlx::query_scalar("SELECT in_force_id FROM units WHERE id = ?") + .bind(unit_id(&state, group, code).await) + .fetch_one(&state.db) + .await + .expect("unit"); + assert_eq!(in_force, None, "{group} {code}"); + let held = *group == "monnaie-libre" && *code != "E2"; + let level = match (held, *code) { + (true, "I1") => "3".to_owned(), + (true, _) => "4".to_owned(), + _ => LOW.to_string(), + }; + assert_eq!(level_of(&state, group, code).await, level, "{group} {code}"); + } + } + // Translations untouched. + assert_eq!(translations(state.clone()).await, translated); + // The article says its new level, as a starting setting of the text. + let changes: Vec<(String, String, String, String, Option)> = sqlx::query_as( + "SELECT d.group_slug, u.code, c.from_level, c.to_level, c.amendment_id FROM inertia_changes c + JOIN units u ON u.id = c.unit_id JOIN documents d ON d.id = c.doc_id + WHERE c.cause = 'amendment' ORDER BY d.group_slug, u.code", + ) + .fetch_all(&state.db) + .await + .expect("changes"); + let line = |g: &str, c: &str, from: &str| { + let id = format!("2026-11-02-{}-proposition-seed", c.to_lowercase()); + (g.to_owned(), c.to_owned(), from.to_owned(), LOW.to_string(), Some(id)) + }; + assert_eq!( + changes, + [line("essai", "E1", "4"), line("essai", "E2", "4"), line("essai", "I1", "3"), line("monnaie-libre", "E2", "4")] + ); + let mut c = Client::of(&state); + c.login("Chiara", false).await; + for group in GROUPS { + let doc = c.doc(group).await; + for code in CODES { + let p = &unit(&doc, code)["proposals"][0]; + assert_eq!( + (p["seedProposal"].as_bool(), p["status"].as_str()), + (Some(true), Some("open")), + "{group} {code}" + ); + assert!(unit(&doc, code)["inForce"].is_null()); + let (_, d) = c.call("GET", &format!("/api/proposals/{}", p["id"].as_str().expect("id")), None).await; + let last = d["history"].as_array().expect("history").last().cloned().expect("a line"); + assert_eq!((last["kind"].as_str(), last["author"].as_str()), (Some("seed_proposal"), None), "{d}"); + assert!(last["diff"].is_null(), "a change of status changes no text"); + } + let e2 = unit(&doc, "E2")["inertiaChanges"].as_array().expect("changes").clone(); + assert_eq!( + (e2[0]["from"].as_u64(), e2[0]["to"].as_u64(), e2[0]["cause"].as_str()), + (Some(4), Some(u64::from(LOW)), Some("amendment")) + ); + } + // Monnaie libre republishes, once per article; the test group never. + 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.reason LIKE 'Proposition seed%' ORDER BY o.id", + ) + .fetch_all(&state.db) + .await + .expect("outbox"); + assert_eq!( + queued, + CODES + .iter() + .map(|c| ("monnaie-libre".to_owned(), format!("Proposition seed : {c} sort de la vigueur"))) + .collect::>() + ); + + // Decided once: a second start changes nothing. + let again = restart(&state, Config::for_tests()).await; + assert_eq!(decisions(&again).await, first); + for group in GROUPS { + for code in CODES { + let v = version(&again, group, code).await; + assert_eq!(kinds(&again, &v.id).await.iter().filter(|k| *k == "seed_proposal").count(), 1); + } + } + let n: (i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM outbox WHERE reason LIKE 'Proposition seed%'), + (SELECT COUNT(*) FROM inertia_changes WHERE cause = 'amendment')", + ) + .fetch_one(&again.db) + .await + .expect("counts"); + assert_eq!(n, (3, 4)); +} + +#[tokio::test] +async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() { + let before = production("refused", Config::for_tests()).await; + // In the test group someone voted against E1: there it stays in force, at its level. + let mut c = Client::of(&before); + c.login("Vera", false).await; + let e1 = version(&before, "essai", "E1").await; + let v = c.vote(&e1.id, "against").await; + assert!(v["entered"].as_array().expect("entered").is_empty()); + + let state = restart(&before, Config::for_tests()).await; + let first = decisions(&state).await; + assert_eq!( + outcomes(&first), + [ + ("essai", "E1", "refused", Some("votes"), "refused", Some("not_a_seed_proposal")), + ("essai", "E2", "applied", None, "applied", None), + ("essai", "I1", "applied", None, "applied", None), + ("monnaie-libre", "E1", "applied", None, "applied", None), + ("monnaie-libre", "E2", "applied", None, "applied", None), + ("monnaie-libre", "I1", "applied", None, "applied", None), + ] + ); + let kept = version(&state, "essai", "E1").await; + assert_eq!((kept.status.as_str(), kept.in_force, kept.revision), ("in_force", true, 1)); + assert_eq!(kinds(&state, &kept.id).await, ["initial"], "no line: nothing changed"); + assert_eq!(level_of(&state, "essai", "E1").await, "4"); + let mut c = Client::of(&state); + c.login("Vera", false).await; + let doc = c.doc("essai").await; + let e1 = &unit(&doc, "E1")["inForce"]; + assert_eq!((e1["seedMinimal"].as_bool(), e1["seedProposal"].as_bool()), (Some(true), Some(false))); + // Decided once: the vote withdrawn, a later start does not look again. + let (s, v) = c.call("POST", &format!("/api/proposals/{}/vote", kept.id), Some(json!({ "choice": null }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let again = restart(&state, Config::for_tests()).await; + assert_eq!(decisions(&again).await, first); + assert!(version(&again, "essai", "E1").await.in_force); +} + +// --------------------------------------------------------------------------- +// Publication: no document without any article in force (default kept while Yvv decides) +// --------------------------------------------------------------------------- + +struct Remote { + dir: std::path::PathBuf, + bare: std::path::PathBuf, +} + +impl Remote { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("ld-seed-proposals-pub-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + let bare = dir.join("textes.git"); + let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git"); + assert!(ok.success()); + Self { dir, bare } + } + + fn config(&self, without_articles: bool) -> Config { + let mut cfg = Config::for_tests(); + cfg.data_dir = self.dir.join("data"); + cfg.publish = Some(ld_server::config::Publish { + remote: self.bare.display().to_string(), + branch: "main".into(), + key: self.dir.join("data/keys/publish_ed25519"), + author_name: "libreDecision".into(), + author_email: "decision@example.org".into(), + without_articles, + }); + cfg + } + + fn show(&self, file: &str) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(&self.bare) + .args(["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")]) + .output() + .expect("git"); + out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8")) + } +} + +impl Drop for Remote { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +async fn outbox(state: &AppState) -> Vec<(String, i64, Option, Option)> { + sqlx::query_as("SELECT reason, attempts, last_error, done_at FROM outbox ORDER BY id") + .fetch_all(&state.db) + .await + .expect("outbox") +} + +#[tokio::test] +async fn a_document_without_any_article_in_force_waits_and_goes_out_with_the_first_text() { + let remote = Remote::new("suspended"); + let state = fresh_with(remote.config(false)).await; + ld_server::publish::pass(&state).await.expect("pass"); + assert_eq!(remote.show("fr.md"), None, "nothing went out"); + // The queue stays, not as a failure: no attempt counted, no error. + assert_eq!(outbox(&state).await, [("publication initiale".to_owned(), 0, None, None)]); + let mut c = Client::of(&state); + c.login("Chiara", false).await; + let doc = c.doc("monnaie-libre").await; + assert_eq!(doc["doc"]["publication"]["state"], "suspended"); + // A restart queues nothing new and sends nothing. + ld_server::publish::resync(&state).await.expect("resync"); + ld_server::publish::pass(&state).await.expect("pass"); + assert_eq!(remote.show("fr.md"), None); + assert_eq!(outbox(&state).await.len(), 1); + + // The first text enters (fifty voices of the Ğ1 web of trust, written here as they + // land after the chain): the document goes out, with it alone. + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned(); + for i in 0..60 { + sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES (?, 'g1', ?, 1, '2026-11-02T00:00:00Z')") + .bind(format!("idty:{}", 9000 + i)) + .bind(format!("Membre {i}")) + .execute(&state.db) + .await + .expect("identity"); + sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, ?, 'for', 1, '2026-11-02T00:00:00Z', 'chain')") + .bind(&e1) + .bind(format!("idty:{}", 9000 + i)) + .execute(&state.db) + .await + .expect("vote"); + } + let entered = ld_server::adoption::run(&state, &doc_id).await.expect("adoption"); + assert_eq!(entered.iter().map(|e| e.code.as_str()).collect::>(), ["E1"]); + ld_server::publish::pass(&state).await.expect("pass"); + let fr = remote.show("fr.md").expect("published"); + assert!(fr.contains("## Unicité du compte"), "{fr}"); + assert!(!fr.contains("Raison d'être") && !fr.contains("## Réciprocité"), "only the text in force: {fr}"); + let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json"); + assert_eq!(json["units"].as_array().expect("units").len(), 1); + assert_eq!(json["units"][0]["status"], "adopted"); + assert!(outbox(&state).await.iter().all(|(_, _, _, done)| done.is_some()), "the whole queue went out"); + let doc = c.doc("monnaie-libre").await; + assert_eq!(doc["doc"]["publication"]["state"], "done"); +} + +#[tokio::test] +async fn publishing_a_document_without_any_article_in_force_is_one_switch_away() { + let remote = Remote::new("switch"); + let state = fresh_with(remote.config(true)).await; + ld_server::publish::pass(&state).await.expect("pass"); + let fr = remote.show("fr.md").expect("published anyway"); + assert!(fr.contains("# Acte d'engagement Certification") && !fr.contains("## "), "{fr}"); + let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json"); + assert_eq!(json["units"], json!([])); + assert!(outbox(&state).await.iter().all(|(_, _, _, done)| done.is_some())); +} + +// --------------------------------------------------------------------------- +// A seed proposal enters like any proposal +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() { + let state = fresh().await; + let mut a = Client::of(&state); + a.login("Yvv", false).await; + let mut b = a.other(); + b.login("Chiara", false).await; + // Two members: W = 2, C = 1 — one voice is not enough (B^W keeps R above 1). + let doc = a.doc("essai").await; + let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned(); + let v = a.vote(&i1, "for").await; + assert!(v["entered"].as_array().expect("entered").is_empty(), "one of two is not enough"); + let v = b.vote(&i1, "for").await; + assert_eq!(v["entered"][0]["code"], "I1"); + let doc = a.doc("essai").await; + let p = &unit(&doc, "I1")["inForce"]; + assert_eq!(p["id"], i1.as_str()); + assert!(p["enteredAt"].is_string()); + assert_eq!((p["seedProposal"].as_bool(), p["seedMinimal"].as_bool()), (Some(false), Some(false))); + let g = a.get("/api/groups/essai").await; + assert_eq!(g["docs"][0]["inForce"], 1); + let adopted: (String, i64, i64) = + sqlx::query_as("SELECT kind, votes_for, electorate FROM adoptions WHERE proposal_id = ?") + .bind(&i1) + .fetch_one(&state.db) + .await + .expect("adoption"); + assert_eq!(adopted, ("text".to_owned(), 2, 2)); + + // From 16 members on, B^W no longer lifts R above C = 1: one voice passes a text in + // the test group (ADR 0007), whatever the level: R = C + B^W at one vote cast. + for i in 0..14 { + let mut m = a.other(); + m.login(&format!("Membre {i}"), false).await; + } + let doc = a.doc("essai").await; + let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned(); + let v = a.vote(&e1, "for").await; + assert_eq!(v["entered"][0]["code"], "E1", "16 members: one voice"); + let electorate: i64 = sqlx::query_scalar("SELECT electorate FROM adoptions WHERE proposal_id = ?") + .bind(&e1) + .fetch_one(&state.db) + .await + .expect("adoption"); + assert_eq!(electorate, 16); +} diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 171b38f..8178b8b 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -1,5 +1,5 @@ { - "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02).", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs, 2 « basse » par défaut ; Yvv, 2026-11-02, ADR 0011). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend), jamais en réinstanciant le document.", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -37,11 +37,11 @@ "code": "I1", "section": "introduction", "position": 1, - "inertia": 3, "versions": [ { "label": "1.0.0", - "status": "in_force", + "status": "open", + "seedProposal": true, "title": { "fr": "Raison d'être", "en": "Purpose", @@ -103,11 +103,11 @@ "code": "E1", "section": "fondamental", "position": 4, - "inertia": 4, "versions": [ { "label": "1.0.0", - "status": "in_force", + "status": "open", + "seedProposal": true, "title": { "fr": "Unicité du compte", "en": "Uniqueness of the account", @@ -125,11 +125,11 @@ "code": "E2", "section": "fondamental", "position": 5, - "inertia": 4, "versions": [ { "label": "1.0.0", - "status": "in_force", + "status": "open", + "seedProposal": true, "title": { "fr": "Réciprocité", "en": "Reciprocity",