diff --git a/CLAUDE.md b/CLAUDE.md index b7dbe54..557ba87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,10 @@ crates/ # · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une # section, le titre du document) · module_amend.rs (ajout gardé) · quorum.rs (le quorum # d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) · - # quorum_amend.rs (les quorums par cran, amendement gardé) · web.rs (sert web/build) + # quorum_amend.rs (les quorums par cran, amendement gardé) · article_amend.rs (un + # article du seed ajouté à un document existant, gardé : E12, lot T4) · retire.rs + # (formule, inertie, ordonnancement sortis du document sans rien effacer : + # units.retired_at, ADR 0014) · web.rs (sert web/build) tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) · # objections.rs · typos.rs (lot F, horloge avancée, migration 0008) · modules.rs (ordre, # titre, ajout gardé) · seed_proposals.rs (Propositions seed, publication suspendue) · @@ -74,10 +77,14 @@ crates/ # seed_quorum_texts.rs (lots T, T2 et T3 : F1.1, F1.2, N1.1 ; aucun chiffre daté, chaque # phrase vraie pour tout W et tout quorum ; fixtures/seed-quorum-m6b.json et # seed-quorum-t2.json, leurs textes depuis M6b et M6c, pour rejouer T2 et T3 sur une - # base comme la production) -seeds/ # acte-engagement-certification.json (trilingue, 33 articles, aucun en vigueur ; - # I1/E1/E2 Propositions seed au cran 2 (seed::SEED_PROPOSAL_LEVEL), inertie 3 ailleurs ; - # modules en vigueur sans vote : le titre à 4, l'ordre de chaque section à 2 ; + # base comme la production ; depuis T4, sur le seed de M8) · amorce.rs (lot T4 : les + # textes d'Yvv, E12, le retrait ; après M6c et après M8, chaque ligne de chaque table + # gardée, le retour arrière) · fixtures/seed-m8/ (le seed de M8 : les derniers textes + # de formule, inertie, ordonnancement, référence du lot U3) +seeds/ # acte-engagement-certification.json (trilingue, 27 articles, 5 sections, aucun en + # vigueur ; I1/E1/E12/E2 Propositions seed au cran 2 (seed::SEED_PROPOSAL_LEVEL), + # inertie 3 ailleurs ; modules en vigueur sans vote : le titre à 4, l'ordre de chaque + # section à 2 ; # quorums par cran : 12 · 12 · 50 · 50 · 50 Monnaie libre, 1 partout essai) # upcoming/.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage web/src/ @@ -134,7 +141,10 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la est la personne ou le groupe, jamais le système. Le français du texte voté fait foi. - **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans `ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`. -- **Invariants** : au départ, aucun article n'est en vigueur : I1, E1 et E2 sont des Propositions +- **Invariants** : les informations de fonctionnement (formule, inertie, ordonnancement) ne se + votent pas : hors du document, une unité retirée (`units.retired_at`) n'est ni lue, ni votée, ni + comptée, ni publiée, et rien n'est effacé (ADR 0014) ; au départ, aucun article n'est en + vigueur : I1, E1, E12 et E2 sont des Propositions seed (ouvertes, mises en avant, au cran 2 ; seuls les modules sont des *seeds a minima*), et la publication d'un document attend son premier texte en vigueur (ADR 0011) ; un quorum par cran (12 · 12 · 50 · 50 · 50 dans Monnaie libre), que l'auteur d'un article change avant le premier diff --git a/crates/ld-server/migrations/0012_retired.sql b/crates/ld-server/migrations/0012_retired.sql new file mode 100644 index 0000000..c6cec90 --- /dev/null +++ b/crates/ld-server/migrations/0012_retired.sql @@ -0,0 +1,49 @@ +-- The information on how the vote works leaves the document (2026-11-03, lot T4, +-- ADR 0014). Additive only: no row is dropped, no column an older image reads is +-- rewritten. +-- +-- Yvv, 2026-11-03: « les 3 derniers modules ne sont pas de votes : ordonnancement, +-- inertie, et formule de vote sont des infos de fonctionnement. Les votes se font à +-- leur place, et les infos doivent trouver leur place dans l'ux. » The articles of the +-- sections `formule`, `inertie` and `ordonnancement`, and the order of each, are no +-- texts to vote. A new base is born without them (the seed no longer holds them); an +-- existing base keeps every row of them — units, versions, revisions, translations — +-- and marks the units retired: the document no longer lists them, counts them, +-- votes on them or publishes them. An older image (rollback) ignores the column and +-- shows them again exactly as they were. + +-- When the unit left the document (NULL: it is in it), and the amendment that took it out. +ALTER TABLE units ADD COLUMN retired_at TEXT; +ALTER TABLE units ADD COLUMN retired_reason TEXT; + +-- The retirement of an existing document's units (src/retire.rs): decided once per +-- document, only where no person left a trace on any of them. Each decision is kept +-- here: applied (and the codes retired), refused (why, and on which unit), or present +-- (the document was born without them). +CREATE TABLE retirement_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + outcome TEXT NOT NULL, -- applied | refused | present + reason TEXT, -- why it was refused + unit_code TEXT, -- the unit the trace was found on + retired TEXT, -- JSON array of the codes retired + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); + +-- An article the seed now holds, added to an existing document (src/article_amend.rs): +-- decided once per document. Each decision is kept here: applied (the unit created, +-- where it was placed and why), refused (why), or present (born with it). +CREATE TABLE article_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + unit_code TEXT NOT NULL, + unit_id TEXT, -- the unit created, or found + outcome TEXT NOT NULL, -- applied | refused | present + reason TEXT, -- why it was refused + placement TEXT, -- between (where the seed puts it) | end (of its section) + placement_reason TEXT, -- why at the end + position REAL, + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); diff --git a/crates/ld-server/src/article_amend.rs b/crates/ld-server/src/article_amend.rs new file mode 100644 index 0000000..15ee9ec --- /dev/null +++ b/crates/ld-server/src/article_amend.rs @@ -0,0 +1,378 @@ +//! Article amendments — an article the seed now holds, added to a document that was +//! born without it. +//! +//! Yvv, 2026-11-03 (lot T4): « AJOUT d'un texte : RESPONSABILITE CIVIQUE → Je déclare sur +//! l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la +//! responsabilité civique, pour certifier à mon tour. » A new base gets it from the seed: +//! E12 « Responsabilité civique », a seed proposal (ADR 0011) at `seed::SEED_PROPOSAL_LEVEL`, +//! between E1 and E2 in the section `fondamental`. An existing base — production holds the +//! certification act twice, Monnaie libre and the test group — gets it through an amendment +//! listed here, decided once per document, on the model of `module_amend`: the article of +//! the seed is written as a new base writes it (`seed::write_unit`: its unit, its version +//! open and put forward, its first revision, its translations), and nothing else is +//! written — no text changes, no vote is touched, no other article moves. +//! +//! **Where it goes.** The order of a section is a module (ADR 0009). In force at the start +//! without a vote (a seed a minima), it reads the positions of the articles. While no vote +//! exists on that order (`quorum::voted_units`: a vote, a line of a ballot, an adoption), the +//! article takes the place the seed gives it: between `after` and `before`, adjacent in the +//! order of the section now, half-way between their positions — so the order in force says +//! it at once. Once someone voted on the order, or when the two are no longer adjacent, or +//! one of them is gone from the section, its place is that of any new article: the end of +//! its section (`ld_engine::order::place_at_end`); the order of the section is then voted +//! as always (an open order completes it at its end, ADR 0009). +//! +//! The document holds a unit of that code already: present when it is the seed's (born +//! with it), refused otherwise (`code_taken`); no such section in the document: refused +//! (`no_section`). Each decision is kept in `article_amendments` (where the article went, +//! and why at the end) and in the logs. Applied where the document publishes, the +//! publication queue gets a row, which waits for the first text (ADR 0011). + +use crate::AppState; +use crate::quorum; +use crate::seed::{self, SeedDoc, SeedUnit}; +use crate::seed_amend::fingerprint; +use crate::util; +use sqlx::SqliteConnection; + +/// One article to add to every document instantiated from a seed. +pub struct ArticleAmendment { + /// 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: one no document holds yet. + pub unit: &'static str, + /// Fingerprint of its text in the seed (source language), which must hash to it: a seed + /// edited later never reaches a base by this way. + pub text: &'static str, + /// The two articles of its section it goes between, in this order. + pub after: &'static str, + pub before: &'static str, + /// Reason given to the publication queue, and so to the commit of the texts. + pub reason: &'static str, +} + +pub const AMENDMENTS: &[ArticleAmendment] = &[ArticleAmendment { + id: "2026-11-03-e12-responsabilite-civique", + doc: "acte-engagement-certification", + unit: "E12", + text: "3fa5a8e5df24e9ebb6b2882968bd0b6989c820a3f60d7c67bdb7fccf17ae1296", + after: "E1", + before: "E2", + reason: "Proposition seed ajoutée : E12, Responsabilité civique (texte d'Yvv du 03/11)", +}]; + +/// What the database says of the document. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Facts { + /// A unit of that code: `Some(true)` the seed's, `Some(false)` another one. + pub existing: Option, + /// The section is in the document (an article of it, not retired). + pub section: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// Born with it: nothing to do. + Present, + Refuse(&'static str), +} + +/// Whether the article is added, apart from the database. +pub fn verdict(f: &Facts) -> Verdict { + match f.existing { + Some(true) => Verdict::Present, + Some(false) => Verdict::Refuse("code_taken"), + None if !f.section => Verdict::Refuse("no_section"), + None => Verdict::Apply, + } +} + +/// Where the article goes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Placement { + /// Where the seed puts it, at this position: the order in force says it at once. + Between(f64), + /// At the end of its section, as any new article (ADR 0009), and why. + End(&'static str), +} + +/// Where it goes, apart from the database: `voted`, whether a vote exists on the order of +/// its section; `members`, the section in its order now (code, position); `positions`, +/// every article of the document. +pub fn placement(voted: bool, members: &[(String, f64)], after: &str, before: &str, positions: &[f64]) -> Placement { + if voted { + return Placement::End("order_voted"); + } + let at = |code: &str| members.iter().position(|(c, _)| c == code); + let (Some(i), Some(j)) = (at(after), at(before)) else { return Placement::End("not_in_section") }; + let (a, b) = (members[i].1, members[j].1); + // Adjacent in the order, and nothing of the document between their places. + if j != i + 1 || a >= b || positions.iter().any(|p| *p > a && *p < b) { + return Placement::End("not_adjacent"); + } + Placement::Between(f64::midpoint(a, b)) +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq)] +pub struct Outcome { + pub amendment: &'static str, + pub group: String, + pub verdict: Verdict, + pub placement: Option, +} + +/// Decides every amendment not yet decided, document by document. +pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ArticleAmendment]) -> anyhow::Result> { + let mut out = Vec::new(); + for a in list { + let Some((seed, unit)) = seed_says(seeds, a) else { 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((v, placement)) = decide(state, a, seed, unit, &doc_id, publish_path.is_some()).await? else { + continue; + }; + match (v, placement) { + (Verdict::Apply, Some(p)) => { + tracing::info!(amendment = a.id, %group, unit = a.unit, placement = ?p, "article added") + } + (Verdict::Refuse(why), _) => { + tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "article amendment refused: the document stays as it is") + } + _ => tracing::info!(amendment = a.id, %group, unit = a.unit, "article amendment: already there"), + } + if v == Verdict::Apply && publish_path.is_some() { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, group, verdict: v, placement }); + } + } + Ok(out) +} + +/// The seed must hold the article as a seed proposal with the text the amendment names, +/// between the two it names, in their section. +fn seed_says<'a>(seeds: &'a [SeedDoc], a: &ArticleAmendment) -> Option<(&'a SeedDoc, &'a SeedUnit)> { + let found = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| { + let src = s.source_lang.as_str(); + let of = |code: &str| s.units.iter().find(|u| u.code == code); + let (u, after, before) = (of(a.unit)?, of(a.after)?, of(a.before)?); + let v = u.versions.iter().find(|v| v.seed_proposal)?; + let print = fingerprint(v.title.get(src)?, v.body.get(src)?); + let placed = u.section.is_some() + && after.section == u.section + && before.section == u.section + && after.position < u.position + && u.position < before.position; + (print == a.text && u.inertia.is_none() && placed).then_some((s, u)) + }); + if found.is_none() { + tracing::error!( + amendment = a.id, + doc = a.doc, + unit = a.unit, + "article amendment: the seed does not hold this article where it names it, nothing decided" + ); + } + found +} + +async fn facts(conn: &mut SqliteConnection, doc_id: &str, a: &ArticleAmendment, section: &str) -> sqlx::Result { + let existing: Option<(String, i64)> = sqlx::query_as( + "SELECT u.id, (SELECT COUNT(*) FROM proposals p WHERE p.unit_id = u.id AND p.origin = 'seed') + FROM units u WHERE u.doc_id = ? AND u.code = ?", + ) + .bind(doc_id) + .bind(a.unit) + .fetch_optional(&mut *conn) + .await?; + let in_section: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND section = ? AND retired_at IS NULL", + ) + .bind(doc_id) + .bind(section) + .fetch_one(&mut *conn) + .await?; + Ok(Facts { existing: existing.map(|(_, seed)| seed > 0), section: in_section > 0 }) +} + +/// The placement, and the end of the section (the place of any new article there). +async fn where_to( + conn: &mut SqliteConnection, + doc_id: &str, + a: &ArticleAmendment, + section: &str, +) -> sqlx::Result<(Placement, f64)> { + let articles: Vec<(String, String, Option, f64)> = sqlx::query_as( + "SELECT id, code, section, position FROM units WHERE doc_id = ? AND kind = 'article' AND retired_at IS NULL", + ) + .bind(doc_id) + .fetch_all(&mut *conn) + .await?; + let order: Option = sqlx::query_scalar( + "SELECT id FROM units WHERE doc_id = ? AND kind = 'order' AND section = ? AND retired_at IS NULL", + ) + .bind(doc_id) + .bind(section) + .fetch_optional(&mut *conn) + .await?; + let voted = match &order { + Some(id) => quorum::voted_units(conn, doc_id).await?.contains(id), + None => false, + }; + let members: Vec<(String, f64)> = crate::order::sections_now(conn, doc_id) + .await? + .into_iter() + .find(|(s, _)| s == section) + .map(|(_, ids)| { + ids.iter().filter_map(|id| articles.iter().find(|x| &x.0 == id).map(|x| (x.1.clone(), x.3))).collect() + }) + .unwrap_or_default(); + let positions: Vec = articles.iter().map(|x| x.3).collect(); + let places: Vec<(Option<&str>, f64)> = articles.iter().map(|x| (x.2.as_deref(), x.3)).collect(); + let end = ld_engine::order::place_at_end(&places, Some(section)); + Ok((placement(voted, &members, a.after, a.before, &positions), end)) +} + +/// One document, in one transaction: `None` when it was decided at an earlier start. +async fn decide( + state: &AppState, + a: &ArticleAmendment, + seed: &SeedDoc, + unit: &SeedUnit, + doc_id: &str, + publishes: bool, +) -> anyhow::Result)>> { + let mut tx = state.db.begin().await?; + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM article_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 section = unit.section.clone().unwrap_or_default(); + let found = facts(&mut tx, doc_id, a, §ion).await?; + let v = verdict(&found); + let now = util::now(); + let (mut placement, mut position, mut unit_id) = (None, None, None); + if v == Verdict::Apply { + let (p, end) = where_to(&mut tx, doc_id, a, §ion).await?; + let at = match p { + Placement::Between(at) => at, + Placement::End(_) => end, + }; + unit_id = Some(seed::write_unit(&mut tx, doc_id, &seed.source_lang, unit, at, &now).await?); + (placement, position) = (Some(p), Some(at)); + if publishes { + let where_ = match p { + Placement::Between(_) => format!("entre {} et {}", a.after, a.before), + Placement::End(_) => "en fin de section".to_owned(), + }; + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(doc_id) + .bind(format!("{}, {where_}", a.reason)) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } else if v == Verdict::Present { + unit_id = sqlx::query_scalar("SELECT id FROM units WHERE doc_id = ? AND code = ?") + .bind(doc_id) + .bind(a.unit) + .fetch_optional(&mut *tx) + .await?; + } + let (outcome, reason) = match v { + Verdict::Apply => ("applied", None), + Verdict::Present => ("present", None), + Verdict::Refuse(why) => ("refused", Some(why)), + }; + let (kind, why_end) = match placement { + Some(Placement::Between(_)) => (Some("between"), None), + Some(Placement::End(why)) => (Some("end"), Some(why)), + None => (None, None), + }; + sqlx::query( + "INSERT INTO article_amendments (amendment_id, doc_id, unit_code, unit_id, outcome, reason, placement, + placement_reason, position, at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(doc_id) + .bind(a.unit) + .bind(&unit_id) + .bind(outcome) + .bind(reason) + .bind(kind) + .bind(why_end) + .bind(position) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some((v, placement))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn section(codes: &[(&str, f64)]) -> Vec<(String, f64)> { + codes.iter().map(|(c, p)| ((*c).to_owned(), *p)).collect() + } + + #[test] + fn a_document_without_the_article_receives_it() { + assert_eq!(verdict(&Facts { existing: None, section: true }), Verdict::Apply); + } + + #[test] + fn born_with_it_or_its_code_taken_it_is_not_written_again() { + assert_eq!(verdict(&Facts { existing: Some(true), section: true }), Verdict::Present); + assert_eq!(verdict(&Facts { existing: Some(false), section: true }), Verdict::Refuse("code_taken")); + assert_eq!(verdict(&Facts { existing: None, section: false }), Verdict::Refuse("no_section")); + } + + #[test] + fn it_goes_between_its_two_while_the_order_was_never_voted() { + let fondamental = section(&[("T1", 3.0), ("E1", 4.0), ("E2", 5.0)]); + let all = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + assert_eq!(placement(false, &fondamental, "E1", "E2", &all), Placement::Between(4.5)); + // A member's article at the end of the section changes nothing between them. + let longer = section(&[("T1", 3.0), ("E1", 4.0), ("E2", 5.0), ("N3", 5.5)]); + assert_eq!(placement(false, &longer, "E1", "E2", &[3.0, 4.0, 5.0, 5.5, 6.0]), Placement::Between(4.5)); + } + + #[test] + fn otherwise_at_the_end_of_its_section_and_why() { + let fondamental = section(&[("T1", 3.0), ("E1", 4.0), ("E2", 5.0)]); + let all = [3.0, 4.0, 5.0]; + assert_eq!(placement(true, &fondamental, "E1", "E2", &all), Placement::End("order_voted")); + // An order voted into force that put E2 first. + let moved = section(&[("T1", 3.0), ("E2", 4.0), ("E1", 5.0)]); + assert_eq!(placement(false, &moved, "E1", "E2", &all), Placement::End("not_adjacent")); + let apart = section(&[("E1", 4.0), ("T1", 4.5), ("E2", 5.0)]); + assert_eq!(placement(false, &apart, "E1", "E2", &all), Placement::End("not_adjacent")); + // Something of another section between their places. + assert_eq!(placement(false, &fondamental, "E1", "E2", &[4.0, 4.2, 5.0]), Placement::End("not_adjacent")); + let gone = section(&[("T1", 3.0), ("E1", 4.0)]); + assert_eq!(placement(false, &gone, "E1", "E2", &all), Placement::End("not_in_section")); + } + + #[test] + fn the_shipped_amendment_adds_e12_between_e1_and_e2() { + let a = &AMENDMENTS[0]; + assert_eq!((a.unit, a.after, a.before), ("E12", "E1", "E2")); + } +} diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 23327be..fc70a9e 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -156,10 +156,12 @@ pub async fn group( sqlx::query_as::<_, DocListRow>( "SELECT d.id, d.slug, d.title, d.summary, (SELECT COUNT(*) FROM units u - WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force, - -- Articles in force; open proposals of articles and modules alike (ADR 0009). + WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL + AND u.retired_at IS NULL) AS in_force, + -- Articles in force; open proposals of articles and modules alike (ADR 0009); + -- nothing of a unit out of the document (ADR 0014). (SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id - WHERE u.doc_id = d.id AND p.status = 'open') AS open + WHERE u.doc_id = d.id AND p.status = 'open' AND u.retired_at IS NULL) AS open FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at", ) .bind(&slug) @@ -298,7 +300,7 @@ pub fn inertia_of(unit: &UnitRow) -> InertiaLevel { pub struct DocState { pub doc: DocRow, pub group: GroupRow, - /// Every unit, articles and modules, by position. + /// Every unit in the document, articles and modules, by position: none retired (ADR 0014). pub units: Vec, /// The units that are modules (ADR 0009), by unit id; any other is an article. pub module_kinds: HashMap, @@ -359,14 +361,17 @@ pub async fn electorate(state: &AppState, group: &GroupRow) -> AppResult<(u32, & pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { let doc = load_doc(state, doc_id).await?; let group = load_group(state, &doc.group_slug).await?; + // The units in the document: a unit retired (ADR 0014) is not read, nor its versions — + // neither listed, nor counted, nor voted on, nor published (`retire`). let units = sqlx::query_as::<_, UnitRow>( - "SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id", + "SELECT id, code, section, position, inertia, in_force_id, quorum FROM units + WHERE doc_id = ? AND retired_at IS NULL ORDER BY position, id", ) .bind(doc_id) .fetch_all(&state.db) .await?; let kinds: Vec<(String, String)> = - sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'") + sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article' AND retired_at IS NULL") .bind(doc_id) .fetch_all(&state.db) .await?; @@ -375,7 +380,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { // Same second (the variants of a seed): the order they were written in, not their // random identifiers. The engine ranks rivals on its own; this order is what people see. let proposals = sqlx::query_as::<_, ProposalRow>(&format!( - "{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?) + "{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ? AND retired_at IS NULL) AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.rowid" )) .bind(doc_id) diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 67b9efe..8954e6f 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -10,6 +10,7 @@ pub mod access; pub mod adoption; +pub mod article_amend; pub mod auth; pub mod ballots; pub mod clock; @@ -31,6 +32,7 @@ pub mod proposals; pub mod publish; pub mod quorum; pub mod quorum_amend; +pub mod retire; pub mod seed; pub mod seed_amend; pub mod seed_proposal_amend; diff --git a/crates/ld-server/src/order.rs b/crates/ld-server/src/order.rs index cf3eb1e..1209628 100644 --- a/crates/ld-server/src/order.rs +++ b/crates/ld-server/src/order.rs @@ -37,7 +37,9 @@ pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Re position: f64, visible: bool, } - // Seen: a text in force, or an open version (`DocState::members` says the same). + // Seen: a text in force, or an open version (`DocState::members` says the same). It reads + // only the columns of before 0012: a document born now, or given its modules by an + // amendment, holds no retired unit (ADR 0014); `article_amend` keeps the live ones. let rows = sqlx::query_as::<_, Row>( "SELECT u.id, u.section, u.position, (u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs index b558e2d..0a17bc0 100644 --- a/crates/ld-server/src/overview.rs +++ b/crates/ld-server/src/overview.rs @@ -137,7 +137,8 @@ pub async fn decisions( /// order of each section (ADR 0009): the version in force (adopted by a vote, /// or a seed a minima), the open versions, and whether `me` holds a valid vote /// on one of them. A change of inertia is not a version of the text; the order -/// of a section of fewer than two articles has nothing to decide; a version held +/// of a section of fewer than two articles has nothing to decide; a unit out of the +/// document (ADR 0014) is no module to vote; a version held /// by an objection is not counted as one to vote while it is held: it takes no /// new *for*, only against or a withdrawal (ADR 0010). async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { @@ -169,7 +170,8 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult 'none')) AS voted FROM units u JOIN proposals p ON p.unit_id = u.id - WHERE u.doc_id = ?2 AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open') + WHERE u.doc_id = ?2 AND u.retired_at IS NULL AND p.kind <> 'inertia' + AND (p.id = u.in_force_id OR p.status = 'open') ORDER BY u.position, u.id", ) .bind(me) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index aad2276..c7e2840 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -60,6 +60,11 @@ pub(crate) async fn guarded( if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) { return Err(AppError::NotFound); } + // A version of a unit out of the document (ADR 0014) is no longer in it: nothing is + // read, voted, corrected, translated or objected on it. + if !st.units.iter().any(|u| u.id == p.unit_id) { + return Err(AppError::NotFound); + } Ok((p, st)) } diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index 19a4faa..9fd1372 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -195,7 +195,8 @@ pub async fn waits_for_a_text(state: &AppState, doc_id: &str) -> anyhow::Result< 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", + "SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND in_force_id IS NOT NULL + AND retired_at IS NULL", ) .bind(doc_id) .fetch_one(&state.db) @@ -317,8 +318,10 @@ async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result { let at: Option = sqlx::query_scalar( "SELECT MAX(d) FROM ( SELECT MAX(at) AS d FROM adoptions WHERE doc_id = ?1 - UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id WHERE u.doc_id = ?1 - UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id WHERE u.doc_id = ?1 + UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id + WHERE u.doc_id = ?1 AND u.retired_at IS NULL + UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id + WHERE u.doc_id = ?1 AND u.retired_at IS NULL UNION ALL SELECT created_at FROM documents WHERE id = ?1)", ) .bind(doc_id) diff --git a/crates/ld-server/src/retire.rs b/crates/ld-server/src/retire.rs new file mode 100644 index 0000000..7c638c9 --- /dev/null +++ b/crates/ld-server/src/retire.rs @@ -0,0 +1,373 @@ +//! Retirements — the units a seed no longer holds, taken out of an existing document +//! without erasing anything (ADR 0014). +//! +//! Yvv, 2026-11-03 (lot T4): « Attention les 3 derniers modules ne sont pas de votes : +//! ordonnancement, inertie, et formule de vote sont des infos de fonctionnement. Les votes +//! se font à leur place, et les infos doivent trouver leur place dans l'ux. […] Vire les 3 +//! du bas de la liste. » The articles of the sections `formule` (F1, F1.1, F1.2, F1.3), +//! `inertie` (N1, N1.1) and `ordonnancement` (O1), and the order of each section, say how +//! the vote works: they are no texts to vote. A new base is born without them (the seed no +//! longer holds them). An existing base keeps every row of them — units, versions, +//! revisions, translations, the publication queue — and marks the units retired +//! (`units.retired_at`, `retired_reason`): nothing else is written on them. +//! +//! **Out of the document.** A unit retired is not read with its document (`docs::load_state` +//! reads the units with `retired_at IS NULL`, and their versions only), so it is neither +//! listed, nor voted on (a vote, a grouped vote, a ballot), nor proposed, corrected, +//! translated or objected on (`proposals::guarded`: 404), nor given a change of inertia, a +//! quorum or an order (the unit is not found: 404), nor counted (`docs::group`, +//! `overview::modules`), nor published (`publish`: `document.json` and the Markdown read the +//! document's state; `waits_for_a_text` counts the articles in it). The order of a section +//! reads its articles in it (`DocState::members`). An older image +//! (rollback) ignores the column and shows them again, exactly as they were. +//! +//! **The guard**, decided once per document, all of them or none: they leave only if no +//! person left a trace on any of them, in any of its versions, whatever became of it — +//! a vote (simulated included), a line of a ballot (whatever its fate), an adoption, a +//! version written by a member (a text, a removal, a change of inertia, an order), a +//! revision written by a person (a typo fix, a substantive change), a typo fix waiting or +//! refused, a translation written by a member, an objection naming it, a quorum set. Their +//! texts may be at any revision the seed amendments wrote (F1.2 and N1.1 at 4 after M6c, 5 +//! after M8): an amendment is no trace of a person. Refused, nothing moves; the reason and +//! the unit it was found on are kept (`retirement_amendments`) and in the logs. Applied +//! where the document publishes, the queue gets a row, which waits for the first text +//! (ADR 0011); the rows queued before for those texts stay, and go out with it. + +use crate::AppState; +use crate::seed::SeedDoc; +use crate::util; +use sqlx::SqliteConnection; + +/// The retirement of lot T4: the information on how the vote works (ADR 0014). +pub const RETIREMENT: &str = "2026-11-03-fonctionnement-hors-vote"; + +/// Units to take out of every document instantiated from a seed, by section. +pub struct Retirement { + /// Stable identifier: each document's decision is kept under it. + pub id: &'static str, + /// Slug of the seed document. + pub doc: &'static str, + /// The sections that leave: their articles, and the order of each. + pub sections: &'static [&'static str], + /// Reason given to the publication queue, and so to the commit of the texts. + pub reason: &'static str, +} + +pub const AMENDMENTS: &[Retirement] = &[Retirement { + id: RETIREMENT, + doc: "acte-engagement-certification", + sections: &["formule", "inertie", "ordonnancement"], + reason: "informations de fonctionnement sorties du document, qui ne se votent pas : formule de vote (F1, F1.1, F1.2, F1.3), inertie (N1, N1.1), ordonnancement (O1), et l'ordre de chaque section (ADR 0014)", +}]; + +/// What the database says of one unit that would leave, all its versions together. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UnitFacts { + pub code: String, + /// Votes that count, of the server, proven or simulated. + pub votes: i64, + /// Lines of ballots, whatever their fate. + pub ballots: i64, + /// Adoptions, versions voted into force or confirmed, changes of inertia by a vote. + pub adopted: i64, + /// Versions written by a member: a text, a removal, a change of inertia, an order. + pub member_versions: i64, + /// Revisions written by a person, or of a kind no seed amendment writes; typo fixes. + pub corrected: i64, + /// Translations written or corrected by a member. + pub member_translations: i64, + /// Objections naming one of its versions, standing or lifted. + pub signals: i64, + /// Its own quorum set, or a change of it journaled. + pub quorum_set: i64, +} + +impl UnitFacts { + /// The first trace of a person found on it, if any. + pub fn trace(&self) -> Option<&'static str> { + [ + (self.votes, "votes"), + (self.ballots, "ballots"), + (self.adopted, "adopted"), + (self.member_versions, "member_proposal"), + (self.corrected, "corrected"), + (self.member_translations, "translation_edited"), + (self.signals, "signalled"), + (self.quorum_set, "quorum_set"), + ] + .into_iter() + .find(|(n, _)| *n > 0) + .map(|(_, why)| why) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// Born without them, or already out: nothing to do. + Present, + /// A trace of a person, and the unit it was found on. + Refuse(&'static str, String), +} + +/// The rule, apart from the database: all of them leave, or none. +pub fn verdict(units: &[UnitFacts]) -> Verdict { + if units.is_empty() { + return Verdict::Present; + } + for u in units { + if let Some(why) = u.trace() { + return Verdict::Refuse(why, u.code.clone()); + } + } + Verdict::Apply +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + pub amendment: &'static str, + pub group: String, + pub verdict: Verdict, + pub retired: Vec, +} + +/// Decides every retirement not yet decided, document by document. +pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Retirement]) -> anyhow::Result> { + let mut out = Vec::new(); + 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((v, retired)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue }; + match &v { + Verdict::Apply => { + tracing::info!(amendment = a.id, %group, retired = ?retired, "units out of the document") + } + Verdict::Present => { + tracing::info!(amendment = a.id, %group, "retirement: none of them in the document") + } + Verdict::Refuse(why, unit) => tracing::warn!( + amendment = a.id, %group, reason = why, %unit, + "retirement refused: a person left a trace, they stay in the document" + ), + } + if v == Verdict::Apply && publish_path.is_some() { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, group, verdict: v, retired }); + } + } + Ok(out) +} + +/// The seed must no longer hold any unit of those sections: a seed that still gives them +/// never takes them out. +fn seed_says(seeds: &[SeedDoc], a: &Retirement) -> bool { + let says = seeds + .iter() + .find(|s| s.slug == a.doc) + .is_some_and(|s| s.units.iter().all(|u| u.section.as_deref().is_none_or(|x| !a.sections.contains(&x)))); + if !says { + tracing::error!( + amendment = a.id, + doc = a.doc, + "retirement: the seed still holds these sections, or no such document, nothing decided" + ); + } + says +} + +async fn unit_facts(conn: &mut SqliteConnection, unit_id: &str, code: &str) -> sqlx::Result { + let n = |sql: &'static str| sqlx::query_scalar::<_, i64>(sql).bind(unit_id); + Ok(UnitFacts { + code: code.to_owned(), + votes: n("SELECT COUNT(*) FROM votes WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)") + .fetch_one(&mut *conn) + .await?, + ballots: n( + "SELECT COUNT(*) FROM ballot_lines WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)", + ) + .fetch_one(&mut *conn) + .await?, + adopted: n("SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1) + + (SELECT COUNT(*) FROM proposals WHERE unit_id = ?1 + AND (entered_at IS NOT NULL OR confirmed_at IS NOT NULL)) + + (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')") + .fetch_one(&mut *conn) + .await?, + member_versions: n( + "SELECT COUNT(*) FROM proposals WHERE unit_id = ?1 AND (origin <> 'seed' OR author_id IS NOT NULL)", + ) + .fetch_one(&mut *conn) + .await?, + corrected: n( + "SELECT (SELECT COUNT(*) FROM typos + WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)) + + (SELECT COUNT(*) FROM revisions + WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1) + AND (author_id IS NOT NULL OR kind NOT IN ('initial', 'amendment', 'seed_proposal')))", + ) + .fetch_one(&mut *conn) + .await?, + member_translations: n("SELECT COUNT(*) FROM translations + WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1) + AND (origin <> 'seed' OR updated_by IS NOT NULL)") + .fetch_one(&mut *conn) + .await?, + signals: n("SELECT COUNT(*) FROM signals + WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1) + OR target_proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)") + .fetch_one(&mut *conn) + .await?, + quorum_set: n("SELECT (SELECT COUNT(*) FROM units WHERE id = ?1 AND quorum IS NOT NULL) + + (SELECT COUNT(*) FROM quorum_changes WHERE unit_id = ?1)") + .fetch_one(&mut *conn) + .await?, + }) +} + +/// One document, in one transaction: `None` when it was decided at an earlier start. +async fn decide( + state: &AppState, + a: &Retirement, + doc_id: &str, + publishes: bool, +) -> anyhow::Result)>> { + let mut tx = state.db.begin().await?; + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM retirement_amendments WHERE amendment_id = ? AND doc_id = ?") + .bind(a.id) + .bind(doc_id) + .fetch_one(&mut *tx) + .await?; + if decided > 0 { + return Ok(None); + } + // The units of those sections still in the document: the articles in their order, then + // the order of each section. + let marks = vec!["?"; a.sections.len()].join(", "); + let sql = format!( + "SELECT id, code FROM units WHERE doc_id = ? AND retired_at IS NULL AND section IN ({marks}) + ORDER BY kind <> 'article', position, code" + ); + let mut q = sqlx::query_as::<_, (String, String)>(&sql).bind(doc_id); + for s in a.sections { + q = q.bind(*s); + } + let units = q.fetch_all(&mut *tx).await?; + let mut facts = Vec::with_capacity(units.len()); + for (id, code) in &units { + facts.push(unit_facts(&mut tx, id, code).await?); + } + let mut v = verdict(&facts); + let now = util::now(); + let mut retired = Vec::new(); + if v == Verdict::Apply { + for (id, code) in &units { + // Only the two columns of the retirement; nothing else of the unit, nothing of its + // versions, revisions or translations. + let done = + sqlx::query("UPDATE units SET retired_at = ?, retired_reason = ? WHERE id = ? AND retired_at IS NULL") + .bind(&now) + .bind(a.id) + .bind(id) + .execute(&mut *tx) + .await?; + if done.rows_affected() == 1 { + retired.push(code.clone()); + } + } + if retired.len() != units.len() { + // What was examined changed under us: nothing is written. + tx.rollback().await?; + tx = state.db.begin().await?; + v = Verdict::Refuse("changed", String::new()); + retired.clear(); + } else if publishes { + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(doc_id) + .bind(a.reason) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } + let (outcome, reason, unit) = match &v { + Verdict::Apply => ("applied", None, None), + Verdict::Present => ("present", None, None), + Verdict::Refuse(why, unit) => ("refused", Some(*why), Some(unit.clone()).filter(|u| !u.is_empty())), + }; + sqlx::query( + "INSERT INTO retirement_amendments (amendment_id, doc_id, outcome, reason, unit_code, retired, at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(doc_id) + .bind(outcome) + .bind(reason) + .bind(unit) + .bind((v == Verdict::Apply).then(|| serde_json::to_string(&retired).unwrap_or_else(|_| "[]".into()))) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some((v, retired))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn untouched(code: &str) -> UnitFacts { + UnitFacts { code: code.into(), ..UnitFacts::default() } + } + + #[test] + fn untouched_they_all_leave() { + assert_eq!(verdict(&[untouched("F1"), untouched("F1.2"), untouched("ordre.formule")]), Verdict::Apply); + } + + #[test] + fn born_without_them_nothing_to_do() { + assert_eq!(verdict(&[]), Verdict::Present); + } + + #[test] + fn any_trace_of_a_person_keeps_them_all_and_says_where() { + let cases = [ + (UnitFacts { votes: 1, ..untouched("N1") }, "votes"), + (UnitFacts { ballots: 1, ..untouched("N1") }, "ballots"), + (UnitFacts { adopted: 1, ..untouched("N1") }, "adopted"), + (UnitFacts { member_versions: 1, ..untouched("N1") }, "member_proposal"), + (UnitFacts { corrected: 1, ..untouched("N1") }, "corrected"), + (UnitFacts { member_translations: 1, ..untouched("N1") }, "translation_edited"), + (UnitFacts { signals: 1, ..untouched("N1") }, "signalled"), + (UnitFacts { quorum_set: 1, ..untouched("N1") }, "quorum_set"), + ]; + for (facts, why) in cases { + let all = [untouched("F1"), facts.clone(), untouched("O1")]; + assert_eq!(verdict(&all), Verdict::Refuse(why, "N1".into()), "{facts:?}"); + } + // The first trace, in the order of the units, then of the kinds. + let two = [ + untouched("F1"), + UnitFacts { signals: 1, votes: 2, ..untouched("F1.1") }, + UnitFacts { quorum_set: 1, ..untouched("N1") }, + ]; + assert_eq!(verdict(&two), Verdict::Refuse("votes", "F1.1".into())); + } + + #[test] + fn the_shipped_retirement_takes_out_the_three_sections_of_operation() { + assert_eq!(AMENDMENTS[0].id, RETIREMENT); + assert_eq!(AMENDMENTS[0].sections, ["formule", "inertie", "ordonnancement"]); + } +} diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index 89bb0b3..6f87f20 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -12,18 +12,26 @@ //! 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; the quorums by level the seed now gives a document -//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none. +//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none; an +//! article the seed now holds, an article amendment (`article_amend`), placed where the +//! seed puts it while the order of its section was never voted on; the units the seed no +//! longer holds — the information on how the vote works (ADR 0014) —, a retirement +//! (`retire`), where no person left a trace on them: they stay in the database, out of +//! the document. use crate::AppState; +use crate::article_amend; use crate::inertia_amend; use crate::module_amend; use crate::modules; use crate::quorum_amend; +use crate::retire; use crate::seed_amend; use crate::seed_proposal_amend; use crate::util::{self, LangMap}; use ld_engine::inertia::{InertiaLevel, quorum_bounds}; use serde::Deserialize; +use sqlx::SqliteConnection; use std::path::Path; /// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « on @@ -132,8 +140,9 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> { Ok(()) } -/// On start: the groups, the documents missing from them, then the seed, -/// inertia and module amendments (each decided once per document). +/// On start: the groups, the documents missing from them, then the seed, inertia, +/// module, seed proposal, quorum, article amendments and the retirements (each decided +/// once per document). pub async fn ensure(state: &AppState) -> anyhow::Result<()> { let seeds = documents(state).await?; seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?; @@ -142,6 +151,8 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> { // After the text amendments: a starting text replaced first, then put forward. seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?; quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?; + article_amend::apply(state, &seeds, article_amend::AMENDMENTS).await?; + retire::apply(state, &seeds, retire::AMENDMENTS).await?; Ok(()) } @@ -225,7 +236,6 @@ fn check(seed: &SeedDoc) -> anyhow::Result<()> { 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(); let now = util::now(); let doc_id = util::id(); let mut tx = state.db.begin().await?; @@ -253,77 +263,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .await?; } for unit in &seed.units { - let unit_id = util::id(); - sqlx::query( - "INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, NULL, ?)", - ) - .bind(&unit_id) - .bind(&doc_id) - .bind(&unit.code) - .bind(&unit.section) - .bind(unit.position) - .bind(unit.level().as_db()) - .bind(&now) - .execute(&mut *tx) - .await?; - for v in &unit.versions { - let pid = util::id(); - let (title, body) = (pick(&v.title), pick(&v.body)); - let status = if v.status == "in_force" { "in_force" } else { "open" }; - sqlx::query( - "INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision, - created_at, updated_at, submitted_at) - VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)", - ) - .bind(&pid) - .bind(&unit_id) - .bind(&title) - .bind(&body) - .bind(status) - .bind(&v.label) - .bind(&now) - .bind(&now) - .bind(&now) - .execute(&mut *tx) - .await?; - sqlx::query( - "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)", - ) - .bind(&pid) - .bind(&title) - .bind(&body) - .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) - .bind(&unit_id) - .execute(&mut *tx) - .await?; - } - let hash = util::source_hash(&title, &body); - for lang in util::LANGS.iter().filter(|l| **l != src) { - let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue }; - sqlx::query( - "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) - VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)", - ) - .bind(&pid) - .bind(lang) - .bind(t) - .bind(b) - .bind(&hash) - .bind(&now) - .execute(&mut *tx) - .await?; - } - } + write_unit(&mut tx, &doc_id, src, unit, unit.position, &now).await?; } // The modules, from the document just written: its title, the order of each section. if let Some(m) = &seed.modules { @@ -343,6 +283,93 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a Ok(()) } +/// Writes one article of the seed into a document, at `position`: its unit (at its level), +/// its versions — in force, open, put forward —, their first revision and their +/// translations. A new base (`instantiate`), or an article a seed adds later +/// (`article_amend`). Returns the unit's id. +pub(crate) async fn write_unit( + conn: &mut SqliteConnection, + doc_id: &str, + src: &str, + unit: &SeedUnit, + position: f64, + now: &str, +) -> sqlx::Result { + let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default(); + let unit_id = util::id(); + sqlx::query( + "INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?)", + ) + .bind(&unit_id) + .bind(doc_id) + .bind(&unit.code) + .bind(&unit.section) + .bind(position) + .bind(unit.level().as_db()) + .bind(now) + .execute(&mut *conn) + .await?; + for v in &unit.versions { + let pid = util::id(); + let (title, body) = (pick(&v.title), pick(&v.body)); + let status = if v.status == "in_force" { "in_force" } else { "open" }; + sqlx::query( + "INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision, + created_at, updated_at, submitted_at) + VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)", + ) + .bind(&pid) + .bind(&unit_id) + .bind(&title) + .bind(&body) + .bind(status) + .bind(&v.label) + .bind(now) + .bind(now) + .bind(now) + .execute(&mut *conn) + .await?; + sqlx::query( + "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)", + ) + .bind(&pid) + .bind(&title) + .bind(&body) + .bind(now) + .execute(&mut *conn) + .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 *conn).await?; + } + if status == "in_force" { + sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") + .bind(&pid) + .bind(&unit_id) + .execute(&mut *conn) + .await?; + } + let hash = util::source_hash(&title, &body); + for lang in util::LANGS.iter().filter(|l| **l != src) { + let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue }; + sqlx::query( + "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)", + ) + .bind(&pid) + .bind(lang) + .bind(t) + .bind(b) + .bind(&hash) + .bind(now) + .execute(&mut *conn) + .await?; + } + } + Ok(unit_id) +} + /// The quorums by level as `documents.quorums` holds them: `[12,12,50,50,50]`. pub fn quorums_json(quorums: &[u32; 5]) -> String { serde_json::to_string(quorums).unwrap_or_else(|_| "null".into()) diff --git a/crates/ld-server/src/seed_amend.rs b/crates/ld-server/src/seed_amend.rs index ac31e5d..0292416 100644 --- a/crates/ld-server/src/seed_amend.rs +++ b/crates/ld-server/src/seed_amend.rs @@ -25,6 +25,15 @@ //! The earlier one is then `superseded_by` it: the seed no longer gives its text, //! so it decides nothing more, without an error (the decisions already taken stay). //! +//! Lot T4 (Yvv, 2026-11-03, « Modif des textes Acte engagement »): the three seed proposals +//! I1, E1 and E2 take new texts. Such an amendment examines the seed proposal only +//! (`Version::SeedProposal`): open, put forward, never in force. Its history holds, besides +//! its text, the line that made it a seed proposal (kind `seed_proposal`, ADR 0011), which +//! changes no text: it is admitted when a seed proposal amendment applied on that very +//! version wrote it. The same day, the articles of operation leave the seed (F1, F1.1, F1.2, +//! F1.3, N1, N1.1, O1; ADR 0014): the last amendments of their texts are superseded by their +//! retirement (`retire`), and decide nothing more either. +//! //! A seeded document is never instantiated again: once in the database, it is //! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30: //! the Préambule becomes « Raison d'être »), new bases get it from the seed; @@ -64,6 +73,8 @@ pub enum Version { InForce, /// An open proposal of the seed, at zero votes, by its label. Open(&'static str), + /// Its seed proposal (ADR 0011), and nothing else: never a text in force. + SeedProposal, } /// One starting text to replace, in every document instantiated from a seed. @@ -86,8 +97,9 @@ pub struct Amendment { /// The earlier amendment of the same text whose text this one replaces, if any: /// its revision 2 is then admitted (see the module). pub follows: Option<&'static str>, - /// The later amendment whose text the seed gives instead, if any: this one then - /// decides nothing more, without an error. + /// The later amendment whose text the seed gives instead, if any — or the retirement + /// that takes the article out of the seed (`retire`): this one then decides nothing + /// more, without an error. pub superseded_by: Option<&'static str>, } @@ -101,7 +113,7 @@ pub const AMENDMENTS: &[Amendment] = &[ to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", reason: "texte de départ remplacé : I1", follows: None, - superseded_by: None, + superseded_by: Some("2026-11-03-i1-amorce"), }, // The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02). Amendment { @@ -124,7 +136,7 @@ pub const AMENDMENTS: &[Amendment] = &[ to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e", reason: "proposition de départ réécrite : N1", follows: None, - superseded_by: None, + superseded_by: Some(crate::retire::RETIREMENT), }, Amendment { id: "2026-11-02-n1-1-regles", @@ -146,7 +158,7 @@ pub const AMENDMENTS: &[Amendment] = &[ to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537", reason: "proposition de départ réécrite : O1", follows: None, - superseded_by: None, + superseded_by: Some(crate::retire::RETIREMENT), }, // The articles that still described the floor C, after the quorum by level (ADR 0013; // Yvv, 2026-11-02 and 03; lot T). F1.1 was never amended; F1.2 and N1.1 replace the @@ -197,7 +209,7 @@ pub const AMENDMENTS: &[Amendment] = &[ to: "8375b78af825e5ce5f978442519c078010ac9ad87f5cbc9358b407aa3668c5e5", reason: "proposition de départ réécrite : F1.1, la part du cran au-delà du quorum", follows: Some("2026-11-03-f1-1-quorum"), - superseded_by: None, + superseded_by: Some(crate::retire::RETIREMENT), }, Amendment { id: "2026-11-03-f1-2-phrases", @@ -242,7 +254,7 @@ pub const AMENDMENTS: &[Amendment] = &[ to: "0c3b800b0c72b1edd4b884c03c6c5479c17609dcf82a95b593d408821e28bb4e", reason: "proposition de départ réécrite : F1.2, la part du cran par défaut au-delà du quorum, à la participation habituelle", follows: Some("2026-11-03-f1-2-phrases"), - superseded_by: None, + superseded_by: Some(crate::retire::RETIREMENT), }, Amendment { id: "2026-11-03-n1-1-chiffres", @@ -253,6 +265,45 @@ pub const AMENDMENTS: &[Amendment] = &[ to: "8107c5fc746121f18c8a440e86e23310c5a59ae8e92f5a07f6b098419980263c", reason: "proposition de départ réécrite : N1.1, sans chiffre daté ; sous le quorum de l'article, autant de « pour » que si ce quorum avait voté ; au-delà, la part du cran à la participation habituelle", follows: Some("2026-11-03-n1-1-phrases"), + superseded_by: Some(crate::retire::RETIREMENT), + }, + // The three seed proposals, after Yvv's texts of 03/11 (lot T4): « Derrière une clé il y a + // un et un seul être humain vivant… » (I1, its second and third paragraphs rewritten too); + // « …qu'un seul et unique compte certifiant » (E1); « …qui s'engagent elles-mêmes, à + // scrupuleusement respecter ces trois principes fondamentaux » (E2). Each replaces the + // text of the seed proposal only, never a text in force: I1 the Raison d'être of 30/10 + // (at its revision 2 in production), E1 and E2 their text of the seed (revision 1). + Amendment { + id: "2026-11-03-i1-amorce", + doc: "acte-engagement-certification", + unit: "I1", + version: Version::SeedProposal, + from: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", + to: "32eb36b4c678c97d4597f0e4cc494a92c9956792839d6eb398a81c1dcc46320f", + reason: "Proposition seed réécrite : I1, la Raison d'être (texte d'Yvv du 03/11)", + follows: Some("2026-10-30-i1-raison-d-etre"), + superseded_by: None, + }, + Amendment { + id: "2026-11-03-e1-amorce", + doc: "acte-engagement-certification", + unit: "E1", + version: Version::SeedProposal, + from: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd", + to: "df31d0ec5f7f0f8f88d930347ce370634881ca62aed52beb3ffe780fe992b9c9", + reason: "Proposition seed réécrite : E1, l'unicité du compte certifiant (texte d'Yvv du 03/11)", + follows: None, + superseded_by: None, + }, + Amendment { + id: "2026-11-03-e2-amorce", + doc: "acte-engagement-certification", + unit: "E2", + version: Version::SeedProposal, + from: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0", + to: "a7dd469004c0e495524a9c38305177c352e36cd240f59a3b43e28d9222a6ae24", + reason: "Proposition seed réécrite : E2, la réciprocité des trois principes (texte d'Yvv du 03/11)", + follows: None, superseded_by: None, }, ]; @@ -285,6 +336,9 @@ pub struct Facts { pub signals: i64, /// Typo fixes someone else wrote on it, whatever became of them (2026-10-31). pub typos: i64, + /// The seed proposal amendments applied on it (ADR 0011): each wrote a line of its + /// history (kind `seed_proposal`) that changes no text. + pub put_forward: i64, pub fingerprint: String, } @@ -318,11 +372,17 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment, earlier: &[&str]) -> Verdic return Verdict::Refuse(match a.version { Version::InForce => "no_text_in_force", Version::Open(_) => "no_open_version", + Version::SeedProposal => "no_seed_proposal", }); }; if f.fingerprint == a.to { return Verdict::Present; } + // A line that made the version a seed proposal changes no text: set aside, as long as a + // seed proposal amendment applied on it wrote each one (ADR 0011). + let status_lines = f.revisions.iter().filter(|k| *k == "seed_proposal").count(); + let texts: Vec<&str> = f.revisions.iter().map(String::as_str).filter(|k| *k != "seed_proposal").collect(); + let status_written = i64::try_from(status_lines).unwrap_or(i64::MAX) <= f.put_forward; // The text the amendments it follows wrote: by them alone, in their order, the last // being the one it follows (a base born later holds the last ones only), one revision // each over the seed's. @@ -345,7 +405,7 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment, earlier: &[&str]) -> Verdic "votes" } else if f.ballots > 0 { "ballots" - } else if f.revisions != history || f.typos > 0 { + } else if texts != history || !status_written || f.typos > 0 { "corrected" } else if f.member_translations > 0 { "translation_edited" @@ -411,6 +471,7 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &' // 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), + Version::SeedProposal => v.seed_proposal, }) }); let src = seed.source_lang.as_str(); @@ -497,6 +558,12 @@ pub(crate) async fn facts( .bind(proposal_id) .fetch_all(&mut *conn) .await?; + let put_forward: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM seed_proposal_amendments WHERE proposal_id = ? AND outcome = 'applied'", + ) + .bind(proposal_id) + .fetch_one(&mut *conn) + .await?; Ok(Some(Facts { origin: p.origin, entered: p.entered_at.is_some(), @@ -509,6 +576,7 @@ pub(crate) async fn facts( member_translations, signals, typos, + put_forward, fingerprint: fingerprint(&p.title, &p.body), })) } @@ -556,6 +624,8 @@ async fn decide( 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")), + // Its seed proposal, open and put forward; a text in force is never examined. + Version::SeedProposal => open(None).fetch_optional(&mut *tx).await?.map(|id| (id, "open")), }; let target_id = examined.as_ref().map(|(id, _)| id.clone()); let found = match &examined { @@ -874,6 +944,57 @@ mod tests { assert_eq!(verdict(Some(&written_by(&["m5", "t", "t2"])), &third, &["m5", "t"]), Verdict::Refuse("revised")); } + /// A version the seed proposal amendment put forward (ADR 0011), as production holds I1, + /// E1 and E2 since M6: over the texts `applied` wrote, one line `seed_proposal` that + /// changes no text, at the same revision. + fn put_forward(applied: &[&str]) -> Facts { + let mut f = written_by(applied); + f.revisions.push("seed_proposal".into()); + f.put_forward = 1; + f + } + + #[test] + fn a_seed_proposal_is_rewritten_over_the_line_that_put_it_forward_and_nothing_else() { + // Lot T4 (2026-11-03): I1 follows the amendment of 30/10, E1 and E2 follow none. + let i1 = Amendment { version: Version::SeedProposal, follows: Some("r"), ..A }; + let e1 = Amendment { version: Version::SeedProposal, ..A }; + // Production since M6: the text of 30/10 (I1) or the seed's (E1), then put forward. + assert_eq!(verdict(Some(&put_forward(&["r"])), &i1, &["r"]), Verdict::Apply); + assert_eq!(verdict(Some(&put_forward(&[])), &e1, &[]), Verdict::Apply); + // A base born with the seed proposals: nothing put them forward, revision 1. + assert_eq!(verdict(Some(&intact()), &i1, &["r"]), Verdict::Apply); + assert_eq!(verdict(Some(&intact()), &e1, &[]), Verdict::Apply); + // The new text already there. + let present = Facts { fingerprint: "new".into(), ..put_forward(&[]) }; + assert_eq!(verdict(Some(&present), &e1, &[]), Verdict::Present); + // No seed proposal: a base older than M6, or a version voted into force or withdrawn. + assert_eq!(verdict(None, &e1, &[]), Verdict::Refuse("no_seed_proposal")); + // A line `seed_proposal` that no amendment wrote, or one more than they wrote. + let unwritten = Facts { put_forward: 0, ..put_forward(&[]) }; + assert_eq!(verdict(Some(&unwritten), &e1, &[]), Verdict::Refuse("corrected")); + let mut twice = put_forward(&[]); + twice.revisions.push("seed_proposal".into()); + assert_eq!(verdict(Some(&twice), &e1, &[]), Verdict::Refuse("corrected")); + // A typo once put forward: a revision no amendment wrote. + let mut typo = put_forward(&["r"]); + typo.revision = 3; + typo.revisions.push("typo".into()); + assert_eq!(verdict(Some(&typo), &i1, &["r"]), Verdict::Refuse("revised")); + // Any trace of a person keeps the text, with its reason. + for (f, why) in [ + (Facts { votes: 1, ..put_forward(&[]) }, "votes"), + (Facts { ballots: 1, ..put_forward(&[]) }, "ballots"), + (Facts { typos: 1, ..put_forward(&[]) }, "corrected"), + (Facts { member_translations: 1, ..put_forward(&[]) }, "translation_edited"), + (Facts { signals: 1, ..put_forward(&[]) }, "signalled"), + (Facts { fingerprint: "other".into(), ..put_forward(&[]) }, "text_differs"), + (Facts { origin: "member".into(), ..put_forward(&[]) }, "not_a_seed"), + ] { + assert_eq!(verdict(Some(&f), &e1, &[]), Verdict::Refuse(why), "{f:?}"); + } + } + #[test] fn the_amendments_followed_are_read_through_the_list_oldest_first() { let by_id = |id: &str| AMENDMENTS.iter().find(|a| a.id == id).expect("amendment"); @@ -895,6 +1016,10 @@ mod tests { ["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum", "2026-11-03-n1-1-phrases"] ); assert!(earlier(AMENDMENTS, by_id("2026-10-30-i1-raison-d-etre")).is_empty()); + // Lot T4: I1 follows the text of 30/10; E1 and E2, the seed's. + assert_eq!(earlier(AMENDMENTS, by_id("2026-11-03-i1-amorce")), ["2026-10-30-i1-raison-d-etre"]); + assert!(earlier(AMENDMENTS, by_id("2026-11-03-e1-amorce")).is_empty()); + assert!(earlier(AMENDMENTS, by_id("2026-11-03-e2-amorce")).is_empty()); // Each one followed wrote the text the next replaces, and is superseded by it. for a in AMENDMENTS.iter().filter(|a| a.follows.is_some()) { let e = by_id(a.follows.expect("follows")); diff --git a/crates/ld-server/src/seed_proposal_amend.rs b/crates/ld-server/src/seed_proposal_amend.rs index 96a0420..8c32e9f 100644 --- a/crates/ld-server/src/seed_proposal_amend.rs +++ b/crates/ld-server/src/seed_proposal_amend.rs @@ -59,6 +59,10 @@ pub struct SeedProposalAmendment { pub levels: &'static [u8], /// Reason given to the publication queue, and so to the texts repository. pub reason: &'static str, + /// The seed amendment that gives the seed proposal another text since (lot T4): the seed + /// no longer puts this one forward, and this amendment decides nothing more, without + /// an error (the decisions already taken stay). + pub superseded_by: Option<&'static str>, } pub const AMENDMENTS: &[SeedProposalAmendment] = &[ @@ -69,6 +73,7 @@ pub const AMENDMENTS: &[SeedProposalAmendment] = &[ text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", levels: &[3], reason: "Proposition seed : I1 sort de la vigueur", + superseded_by: Some("2026-11-03-i1-amorce"), }, SeedProposalAmendment { id: "2026-11-02-e1-proposition-seed", @@ -77,6 +82,7 @@ pub const AMENDMENTS: &[SeedProposalAmendment] = &[ text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd", levels: &[3, 4], reason: "Proposition seed : E1 sort de la vigueur", + superseded_by: Some("2026-11-03-e1-amorce"), }, SeedProposalAmendment { id: "2026-11-02-e2-proposition-seed", @@ -85,6 +91,7 @@ pub const AMENDMENTS: &[SeedProposalAmendment] = &[ text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0", levels: &[3, 4], reason: "Proposition seed : E2 sort de la vigueur", + superseded_by: Some("2026-11-03-e2-amorce"), }, ]; @@ -257,6 +264,15 @@ fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool { }); match unit { Some((u, print)) if print == a.text && u.inertia.is_none() => true, + _ if a.superseded_by.is_some() => { + tracing::debug!( + amendment = a.id, + unit = a.unit, + superseded_by = a.superseded_by, + "seed proposal amendment superseded: nothing decided" + ); + false + } _ => { tracing::error!( amendment = a.id, @@ -459,6 +475,7 @@ mod tests { text: "seed", levels: &[3, 4], reason: "Proposition seed : E1 sort de la vigueur", + superseded_by: None, }; fn intact() -> Facts { diff --git a/crates/ld-server/tests/amorce.rs b/crates/ld-server/tests/amorce.rs new file mode 100644 index 0000000..9b11be1 --- /dev/null +++ b/crates/ld-server/tests/amorce.rs @@ -0,0 +1,1175 @@ +//! Lot T4 (Yvv, 2026-11-03, « Modif des textes Acte engagement »): the texts and the +//! structure of the certification act. +//! +//! - I1 « Raison d'être », E1 « Unicité du compte » and E2 « Réciprocité », the three seed +//! proposals, take Yvv's texts; a new seed proposal, E12 « Responsabilité civique », comes +//! between E1 and E2, at the level of seed proposals. +//! - The three sections at the bottom — `formule` (F1, F1.1, F1.2, F1.3), `inertie` (N1, +//! N1.1), `ordonnancement` (O1) — and the order of each leave the document: they are +//! information on how the vote works, not texts to vote (« Les votes se font à leur place, +//! et les infos doivent trouver leur place dans l'ux », ADR 0014). Nothing is erased: an +//! existing base marks them retired (`units.retired_at`), only where no person left a trace +//! on them; an image that ignores the column shows them again as they were. +//! +//! Each change reaches production by a guarded amendment, decided once per document, played +//! here on a base made as production is after M6c and after M8. + +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 std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use tower::ServiceExt; + +const GROUPS: [&str; 2] = ["essai", "monnaie-libre"]; +/// The three seed proposals whose texts change. +const AMORCE: [&str; 3] = ["I1", "E1", "E2"]; +/// The new article, its amendment, and the retirement. +const NEW: &str = "E12"; +const ADD: &str = "2026-11-03-e12-responsabilite-civique"; +const RETIRE: &str = "2026-11-03-fonctionnement-hors-vote"; +/// What leaves the document: seven articles, the order of their three sections. +const OUT: [&str; 7] = ["F1", "F1.1", "F1.2", "F1.3", "N1", "N1.1", "O1"]; +const OUT_ORDERS: [&str; 3] = ["ordre.formule", "ordre.inertie", "ordre.ordonnancement"]; +const OUT_SECTIONS: [&str; 3] = ["formule", "inertie", "ordonnancement"]; +/// What stays. +const SECTIONS: [&str; 5] = ["introduction", "fondamental", "technique", "conclusion", "annexe"]; +const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL; + +/// Yvv's words (03/11), the French that is voted — « il y a une et une seule clé » where +/// he wrote « il n'y a une et une seule » (read as a slip, the parallel of the first half); +/// the quotation keeps the non-breaking spaces of the one it replaces. +const I1_FR: &str = "*«\u{a0}Derrière une clé il y a un et un seul être humain vivant\u{a0}; derrière un être humain, il y a une et une seule clé certifiante.\u{a0}»*\n\nC'est l'unique enjeu de la certification et de votre engagement, il donne à notre toile fiduciaire sa valeur : pouvoir nous fier à l'unicité et l'intégrité de tout geste signé par cette clé.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle, qui fonde l'usage de la première monnaie libre."; +const E1_FR: &str = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant."; +const E12_TITLE: [(&str, &str); 3] = + [("fr", "Responsabilité civique"), ("en", "Civic responsibility"), ("es", "Responsabilidad cívica")]; +const E12_FR: &str = "Je déclare sur l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la responsabilité civique, pour certifier à mon tour."; +const E2_FR: &str = "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui s'engagent elles-mêmes, à scrupuleusement respecter ces trois principes fondamentaux."; + +fn new_text(code: &str) -> &'static str { + match code { + "I1" => I1_FR, + "E1" => E1_FR, + "E2" => E2_FR, + _ => E12_FR, + } +} + +// --------------------------------------------------------------------------- +// The client +// --------------------------------------------------------------------------- + +struct Client { + router: axum::Router, + cookie: Option, +} + +impl Client { + fn of(state: &AppState) -> Self { + Self { router: app(state.clone()), cookie: None } + } + + async fn as_(state: &AppState, name: &str, admin: bool) -> Self { + let mut c = Self::of(state); + let (s, v) = c.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + c + } + + 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 ok(&mut self, method: &str, uri: &str, body: Option) -> Value { + let (s, v) = self.call(method, uri, body).await; + assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}"); + v + } + + async fn get(&mut self, uri: &str) -> Value { + self.ok("GET", uri, None).await + } + + 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) -> Option<&'a Value> { + doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code) +} + +fn codes(doc: &Value) -> Vec { + doc["units"].as_array().expect("units").iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect() +} + +fn order_sections(doc: &Value) -> Vec { + doc["modules"]["orders"] + .as_array() + .expect("orders") + .iter() + .map(|o| o["section"].as_str().expect("section").to_owned()) + .collect() +} + +/// The codes of a section in the order it reads in now. +fn read_order(doc: &Value, section: &str) -> Vec { + let o = doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section); + let members = o.expect("order")["members"].as_array().expect("members").clone(); + members + .iter() + .map(|id| { + doc["units"] + .as_array() + .expect("units") + .iter() + .find(|u| u["id"] == *id) + .and_then(|u| u["code"].as_str()) + .expect("member") + .to_owned() + }) + .collect() +} + +// --------------------------------------------------------------------------- +// The seeds, and a base made as production is +// --------------------------------------------------------------------------- + +fn read(path: PathBuf) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") +} + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +/// The seed of today (lot T4). +fn today_seed() -> Value { + read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) +} + +/// The seed production runs since M8 (de1b7eb), the last with the articles of operation. +fn m8_seed() -> Value { + read(fixtures().join("seed-m8/acte-engagement-certification.json")) +} + +/// The seed production ran from M5 (3f10a0e, 02/11) to M6. +fn production_seed() -> Value { + read(fixtures().join("seed-production-2026-11-02/acte-engagement-certification.json")) +} + +fn seed_unit<'a>(seed: &'a Value, code: &str) -> &'a Value { + seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +/// A seed with the first version of `codes` taken from `from` (a seed, or a map code → text). +fn with_texts(base: &Value, from: &Value, codes: &[&str]) -> Value { + let mut seed = base.clone(); + for u in seed["units"].as_array_mut().expect("units") { + let code = u["code"].as_str().expect("code").to_owned(); + if codes.contains(&code.as_str()) { + let v = from["units"].get(&code).unwrap_or_else(|| &seed_unit(from, &code)["versions"][0]); + u["versions"][0]["title"] = v["title"].clone(); + u["versions"][0]["body"] = v["body"].clone(); + } + } + seed +} + +const PREAMBULE: [(&str, &str, &str); 3] = [ + ( + "fr", + "Préambule", + "Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.", + ), + ( + "en", + "Preamble", + "This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.", + ), + ( + "es", + "Preámbulo", + "La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado.", + ), +]; + +const RULES: [&str; 3] = ["F1.1", "F1.2", "N1.1"]; + +fn seed_dir(tag: &str, seed: &Value) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ld-amorce-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); + dir +} + +fn config(dir: PathBuf) -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = dir; + cfg +} + +async fn start(db: &sqlx::SqlitePool, cfg: Config) -> AppState { + let state = AppState::new(cfg, db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + state +} + +/// Production's history: born on 21/10 (the Préambule, the rule articles of before), +/// started by M5 (its seed: I1 becomes the Raison d'être, the rule articles rewritten), by +/// M6 (the seed proposals, the quorums by level), by M6b (lot T) and by M6c (lot T2). +async fn production_after_m6c(tag: &str) -> AppState { + let prod = production_seed(); + let mut born = with_texts(&prod, &read(fixtures().join("seed-regles-avant.json")), &["F1.2", "N1", "N1.1", "O1"]); + let i1 = born["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); + } + let db = open_db("sqlite::memory:").await.expect("db"); + let first = AppState::new(config(seed_dir(&format!("{tag}-born"), &born)), db.clone()).await.expect("state"); + seed::documents(&first).await.expect("born"); + start(&db, config(seed_dir(&format!("{tag}-m5"), &prod))).await; + let m8 = m8_seed(); + start(&db, config(seed_dir(&format!("{tag}-m6"), &with_texts(&m8, &prod, &RULES)))).await; + let m6b = read(fixtures().join("seed-quorum-m6b.json")); + start(&db, config(seed_dir(&format!("{tag}-m6b"), &with_texts(&m8, &m6b, &RULES)))).await; + let t2 = read(fixtures().join("seed-quorum-t2.json")); + start(&db, config(seed_dir(&format!("{tag}-m6c"), &with_texts(&m8, &t2, &RULES)))).await +} + +/// … then by M8 (lots T3, T3b, T3c: F1.2 and N1.1 at their revision 5). +async fn production_after_m8(tag: &str) -> AppState { + let base = production_after_m6c(tag).await; + start(&base.db, config(seed_dir(&format!("{tag}-m8"), &m8_seed()))).await +} + +/// The start of lot T4: the seed of today. +async fn t4(base: &AppState) -> AppState { + start(&base.db, Config::for_tests()).await +} + +// --------------------------------------------------------------------------- +// What the database holds +// --------------------------------------------------------------------------- + +async fn count(state: &AppState, sql: &str) -> i64 { + sqlx::query_scalar(sql).fetch_one(&state.db).await.expect(sql) +} + +async fn doc_id(state: &AppState, group: &str) -> String { + sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = ?") + .bind(group) + .fetch_one(&state.db) + .await + .expect("doc") +} + +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") +} + +/// The first version of the seed of an article: (id, status, revision, title, body, seed_featured). +async fn seed_version(state: &AppState, group: &str, code: &str) -> (String, String, i64, String, String, bool) { + sqlx::query_as( + "SELECT p.id, p.status, p.revision, p.title, p.body, p.seed_featured 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' ORDER BY p.rowid LIMIT 1", + ) + .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") +} + +async fn translation(state: &AppState, id: &str, lang: &str) -> (String, String, String, String, Option) { + sqlx::query_as( + "SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?", + ) + .bind(id) + .bind(lang) + .fetch_one(&state.db) + .await + .expect("translation") +} + +/// Every table, and its number of rows. +async fn tables(state: &AppState) -> BTreeMap { + let names: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .fetch_all(&state.db) + .await + .expect("tables"); + let mut out = BTreeMap::new(); + for t in names { + out.insert(t.clone(), count(state, &format!("SELECT COUNT(*) FROM \"{t}\"")).await); + } + out +} + +/// Every row of every table, by rowid: what a deletion would take away. +async fn rowids(state: &AppState) -> BTreeMap> { + let mut out = BTreeMap::new(); + for t in tables(state).await.into_keys() { + let ids: Vec = + sqlx::query_scalar(&format!("SELECT rowid FROM \"{t}\"")).fetch_all(&state.db).await.expect("rowids"); + out.insert(t, ids.into_iter().collect()); + } + out +} + +/// Every row of a table as text, all its columns but `skip`, through `filter` (a WHERE). +async fn rows(state: &AppState, table: &str, skip: &[&str], filter: &str) -> Vec { + let columns: Vec = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")) + .fetch_all(&state.db) + .await + .expect("columns"); + let cols: Vec = + columns.iter().filter(|c| !skip.contains(&c.as_str())).map(|c| format!("quote(\"{c}\")")).collect(); + let sql = format!("SELECT {} FROM \"{table}\" WHERE {filter} ORDER BY rowid", cols.join(" || '|' || ")); + sqlx::query_scalar(&sql).fetch_all(&state.db).await.expect("rows") +} + +/// The units that leave, by section (their articles and their orders). +const OF_THE_OUT: &str = "section IN ('formule', 'inertie', 'ordonnancement')"; +const OF_THEIR_VERSIONS: &str = "proposal_id IN (SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.section IN ('formule', 'inertie', 'ordonnancement'))"; + +/// What the articles of operation hold: units (without the columns of the retirement), +/// their versions, revisions, translations. +async fn the_rules_of_operation(state: &AppState) -> [Vec; 4] { + [ + rows(state, "units", &["retired_at", "retired_reason"], OF_THE_OUT).await, + rows( + state, + "proposals", + &[], + "unit_id IN (SELECT id FROM units WHERE section IN ('formule', 'inertie', 'ordonnancement'))", + ) + .await, + rows(state, "revisions", &[], OF_THEIR_VERSIONS).await, + rows(state, "translations", &[], OF_THEIR_VERSIONS).await, + ] +} + +/// Each decision of the retirement: (group, outcome, reason, unit). +async fn retirements(state: &AppState) -> Vec<(String, String, Option, Option)> { + sqlx::query_as( + "SELECT d.group_slug, r.outcome, r.reason, r.unit_code FROM retirement_amendments r + JOIN documents d ON d.id = r.doc_id WHERE r.amendment_id = ? ORDER BY d.group_slug", + ) + .bind(RETIRE) + .fetch_all(&state.db) + .await + .expect("retirement_amendments") +} + +/// Each decision of the new article: (group, outcome, reason, placement, placement reason). +async fn additions(state: &AppState) -> Vec<(String, String, Option, Option, Option)> { + sqlx::query_as( + "SELECT d.group_slug, a.outcome, a.reason, a.placement, a.placement_reason FROM article_amendments a + JOIN documents d ON d.id = a.doc_id WHERE a.amendment_id = ? ORDER BY d.group_slug", + ) + .bind(ADD) + .fetch_all(&state.db) + .await + .expect("article_amendments") +} + +/// Each decision of the new texts: (group, unit, outcome, reason). +async fn rewrites(state: &AppState) -> Vec<(String, String, String, Option)> { + sqlx::query_as( + "SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s + JOIN documents d ON d.id = s.doc_id WHERE s.amendment_id LIKE '2026-11-03-%-amorce' + ORDER BY d.group_slug, s.unit_code", + ) + .fetch_all(&state.db) + .await + .expect("seed_amendments") +} + +async fn retired(state: &AppState, group: &str) -> Vec { + sqlx::query_scalar( + "SELECT u.code FROM units u JOIN documents d ON d.id = u.doc_id + WHERE d.group_slug = ? AND u.retired_at IS NOT NULL ORDER BY u.code", + ) + .bind(group) + .fetch_all(&state.db) + .await + .expect("retired") +} + +fn all_out() -> Vec { + let mut v: Vec = OUT.iter().chain(OUT_ORDERS.iter()).map(|c| (*c).to_owned()).collect(); + v.sort(); + v +} + +// --------------------------------------------------------------------------- +// A new base +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_new_base_is_born_with_the_texts_of_t4_and_without_the_rules_of_operation() { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = start(&db, Config::for_tests()).await; + let today = today_seed(); + let mut c = Client::as_(&state, "Chiara", false).await; + for group in GROUPS { + let doc = c.doc(group).await; + let codes = codes(&doc); + assert_eq!(codes.len(), 27, "{group}: {codes:?}"); + for code in OUT { + assert!(!codes.contains(&code.to_owned()), "{group}: {code} is no article to vote"); + } + assert_eq!(order_sections(&doc), SECTIONS, "{group}"); + assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", NEW, "E2"], "{group}"); + for code in [AMORCE.as_slice(), &[NEW]].concat() { + let u = unit(&doc, code).expect(code); + let p = &u["proposals"][0]; + assert_eq!(p["body"], new_text(code), "{group} {code}"); + assert_eq!( + (p["status"].as_str(), p["seedProposal"].as_bool()), + (Some("open"), Some(true)), + "{group} {code}" + ); + assert_eq!(p["revision"], 1, "{group} {code}"); + assert_eq!(u["inertia"], LOW, "{group} {code}"); + assert!(u["inForce"].is_null(), "{group} {code}"); + let v = &seed_unit(&today, code)["versions"][0]; + for lang in ["en", "es"] { + assert_eq!(p["translations"][lang]["title"], v["title"][lang], "{group} {code} {lang}"); + assert_eq!(p["translations"][lang]["body"], v["body"][lang], "{group} {code} {lang}"); + assert_eq!(p["translations"][lang]["stale"], false, "{group} {code} {lang}"); + } + } + let e12 = &unit(&doc, NEW).expect("E12")["proposals"][0]; + for (lang, title) in E12_TITLE { + let shown = if lang == "fr" { &e12["title"] } else { &e12["translations"][lang]["title"] }; + assert_eq!(shown, title, "{group} {lang}"); + } + // The counts: no article in force; 29 open versions (27 articles, E3 and E7 two each); + // 33 modules to vote (27 articles, the title, the order of 5 sections). + 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(29)), "{group}"); + let d = c.get(&format!("/api/groups/{group}/decisions")).await; + assert_eq!(d["docs"][0]["toVote"], 33, "{group}"); + } + assert_eq!(count(&state, &format!("SELECT COUNT(*) FROM units WHERE {OF_THE_OUT}")).await, 0, "never born"); + // Born with all of it: each amendment says so, once per document. + let present = |g: &str| (g.to_owned(), "present".to_owned(), None, None); + assert_eq!(retirements(&state).await, [present("essai"), present("monnaie-libre")]); + let added = additions(&state).await; + assert_eq!( + added.iter().map(|a| (a.0.as_str(), a.1.as_str())).collect::>(), + [("essai", "present"), ("monnaie-libre", "present")] + ); + let rw = rewrites(&state).await; + assert_eq!(rw.len(), 6, "{rw:?}"); + assert!(rw.iter().all(|(_, _, o, _)| o == "present"), "{rw:?}"); +} + +// --------------------------------------------------------------------------- +// Production, after M6c and after M8 +// --------------------------------------------------------------------------- + +/// What lot T4 does to a base made as production is, and the proof that nothing is erased. +/// `rules_revision`: F1.2 and N1.1 at 4 (after M6c) or 5 (after M8). +async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) { + // The base as production holds it. + for group in GROUPS { + for code in AMORCE { + let (id, status, revision, _, _, featured) = seed_version(&base, group, code).await; + assert_eq!((status.as_str(), featured), ("open", true), "{group} {code}"); + let expected: &[&str] = + if code == "I1" { &["initial", "amendment", "seed_proposal"] } else { &["initial", "seed_proposal"] }; + assert_eq!(kinds(&base, &id).await, expected, "{group} {code}"); + assert_eq!(revision, if code == "I1" { 2 } else { 1 }, "{group} {code}"); + } + for code in ["F1.2", "N1.1"] { + assert_eq!(seed_version(&base, group, code).await.2, rules_revision, "{group} {code}"); + } + assert_eq!(seed_version(&base, group, "F1.1").await.2, 3, "{group} F1.1"); + } + let before = tables(&base).await; + let before_rows = rowids(&base).await; + let kept = the_rules_of_operation(&base).await; + let untouched_versions = + rows(&base, "proposals", &[], "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2'))") + .await; + + let state = t4(&base).await; + // Taken at once, before anyone signs in (a session writes its identity). + let after = tables(&state).await; + let after_rows = rowids(&state).await; + + // I1, E1 and E2: Yvv's texts, written over the seed proposal, one revision more. + let today = today_seed(); + for group in GROUPS { + for code in AMORCE { + let (id, status, revision, title, body, featured) = seed_version(&state, group, code).await; + assert_eq!(body, new_text(code), "{group} {code}"); + assert_eq!((status.as_str(), featured), ("open", true), "{group} {code}"); + assert_eq!(revision, if code == "I1" { 3 } else { 2 }, "{group} {code}"); + let expected: &[&str] = if code == "I1" { + &["initial", "amendment", "seed_proposal", "amendment"] + } else { + &["initial", "seed_proposal", "amendment"] + }; + assert_eq!(kinds(&state, &id).await, expected, "{group} {code}"); + let v = &seed_unit(&today, code)["versions"][0]; + for lang in ["en", "es"] { + let (t, b, hash, origin, by) = translation(&state, &id, lang).await; + assert_eq!( + (json!(t), json!(b)), + (v["title"][lang].clone(), v["body"][lang].clone()), + "{group} {code} {lang}" + ); + assert_eq!(hash, ld_server::seed_amend::fingerprint(&title, &body), "{group} {code} {lang}"); + assert_eq!((origin.as_str(), by), ("seed", None)); + } + } + } + let rw = rewrites(&state).await; + assert_eq!(rw.len(), 6, "{rw:?}"); + assert!(rw.iter().all(|(_, _, o, r)| o == "applied" && r.is_none()), "{rw:?}"); + + // E12: a seed proposal at the level of the others, between E1 and E2. + let between = |g: &str| (g.to_owned(), "applied".to_owned(), None, Some("between".to_owned()), None); + assert_eq!(additions(&state).await, [between("essai"), between("monnaie-libre")]); + for group in GROUPS { + let (id, status, revision, title, body, featured) = seed_version(&state, group, NEW).await; + assert_eq!((title.as_str(), body.as_str()), ("Responsabilité civique", E12_FR), "{group}"); + assert_eq!((status.as_str(), revision, featured), ("open", 1, true), "{group}"); + assert_eq!(kinds(&state, &id).await, ["initial"], "{group}"); + let (section, position, inertia, in_force): (String, f64, String, Option) = + sqlx::query_as("SELECT section, position, inertia, in_force_id FROM units WHERE id = ?") + .bind(unit_id(&state, group, NEW).await) + .fetch_one(&state.db) + .await + .expect("unit"); + assert_eq!( + (section.as_str(), position, inertia, in_force), + ("fondamental", 4.5, LOW.to_string(), None), + "{group}" + ); + for (lang, t) in E12_TITLE.iter().skip(1) { + let (title_tr, _, hash, origin, _) = translation(&state, &id, lang).await; + assert_eq!((title_tr.as_str(), origin.as_str()), (*t, "seed"), "{group} {lang}"); + assert_eq!(hash, ld_server::seed_amend::fingerprint(&title, &body), "{group} {lang}"); + } + } + + // The rules of operation: retired in both groups, nothing else written on them. + let mark = |g: &str| (g.to_owned(), "applied".to_owned(), None, None); + assert_eq!(retirements(&state).await, [mark("essai"), mark("monnaie-libre")]); + for group in GROUPS { + assert_eq!(retired(&state, group).await, all_out(), "{group}"); + } + let reasons: Vec = + sqlx::query_scalar(&format!("SELECT DISTINCT retired_reason FROM units WHERE {OF_THE_OUT}")) + .fetch_all(&state.db) + .await + .expect("reasons"); + assert_eq!(reasons, [RETIRE]); + assert_eq!(the_rules_of_operation(&state).await, kept, "their rows, as they were"); + + // Through the API: neither listed nor counted. + let mut c = Client::as_(&state, "Chiara", false).await; + for group in GROUPS { + let doc = c.doc(group).await; + let codes = codes(&doc); + assert_eq!(codes.len(), 27, "{group}: {codes:?}"); + assert!(OUT.iter().all(|o| !codes.contains(&(*o).to_owned())), "{group}: {codes:?}"); + assert_eq!(order_sections(&doc), SECTIONS, "{group}"); + assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", NEW, "E2"], "{group}"); + let all = c.get(&format!("/api/docs/{}/preview?mode=all", doc["doc"]["id"].as_str().expect("id"))).await; + assert_eq!(all["units"].as_array().expect("units").len(), 27, "{group}"); + 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(29)), "{group}"); + let d = c.get(&format!("/api/groups/{group}/decisions")).await; + assert_eq!(d["docs"][0]["toVote"], 33, "{group}"); + } + + // Nothing erased, anywhere, the publication queue included: every row of before is + // there, and each table holds exactly what lot T4 adds. + for (t, ids) in &before_rows { + let missing: Vec<&i64> = ids.difference(&after_rows[t]).collect(); + assert!(missing.is_empty(), "{t}: rows erased {missing:?}"); + } + let added: BTreeMap<&str, i64> = [ + // E12, in each document. + ("units", 2), + ("proposals", 2), + // E12's first revision, and the new texts of I1, E1, E2, in each document. + ("revisions", 8), + // E12 in en and es; I1, E1, E2 replace their own. + ("translations", 4), + ("seed_amendments", 6), + ("article_amendments", 2), + ("retirement_amendments", 2), + // Monnaie libre: I1, E1, E2, E12, the retirement. + ("outbox", 5), + ] + .into_iter() + .collect(); + for (t, n) in &after { + let was = before.get(t).copied().unwrap_or(0); + assert_eq!(n - was, added.get(t.as_str()).copied().unwrap_or(0), "{t}: {was} → {n}"); + } + eprintln!( + "lot T4, rows by table (before → after): {}", + after + .iter() + .map(|(t, n)| format!("{t} {} → {n}", before.get(t).copied().unwrap_or(0))) + .collect::>() + .join(", ") + ); + // Every other version untouched. + let others = rows( + &state, + "proposals", + &[], + "unit_id NOT IN (SELECT u.id FROM units u WHERE u.code IN ('I1', 'E1', 'E2', 'E12'))", + ) + .await; + assert_eq!(others, untouched_versions); + // The queue of Monnaie libre: a row for each change, waiting for the first text. + 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 ORDER BY o.id DESC LIMIT 5", + ) + .fetch_all(&state.db) + .await + .expect("outbox"); + assert!(queued.iter().all(|(g, _)| g == "monnaie-libre"), "{queued:?}"); + assert!(queued.iter().any(|(_, r)| r.contains("formule")), "{queued:?}"); + assert!(queued.iter().any(|(_, r)| r.contains(NEW)), "{queued:?}"); + + // Decided once: a second start replays nothing (the sessions opened above, and what they + // wrote of their identities and groups, aside). + let settled = tables(&state).await; + let again = t4(&state).await; + assert_eq!(tables(&again).await, settled); + let unchanged = |t: &BTreeMap| { + t.iter() + .filter(|(k, _)| !matches!(k.as_str(), "identities" | "sessions" | "memberships")) + .map(|(k, n)| (k.clone(), *n)) + .collect::>() + }; + assert_eq!(unchanged(&settled), unchanged(&after)); + assert_eq!(retirements(&again).await, [mark("essai"), mark("monnaie-libre")]); + assert_eq!(rewrites(&again).await, rw); + assert_eq!(additions(&again).await, [between("essai"), between("monnaie-libre")]); +} + +#[tokio::test] +async fn production_after_m8_gets_lot_t4_once_and_nothing_is_erased() { + production_gets_lot_t4_once(production_after_m8("m8").await, 5).await; +} + +#[tokio::test] +async fn production_after_m6c_gets_lot_t4_once_and_nothing_is_erased() { + production_gets_lot_t4_once(production_after_m6c("m6c").await, 4).await; +} + +// --------------------------------------------------------------------------- +// After the retirement, the API refuses everything on them +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn nothing_is_voted_proposed_corrected_or_set_on_the_rules_of_operation() { + let state = t4(&production_after_m8("refusals").await).await; + let mut yvv = Client::as_(&state, "Yvv", true).await; + let mut chiara = Client::as_(&state, "Chiara", false).await; + for group in GROUPS { + let doc = doc_id(&state, group).await; + let unit = unit_id(&state, group, "F1.2").await; + let (f12, _, _, title, body, _) = seed_version(&state, group, "F1.2").await; + let (e3, ..) = seed_version(&state, group, "E3").await; + let order = unit_id(&state, group, "ordre.formule").await; + let mut ids = Vec::new(); + for code in ["F1.3", "F1.2", "F1.1", "F1"] { + ids.push(unit_id(&state, group, code).await); + } + let cases: Vec<(&str, String, Option, StatusCode)> = vec![ + ("POST", format!("/api/proposals/{f12}/vote"), Some(json!({ "choice": "for" })), StatusCode::NOT_FOUND), + ( + "POST", + format!("/api/docs/{doc}/votes"), + Some(json!({ "lines": [{ "proposalId": f12, "choice": "for" }] })), + StatusCode::BAD_REQUEST, + ), + ("GET", format!("/api/proposals/{f12}"), None, StatusCode::NOT_FOUND), + ( + "PATCH", + format!("/api/proposals/{f12}"), + Some(json!({ "title": title, "body": format!("{body}.") })), + StatusCode::NOT_FOUND, + ), + ( + "PUT", + format!("/api/proposals/{f12}/translations/en"), + Some(json!({ "title": "Default settings", "body": "A member's translation." })), + StatusCode::NOT_FOUND, + ), + ( + "POST", + format!("/api/proposals/{f12}/objections"), + Some(json!({ "otherId": e3, "kind": "redit", "reason": "Ces deux textes disent la même chose." })), + StatusCode::NOT_FOUND, + ), + ( + "POST", + format!("/api/proposals/{e3}/objections"), + Some(json!({ "otherId": f12, "kind": "redit", "reason": "Ces deux textes disent la même chose." })), + StatusCode::BAD_REQUEST, + ), + ( + "POST", + format!("/api/docs/{doc}/proposals"), + Some(json!({ "unitId": unit, "title": "Paramètres", "body": "Un autre texte.", "submit": true })), + StatusCode::NOT_FOUND, + ), + ( + "POST", + format!("/api/docs/{doc}/proposals"), + Some(json!({ "section": "formule", "title": "Formule", "body": "Un nouvel article.", "submit": true })), + StatusCode::BAD_REQUEST, + ), + ("POST", format!("/api/units/{unit}/inertia"), Some(json!({ "target": 4 })), StatusCode::NOT_FOUND), + ("PUT", format!("/api/units/{unit}/quorum"), Some(json!({ "quorum": 2 })), StatusCode::NOT_FOUND), + ("POST", format!("/api/units/{order}/order"), Some(json!({ "order": ids })), StatusCode::NOT_FOUND), + ]; + for (method, uri, body, expected) in cases { + for who in [&mut yvv, &mut chiara] { + let (s, v) = who.call(method, &uri, body.clone()).await; + assert_eq!(s, expected, "{group} {method} {uri}: {v}"); + } + } + } + // Nothing was written by any of them. + for (table, sql) in [ + ("votes", "SELECT COUNT(*) FROM votes"), + ("ballot_lines", "SELECT COUNT(*) FROM ballot_lines"), + ("typos", "SELECT COUNT(*) FROM typos"), + ("signals", "SELECT COUNT(*) FROM signals"), + ("quorum_changes", "SELECT COUNT(*) FROM quorum_changes"), + ("member versions", "SELECT COUNT(*) FROM proposals WHERE origin <> 'seed'"), + ("member translations", "SELECT COUNT(*) FROM translations WHERE origin <> 'seed'"), + ] { + assert_eq!(count(&state, sql).await, 0, "{table}"); + } +} + +// --------------------------------------------------------------------------- +// Any trace of a person keeps them in the document +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +enum Trace { + /// A vote of the server (the test group). + Vote, + /// A simulated vote (Monnaie libre): a vote, and the line of its ballot. + Simulated, + /// A simulated vote withdrawn: the line of its ballot stays. + Withdrawn, + /// A typo fix someone else wrote, waiting for an administrator. + Typo, + /// A member's translation. + Translation, + /// An objection naming it. + Objection, + /// A member's article in one of the sections. + Article, + /// A member's change of inertia. + Inertia, + /// A member's order of one of the sections. + Order, + /// A quorum set by an administrator. + Quorum, +} + +impl Trace { + fn group(self) -> &'static str { + match self { + Trace::Simulated | Trace::Withdrawn => "monnaie-libre", + _ => "essai", + } + } + + /// The reason and the unit the refusal names. + fn expected(self) -> (&'static str, &'static str) { + match self { + Trace::Vote => ("votes", "N1"), + Trace::Simulated => ("votes", "F1.2"), + Trace::Withdrawn => ("ballots", "N1.1"), + Trace::Typo => ("corrected", "F1.1"), + Trace::Translation => ("translation_edited", "O1"), + Trace::Objection => ("signalled", "F1.3"), + Trace::Article => ("member_proposal", "N3"), + Trace::Inertia => ("member_proposal", "F1"), + Trace::Order => ("member_proposal", "ordre.formule"), + Trace::Quorum => ("quorum_set", "N1.1"), + } + } +} + +async fn leave(state: &AppState, trace: Trace) { + let group = trace.group(); + let mut vera = Client::as_(state, "Vera", false).await; + let mut yvv = Client::as_(state, "Yvv", true).await; + let doc = doc_id(state, group).await; + let id = |code: &'static str| async move { seed_version(state, group, code).await }; + match trace { + Trace::Vote => { + let (p, ..) = id("N1").await; + vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "against" }))).await; + } + Trace::Simulated => { + let (p, ..) = id("F1.2").await; + let v = vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!(v["simulated"], true); + } + Trace::Withdrawn => { + let (p, ..) = id("N1.1").await; + vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + vera.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": null }))).await; + let votes = count(state, &format!("SELECT COUNT(*) FROM votes WHERE proposal_id = '{p}'")).await; + assert_eq!(votes, 0, "withdrawn"); + } + Trace::Typo => { + let (p, _, _, title, body, _) = id("F1.1").await; + let fixed = body.replacen('\'', "\u{2019}", 1); + let v = + vera.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": title, "body": fixed }))).await; + assert_eq!(v["edit"], "typo_pending", "{v}"); + } + Trace::Translation => { + let (p, ..) = id("O1").await; + vera.ok( + "PUT", + &format!("/api/proposals/{p}/translations/en"), + Some(json!({ "title": "Ordering", "body": "A member's translation." })), + ) + .await; + } + Trace::Objection => { + let (p, ..) = id("F1.3").await; + let (e3, ..) = id("E3").await; + vera.ok( + "POST", + &format!("/api/proposals/{p}/objections"), + Some(json!({ "otherId": e3, "kind": "redit", "reason": "Ces deux textes disent la même chose." })), + ) + .await; + } + Trace::Article => { + vera.ok( + "POST", + &format!("/api/docs/{doc}/proposals"), + Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre est à 4.", "submit": true })), + ) + .await; + } + Trace::Inertia => { + let u = unit_id(state, group, "F1").await; + vera.ok("POST", &format!("/api/units/{u}/inertia"), Some(json!({ "target": 4 }))).await; + } + Trace::Order => { + let u = unit_id(state, group, "ordre.formule").await; + let mut ids = Vec::new(); + for code in ["F1.3", "F1.2", "F1.1", "F1"] { + ids.push(unit_id(state, group, code).await); + } + vera.ok("POST", &format!("/api/units/{u}/order"), Some(json!({ "order": ids }))).await; + } + Trace::Quorum => { + let u = unit_id(state, group, "N1.1").await; + yvv.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 1 }))).await; + } + } +} + +#[tokio::test] +async fn any_trace_of_a_person_keeps_the_rules_of_operation_in_the_document() { + for trace in [ + Trace::Vote, + Trace::Simulated, + Trace::Withdrawn, + Trace::Typo, + Trace::Translation, + Trace::Objection, + Trace::Article, + Trace::Inertia, + Trace::Order, + Trace::Quorum, + ] { + let base = production_after_m8(&format!("trace-{trace:?}")).await; + leave(&base, trace).await; + let kept = the_rules_of_operation(&base).await; + let state = t4(&base).await; + let (reason, code) = trace.expected(); + let touched = trace.group(); + let decided: Vec<(String, String, Option, Option)> = GROUPS + .iter() + .map(|g| { + if *g == touched { + ((*g).to_owned(), "refused".to_owned(), Some(reason.to_owned()), Some(code.to_owned())) + } else { + ((*g).to_owned(), "applied".to_owned(), None, None) + } + }) + .collect(); + assert_eq!(retirements(&state).await, decided, "{trace:?}"); + // Refused: nothing moves there; they stay listed and votable. + assert!(retired(&state, touched).await.is_empty(), "{trace:?}"); + assert_eq!(the_rules_of_operation(&state).await, kept, "{trace:?}"); + let mut c = Client::as_(&state, "Chiara", false).await; + let doc = c.doc(touched).await; + let listed = codes(&doc); + assert!(OUT.iter().all(|o| listed.contains(&(*o).to_owned())), "{trace:?}: {listed:?}"); + assert!(order_sections(&doc).contains(&"formule".to_owned()), "{trace:?}"); + let other = GROUPS.iter().find(|g| **g != touched).expect("other"); + assert_eq!(retired(&state, other).await, all_out(), "{trace:?}"); + // Decided once: still refused at the next start. + let again = t4(&state).await; + assert_eq!(retirements(&again).await, decided, "{trace:?}"); + } +} + +// --------------------------------------------------------------------------- +// The texts of I1, E1, E2 and the place of E12, guarded +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_vote_on_a_seed_proposal_keeps_its_text_and_a_voted_order_puts_e12_at_the_end() { + let base = production_after_m8("guards").await; + let mut vera = Client::as_(&base, "Vera", false).await; + // In the test group: a vote on E1, and a vote on the order of « fondamental » in force. + let (e1, _, _, _, e1_body, _) = seed_version(&base, "essai", "E1").await; + vera.ok("POST", &format!("/api/proposals/{e1}/vote"), Some(json!({ "choice": "against" }))).await; + let order: String = sqlx::query_scalar( + "SELECT u.in_force_id FROM units u JOIN documents d ON d.id = u.doc_id + WHERE d.group_slug = 'essai' AND u.code = 'ordre.fondamental'", + ) + .fetch_one(&base.db) + .await + .expect("order in force"); + vera.ok("POST", &format!("/api/proposals/{order}/vote"), Some(json!({ "choice": "for" }))).await; + + let state = t4(&base).await; + let rw = rewrites(&state).await; + let expected: Vec<(String, String, String, Option)> = [ + ("essai", "E1", "refused", Some("votes")), + ("essai", "E2", "applied", None), + ("essai", "I1", "applied", None), + ("monnaie-libre", "E1", "applied", None), + ("monnaie-libre", "E2", "applied", None), + ("monnaie-libre", "I1", "applied", None), + ] + .iter() + .map(|(g, u, o, r)| ((*g).to_owned(), (*u).to_owned(), (*o).to_owned(), r.map(str::to_owned))) + .collect(); + assert_eq!(rw, expected); + let (_, _, revision, _, body, _) = seed_version(&state, "essai", "E1").await; + assert_eq!((revision, body), (1, e1_body), "the text voted on stays"); + assert_eq!(seed_version(&state, "monnaie-libre", "E1").await.4, E1_FR); + // E12: at the end of « fondamental » where its order was voted on, between E1 and E2 elsewhere. + let added = additions(&state).await; + assert_eq!( + added, + [ + ("essai".to_owned(), "applied".to_owned(), None, Some("end".to_owned()), Some("order_voted".to_owned())), + ("monnaie-libre".to_owned(), "applied".to_owned(), None, Some("between".to_owned()), None), + ] + ); + let mut c = Client::as_(&state, "Chiara", false).await; + assert_eq!(read_order(&c.doc("essai").await, "fondamental"), ["T1", "E1", "E2", NEW]); + assert_eq!(read_order(&c.doc("monnaie-libre").await, "fondamental"), ["T1", "E1", NEW, "E2"]); + // The retirement does not look at « fondamental »: applied in both. + let mark = |g: &str| (g.to_owned(), "applied".to_owned(), None, None); + assert_eq!(retirements(&state).await, [mark("essai"), mark("monnaie-libre")]); +} + +// --------------------------------------------------------------------------- +// Rollback: an image that ignores the column shows them as they were +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn an_image_that_ignores_the_column_shows_the_rules_of_operation_as_they_were() { + let base = production_after_m8("rollback").await; + let mut c = Client::as_(&base, "Chiara", false).await; + let view = |doc: &Value| -> Vec { + let units = + doc["units"].as_array().expect("units").iter().filter(|u| OUT.contains(&u["code"].as_str().unwrap_or(""))); + let orders = doc["modules"]["orders"] + .as_array() + .expect("orders") + .iter() + .filter(|o| OUT_SECTIONS.contains(&o["section"].as_str().unwrap_or(""))); + units.chain(orders).cloned().collect() + }; + let mut before = Vec::new(); + for group in GROUPS { + let shown = view(&c.doc(group).await); + assert_eq!(shown.len(), 10, "{group}"); + before.push(shown); + } + let state = t4(&base).await; + for group in GROUPS { + assert!(view(&c.doc(group).await).is_empty(), "{group}: retired"); + } + // The image of before (de1b7eb) reads `units` without the column: as here with it empty. + sqlx::query("UPDATE units SET retired_at = NULL") + .execute(&state.db) + .await + .expect("as the image of before reads it"); + let mut c = Client::as_(&state, "Chiara", false).await; + for (i, group) in GROUPS.iter().enumerate() { + assert_eq!(view(&c.doc(group).await), before[i], "{group}: the same units, versions, texts, translations"); + } +} + +// --------------------------------------------------------------------------- +// Publication: never in the texts published +// --------------------------------------------------------------------------- + +struct Remote { + dir: PathBuf, + bare: PathBuf, +} + +impl Remote { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("ld-amorce-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) -> 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: false, + simulated: false, + }); + cfg + } + + fn git(&self, args: &[&str]) -> Option { + let out = std::process::Command::new("git").arg("-C").arg(&self.bare).args(args).output().expect("git"); + out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8")) + } + + fn show(&self, file: &str) -> Option { + self.git(&["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")]) + } +} + +impl Drop for Remote { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +#[tokio::test] +async fn the_rules_of_operation_are_never_published() { + let remote = Remote::new("t4"); + let base = production_after_m8("publish").await; + let queue_before = count(&base, "SELECT COUNT(*) FROM outbox").await; + let state = start(&base.db, remote.config()).await; + ld_server::publish::pass(&state).await.expect("pass"); + assert_eq!(remote.show("fr.md"), None, "no article in force: the queue waits"); + // The first text enters by proven votes: the document goes out, with its queue. + let doc = doc_id(&state, "monnaie-libre").await; + let (e1, ..) = seed_version(&state, "monnaie-libre", "E1").await; + let revision = seed_version(&state, "monnaie-libre", "E1").await.2; + for i in 0..60 { + sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, created_at) VALUES (?, 'g1', ?, 1, '2026-11-03T00: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', ?, '2026-11-03T00:00:00Z', 'chain')") + .bind(&e1) + .bind(format!("idty:{}", 9000 + i)) + .bind(revision) + .execute(&state.db) + .await + .expect("vote"); + } + let entered = ld_server::adoption::run(&state, &doc).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.contains(E1_FR), "{fr}"); + for word in ["Formule", "Inertie", "Ordonnancement", "Paramètres par défaut", "Niveaux d'inertie"] { + assert!(!fr.contains(word), "{word}: {fr}"); + } + let json: Value = serde_json::from_str(&remote.show("document.json").expect("json")).expect("json"); + let units: Vec<&str> = json["units"].as_array().expect("units").iter().filter_map(|u| u["code"].as_str()).collect(); + assert_eq!(units, ["E1"]); + let sections: Vec<&str> = + json["modules"]["orders"].as_array().expect("orders").iter().filter_map(|o| o["section"].as_str()).collect(); + assert_eq!(sections, SECTIONS); + // The queue: every row kept, gone out together; the commit says each reason, those of the + // texts of operation written before included. + let rows: Vec<(String, Option)> = sqlx::query_as( + "SELECT o.reason, o.done_at FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'monnaie-libre' ORDER BY o.id", + ) + .fetch_all(&state.db) + .await + .expect("outbox"); + assert!(rows.iter().all(|(_, done)| done.is_some()), "{rows:?}"); + assert!(count(&state, "SELECT COUNT(*) FROM outbox").await > queue_before + 5); + let message = remote.git(&["log", "-1", "--format=%B", "main"]).expect("log"); + assert!(message.contains("F1.2") && message.contains("formule"), "{message}"); +} diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index e83a155..1be41ba 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -442,7 +442,8 @@ async fn previews_and_assistant() { a.login("Yvv", false).await; let id = doc_id(&mut a, "monnaie-libre").await; let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; - assert_eq!(v["units"].as_array().expect("u").len(), 33); + // 27 articles since lot T4 (the articles of operation out of the document, ADR 0014; E12 in). + assert_eq!(v["units"].as_array().expect("u").len(), 27); let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await; assert_eq!(v["units"].as_array().expect("u").len(), 0, "nothing in force, nothing well rated at zero votes"); let (_, v) = a @@ -951,11 +952,21 @@ const PREAMBULE: [(&str, &str, &str); 3] = [ const RAISON_D_ETRE: &str = "*«\u{a0}Derrière une clé il n'y a qu'un seul être humain vivant\u{a0}; derrière un être humain, il n'y a qu'une seule clé certifiante.\u{a0}»*\n\nC'est le seul et unique propos de la certification"; +/// The Raison d'être since lot T4 (Yvv, 03/11): its beginning. +const RAISON_D_ETRE_T4: &str = "*«\u{a0}Derrière une clé il y a un et un seul être humain vivant\u{a0}; derrière un être humain, il y a une et une seule clé certifiante.\u{a0}»*\n\nC'est l'unique enjeu de la certification"; + fn today_seed() -> Value { let path = Config::for_tests().seed_dir.join("acte-engagement-certification.json"); serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json") } +/// The seed of M8 (de1b7eb): the last that gave I1 the Raison d'être of 30/10. +fn m8_seed() -> Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/seed-m8/acte-engagement-certification.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json") +} + /// The seed production runs, I1 in force: the amendment of 30/10 is replayed on it. fn production_seed() -> Value { let path = production_seed_dir().join("acte-engagement-certification.json"); @@ -1026,15 +1037,24 @@ async fn a_new_base_starts_with_the_raison_d_etre() { for group in ["monnaie-libre", "essai"] { let i1 = i1_of(&mut c, group).await; assert_eq!(i1["title"], "Raison d'être"); - assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}"); + assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE_T4), "{i1}"); assert_eq!((i1["revision"].as_i64(), i1["seedProposal"].as_bool()), (Some(1), Some(true))); for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] { let tr = &i1["translations"][lang]; assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}"); } } - // Born with the text, put forward: nothing to replace, and it is said once. - assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]); + // Born with the text of lot T4, put forward: nothing to replace, and it is said once; the + // amendment of 30/10, superseded, decides nothing on a new base. + assert!(decisions(&state).await.is_empty()); + let t4: Vec<(String, String)> = sqlx::query_as( + "SELECT d.group_slug, s.outcome FROM seed_amendments s JOIN documents d ON d.id = s.doc_id + WHERE s.amendment_id = '2026-11-03-i1-amorce' ORDER BY d.group_slug", + ) + .fetch_all(&state.db) + .await + .expect("decisions"); + assert_eq!(t4, [("essai".to_owned(), "present".to_owned()), ("monnaie-libre".to_owned(), "present".to_owned())]); } #[tokio::test] @@ -1043,13 +1063,21 @@ async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() { assert_eq!(a.doc, "acte-engagement-certification"); let (_, title, body) = PREAMBULE[0]; assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.from, "the Préambule bases hold"); - let seed = today_seed(); - let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1"); - let v = &i1["versions"][0]; - // Today the starting text is a seed proposal (ADR 0011); the text is the same. + let i1_of = |seed: &Value| -> (String, String, Value) { + let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1"); + let v = &i1["versions"][0]; + let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body")); + (title.to_owned(), body.to_owned(), v.clone()) + }; + // Until M8 the starting text is a seed proposal (ADR 0011); the text is the same. + let (title, body, v) = i1_of(&m8_seed()); assert_eq!((v["status"].as_str(), v["seedProposal"].as_bool()), (Some("open"), Some(true))); - let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body")); - assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today"); + assert_eq!(ld_server::seed_amend::fingerprint(&title, &body), a.to, "the text of the seed of M8"); + // Lot T4 replaces it, after it only (Yvv's text of 03/11): this one decides nothing more. + let t4 = ld_server::seed_amend::AMENDMENTS.iter().find(|x| x.id == "2026-11-03-i1-amorce").expect("lot T4"); + assert_eq!((t4.from, t4.follows, a.superseded_by), (a.to, Some(a.id), Some(t4.id))); + let (title, body, _) = i1_of(&today_seed()); + assert_eq!(ld_server::seed_amend::fingerprint(&title, &body), t4.to, "the text of the seed today"); } #[tokio::test] diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index 3ca56fb..a5b8152 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -794,10 +794,10 @@ async fn a_session_sees_its_own_ballots_only() { assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None)); let theirs = other.doc("monnaie-libre").await; assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against"))); - // « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide). + // « Décisions » counts the same way (27 articles, the title, 5 orders; lot T4, ADR 0014). let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await; assert_eq!(d["canVote"], true); - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(32))); } #[tokio::test] @@ -2271,12 +2271,12 @@ async fn on_a_base_like_production_the_three_migrations_pass_in_order_and_0010_r let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state"); let mut all = sqlx::migrate!("./migrations"); all.set_ignore_missing(true); - all.run(&pool).await.expect("0009 to 0011"); + all.run(&pool).await.expect("0009 to 0012"); let order: Vec = sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY rowid") .fetch_all(&pool) .await .expect("order"); - assert_eq!(order, (1..=11).collect::>(), "0009, then 0010, then 0011"); + assert_eq!(order, (1..=12).collect::>(), "0009, then 0010, then 0011 (and 0012 since lot T4)"); assert_eq!(tables(pool.clone()).await, written, "no row rewritten"); let added: Vec<(String, String, Option)> = sqlx::query_as( "SELECT b.id, b.mode, l.outcome FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id ORDER BY b.id", diff --git a/crates/ld-server/tests/fixtures/seed-m8/acte-engagement-certification.json b/crates/ld-server/tests/fixtures/seed-m8/acte-engagement-certification.json new file mode 100644 index 0000000..850dca6 --- /dev/null +++ b/crates/ld-server/tests/fixtures/seed-m8/acte-engagement-certification.json @@ -0,0 +1,793 @@ +{ + "_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 », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. 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). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran. Trois phrases reprises après la relecture de M6b (lot T2) : la part du cran, la plus haute au quorum, baisse quand la participation monte (F1.1) ; l'auteur fixe le quorum tant que l'article ne garde la trace d'aucun vote (F1.2) ; le quorum n'est pas un nombre minimal de votants (N1.1). Lot T3 (Yvv, 03/11 : « pas de chiffre datés dans un texte voté ») : l'exemple chiffré de N1.1 retiré (aucun nombre des textes ne dépend de W ni d'une participation) ; N1.1, sous le quorum, il faut autant de « pour » que si le quorum avait voté (vrai pour tout quorum) ; N1.1 et F1.2, la part du cran comme d'habitude au-delà du quorum. Après la relecture de 2ce2028 (lot T3b, même amendement) : N1.1, G se calcule pour que la part exigée, au-delà du quorum, soit celle du cran quand le groupe vote comme d'habitude (vrai pour tout W et tout quorum). Après la relecture de e7ab82f (lot T3c, mêmes amendements) : F1.2 et N1.1 écrivent le terme défini, « à la participation habituelle » (la médiane ramenée entre 1 % et 50 % de W, celle sur laquelle G se calcule), à la place de « comme d'habitude », faux quand la médiane sort de ses bornes ; N1.1, « sous le quorum de l'article », « la part exigée », « Tant qu'elle repose sur moins de 5 propositions », « Elle est ramenée entre 1 % et 50 % » (liste fermée des phrases : docs/chantiers/acte-certification/t3c-liste-fermee.md).", + "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, + "quorums": [12, 12, 50, 50, 50], + "publishPath": "monnaie-libre/acte-engagement-certification" + }, + { + "group": "essai", + "voteFloor": 1, + "quorums": [1, 1, 1, 1, 1], + "publishPath": null + } + ], + "modules": { + "title": { + "inertia": 4 + }, + "order": { + "inertia": 2 + } + }, + "units": [ + { + "code": "I1", + "section": "introduction", + "position": 1, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "seedProposal": true, + "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, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "seedProposal": true, + "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, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "seedProposal": true, + "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": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».", + "en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.", + "es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»." + } + } + ] + }, + { + "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- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. Dans Monnaie libre, jamais moins de 2 : 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 %, un quorum de 50 dans Monnaie libre, et, au-delà du quorum, le groupe demande 75 % de « pour » à sa participation habituelle. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, 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- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as the article keeps no trace of any vote. In Monnaie libre, never less than 2: 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%, a quorum of 50 in Monnaie libre, and, beyond the quorum, the group asks for 75% of votes in favour at its usual turnout. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, 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- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras el artículo no guarde rastro de ningún voto. En Monnaie libre, nunca menos de 2: 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 %, un quórum de 50 en Monnaie libre, y, más allá del quórum, el grupo pide un 75 % de votos a favor con su participación habitual. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, 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 une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum : sous le quorum de l'article, les votants qui manquent comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté. La part exigée dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà de ce quorum, 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)`, où T' est le nombre de votants, ou le quorum de l'article s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, la part exigée au-delà du quorum de l'article, à la participation habituelle, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % à la participation habituelle, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % à la participation habituelle, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % à la participation habituelle, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % à la participation habituelle, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % à la participation habituelle, 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 qu'elle repose sur moins de 5 propositions, elle vaut deux fois le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle est ramenée 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, au-delà du quorum de l'article, soit exactement celle du cran à la participation habituelle : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est cette part du cran (60, 67, 75, 83 ou 90 %) et p̃ la participation habituelle divisée par W.", + "en": "At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum: below the article's quorum, the voters who are missing count as votes against, and it takes as many votes in favour as if that quorum had voted. The share asked depends on the level and on the number of voters, compared with the group's usual turnout: beyond that quorum, 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)`, where T' is the number of voters, or the article's quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked beyond the article's quorum, at the usual turnout, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% at the usual turnout, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% at the usual turnout, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% at the usual turnout, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% at the usual turnout, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% at the usual turnout, 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. While it rests on fewer than 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It is kept between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, beyond the article's quorum, is exactly that of the level at the usual turnout: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is that share of the level (60, 67, 75, 83 or 90%) and p̃ the usual turnout divided by W.", + "es": "En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum: por debajo del quórum del artículo, los votantes que faltan cuentan como «no», y hacen falta tantos votos a favor como si hubiera votado ese quórum. La proporción exigida depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá de ese quórum, 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)`, donde T' es el número de votantes, o el quórum del artículo si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida más allá del quórum del artículo, con la participación habitual, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % con la participación habitual, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % con la participación habitual, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % con la participación habitual, 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**: quórum 50; 83 % con la participación habitual, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % con la participación habitual, 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. Mientras se base en menos de 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se acota 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, más allá del quórum del artículo, sea exactamente la del nivel con la participación habitual: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es esa proporción del nivel (60, 67, 75, 83 o 90 %) y p̃ la participación habitual dividida por W." + } + } + ] + }, + { + "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/inertia.rs b/crates/ld-server/tests/inertia.rs index 06b1ef1..afef4e6 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -112,7 +112,7 @@ async fn the_five_levels_and_the_scale_are_served() { let (mut c, _) = Client::new().await; c.login("Chiara").await; let doc = c.doc("monnaie-libre").await; - // 3 everywhere, low for the three seed proposals (ADR 0011). + // 3 everywhere, low for the four seed proposals (ADR 0011; E12 since lot T4). let other: Vec<&str> = doc["units"] .as_array() .expect("units") @@ -120,8 +120,8 @@ async fn the_five_levels_and_the_scale_are_served() { .filter(|u| u["inertia"] != 3) .map(|u| u["code"].as_str().expect("code")) .collect(); - assert_eq!(other, ["I1", "E1", "E2"]); - assert!(["I1", "E1", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW)); + assert_eq!(other, ["I1", "E1", "E12", "E2"]); + assert!(["I1", "E1", "E12", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW)); // No vote yet: the reference, 2C/W = 100 voters out of 5745. let p = &doc["doc"]["participation"]; diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs index e853df6..c66be72 100644 --- a/crates/ld-server/tests/modules.rs +++ b/crates/ld-server/tests/modules.rs @@ -146,8 +146,9 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() { c.login("Chiara").await; for group in ["monnaie-libre", "essai"] { let doc = c.doc(group).await; - // The articles stay the articles: 33 of them, none of the modules. - assert_eq!(doc["units"].as_array().expect("units").len(), 33, "{group}"); + // The articles stay the articles: 27 of them, none of the modules (since lot T4, the + // rules of operation are out of the document, ADR 0014). + assert_eq!(doc["units"].as_array().expect("units").len(), 27, "{group}"); let title = &doc["modules"]["title"]; assert_eq!( (title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()), @@ -163,27 +164,15 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() { // One order per section, in the order of the document, each at level 2. let orders = doc["modules"]["orders"].as_array().expect("orders"); let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect(); - assert_eq!( - sections, - [ - "introduction", - "fondamental", - "technique", - "conclusion", - "annexe", - "formule", - "inertie", - "ordonnancement" - ] - ); + assert_eq!(sections, ["introduction", "fondamental", "technique", "conclusion", "annexe"]); assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true)); // Its version in force is the order the section reads in now. let fondamental = order_of(&doc, "fondamental"); - assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E2"]); + assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E12", "E2"]); assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]); - assert_eq!(fondamental["moot"], false); - // « Ordonnancement » holds one article: nothing to decide. - assert_eq!(order_of(&doc, "ordonnancement")["moot"], true); + // Every section holds two articles or more: each order has something to decide + // (one of a single article: `an_order_of_one_article_has_nothing_to_decide`). + assert!(orders.iter().all(|o| o["moot"] == false), "{group}"); } // Born with them: the addition says so, once per document. let rows: Vec<(String, String)> = sqlx::query_as( @@ -197,11 +186,35 @@ async fn a_new_base_has_its_title_and_the_order_of_each_section() { let doc = c.doc("monnaie-libre").await; let id = doc["doc"]["id"].as_str().expect("id"); let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; - assert_eq!(all["units"].as_array().expect("units").len(), 33); - // The list of the group: the articles in force — none, I1, E1 and E2 are seed proposals - // (ADR 0011), three more open versions —; no module has an open proposal yet. + assert_eq!(all["units"].as_array().expect("units").len(), 27); + // The list of the group: the articles in force — none, I1, E1, E12 and E2 are seed + // proposals (ADR 0011) —, one open version per article and two more (E3, E7); no module + // has an open proposal yet. let g = c.ok("GET", "/api/groups/monnaie-libre", None).await; - assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(35))); + assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(29))); +} + +/// The seed of M8 (tests/fixtures/seed-m8): « Ordonnancement » held one article, O1. Lot T4 +/// takes that section out of the document (ADR 0014); a base born on that seed still has it. +fn m8_config() -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8"); + cfg +} + +#[tokio::test] +async fn an_order_of_one_article_has_nothing_to_decide() { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(m8_config(), db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + let mut c = Client::of(&state); + c.login("Chiara").await; + for group in ["monnaie-libre", "essai"] { + let doc = c.doc(group).await; + assert_eq!(codes(&doc, &order_of(&doc, "ordonnancement")["members"]), ["O1"], "{group}"); + assert_eq!(order_of(&doc, "ordonnancement")["moot"], true, "{group}"); + assert_eq!(order_of(&doc, "conclusion")["moot"], false, "{group}"); + } } #[tokio::test] @@ -251,7 +264,7 @@ async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_f assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed"))); assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]); // Nothing else moved: the other sections read as before, the texts are the same. - assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2"]); + assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E12", "E2"]); assert_eq!(read_order(&doc, "technique")[0], "T2"); } @@ -259,12 +272,12 @@ async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_f async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { let (mut yvv, mut chiara, state) = two().await; // The order stays under vote with one voice. - two_voices(&state, "ordre.inertie").await; + two_voices(&state, "ordre.conclusion").await; let doc = yvv.doc("essai").await; - let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned(); + let module = order_of(&doc, "conclusion")["id"].as_str().expect("id").to_owned(); let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); - let (n1, n11) = (id_of(&doc, "N1"), id_of(&doc, "N1.1")); - let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [n11, n1] }))).await["id"] + let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2")); + let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [k2, k1] }))).await["id"] .as_str() .expect("id") .to_owned(); @@ -286,20 +299,22 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { .ok( "POST", &format!("/api/docs/{doc_id}/proposals"), - Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre du document est à 4.", "submit": true })), + Some( + json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel pour finir.", "submit": true }), + ), ) .await; let doc = yvv.doc("essai").await; - let fresh = unit(&doc, "N1")["code"] + let fresh = unit(&doc, "K1")["code"] .as_str() .map(|_| ()) .and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned()); let fresh = fresh.expect("the new article"); let new_code = fresh["code"].as_str().expect("code").to_owned(); - assert_eq!(read_order(&doc, "inertie"), ["N1", "N1.1", new_code.as_str()], "a new article goes at the end"); - let module_now = order_of(&doc, "inertie"); + assert_eq!(read_order(&doc, "conclusion"), ["K1", "K2", new_code.as_str()], "a new article goes at the end"); + let module_now = order_of(&doc, "conclusion"); let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p"); - assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1", new_code.as_str()]); + assert_eq!(codes(&doc, &open["order"]["ids"]), ["K2", "K1", new_code.as_str()]); assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]); // Its vote stays: completing an order never asks the voters again. assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for"))); @@ -307,47 +322,47 @@ async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { let added = created["id"].as_str().expect("id"); chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await; let doc = yvv.doc("essai").await; - let open = order_of(&doc, "inertie")["proposals"] + let open = order_of(&doc, "conclusion")["proposals"] .as_array() .expect("p") .iter() .find(|x| x["id"] == p.as_str()) .cloned() .expect("p"); - assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1"]); + assert_eq!(codes(&doc, &open["order"]["ids"]), ["K2", "K1"]); assert!(open["order"]["added"].as_array().expect("a").is_empty()); // An order proposed with three articles, one of which then leaves: dropped from it. let created = chiara .ok( "POST", &format!("/api/docs/{doc_id}/proposals"), - Some(json!({ "section": "inertie", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })), + Some(json!({ "section": "conclusion", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })), ) .await; let other = created["unitId"].as_str().expect("unit").to_owned(); - let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, n11, n1] }))).await + let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, k2, k1] }))).await ["id"] .as_str() .expect("id") .to_owned(); chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await; let doc = yvv.doc("essai").await; - let o = order_of(&doc, "inertie")["proposals"] + let o = order_of(&doc, "conclusion")["proposals"] .as_array() .expect("p") .iter() .find(|x| x["id"] == three.as_str()) .cloned() .expect("o"); - assert_eq!(codes(&doc, &o["order"]["ids"]), ["N1.1", "N1"]); + assert_eq!(codes(&doc, &o["order"]["ids"]), ["K2", "K1"]); assert_eq!(o["order"]["dropped"], json!([other])); // Completed, it reads like the first one: the second voice brings that order in. let v = chiara.vote(&p, "for").await; assert_eq!(v["entered"][0]["kind"], "order"); let doc = yvv.doc("essai").await; - assert_eq!(read_order(&doc, "inertie"), ["N1.1", "N1"]); + assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]); // Now the order in force: the other one changes nothing more and cannot enter. - let o = order_of(&doc, "inertie")["proposals"] + let o = order_of(&doc, "conclusion")["proposals"] .as_array() .expect("p") .iter() @@ -503,10 +518,10 @@ async fn a_new_article_goes_at_the_end_of_the_section_asked_for() { .to_owned() }; let (ca, cb) = (code(&a), code(&b)); - assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2", ca.as_str()]); + assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E12", "E2", ca.as_str()]); assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]); // It joins the order of its section, at its end. - assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E2", ca.as_str()]); + assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E12", "E2", ca.as_str()]); } // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index 9e5c782..33257aa 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -169,11 +169,11 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { assert_eq!(docs.len(), 1); assert_eq!(docs[0]["slug"], "acte-engagement-certification"); assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification"); - // 3 seed proposals (ADR 0011) + 30 open articles, the two variants counting once; the title - // of the document and the order of 7 sections (« ordonnancement » holds one article: nothing - // to decide). - assert_eq!(docs[0]["toVote"], 41); - assert_eq!(docs[0]["withoutMyVote"], 41); + // 4 seed proposals (ADR 0011) + 23 open articles, the two variants counting once; the title + // of the document and the order of its 5 sections (since lot T4, the rules of operation are + // out of the document, ADR 0014). + assert_eq!(docs[0]["toVote"], 33); + assert_eq!(docs[0]["withoutMyVote"], 33); let doc = the_doc(&mut c, "essai").await; // Votes that adopt nothing: the quorum 2 (ADR 0013), one voice of two. @@ -183,9 +183,9 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { c.vote(&version(&doc, "E3", "1"), "against").await; c.vote(&version(&doc, "I1", "0"), "for").await; let d = decisions(&mut c, "essai").await; - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31))); // Someone else has not voted yet. - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33); } #[tokio::test] @@ -201,7 +201,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou let v = yvv.vote(&e3, "for").await; assert_eq!(v["entered"][0]["code"], "E3"); let d = decisions(&mut yvv, "essai").await; - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32))); // A new article by Chiara: one more module to vote. let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); @@ -216,9 +216,9 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou .await; assert_eq!(s, StatusCode::OK, "{v}"); let added = v["id"].as_str().expect("id").to_owned(); - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33); yvv.vote(&added, "against").await; - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32); // Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote. let (s, v) = chiara .call( @@ -231,7 +231,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou .await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["edit"], "substantive"); - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33); } #[tokio::test] @@ -251,7 +251,7 @@ async fn a_version_held_by_an_objection_is_not_to_vote_while_it_is_held() { assert_eq!(s, StatusCode::OK, "{v}"); // Nobody can vote on it while it is held: one module fewer to vote, for everyone. let d = decisions(&mut yvv, "essai").await; - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32))); // The list of the group still counts it among the proposals in progress: it is open. let g = yvv.get("/api/groups/essai").await; let open = doc["units"] @@ -271,10 +271,10 @@ async fn while_the_chain_is_closed_a_guest_votes_in_simulation_and_has_a_count_o // No vote account: Monnaie libre votes in simulation, for whoever opens it (ADR 0012). let d = decisions(&mut guest, "monnaie-libre").await; assert_eq!(d["canVote"], true); - assert_eq!(d["docs"][0]["toVote"], 41); - assert_eq!(d["docs"][0]["withoutMyVote"], 41); + assert_eq!(d["docs"][0]["toVote"], 33); + assert_eq!(d["docs"][0]["withoutMyVote"], 33); // In the test group, the same person votes. let d = decisions(&mut guest, "essai").await; assert_eq!(d["canVote"], true); - assert_eq!(d["docs"][0]["withoutMyVote"], 41); + assert_eq!(d["docs"][0]["withoutMyVote"], 33); } diff --git a/crates/ld-server/tests/quorum.rs b/crates/ld-server/tests/quorum.rs index 448a78f..d8aef44 100644 --- a/crates/ld-server/tests/quorum.rs +++ b/crates/ld-server/tests/quorum.rs @@ -329,6 +329,14 @@ fn production_config() -> Config { cfg } +/// The seed of M6 to M8 (de1b7eb), which wrote the quorums and put I1 forward: since lot T4 the +/// seed of today gives I1 another text, and a base of 02/11 keeps it in force under it. +fn m8_config() -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8"); + cfg +} + 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"); @@ -374,8 +382,8 @@ async fn the_quorum_amendment_writes_the_quorums_by_level_once() { assert_eq!(c.doc("monnaie-libre").await["doc"]["quorums"], json!([50, 50, 50, 50, 50])); assert_eq!(c.doc("essai").await["doc"]["quorums"], json!([1, 1, 1, 1, 1])); - // Started with today's seed: written, where none was. - let state = restart(&born, Config::for_tests()).await; + // Started with the seed of M6 (and of M8): written, where none was. + let state = restart(&born, m8_config()).await; let first = decisions(&state).await; assert_eq!( first.iter().map(|(g, f, t, o, r)| row(g, f.as_deref(), t, o, r.as_deref())).collect::>(), diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index b65f5bc..c873c34 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -4,6 +4,10 @@ //! 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. +//! +//! Lot T4 (2026-11-03): a fourth, E12 « Responsabilité civique », and Yvv's texts for the three +//! (`tests/amorce.rs`). A new base is born with the four; the bases made as production are +//! started here with the seed of M8, the last that gave the three their texts of before. use axum::body::Body; use axum::http::{Request, StatusCode, header}; @@ -107,7 +111,7 @@ async fn fresh_with(cfg: Config) -> AppState { } #[tokio::test] -async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() { +async fn a_new_base_is_born_with_four_seed_proposals_and_no_article_in_force() { let state = fresh().await; let mut c = Client::of(&state); c.login("Chiara", false).await; @@ -121,7 +125,8 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() .filter_map(|u| u["code"].as_str()) .collect(); assert!(in_force.is_empty(), "{group}: no article in force, {in_force:?}"); - for code in CODES { + for code in CODES.iter().chain(&["E12"]) { + let code = *code; let u = unit(&doc, code); let p = &u["proposals"][0]; assert_eq!( @@ -135,19 +140,20 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() 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. + // Only those four; 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}"); + assert_eq!(marked.len(), 4, "{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. + // The counts: no article in force, 29 open versions (27 articles, E3 and E7 two each); + // 33 modules to vote (27 articles, the title, the order of 5 sections; lot T4). 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}"); + assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(29)), "{group}"); let d = c.get(&format!("/api/groups/{group}/decisions")).await; - assert_eq!(d["docs"][0]["toVote"], 41, "{group}"); + assert_eq!(d["docs"][0]["toVote"], 33, "{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; @@ -155,7 +161,8 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() 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. + // Born with them: the seed proposal amendments, superseded since lot T4 (the seed puts + // forward Yvv's texts, not theirs), decide nothing; the texts of lot T4 are said present. 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", @@ -163,14 +170,13 @@ async fn a_new_base_is_born_with_three_seed_proposals_and_no_article_in_force() .fetch_all(&state.db) .await .expect("decisions"); - assert_eq!(decided.len(), 6, "{decided:?}"); - assert!(decided.iter().all(|(_, _, o)| o == "present"), "{decided:?}"); + assert!(decided.is_empty(), "{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. + // The Raison d'être of a new base is the text its own amendment of lot T4 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", + "SELECT outcome FROM seed_amendments WHERE amendment_id = '2026-11-03-i1-amorce' ORDER BY doc_id", ) .fetch_all(&state.db) .await @@ -242,7 +248,16 @@ async fn production(tag: &str, cfg: Config) -> AppState { state } -/// The same base, started again with today's seed (and its amendments). +/// The seed of M6 for these three texts, unchanged until M8 (de1b7eb): since lot T4 (03/11) the +/// seed of today gives them Yvv's texts, and their seed proposal amendments are superseded +/// (`tests/amorce.rs`). +fn m8() -> Config { + let mut cfg = Config::for_tests(); + cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8"); + cfg +} + +/// The same base, started again with a later 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"); @@ -367,7 +382,7 @@ async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once( }; let translated = translations(before.clone()).await; - let state = restart(&before, Config::for_tests()).await; + let state = restart(&before, m8()).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). @@ -480,7 +495,7 @@ async fn the_production_base_turns_its_untouched_texts_into_seed_proposals_once( ); // Decided once: a second start changes nothing. - let again = restart(&state, Config::for_tests()).await; + let again = restart(&state, m8()).await; assert_eq!(decisions(&again).await, first); for group in GROUPS { for code in CODES { @@ -508,7 +523,7 @@ async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() { 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 state = restart(&before, m8()).await; let first = decisions(&state).await; assert_eq!( outcomes(&first), @@ -533,7 +548,7 @@ async fn a_vote_keeps_the_text_in_force_and_the_refusal_is_kept() { // 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; + let again = restart(&state, m8()).await; assert_eq!(decisions(&again).await, first); assert!(version(&again, "essai", "E1").await.in_force); } diff --git a/crates/ld-server/tests/seed_quorum_texts.rs b/crates/ld-server/tests/seed_quorum_texts.rs index d5b848b..6822e6e 100644 --- a/crates/ld-server/tests/seed_quorum_texts.rs +++ b/crates/ld-server/tests/seed_quorum_texts.rs @@ -39,9 +39,17 @@ //! engine on one grid (`grid`): W from 100, medians under 1 % and above 50 % of W, every quorum an //! author may set, the five levels — the closed list given to the reviewer is //! `docs/chantiers/acte-certification/t3c-liste-fermee.md`. +//! +//! Lot T4 (2026-11-03, Yvv: « ordonnancement, inertie, et formule de vote sont des infos de +//! fonctionnement »): F1.1, F1.2 and N1.1 leave the document (ADR 0014); they are no texts to +//! vote any more. Their last texts — the seed of M8 (de1b7eb), kept in +//! `tests/fixtures/seed-m8/` — stay the reference of what the interface will say of the rules +//! (lot U3), and every truth this file checks of them against the engine still holds there. The +//! amendments that wrote them are replayed on that seed, as production played them. use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds}; use ld_engine::threshold::{FormulaParams, Tally, judged_voters, passes, required_ratio, wot_threshold, yes_needed}; +use ld_server::retire::RETIREMENT; use ld_server::seed_amend::{self, AMENDMENTS, Version}; use ld_server::wot::PLAUSIBLE; use ld_server::{AppState, Config, open_db, seed}; @@ -64,8 +72,15 @@ fn read(path: PathBuf) -> Value { serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") } -fn today_seed() -> Value { - read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) +/// The seed production runs since M8 (de1b7eb): the last that holds F1.1, F1.2 and N1.1, and +/// the reference of their texts since lot T4 took them out of the document. +fn m8_seed() -> Value { + read(fixtures().join("seed-m8/acte-engagement-certification.json")) +} + +/// A start with the seed of M8: lot T3 decides. +fn m8_config(tag: &str) -> Config { + config(seed_dir(&format!("{tag}-m8"), &m8_seed())) } fn fixtures() -> PathBuf { @@ -89,7 +104,7 @@ fn m6b_texts() -> Value { /// The seed production runs since M6b: today's, with the texts of lot T. fn m6b_seed() -> Value { - with_texts(&today_seed(), &m6b_texts(), &CODES) + with_texts(&m8_seed(), &m6b_texts(), &CODES) } /// F1.1, F1.2 and N1.1 as lot T2 wrote them: production's since M6c (84f015a), unchanged by M7b. @@ -99,7 +114,7 @@ fn t2_texts() -> Value { /// The seed production runs since M6c: today's, with the texts of lot T2. fn t2_seed() -> Value { - with_texts(&today_seed(), &t2_texts(), &CODES) + with_texts(&m8_seed(), &t2_texts(), &CODES) } fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value { @@ -481,7 +496,7 @@ fn formulas(code: &str, lang: &str) -> Vec { /// article and language listed at once. #[test] fn every_number_of_the_quorum_texts_is_the_engine_s() { - let seed = today_seed(); + let seed = m8_seed(); let f = figures(&seed); let mut wrong = Vec::new(); for code in CODES { @@ -821,7 +836,7 @@ fn t3_from_t2(code: &str, lang: &str, t2: &str) -> String { #[test] fn the_texts_of_lot_t3_change_these_phrases_only() { - let (t2, today) = (t2_seed(), today_seed()); + let (t2, today) = (t2_seed(), m8_seed()); for code in CODES { for lang in LANGS { let (before, now) = (text(&t2, code, lang), text(&today, code, lang)); @@ -1006,7 +1021,7 @@ fn share_at(w: u32, voters: f64, params: &FormulaParams) -> f64 { /// quorum of 2, two « pour » are asked, at every level, on every W. #[test] fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { - let today = today_seed(); + let today = m8_seed(); let said = [ ( "fr", @@ -1104,7 +1119,7 @@ fn under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted() { /// quorum 12), while the share asked falls from 73,5 % to 73,2 %. #[test] fn the_share_asked_falls_beyond_the_article_s_quorum_down_to_m() { - let today = today_seed(); + let today = m8_seed(); for (lang, phrase) in [ ( "fr", @@ -1175,7 +1190,7 @@ fn the_share_asked_falls_beyond_the_article_s_quorum_down_to_m() { /// asked of more voters than came; when everyone votes, M_k, at every quorum. #[test] fn the_share_of_a_level_is_asked_at_the_usual_turnout_beyond_the_article_s_quorum() { - let today = today_seed(); + let today = m8_seed(); for (lang, intro) in [ ( "fr", @@ -1247,7 +1262,7 @@ fn the_share_of_a_level_is_asked_at_the_usual_turnout_beyond_the_article_s_quoru /// share asked there is s beyond the article's quorum, and lower, judged as the quorum, under it. #[test] fn g_gives_the_share_of_the_level_at_the_usual_turnout_beyond_the_article_s_quorum() { - let today = today_seed(); + let today = m8_seed(); // The sentence of G closes N1.1, in each language; nothing of the sentence of T2 is left. for lang in LANGS { let (_, body) = text(&today, "N1.1", lang); @@ -1324,7 +1339,7 @@ fn g_gives_the_share_of_the_level_at_the_usual_turnout_beyond_the_article_s_quor /// reste entre » read as a fact, not as the rule that brings the median back. #[test] fn the_participation_habituelle_is_the_median_brought_back_within_its_bounds() { - let today = today_seed(); + let today = m8_seed(); // The texts write the defined term, never the paraphrase: in N1.1, the list, its five lines and // G (and, in French and Spanish, « comparé à la participation habituelle du groupe », already // there); in F1.2, « Par défaut ». @@ -1421,7 +1436,7 @@ fn the_participation_habituelle_is_the_median_brought_back_within_its_bounds() { /// paragraph says after it bring it back. The voted text is not changed for this. #[test] fn the_reference_turnout_is_a_parameter_the_bounds_bring_back() { - let seed = today_seed(); + let seed = m8_seed(); let f = figures(&seed); let vote_floor = f64::from(f.reference) / 2.0; for w in ELECTORATES.into_iter().chain([199, 200, 10_000, 10_001]) { @@ -1629,7 +1644,7 @@ fn close(a: f64, b: f64) -> bool { #[test] fn the_formulas_of_the_texts_are_those_the_engine_computes() { - let seed = today_seed(); + let seed = m8_seed(); for lang in LANGS { let (_, f11) = text(&seed, "F1.1", lang); let (_, n11) = text(&seed, "N1.1", lang); @@ -1693,7 +1708,7 @@ fn the_formulas_of_the_texts_are_those_the_engine_computes() { #[test] fn the_quorum_texts_follow_the_rules_of_form() { - let seed = today_seed(); + let seed = m8_seed(); let titles: HashMap<&str, Vec<&str>> = LANGS .iter() .map(|l| { @@ -1812,8 +1827,8 @@ fn the_amendments_of_lot_t2_went_from_the_texts_of_m6b_to_those_of_t2() { assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text production holds since M6c"); assert_ne!(a.from, a.to, "{code}"); // It follows lot T's, which wrote the text it replaces; lot T3's replaces it in turn, - // where it changes the text (F1.2, N1.1). - let later = AMENDMENTS.iter().find(|l| l.follows == Some(a.id)).map(|l| l.id); + // where it changes the text (F1.2, N1.1); F1.1 leaves the seed with lot T4 (ADR 0014). + let later = AMENDMENTS.iter().find(|l| l.follows == Some(a.id)).map(|l| l.id).or(Some(RETIREMENT)); assert_eq!( (a.follows, t.to, t.superseded_by, a.superseded_by), (Some(t.id), a.from, Some(a.id), later), @@ -1873,7 +1888,7 @@ async fn production_after_m6(tag: &str) -> AppState { let first = AppState::new(config(seed_dir(&format!("{tag}-born"), &born)), db.clone()).await.expect("state"); seed::documents(&first).await.expect("born"); start(&db, config(seed_dir(&format!("{tag}-m5"), &prod))).await; - let m6 = with_texts(&today_seed(), &prod, &CODES); + let m6 = with_texts(&m8_seed(), &prod, &CODES); start(&db, config(seed_dir(&format!("{tag}-m6"), &m6))).await } @@ -2128,8 +2143,8 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() { #[tokio::test] async fn a_new_base_is_born_with_the_quorum_texts() { let db = open_db("sqlite::memory:").await.expect("db"); - let state = start(&db, Config::for_tests()).await; - let today = today_seed(); + let state = start(&db, m8_config("new")).await; + let today = m8_seed(); for group in GROUPS { for code in CODES { let (_, title, body, revision) = proposal(&state, group, code).await; @@ -2430,7 +2445,7 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_three_phrases() { let first = AppState::new(config(seed_dir("t2-born-m5", &prod)), db.clone()).await.expect("state"); seed::documents(&first).await.expect("born"); start(&db, config(seed_dir("t2-born-m5-m5", &prod))).await; - start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&today_seed(), &prod, &CODES)))).await; + start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&m8_seed(), &prod, &CODES)))).await; let base = start(&db, m6b_config("t2-born-m5")).await; for group in GROUPS { for code in CODES { @@ -2461,7 +2476,7 @@ const T3_CODES: [&str; 2] = ["F1.2", "N1.1"]; #[test] fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() { - let (t2, today) = (t2_seed(), today_seed()); + let (t2, today) = (t2_seed(), m8_seed()); for code in CODES { let t2a = amendment(code, "-phrases"); let found = AMENDMENTS.iter().find(|a| a.unit == code && a.id.ends_with("-chiffres")); @@ -2470,7 +2485,7 @@ fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() { for lang in LANGS { assert_eq!(text(&t2, code, lang), text(&today, code, lang), "{code} {lang}"); } - assert_eq!(t2a.superseded_by, None, "{code}"); + assert_eq!(t2a.superseded_by, Some(RETIREMENT), "{code}: out of the seed since lot T4"); continue; } let a = found.unwrap_or_else(|| panic!("{code}: the amendment of lot T3")); @@ -2481,10 +2496,11 @@ fn the_amendments_of_lot_t3_go_from_the_texts_of_t2_to_the_seed_of_today() { let (title, body) = text(&today, code, "fr"); assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text of the seed today"); assert_ne!(a.from, a.to, "{code}"); - // It follows lot T2's, which wrote the text it replaces; nothing replaces it yet. + // It follows lot T2's, which wrote the text it replaces; lot T4 takes the article out of + // the seed (ADR 0014), and with it this amendment. assert_eq!( (a.follows, t2a.to, t2a.superseded_by, a.superseded_by), - (Some(t2a.id), a.from, Some(a.id), None), + (Some(t2a.id), a.from, Some(a.id), Some(RETIREMENT)), "{code}" ); let t = amendment(code, "-quorum"); @@ -2513,7 +2529,7 @@ fn history(revision: i64) -> Vec<&'static str> { #[tokio::test] async fn production_after_m6c_gets_the_texts_without_dated_figures_once() { let base = production_after_m6c("t3-apply").await; - let (t2, today) = (t2_seed(), today_seed()); + let (t2, today) = (t2_seed(), m8_seed()); // The base is as production after M6c: the texts of lot T2, F1.1 at revision 3, F1.2 and // N1.1 at revision 4. for group in GROUPS { @@ -2533,7 +2549,7 @@ async fn production_after_m6c_gets_the_texts_without_dated_figures_once() { let revisions = count(&base, "SELECT COUNT(*) FROM revisions").await; // Started with today's seed. - let state = start(&base.db, Config::for_tests()).await; + let state = start(&base.db, m8_config("t3-apply")).await; let mut touched = Vec::new(); for group in GROUPS { for code in T3_CODES { @@ -2597,7 +2613,7 @@ async fn production_after_m6c_gets_the_texts_without_dated_figures_once() { assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}"); assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 2); // Decided once: a second start replays nothing. - let again = start(&state.db, Config::for_tests()).await; + let again = start(&state.db, m8_config("t3-apply-again")).await; assert_eq!(decisions(&again, "-chiffres").await, expected); assert_eq!(proposals(&again).await, proposals(&state).await); assert_eq!(translations(&again).await, translations(&state).await); @@ -2649,7 +2665,7 @@ async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() { luna.vote(&f12_essai, Some("against")).await; luna.vote(&f12_essai, None).await; - let state = start(&base.db, Config::for_tests()).await; + let state = start(&base.db, m8_config("t3-refuse")).await; let refused = |g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned())); let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None); @@ -2662,7 +2678,7 @@ async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() { refused("monnaie-libre", "N1.1", "votes"), ] ); - let (t2, today) = (t2_seed(), today_seed()); + let (t2, today) = (t2_seed(), m8_seed()); for group in GROUPS { for code in T3_CODES { let kept = (group, code) != ("essai", "F1.2"); @@ -2676,7 +2692,7 @@ async fn an_article_voted_on_keeps_the_text_of_t2_and_says_why() { assert!(queued(&state, "-chiffres").await.is_empty()); // The refusals stand: decided once, never tried again, even once the vote is gone. sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&n11).execute(&state.db).await.expect("delete"); - let again = start(&state.db, Config::for_tests()).await; + let again = start(&state.db, m8_config("t3-refuse-again")).await; assert_eq!(decisions(&again, "-chiffres").await, decisions(&state, "-chiffres").await); let (_, title, body, _) = proposal(&again, "monnaie-libre", "N1.1").await; assert_eq!((title.as_str(), body.as_str()), text(&t2, "N1.1", "fr")); @@ -2691,7 +2707,7 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() { let first = AppState::new(config(seed_dir("t3-born-m5", &prod)), db.clone()).await.expect("state"); seed::documents(&first).await.expect("born"); start(&db, config(seed_dir("t3-born-m5-m5", &prod))).await; - start(&db, config(seed_dir("t3-born-m5-m6", &with_texts(&today_seed(), &prod, &CODES)))).await; + start(&db, config(seed_dir("t3-born-m5-m6", &with_texts(&m8_seed(), &prod, &CODES)))).await; start(&db, m6b_config("t3-born-m5")).await; let base = start(&db, t2_config("t3-born-m5")).await; for group in GROUPS { @@ -2700,8 +2716,8 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() { assert_eq!(kinds(&base, &id).await, history(3), "{group} {code}: lots T and T2"); } } - let state = start(&db, Config::for_tests()).await; - let today = today_seed(); + let state = start(&db, m8_config("t3-born-m5")).await; + let today = m8_seed(); for group in GROUPS { for code in T3_CODES { let (id, title, body, revision) = proposal(&state, group, code).await; @@ -2720,8 +2736,8 @@ async fn a_base_born_with_the_texts_of_m5_gets_the_texts_of_t3() { async fn a_base_born_with_the_texts_of_t2_gets_the_texts_of_t3() { let db = open_db("sqlite::memory:").await.expect("db"); start(&db, t2_config("t3-born-t2")).await; - let state = start(&db, Config::for_tests()).await; - let today = today_seed(); + let state = start(&db, m8_config("t3-born-t2")).await; + let today = m8_seed(); for group in GROUPS { for code in CODES { let (id, title, body, revision) = proposal(&state, group, code).await; diff --git a/crates/ld-server/tests/seed_rules.rs b/crates/ld-server/tests/seed_rules.rs index 6828d63..697069d 100644 --- a/crates/ld-server/tests/seed_rules.rs +++ b/crates/ld-server/tests/seed_rules.rs @@ -4,7 +4,10 @@ //! //! Played with the seed M5 shipped (3f10a0e, the fixture of production): since lot T //! (2026-11-03), today's seed gives F1.2 and N1.1 the texts of the quorum, and their -//! amendments of 02/11 are superseded (`tests/seed_quorum_texts.rs`). +//! amendments of 02/11 are superseded (`tests/seed_quorum_texts.rs`). Since lot T4 (the same +//! day, ADR 0014), the four leave the seed: the seed of M8 (`tests/fixtures/seed-m8/`), the last +//! that holds them, stands for « today » here, and their last amendments are superseded by +//! their retirement (`retire`). use ld_server::seed_amend::{self, AMENDMENTS, Version}; use ld_server::{AppState, Config, open_db, seed}; @@ -21,8 +24,13 @@ fn before() -> Value { read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-regles-avant.json")) } +/// The seed of M8 (de1b7eb): the last that holds the rule articles. +fn m8_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8") +} + fn today_seed() -> Value { - read(Config::for_tests().seed_dir.join("acte-engagement-certification.json")) + read(m8_dir().join("acte-engagement-certification.json")) } /// The seed M5 shipped: production's since 2026-11-02. @@ -125,12 +133,14 @@ fn the_shipped_amendments_go_from_the_texts_of_before_to_the_seed_of_m5() { let (t, b) = fr(version(&today, code)); let now = seed_amend::fingerprint(t, b); let mut last = a; - while let Some(later) = last.superseded_by { + while let Some(later) = last.superseded_by.filter(|l| *l != ld_server::retire::RETIREMENT) { let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment"); assert_eq!((l.unit, l.follows, l.from), (code, Some(last.id), last.to), "{code}: {}", l.id); last = l; } assert_eq!(now, last.to, "{code}: the text of today"); + // Lot T4 takes the article out of the seed: the last amendment of its text decides nothing more. + assert_eq!(last.superseded_by, Some(ld_server::retire::RETIREMENT), "{code}"); } } @@ -217,9 +227,11 @@ async fn untouched_rule_articles_are_replaced_once_and_a_vote_keeps_its_text() { } #[tokio::test] -async fn a_new_base_is_born_with_the_rules_of_today() { +async fn a_base_born_on_the_seed_of_m8_holds_its_rules() { let db = open_db("sqlite::memory:").await.expect("db"); - let state = AppState::new(Config::for_tests(), db).await.expect("state"); + let mut cfg = Config::for_tests(); + cfg.seed_dir = m8_dir(); + let state = AppState::new(cfg, db).await.expect("state"); seed::ensure(&state).await.expect("seed"); let today = today_seed(); for group in ["essai", "monnaie-libre"] { diff --git a/docs/adr/0014-informations-de-fonctionnement.md b/docs/adr/0014-informations-de-fonctionnement.md new file mode 100644 index 0000000..4fc2907 --- /dev/null +++ b/docs/adr/0014-informations-de-fonctionnement.md @@ -0,0 +1,143 @@ +# 0014 — Les informations de fonctionnement ne se votent pas + +> Décision d'Yvv du 2026-11-03 (lot T4). Complète [0009](0009-modules-ordre-et-titre.md) (les +> modules) et [0011](0011-propositions-seed.md) (les Propositions seed). La migration est la +> `0012`. Les textes, avant et après, sont relus dans +> `docs/chantiers/acte-certification/textes-amorce.md`. + +**Contexte.** Le seed de l'Acte d'engagement portait, depuis la maquette, trois sections au bas du +document : `formule` (F1 « Formule de vote », F1.1 « Formule du seuil WoT », F1.2 « Paramètres par +défaut », F1.3 « Processus de dépôt officiel »), `inertie` (N1 « Réglage de l'inertie », N1.1 +« Niveaux d'inertie ») et `ordonnancement` (O1 « Ordonnancement du document »), chacune avec son +module « ordre des articles » (ADR 0009). Les lots T à T3c ont réécrit F1.1, F1.2 et N1.1 pour +qu'ils disent les règles du moteur. Yvv, 2026-11-03 : « Attention les 3 derniers modules ne sont pas +de votes : ordonnancement, inertie, et formule de vote sont des infos de fonctionnement. Les votes +se font à leur place, et les infos doivent trouver leur place dans l'ux. […] Vire les 3 du bas de +la liste. » + +## Décision + +**Ce qui sort du document** : les sept articles F1, F1.1, F1.2, F1.3, N1, N1.1, O1, et les trois +modules `ordre.formule`, `ordre.inertie`, `ordre.ordonnancement`. Le document garde cinq +sections (introduction, fondamental, technique, conclusion, annexe) et 27 articles, dont quatre +Propositions seed : I1, E1, E12 « Responsabilité civique » (nouveau, entre E1 et E2) et E2. + +**Hors du document, une unité n'est rien pour le vote.** Elle n'est ni lue avec son document +(`docs::load_state` ne lit que les unités sans `retired_at`, et leurs versions), donc ni listée, +ni comptée (`docs::group` : « propositions en cours » ; `overview::modules` : « modules à +voter »), ni mise dans l'ordre d'une section (`DocState::members`), ni publiée (`document.json` et +le Markdown lisent l'état du document ; `publish::waits_for_a_text` et `last_change` l'ignorent). +**Aucun vote futur** : l'API répond 404 à un vote, à la lecture d'une de ses versions, à une +correction, une traduction, une objection faite depuis elle, une proposition sur elle, un +changement d'inertie, un quorum, un ordre (`proposals::guarded` : la version doit être celle d'une +unité du document) ; 400 à un vote groupé qui la nomme (`lines`), à une objection qui la vise +(`other`) et à un nouvel article dans une section sortie (`section`). Rien n'est écrit par ces +refus. + +**Une base neuve** naît sans eux : le seed ne les contient plus. + +**Une base existante** (la production : Monnaie libre et le groupe d'essai) **n'efface rien**. La +migration `0012` est additive : `units.retired_at` (quand l'unité est sortie) et +`units.retired_reason` (l'amendement qui l'a sortie), les journaux `retirement_amendments` et +`article_amendments`. Le retrait `2026-11-03-fonctionnement-hors-vote` +(`crates/ld-server/src/retire.rs`) est décidé **une fois par document**, pour toutes les unités ou +aucune, et n'écrit que ces deux colonnes : ni leurs versions, ni leurs révisions, ni leurs +traductions, ni la file de publication ne changent. Il n'a lieu que si **aucune personne n'a +laissé de trace** sur l'une d'elles, dans l'une de ses versions, quoi qu'il en soit advenu, et il +dit la première trouvée (raison et code de l'unité) : + +| Trace | Raison du refus | +|---|---| +| un vote du serveur, prouvé ou simulé | `votes` | +| une ligne de bulletin, quel que soit son sort | `ballots` | +| une adoption, une version entrée en vigueur ou confirmée, une inertie changée par un vote | `adopted` | +| une version écrite par un membre (texte, retrait, inertie, ordre) | `member_proposal` | +| une révision écrite par une personne, ou d'un genre qu'aucun amendement n'écrit ; une coquille, en attente ou refusée | `corrected` | +| une traduction écrite ou corrigée par un membre | `translation_edited` | +| une objection qui la nomme, levée ou non | `signalled` | +| un quorum fixé (`units.quorum`, `quorum_changes`) | `quorum_set` | + +Un amendement du seed n'est pas une trace de personne : F1.1 à sa révision 3, F1.2 et N1.1 à leur +révision 4 (après M6c) ou 5 (après M8) sortent comme les autres. Refusé, rien ne bouge ; la +décision est consignée (`retirement_amendments` : l'issue, la raison, l'unité, les codes sortis) +et dans les journaux. Appliqué là où le document publie (Monnaie libre), une ligne s'ajoute à la +file ; comme toutes, elle attend le premier texte en vigueur (ADR 0011). + +Les amendements qui réécrivaient ces textes (`2026-11-02-n1-regles`, `-o1-regles`, +`2026-11-03-f1-1-phrases`, `-f1-2-chiffres`, `-n1-1-chiffres`) sont **dépassés par le retrait** +(`superseded_by`) : le seed ne donne plus leur texte, ils ne se décident plus. Une production +restée à M6c sort donc F1.2 et N1.1 avec le texte de M6c. + +## Les textes d'amorce + +I1, E1 et E2 prennent les textes d'Yvv du 03/11 par trois amendements gardés de +`seed_amend.rs` sur la Proposition seed elle-même (`Version::SeedProposal`), jamais sur un texte +en vigueur : `2026-11-03-i1-amorce` (suit `2026-10-30-i1-raison-d-etre`), `2026-11-03-e1-amorce`, +`2026-11-03-e2-amorce`. Même garde que tout amendement du seed (une trace de personne refuse, +avec sa raison) ; la ligne d'historique `seed_proposal`, qui ne change aucun texte, est admise +quand l'amendement de Proposition seed l'a écrite, et seulement elle. Les amendements de +Proposition seed (`seed_proposal_amend.rs`) sont dépassés par eux. + +E12 entre par un ajout gardé (`crates/ld-server/src/article_amend.rs`, sur le modèle de +`module_amend`), une fois par document : écrit comme une base neuve l'écrit +(`seed::write_unit`), **entre E1 et E2** tant qu'aucun vote n'existe sur l'ordre de la section +(`quorum::voted_units`) et qu'ils s'y suivent — l'ordre en vigueur, qui lit les positions, le dit +aussitôt —, sinon **en fin de section**, comme tout nouvel article. Refus : `code_taken` (une +autre unité porte déjà ce code), `no_section`. Le code E12 suit la famille des engagements (E3 à +E11 existent) ; la place vient de l'ordre de la section, pas du numéro. + +## Ce que dira l'interface (lot U3) + +Les informations de fonctionnement se montrent **hors du vote** : la formule du seuil, les crans +d'inertie, le quorum, l'ordonnancement, à leur place dans l'écran (Yvv : « les infos doivent +trouver leur place dans l'ux »), d'après les paramètres du moteur et du document, pas d'après +un texte voté. Les vérités que F1.1, F1.2 et N1.1 portaient, chacune vérifiée par un test +(lots T à T3c, `t3c-liste-fermee.md`), en sont la référence ; leurs derniers textes restent dans +`crates/ld-server/tests/fixtures/seed-m8/` et, en production, dans leurs lignes. T4 ne touche pas +`web/`. + +## La file de publication + +Rien n'y est effacé. Les lignes écrites pour ces textes avant T4 (les amendements de M5 à M8, +« F1.2 », « N1.1 »…) restent telles quelles ; comme toute la file, elles attendent le premier +texte en vigueur (ADR 0011) et partent avec lui : leurs raisons sont des lignes du message de +commit, alors que le contenu publié (`document.json`, le Markdown) ne contient plus ces +articles ; la ligne du retrait, ajoutée à la même file, dit qu'ils sont sortis. + +## Le retour arrière + +L'image d'avant (`de1b7eb`) ne connaît pas `retired_at` : elle lit les unités sans cette colonne +et les montre de nouveau, **telles qu'elles étaient** (versions, révisions, traductions, ordres +intacts : le retrait n'a écrit que ses deux colonnes). Elle démarre sur la base migrée : les +migrations inconnues sont tolérées (`open_db`). Ce que le retour ne défait pas : E12 reste +(une Proposition seed, que l'image d'avant lit comme les autres), I1, E1 et E2 gardent leurs +textes de T4 (une révision de plus). + +**Prouvé** par le test `an_image_that_ignores_the_column_shows_the_rules_of_operation_as_they_were` +(`tests/amorce.rs` : la colonne remise à vide, la vue est celle d'avant), et le 2026-11-03 avec +l'image de `de1b7eb` elle-même, hors du dépôt : une base née du seed de production du 02/11 puis +démarrée par `de1b7eb` (un vote, un article de membre) ; l'image de T4 démarre dessus ; puis +`de1b7eb` de nouveau. Les sept articles et les trois ordres se lisent à l'identique dans les deux +groupes ; aucune ligne d'aucune table n'a disparu ; T4 n'a modifié que `retired_at` et +`retired_reason` (20 unités), et les trois Propositions seed amendées (6 versions, 12 +traductions) ; `de1b7eb` relancée n'a rien écrit (une session de lecture). **Sur une copie de la +production**, la même procédure : relever chaque ligne de chaque table (rowid compris) ; démarrer +l'image de T4 sur la copie, sans clé de publication ; relever de nouveau (aucune ligne absente, +aucune colonne changée hors de celles-là) ; démarrer `de1b7eb` sur la copie et comparer +`GET /api/docs/{id}` d'avant et d'après pour F1 à O1 et `ordre.formule`, `ordre.inertie`, +`ordre.ordonnancement`. + +Limite : le retrait n'est décidé qu'une fois ; si, après un +retour arrière, quelqu'un agit sur ces articles puis T4 revient, `retired_at` les sort de +nouveau sans relire la garde. Les remettre serait alors une décision (`retired_at` à NULL : une +écriture, pas un effacement). + +## Limites connues + +- **Une base d'avant M6** (I1, E1, E2 en vigueur, pas Propositions seed) : l'amendement de + Proposition seed étant dépassé, ils restent en vigueur avec leur texte, et les trois + amendements d'amorce refusent (`no_seed_proposal`). Aucune base réelle n'est dans ce cas. +- Un article de membre prend le code `N` (`proposals::new_unit`) d'après le nombre de codes + en `N` du document, unités sorties comprises : en production, N1 et N1.1 restent dans la base + et aucun code n'est repris ; dans une base neuve, le premier article de membre est N1, sans + rapport avec l'ancien « Réglage de l'inertie ». diff --git a/docs/adr/README.md b/docs/adr/README.md index cd01ec4..ebadd89 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,3 +18,4 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan | [0011](0011-propositions-seed.md) | Les Propositions seed : rien en vigueur au départ, trois textes mis en avant au cran 2, la publication attend le premier texte (complète 0002) | 2026-11-02 | | [0012](0012-tampon-des-votes.md) | Le tampon des votes : l'intention visible tout de suite, la preuve de la chaîne, la réconciliation a posteriori ; la simulation tant que la chaîne est fermée (complète 0008, révise 0010) | 2026-11-02 | | [0013](0013-quorum.md) | Le quorum par cran (12 · 12 · 50 · 50 · 50), fixé par l'auteur avant le premier vote ; sous le quorum, les votants qui manquent comptent comme « non » (remplace le C de 0002 et 0007) | 2026-11-03 | +| [0014](0014-informations-de-fonctionnement.md) | Les informations de fonctionnement ne se votent pas : formule, inertie, ordonnancement sortent du document sans rien effacer (`units.retired_at`) ; E12 « Responsabilité civique » ajoutée (complète 0009 et 0011) | 2026-11-03 | diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 8abfaf6..4556a21 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -17,13 +17,13 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. |---|---|---|---| | C1 | L'accueil, sans session | ouvrir `/` sans être connectée | la clé de voûte en grand, jamais inclinée ; sous l'arche, centrés et dans cet ordre : « boîte à outils », « contexte et cadrage », « processus et modalités » ; puis le titre centré sur deux lignes, « Rendre possible » / « la décision collective » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints | | C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici | -| C3 | Le texte au départ : les Propositions seed | Monnaie libre → Docs de référence → Acte d'engagement Certification | aucun article en vigueur (ADR 0011) : « Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition. » ; puis, **en tête du texte**, en entier, Raison d'être (sous « Introduction »), Unicité du compte et Réciprocité (sous « Engagements fondamentaux »), chacune marquée « Proposition seed » (au survol : « Texte de départ mis en avant pour lancer le vote… »), sur un lavis d'eau, avec « 0 pour · 0 contre · 9 oui sur 12 » et « basse » (au lecteur d'écran : « inertie 2 / 5 · basse ») ; chaque section lue finit par une ligne d'ordre (C25) ; en dessous, « Propositions en cours » **repliées par section** (8 lignes : le nom, « 11 articles », un petit niveau d'eau par article) ; « Tout déplier » : 30 lignes, une par article (les trois Propositions seed ne s'y répètent pas) | +| C3 | Le texte au départ : les Propositions seed | Monnaie libre → Docs de référence → Acte d'engagement Certification | aucun article en vigueur (ADR 0011) : « Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition. » ; puis, **en tête du texte**, en entier, Raison d'être (sous « Introduction »), Unicité du compte, Responsabilité civique et Réciprocité (sous « Engagements fondamentaux »), chacune marquée « Proposition seed » (au survol : « Texte de départ mis en avant pour lancer le vote… »), sur un lavis d'eau, avec « 0 pour · 0 contre · 9 oui sur 12 » et « basse » (au lecteur d'écran : « inertie 2 / 5 · basse ») ; chaque section lue finit par une ligne d'ordre (C25) ; en dessous, « Propositions en cours » **repliées par section** (5 lignes : le nom, « 11 articles », un petit niveau d'eau par article ; formule, inertie et ordonnancement sont sortis du document, ADR 0014) ; « Tout déplier » : 23 lignes, une par article (les quatre Propositions seed ne s'y répètent pas) | | C4 | Gestes contextuels | groupe d'essai, où la Raison d'être est entrée en vigueur par le vote : la survoler → « Le vote » | deux gestes sous l'article, « Le vote » et « Proposer une modification » (ses autres versions se lisent dans le vote) ; le vote se **déplie sous l'article**, sans fenêtre : « N oui sur Q votants pour passer » et les voix qui manquent ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie | | C5 | Entrée en vigueur | groupe d'essai (Raison d'être, Unicité du compte et Réciprocité entrées en vigueur par le vote) : déplier le vote de « Les deux garanties réciproques », « Pour » ; une deuxième personne si cette voix ne suffit pas | à la voix qui suffit, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (autant de « pour » que de voix, 0 contre) | | C6 | Coquille sans vote | groupe d'essai, **en administratrice** (un texte du seed n'a pas d'auteur) : Unicité du compte → « Le vote » → « Coquilles, traductions, objections, historique » (le panneau « Détails », sans onglets) → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » (sans être administratrice, la correction attend : C31) | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît | | C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit | -| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis aucun (rien en vigueur dans Monnaie libre, rien de bien coté sans « pour ») | +| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 27 articles avec la mention d'aperçu, puis aucun (rien en vigueur dans Monnaie libre, rien de bien coté sans « pour ») | | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | C11 | Une personne, une voix | avec un lien d'invitation et un portefeuille dont le compte n'est pas membre de la toile (Bob, chaîne simulée), voter « Pour » sur un article de Monnaie libre | le vote dit que la voix est celle de l'identité Ğ1 du portefeuille qui signe ; après signature : « Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1. », et sous l'article « Ton dernier vote n'a pas été compté… », 0 pour ; dans le groupe d'essai, la même personne vote directement, sans portefeuille | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | @@ -33,7 +33,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur | | C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) | | C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant | -| C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 41 modules à voter · dont 41 sans ton vote » (33 articles, le titre, l'ordre de 7 sections), puis « dont 40 » ; la ligne ouvre le lecteur | +| C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote » (27 articles, le titre, l'ordre de 5 sections), puis « dont 32 » ; la ligne ouvre le lecteur | | C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification → « Si tout passe », puis EN, puis ES | en tête du texte, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » | | C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Tout déplier » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version déplie son vote sous elle (cran 3, quorum 50 : « 39 oui sur 50 votants pour passer »), sans « Autres versions » : l'autre version est juste là, dans la liste (lot U2) ; un nouveau clic sur la ligne la replie | | C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis, en tête du texte, Unicité du compte (Proposition seed) → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité (entrée en vigueur par le vote) → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois 50, le quorum de référence : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; quorum 12, 12, 50, 50, 50 (ADR 0013) ; voix pour à 100 votants 60, 67, 75, 83, 90, « Voix pour : à 100 votants, comme d'habitude. », « Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude. », « Quorum : en dessous, les votants qui manquent comptent comme « non ». … » ; depuis le vote déplié : « Inertie · 2 / 5 · basse », la ligne du cran 2 marquée « cet article », à 0 votant 8, 9, 39, 43, 46 voix, « Voix pour : à 0 votant, la participation de cette version. » ; le sélecteur montre les cinq crans, 2 « actuelle », atteignable au clavier mais jamais choisi ; un seul arrêt de tabulation ; les flèches passent d'un cran à l'autre et le choisissent, en boucle ; le vote de la proposition se déplie : « inertie vers 5 / 5 · très haute », votée à « inertie 2 / 5 · basse » | @@ -41,20 +41,20 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour | | C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité (Propositions seed), « Signer et envoyer (2) » ; puis, en tête du texte, sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : la Raison d'être → « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous la proposition « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre | | C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous la première Proposition seed : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et « 9 oui sur 12 votants pour passer » restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là | -| C25 | L'ordre des articles | groupe d'essai : « Tout déplier » ; en fin de « Conclusion », la ligne « Ordre de la section » → « Réordonner » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (quorum 1 dans le groupe d'essai : ADR 0013) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par **une ligne** « Ordre de la section » (7, aucune pour l'Ordonnancement, un seul article) : la poignée, les crans « basse », « seed a minima · 0 vote », **jamais la liste de ses articles** ; à la fin du texte quand la section s'y lit, sinon à la fin de sa section dans « Propositions en cours » ; le vote d'un ordre en vigueur déplié sous sa ligne montre la liste entière, et son inertie une fois, « Inertie · 2 / 5 · basse » (lot U2) ; « Réordonner » : les articles de la section, sur place, sans panneau ; l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur, et il le dit à côté (« C'est l'ordre en vigueur : déplace au moins un article. ») ; le vote de l'ordre proposé se déplie sous la ligne, l'ordre proposé en clair, « Ordre en vigueur » parmi ses « Autres versions », « 1 ordre proposé » sur la ligne ; aucune ligne d'ordre parmi les propositions ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, les gestes du titre sans survol (réordonner sur un téléphone : C35) | +| C25 | L'ordre des articles | groupe d'essai : « Tout déplier » ; en fin de « Conclusion », la ligne « Ordre de la section » → « Réordonner » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (quorum 1 dans le groupe d'essai : ADR 0013) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par **une ligne** « Ordre de la section » (5 ; aucune pour la formule, l'inertie, l'ordonnancement, sortis du document : ADR 0014) : la poignée, les crans « basse », « seed a minima · 0 vote », **jamais la liste de ses articles** ; à la fin du texte quand la section s'y lit, sinon à la fin de sa section dans « Propositions en cours » ; le vote d'un ordre en vigueur déplié sous sa ligne montre la liste entière, et son inertie une fois, « Inertie · 2 / 5 · basse » (lot U2) ; « Réordonner » : les articles de la section, sur place, sans panneau ; l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur, et il le dit à côté (« C'est l'ordre en vigueur : déplace au moins un article. ») ; le vote de l'ordre proposé se déplie sous la ligne, l'ordre proposé en clair, « Ordre en vigueur » parmi ses « Autres versions », « 1 ordre proposé » sur la ligne ; aucune ligne d'ordre parmi les propositions ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, les gestes du titre sans survol (réordonner sur un téléphone : C35) | | C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre, une ligne : « Titre du document · seed a minima · 0 vote », les crans « inertie 4 / 5 · haute », deux gestes ; le panneau nommé « Proposer un autre titre » ; le titre proposé se vote **sous le titre** (« 1 titre proposé »), jamais parmi les propositions ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre, son inertie dite une fois (« Inertie · 4 / 5 · haute ») ; en anglais, l'ancien titre traduit, marqué « translation needs review » | | C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude. » | | C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris | | C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste | | C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » | -| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » | +| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est l'unique » → « C’est l’unique » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est l'unique enjeu ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est l’unique enjeu » et l'historique « validée par Ada » | | C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page | | C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote--.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé | | C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « Voter en lot », « Contre » encore, « Voter (1) » ; puis « Tes bulletins » | « Voter en lot » est là, marqué « vote simulé » ; aucune fenêtre de portefeuille ; « Vote simulé. », **jamais** « Vote enregistré. » ; « Ton vote : contre · simulé » aussitôt, et un « contre » de plus dans les totaux ; dans l'API, `myVoteState` « simulated » et `tallySimulated` ; le panier : « 1 vote simulé. », jamais « enregistré » ; dans « Tes bulletins » : « Simulé : compté pour « Sacha » tant que les votes simulés ne sont pas effacés », sans bloc (l'ouverture de la chaîne n'efface rien : c'est un acte humain, sans date promise) | -| C35 | Réordonner une section, par poignées, puis voter (lot U) | groupe d'essai : à la fin des « Engagements fondamentaux », la ligne « Ordre de la section » → « Réordonner » ; glisser la poignée de Réciprocité sur Unicité du compte ; au clavier, flèche haut puis bas ; « Descendre « Réciprocité » » ; cliquer Réciprocité puis la place 2 ; « Annuler », puis « Réordonner » et « Monter « Réciprocité » » ; « Proposer cet ordre » ; « Pour » (une deuxième voix si la sienne ne suffit pas) ; puis, sur un téléphone, dans Monnaie libre : « Tout déplier », « Ordre des articles · Engagements techniques » → « Réordonner » | la ligne ne répète aucun article ; « Réordonner » : les articles de la section, en vigueur ou seulement proposés (« Engagements fondamentaux » marqué « proposé »), sur place (pas de panneau), une poignée devant chacun, les articles en vigueur de la section repliés dans la liste (pas deux fois) ; chaque geste annonce la place (« « Réciprocité » en place 2 sur 3. ») ; « Proposer cet ordre » éteint sur l'ordre en vigueur, et il le dit à côté, lu aussi comme sa description (« C'est l'ordre en vigueur : déplace au moins un article. ») : la phrase s'en va dès qu'un article bouge et revient quand l'ordre redevient celui en vigueur (lot U2) ; « Annuler » rend la section telle qu'elle se lisait ; « Ordre proposé au vote. », son vote déplié aussitôt sous la ligne, l'ordre proposé en clair, « 1 ordre proposé » ; « Vote enregistré. » ; en vigueur, la section se lit dans le nouvel ordre, « adopté le … » ; sur un téléphone : 11 articles, rien ne déborde de la page, l'article, sa poignée et ses flèches font au moins 44 px | +| C35 | Réordonner une section, par poignées, puis voter (lot U) | groupe d'essai : à la fin des « Engagements fondamentaux », la ligne « Ordre de la section » → « Réordonner » ; glisser la poignée de Réciprocité sur Responsabilité civique ; au clavier, flèche haut puis bas ; « Descendre « Réciprocité » » ; cliquer Réciprocité puis la place 2 ; « Annuler », puis « Réordonner » et deux fois « Monter « Réciprocité » » ; « Proposer cet ordre » ; « Pour » (une deuxième voix si la sienne ne suffit pas) ; puis, sur un téléphone, dans Monnaie libre : « Tout déplier », « Ordre des articles · Engagements techniques » → « Réordonner » | la ligne ne répète aucun article ; « Réordonner » : les articles de la section, en vigueur ou seulement proposés (« Engagements fondamentaux » marqué « proposé »), sur place (pas de panneau), une poignée devant chacun, les articles en vigueur de la section repliés dans la liste (pas deux fois) ; chaque geste annonce la place (« « Réciprocité » en place 3 sur 4. ») ; « Proposer cet ordre » éteint sur l'ordre en vigueur, et il le dit à côté, lu aussi comme sa description (« C'est l'ordre en vigueur : déplace au moins un article. ») : la phrase s'en va dès qu'un article bouge et revient quand l'ordre redevient celui en vigueur (lot U2) ; « Annuler » rend la section telle qu'elle se lisait ; « Ordre proposé au vote. », son vote déplié aussitôt sous la ligne, l'ordre proposé en clair, « 1 ordre proposé » ; « Vote enregistré. » ; en vigueur, la section se lit dans le nouvel ordre, « adopté le … » ; sur un téléphone : 11 articles, rien ne déborde de la page, l'article, sa poignée et ses flèches font au moins 44 px | | C36 | Combien de oui pour passer, combien de non pour bloquer (ADR 0013 ; Yvv, 02/11) | Monnaie libre : la Raison d'être (en tête) → « Le vote » ; « Tout déplier » ; puis, l'automate donnant à une proposition du groupe d'essai les totaux 9 pour · 4 contre (cran 2, quorum 12), puis 3 pour · 0 contre | avant tout vote, sous l'article : « 9 oui sur 12 votants pour passer », au survol « Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ». » ; la ligne de la Proposition seed, juste au-dessus, ne redit plus les totaux ni les oui (lot U2) ; dans la liste, en bref : « 39 oui sur 50 » (cran 3) ; à 9 pour · 4 contre : « 11 oui pour passer · 4 non pour bloquer » sous l'article, « passe à 11 oui · bloquée à 4 non » dans la liste, « Il manque 2 voix pour. » ; à 3 pour · 0 contre : « 9 oui pour passer · le quorum la bloque », « passe à 9 oui » | | C37 | Le quorum d'un article, par son autrice, avant le premier vote (ADR 0013) | groupe d'essai : proposer un article, puis son vote → « Quorum » : 3 → « Fixer ce quorum » ; « Revenir au défaut (1) » ; 100000 → « Fixer ce quorum » ; une autre personne ouvre le même vote, puis vote « contre » ; l'autrice tente 2 | le réglage n'apparaît qu'à l'autrice (`canSetQuorum`) : « Quorum · 1 votant », « Par défaut, celui de son cran : 1 votant. », le champ « Quorum de l'article » ; « Quorum fixé : 3 votants. », la ligne dit « … oui sur 3 votants pour passer » ; « Quorum revenu au défaut : 1 votant. » ; hors bornes (400) : « Ce quorum sort des bornes : au moins 2 votants (1 dans le groupe d'essai), au plus … » ; l'autre personne ne voit pas le réglage ; après son vote (409) : « Quelqu'un a déjà voté sur cet article : son quorum ne change pas. » (lot T2), puis le réglage disparaît | -| C38 | Ce que compte l'en-tête ; un changement d'inertie d'un ordre ou du titre (lot U2) | Monnaie libre : lire la ligne de faits sous le résumé ; « Tout déplier », compter les versions de la liste ; puis, dans le groupe d'essai, proposer un changement d'inertie de l'ordre de « Conclusion » et du titre (l'automate passe par `POST /api/units/{id}/inertia`, puis les retire) | « 0 article en vigueur · 3 Propositions seed · N propositions en cours », N le nombre de versions que montre la liste : les Propositions seed lues en tête sont dites à part, les ordres et titres proposés ne sont pas comptés (ils se votent à la ligne de leur module) ; sous le titre, la pastille « 1 changement d'inertie proposé », qui déplie son vote sous le titre ; à la fin de « Conclusion », la même pastille sur la ligne d'ordre, qui déplie son vote là ; aucun « inertie vers … » dans « Propositions en cours », et l'en-tête compte toujours ce que la liste montre | +| C38 | Ce que compte l'en-tête ; un changement d'inertie d'un ordre ou du titre (lot U2) | Monnaie libre : lire la ligne de faits sous le résumé ; « Tout déplier », compter les versions de la liste ; puis, dans le groupe d'essai, proposer un changement d'inertie de l'ordre de « Conclusion » et du titre (l'automate passe par `POST /api/units/{id}/inertia`, puis les retire) | « 0 article en vigueur · 4 Propositions seed · N propositions en cours », N le nombre de versions que montre la liste : les Propositions seed lues en tête sont dites à part, les ordres et titres proposés ne sont pas comptés (ils se votent à la ligne de leur module) ; sous le titre, la pastille « 1 changement d'inertie proposé », qui déplie son vote sous le titre ; à la fin de « Conclusion », la même pastille sur la ligne d'ordre, qui déplie son vote là ; aucun « inertie vers … » dans « Propositions en cours », et l'en-tête compte toujours ce que la liste montre | ## Au-delà de la liste @@ -99,3 +99,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-11-03 | automate | local, lot T3 sur `b2d653e` (N1.1 et F1.2 sans chiffre daté, chaque phrase vraie pour tout W et tout quorum ; aucun contrôle changé) | C1–C38, M1, M2 | 40 / 40 verts (42 tests) au second passage ; le premier, 40 / 42 : C33 et C38 tombés sur `net::ERR_NETWORK_CHANGED` (le réseau du poste a changé pendant le passage, vu dans leurs traces) | | 2026-11-03 | automate | local, lot T3b sur `582d6ba` (après la relecture de `2ce2028` : la phrase de G de N1.1, « autant de « pour » » ; aucun contrôle changé) | C1–C38, M1, M2 | 40 / 40 verts (42 tests) au second passage ; le premier, 40 / 42 : C8 et C32 tombés sur `net::ERR_NETWORK_CHANGED` (modules de l'interface non chargés, vu dans leurs traces) | | 2026-11-03 | automate | local, lot T3c sur `42c803e` (après la relecture de `e7ab82f` : « à la participation habituelle » à la place de « comme d'habitude » dans F1.2 et N1.1, « le quorum de l'article », « la part exigée », « repose sur moins de 5 propositions », « ramenée » ; aucun contrôle changé) | C1–C38, M1, M2 | 40 / 40 verts (42 tests), du premier coup (19 h 51 - 19 h 55, sous le verrou `/tmp/ld-e2e.lock`) | +| 2026-11-03 | automate | local, lot T4 sur `3996f6f` (les textes d'Yvv du 03/11 pour I1, E1, E2 ; E12 « Responsabilité civique » entre E1 et E2 ; formule, inertie et ordonnancement sortis du document, ADR 0014 : C3, C9, C17, C18, C25, C31, C35, C38 repris ; C35 glisse d'une place) | C1–C38, M1, M2 | 40 / 40 verts (42 tests) à la boucle finale (21 h 47 - 21 h 50, sous le verrou `/tmp/ld-e2e.lock`) ; avant, 39 / 42 (C18, C31, C35 lisaient l'ancien texte d'I1 ou trois articles), puis 41 / 42 (C35 : un glisser de deux places réordonne mais n'est pas annoncé, signalé pour U3) | diff --git a/docs/chantiers/acte-certification/textes-amorce.md b/docs/chantiers/acte-certification/textes-amorce.md new file mode 100644 index 0000000..9f4864e --- /dev/null +++ b/docs/chantiers/acte-certification/textes-amorce.md @@ -0,0 +1,296 @@ +# Textes d'amorce et structure de l'Acte d'engagement (lot T4) + +> Préparé le 2026-11-03 pour ta relecture, Yvv (lot T4, branche `lot/t4-amorce`), d'après ta +> demande du 03/11 vers 20 h 38 : « Modif des textes Acte engagement » (I1, E1, un article +> nouveau « Responsabilité civique », E2) et « les 3 derniers modules ne sont pas de votes : +> ordonnancement, inertie, et formule de vote sont des infos de fonctionnement […] Vire les 3 du +> bas de la liste. » Les textes sont dans `seeds/acte-engagement-certification.json` et dans des +> amendements gardés pour la production ; la décision durable est l'ADR 0014. **Rien n'est en +> ligne.** L'interface des informations de fonctionnement est le lot U3. + +## À confirmer : les choix faits pour toi + +Chacun est appliqué ; dis lequel tu changes. + +1. **« il y a une et une seule clé certifiante ».** Ta phrase disait « derrière un être humain, + il n'y a une et une seule clé certifiante ». Lu comme une coquille, sur le parallèle avec + « il y a un et un seul être humain vivant ». L'autre lecture serait « il n'y a qu'une seule + clé » (le texte d'avant). +2. **I1 en trois paragraphes**, le premier en italique entre « », comme aujourd'hui, avec les + espaces insécables du texte d'avant (à l'intérieur des guillemets, devant le point-virgule). +3. **Tes virgules, gardées telles quelles** : « de nature contractuelle, qui fonde l'usage » + (I1) et « qui s'engagent elles-mêmes, à scrupuleusement respecter » (E2). La seconde sépare + le verbe de son complément ; sans elle : « qui s'engagent elles-mêmes à scrupuleusement + respecter ». En anglais et en espagnol, la première est rendue, la seconde non (elle n'y + aurait pas de sens). +4. **Le deuxième paragraphe d'I1** est le tien mot pour mot : « C'est l'unique enjeu de la + certification et de votre engagement, il donne à notre toile fiduciaire sa valeur : … ». +5. **« ces trois principes fondamentaux »** (E2) se lit, comme « ces deux présents engagements » + avant, sur les articles de la section : E1, E12 et E2. +6. **L'article nouveau** : code **E12**, Proposition seed (ouverte, mise en avant, 0 vote), au + **cran 2** comme I1, E1 et E2, section « fondamental », **entre E1 et E2**. Titres : « Civic + responsibility », « Responsabilidad cívica ». Le code : E3 à E11 existent, E12 est le premier + libre de la famille des engagements ; c'est un identifiant, la place vient de l'ordre de la + section (ADR 0009), pas du numéro. Aucune unité ne porte E12, en production non plus (les + articles de membre prennent des codes en N). +7. **Les titres restent** : « Raison d'être », « Unicité du compte », « Réciprocité ». +8. **Les traductions** : l'anglais britannique et les guillemets “ ” des textes d'aujourd'hui ; + l'espagnol avec «» serrés. « votre engagement » est rendu « your commitment » et « su + compromiso » (le vouvoiement, comme le français). « l'unique enjeu » : « the one thing at + stake », « lo único que está en juego ». +9. **En production, rien n'est effacé** (ADR 0014) : les sept articles et les trois ordres + sortent du document par une marque (`units.retired_at`), et seulement si personne n'y a + laissé de trace. L'ordre de « fondamental » prend E12 entre E1 et E2 tant que personne n'a + voté sur cet ordre ; sinon E12 va en fin de section, comme tout nouvel article. + +## Le document après T4 + +Cinq sections, 27 articles : introduction (I1, I2) ; fondamental (T1, E1, **E12**, E2) ; +technique (T2, E3 à E7, T3, E8 à E11) ; conclusion (K1, K2) ; annexe (X1 à X2.3). Quatre Propositions +seed au cran 2 : I1, E1, E12, E2. Aucun article en vigueur au départ (ADR 0011). « Modules à +voter » : 33 (27 articles, le titre, l'ordre de 5 sections), contre 41 avant. + +## Les textes, avant et après + +Les blocs sont les textes exacts du seed (Markdown : `*…*` est l'italique), espaces insécables compris. « Avant » : le texte en ligne aujourd'hui, celui du seed de M8. + +### I1 — Raison d'être + +#### I1 · Français (fait foi) + +Titre : **Raison d'être** + +Avant : + +```markdown +*« 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. »* + +C'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. + +Cet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre. +``` + +Après : + +```markdown +*« Derrière une clé il y a un et un seul être humain vivant ; derrière un être humain, il y a une et une seule clé certifiante. »* + +C'est l'unique enjeu de la certification et de votre engagement, il donne à notre toile fiduciaire sa valeur : pouvoir nous fier à l'unicité et l'intégrité de tout geste signé par cette clé. + +Cet acte d'engagement est par ailleurs le seul acte de nature contractuelle, qui fonde l'usage de la première monnaie libre. +``` + +#### I1 · English + +Titre : **Purpose** + +Avant : + +```markdown +*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”* + +This 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. + +This commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded. +``` + +Après : + +```markdown +*“Behind a key there is one and only one living human being; behind a human being, there is one and only one certifying key.”* + +This is the one thing at stake in certification and in your commitment; it gives our fiduciary web its value: being able to rely on the uniqueness and integrity of every action signed with this key. + +This commitment act is, moreover, the only act of a contractual nature, which founds the use of the first libre currency. +``` + +#### I1 · Español + +Titre : **Razón de ser** + +Avant : + +```markdown +*«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.»* + +Este 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. + +Esta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre. +``` + +Après : + +```markdown +*«Detrás de una clave hay un solo y único ser humano vivo; detrás de un ser humano, hay una sola y única clave certificadora.»* + +Es lo único que está en juego en la certificación y en su compromiso; da su valor a nuestra red fiduciaria: poder fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. + +Esta acta de compromiso es, además, el único acto de naturaleza contractual, que funda el uso de la primera moneda libre. +``` + +### E1 — Unicité du compte + +#### E1 · Français (fait foi) + +Titre : **Unicité du compte** + +Avant : + +```markdown +Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1. +``` + +Après : + +```markdown +Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant. +``` + +#### E1 · English + +Titre : **Uniqueness of the account** + +Avant : + +```markdown +I commit on my honour to have, now and ever, no more than one single and unique account co-creating G1 money. +``` + +Après : + +```markdown +I commit on my honour to have, now and ever, no more than one single and unique certifying account. +``` + +#### E1 · Español + +Titre : **Unicidad de la cuenta** + +Avant : + +```markdown +Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta cocreadora de moneda G1. +``` + +Après : + +```markdown +Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta certificadora. +``` + +### E12 — Responsabilité civique (nouveau) + +#### E12 · Français (fait foi) + +Titre : **Responsabilité civique** + +```markdown +Je déclare sur l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la responsabilité civique, pour certifier à mon tour. +``` + +#### E12 · English + +Titre : **Civic responsibility** + +```markdown +I declare on my honour that I have the legal and physical capacity to sign a contract and to assume its civic responsibility, in order to certify in my turn. +``` + +#### E12 · Español + +Titre : **Responsabilidad cívica** + +```markdown +Declaro por mi honor tener la capacidad legal y física de firmar un contrato y de asumir su responsabilidad cívica, para certificar a mi vez. +``` + +### E2 — Réciprocité + +#### E2 · Français (fait foi) + +Titre : **Réciprocité** + +Avant : + +```markdown +Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent scrupuleusement ces deux présents engagements fondamentaux. +``` + +Après : + +```markdown +Je m'engage sur l'honneur à ne certifier que des personnes physiques qui s'engagent elles-mêmes, à scrupuleusement respecter ces trois principes fondamentaux. +``` + +#### E2 · English + +Titre : **Reciprocity** + +Avant : + +```markdown +I commit on my honour to certify only natural persons who scrupulously respect these two fundamental commitments. +``` + +Après : + +```markdown +I commit on my honour to certify only natural persons who themselves commit to scrupulously respecting these three fundamental principles. +``` + +#### E2 · Español + +Titre : **Reciprocidad** + +Avant : + +```markdown +Me comprometo por mi honor a certificar únicamente a personas físicas que respeten escrupulosamente estos dos compromisos fundamentales. +``` + +Après : + +```markdown +Me comprometo por mi honor a certificar únicamente a personas físicas que se comprometan ellas mismas a respetar escrupulosamente estos tres principios fundamentales. +``` + +## Ce qui sort du document + +| Section | Article | Titre | +|---|---|---| +| formule | F1 | Formule de vote | +| formule | F1.1 | Formule du seuil WoT | +| formule | F1.2 | Paramètres par défaut | +| formule | F1.3 | Processus de dépôt officiel | +| inertie | N1 | Réglage de l'inertie | +| inertie | N1.1 | Niveaux d'inertie | +| ordonnancement | O1 | Ordonnancement du document | + +Et l'ordre des articles de chacune de ces trois sections (`ordre.formule`, `ordre.inertie`, +`ordre.ordonnancement`). Ils ne sont plus ni listés, ni votables (vote, proposition, coquille, +objection, inertie, quorum : refusés), ni comptés, ni publiés. Aucun autre article ne les +cite. + +**Leurs textes ne se perdent pas.** Ce qu'ils disaient de vrai (la formule, les crans, le +quorum, chaque phrase vérifiée par un test des lots T à T3c, `t3c-liste-fermee.md`) est la +référence de l'écran du lot U3, qui les montrera hors du vote, à leur place dans l'interface. +Leurs derniers textes restent dans `crates/ld-server/tests/fixtures/seed-m8/` et, en +production, dans leurs lignes. + +## En production + +- **I1, E1, E2** : trois amendements gardés (`2026-11-03-i1-amorce`, `-e1-amorce`, + `-e2-amorce`) réécrivent la Proposition seed, une fois par document, seulement si le texte en + place est celui qu'on attend (son empreinte) et que personne n'y a touché : un vote, un + bulletin, une coquille, une traduction de membre, une objection suffisent à garder le texte, + avec sa raison. L'ancien texte reste dans l'historique de la version. +- **E12** : un ajout gardé (`2026-11-03-e12-responsabilite-civique`), une fois par document. +- **Formule, inertie, ordonnancement** : le retrait `2026-11-03-fonctionnement-hors-vote`, + une fois par document, tous ou aucun. +- **La file de publication** (suspendue jusqu'au premier texte en vigueur) : rien n'y est + effacé. Les lignes déjà écrites pour F1.2, N1.1… restent et partiront avec le premier texte, + comme lignes du message de commit ; les textes publiés ne contiennent plus ces articles. T4 + ajoute ses propres lignes (les trois textes, E12, le retrait). +- **Un retour arrière** vers l'image d'avant remet les sept articles et les trois ordres tels + qu'ils étaient (vérifié avec l'image `de1b7eb`) ; E12 et les nouveaux textes d'I1, E1, E2 + restent. diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 850dca6..a4965e9 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). 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 », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. 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). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran. Trois phrases reprises après la relecture de M6b (lot T2) : la part du cran, la plus haute au quorum, baisse quand la participation monte (F1.1) ; l'auteur fixe le quorum tant que l'article ne garde la trace d'aucun vote (F1.2) ; le quorum n'est pas un nombre minimal de votants (N1.1). Lot T3 (Yvv, 03/11 : « pas de chiffre datés dans un texte voté ») : l'exemple chiffré de N1.1 retiré (aucun nombre des textes ne dépend de W ni d'une participation) ; N1.1, sous le quorum, il faut autant de « pour » que si le quorum avait voté (vrai pour tout quorum) ; N1.1 et F1.2, la part du cran comme d'habitude au-delà du quorum. Après la relecture de 2ce2028 (lot T3b, même amendement) : N1.1, G se calcule pour que la part exigée, au-delà du quorum, soit celle du cran quand le groupe vote comme d'habitude (vrai pour tout W et tout quorum). Après la relecture de e7ab82f (lot T3c, mêmes amendements) : F1.2 et N1.1 écrivent le terme défini, « à la participation habituelle » (la médiane ramenée entre 1 % et 50 % de W, celle sur laquelle G se calcule), à la place de « comme d'habitude », faux quand la médiane sort de ses bornes ; N1.1, « sous le quorum de l'article », « la part exigée », « Tant qu'elle repose sur moins de 5 propositions », « Elle est ramenée entre 1 % et 50 % » (liste fermée des phrases : docs/chantiers/acte-certification/t3c-liste-fermee.md).", + "_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, E12, 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 », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. 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). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend, article_amend, retire), jamais en réinstanciant le document. Lot T4 (Yvv, 2026-11-03 : « Modif des textes Acte engagement ») : I1, E1 et E2 réécrits ; E12 « Responsabilité civique », Proposition seed, entre E1 et E2 ; les sections formule (F1, F1.1, F1.2, F1.3), inertie (N1, N1.1) et ordonnancement (O1) sortent du document : ce sont des informations de fonctionnement, qui ne se votent pas (ADR 0014) ; leurs derniers textes restent dans crates/ld-server/tests/fixtures/seed-m8/ (textes relus dans docs/chantiers/acte-certification/textes-amorce.md).", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -50,9 +50,9 @@ "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." + "fr": "*« Derrière une clé il y a un et un seul être humain vivant ; derrière un être humain, il y a une et une seule clé certifiante. »*\n\nC'est l'unique enjeu de la certification et de votre engagement, il donne à notre toile fiduciaire sa valeur : pouvoir nous fier à l'unicité et l'intégrité de tout geste signé par cette clé.\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 one and only one living human being; behind a human being, there is one and only one certifying key.”*\n\nThis is the one thing at stake in certification and in your commitment; it gives our fiduciary web its value: being able to rely on the uniqueness and integrity of every action signed with this key.\n\nThis commitment act is, moreover, the only act of a contractual nature, which founds the use of the first libre currency.", + "es": "*«Detrás de una clave hay un solo y único ser humano vivo; detrás de un ser humano, hay una sola y única clave certificadora.»*\n\nEs lo único que está en juego en la certificación y en su compromiso; da su valor a nuestra red fiduciaria: poder fiarnos de la unicidad y la integridad de toda acción firmada con esta clave.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual, que funda el uso de la primera moneda libre." } } ] @@ -116,9 +116,31 @@ "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." + "fr": "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant.", + "en": "I commit on my honour to have, now and ever, no more than one single and unique certifying account.", + "es": "Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta certificadora." + } + } + ] + }, + { + "code": "E12", + "section": "fondamental", + "position": 4.5, + "versions": [ + { + "label": "1.0.0", + "status": "open", + "seedProposal": true, + "title": { + "fr": "Responsabilité civique", + "en": "Civic responsibility", + "es": "Responsabilidad cívica" + }, + "body": { + "fr": "Je déclare sur l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la responsabilité civique, pour certifier à mon tour.", + "en": "I declare on my honour that I have the legal and physical capacity to sign a contract and to assume its civic responsibility, in order to certify in my turn.", + "es": "Declaro por mi honor tener la capacidad legal y física de firmar un contrato y de asumir su responsabilidad cívica, para certificar a mi vez." } } ] @@ -138,9 +160,9 @@ "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." + "fr": "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui s'engagent elles-mêmes, à scrupuleusement respecter ces trois principes fondamentaux.", + "en": "I commit on my honour to certify only natural persons who themselves commit to scrupulously respecting these three fundamental principles.", + "es": "Me comprometo por mi honor a certificar únicamente a personas físicas que se comprometan ellas mismas a respetar escrupulosamente estos tres principios fundamentales." } } ] @@ -634,160 +656,6 @@ } } ] - }, - { - "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": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».", - "en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.", - "es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»." - } - } - ] - }, - { - "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- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. Dans Monnaie libre, jamais moins de 2 : 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 %, un quorum de 50 dans Monnaie libre, et, au-delà du quorum, le groupe demande 75 % de « pour » à sa participation habituelle. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, 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- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as the article keeps no trace of any vote. In Monnaie libre, never less than 2: 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%, a quorum of 50 in Monnaie libre, and, beyond the quorum, the group asks for 75% of votes in favour at its usual turnout. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, 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- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras el artículo no guarde rastro de ningún voto. En Monnaie libre, nunca menos de 2: 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 %, un quórum de 50 en Monnaie libre, y, más allá del quórum, el grupo pide un 75 % de votos a favor con su participación habitual. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, 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 une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum : sous le quorum de l'article, les votants qui manquent comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté. La part exigée dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà de ce quorum, 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)`, où T' est le nombre de votants, ou le quorum de l'article s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, la part exigée au-delà du quorum de l'article, à la participation habituelle, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % à la participation habituelle, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % à la participation habituelle, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % à la participation habituelle, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % à la participation habituelle, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % à la participation habituelle, 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 qu'elle repose sur moins de 5 propositions, elle vaut deux fois le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle est ramenée 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, au-delà du quorum de l'article, soit exactement celle du cran à la participation habituelle : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est cette part du cran (60, 67, 75, 83 ou 90 %) et p̃ la participation habituelle divisée par W.", - "en": "At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum: below the article's quorum, the voters who are missing count as votes against, and it takes as many votes in favour as if that quorum had voted. The share asked depends on the level and on the number of voters, compared with the group's usual turnout: beyond that quorum, 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)`, where T' is the number of voters, or the article's quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked beyond the article's quorum, at the usual turnout, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% at the usual turnout, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% at the usual turnout, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% at the usual turnout, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% at the usual turnout, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% at the usual turnout, 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. While it rests on fewer than 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It is kept between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, beyond the article's quorum, is exactly that of the level at the usual turnout: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is that share of the level (60, 67, 75, 83 or 90%) and p̃ the usual turnout divided by W.", - "es": "En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum: por debajo del quórum del artículo, los votantes que faltan cuentan como «no», y hacen falta tantos votos a favor como si hubiera votado ese quórum. La proporción exigida depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá de ese quórum, 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)`, donde T' es el número de votantes, o el quórum del artículo si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida más allá del quórum del artículo, con la participación habitual, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % con la participación habitual, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % con la participación habitual, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % con la participación habitual, 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**: quórum 50; 83 % con la participación habitual, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % con la participación habitual, 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. Mientras se base en menos de 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se acota 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, más allá del quórum del artículo, sea exactamente la del nivel con la participación habitual: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es esa proporción del nivel (60, 67, 75, 83 o 90 %) y p̃ la participación habitual dividida por W." - } - } - ] - }, - { - "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/web/e2e/entree.spec.ts b/web/e2e/entree.spec.ts index 99376cb..51a4563 100644 --- a/web/e2e/entree.spec.ts +++ b/web/e2e/entree.spec.ts @@ -142,10 +142,10 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to const rows = page.locator('ul.lines > li'); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText("Acte d'engagement Certification"); - // 33 articles, the title of the document and the order of 7 sections (ADR 0009; the - // section of a single article has no order to decide). - await expect(rows.first()).toContainText('41 modules à voter'); - await expect(rows.first()).toContainText('dont 41 sans ton vote'); + // 27 articles, the title of the document and the order of its 5 sections (ADR 0009; since + // lot T4, the rules of operation are out of the document, ADR 0014). + await expect(rows.first()).toContainText('33 modules à voter'); + await expect(rows.first()).toContainText('dont 33 sans ton vote'); // A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre » // counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie // libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's @@ -165,8 +165,8 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to .poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 }) .toBe('confirmed'); await page.reload(); - await expect(rows.first()).toContainText('41 modules à voter'); - await expect(rows.first()).toContainText('dont 40 sans ton vote'); + await expect(rows.first()).toContainText('33 modules à voter'); + await expect(rows.first()).toContainText('dont 32 sans ton vote'); await rows.first().getByRole('link').click(); await expect(page).toHaveURL(path(DOC)); }); diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index 908967f..130e27e 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -119,7 +119,8 @@ export async function testDoc(page: Page): Promise<{ * force in the test group, as the vote does it: voices for until each enters, then those * voices withdrawn. A version in force stays so (a permanent vote: only a rival displaces * it), at zero votes, as the controls written before the seed proposals found it. Never in - * Monnaie libre, whose vote goes through the chain; nothing done twice. + * Monnaie libre, whose vote goes through the chain; nothing done twice. E12 « Responsabilité + * civique », the fourth (lot T4), stays a seed proposal: open, as C35 reads it. */ export async function seedsInForce(browser: Browser) { const voices = [await person(browser, 'Amorce A'), await person(browser, 'Amorce B')]; diff --git a/web/e2e/modules.spec.ts b/web/e2e/modules.spec.ts index f21f6a5..38696bd 100644 --- a/web/e2e/modules.spec.ts +++ b/web/e2e/modules.spec.ts @@ -42,9 +42,10 @@ test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée await signIn(page, 'Chiara'); await page.goto(TEST_DOC); await unfold(page); - // Each section ends with its order, in one line; a section of one article has nothing to decide. + // Each section ends with its order, in one line: five sections (formule, inertie and + // ordonnancement are out of the document, ADR 0014). const modules = page.getByRole('group', { name: /^Ordre des articles · / }); - await expect(modules).toHaveCount(7); + await expect(modules).toHaveCount(5); await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0); const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' }); await expect(order).toContainText('Ordre de la section'); @@ -62,7 +63,7 @@ test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }); // Its inertia, said once, in its own place (lot U2). await expect(fondVote.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible(); - await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']); + await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Responsabilité civique', 'Réciprocité']); await fond.getByRole('button', { name: 'Le vote' }).click(); // Another order: « Réordonner », the article, then its new place — in place, no panel. diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts index 30aff62..d84f530 100644 --- a/web/e2e/objections.spec.ts +++ b/web/e2e/objections.spec.ts @@ -243,11 +243,11 @@ test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s const panel = vera.locator('aside.panel'); await panel.getByText('Corriger une coquille').click(); const body = panel.getByLabel('Texte', { exact: true }); - await body.fill((await body.inputValue()).replace("C'est le seul", 'C’est le seul')); + await body.fill((await body.inputValue()).replace("C'est l'unique", 'C’est l’unique')); await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible(); // In force, only a validated fix changes the text. - await expect(purpose).toContainText("C'est le seul et unique propos"); + await expect(purpose).toContainText("C'est l'unique enjeu"); await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le"); await ada.reload(); @@ -258,7 +258,7 @@ test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s await expect(ada.getByText('Correction appliquée.')).toBeVisible(); await vera.reload(); - await expect(purpose).toContainText('C’est le seul et unique propos'); + await expect(purpose).toContainText('C’est l’unique enjeu'); const doc = await testDoc(vera); const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être"); const after = await details(vera, unit?.inForce?.id ?? ''); diff --git a/web/e2e/ordre-quorum.spec.ts b/web/e2e/ordre-quorum.spec.ts index 7d1d5be..4a2047c 100644 --- a/web/e2e/ordre-quorum.spec.ts +++ b/web/e2e/ordre-quorum.spec.ts @@ -10,6 +10,8 @@ test.beforeAll(async ({ browser }) => seedsInForce(browser)); const EF = 'Engagements fondamentaux'; const E1 = 'Unicité du compte'; +// Lot T4 (Yvv, 2026-11-03): between the two, a seed proposal, left open by `seedsInForce`. +const EC = 'Responsabilité civique'; const E2 = 'Réciprocité'; const ORDER = `Ordre des articles · ${EF}`; const CSRF = { 'x-requested-with': 'libredecision' }; @@ -41,11 +43,11 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss await expect(page.locator('aside.panel')).toHaveCount(0); const list = page.locator('section.text').getByRole('list', { name: 'Nouvel ordre des articles' }); const names = list.locator('button.item .name'); - await expect(names).toHaveText([EF, E1, E2]); + await expect(names).toHaveText([EF, E1, EC, E2]); // The articles in force of the section are in the list now, not twice; the one only proposed says so. await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(0); await expect(list.locator('li', { hasText: EF })).toContainText('proposé'); - await expect(list.locator('.grip')).toHaveCount(3); + await expect(list.locator('.grip')).toHaveCount(4); const submit = page.getByRole('button', { name: 'Proposer cet ordre' }); await expect(submit).toBeDisabled(); // Off, and it says why (UX-lecteur.md: « le bouton reste éteint et le dit » — lot U2). @@ -54,43 +56,45 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss await expect(submit).toHaveAccessibleDescription(why); await shot(page, 'c35-2-poignees'); - // By dragging its handle: Réciprocité onto the place of Unicité du compte. - await list.locator('li', { hasText: E2 }).locator('.grip').dragTo(list.locator('li', { hasText: E1 })); - await expect(names).toHaveText([EF, E2, E1]); - await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached(); + // By dragging its handle: Réciprocité onto the place of the article above it, Responsabilité + // civique (lot T4; a drag over two rows is not announced, see the report of lot T4 for U3). + await list.locator('li', { hasText: E2 }).locator('.grip').dragTo(list.locator('li', { hasText: EC })); + await expect(names).toHaveText([EF, E1, E2, EC]); + await expect(page.getByText(`« ${E2} » en place 3 sur 4.`)).toBeAttached(); await expect(submit).toBeEnabled(); await expect(page.getByText(why)).toHaveCount(0); // With the keyboard: the article keeps the focus, the arrows move it. const items = list.locator('button.item'); - await items.nth(1).focus(); + await items.nth(2).focus(); await page.keyboard.press('ArrowUp'); - await expect(names).toHaveText([E2, EF, E1]); - await expect(items.nth(0)).toBeFocused(); + await expect(names).toHaveText([EF, E2, E1, EC]); + await expect(items.nth(1)).toBeFocused(); await page.keyboard.press('ArrowDown'); - await expect(names).toHaveText([EF, E2, E1]); + await expect(names).toHaveText([EF, E1, E2, EC]); // Without dragging: its arrows, or an article then its new place. await list.getByRole('button', { name: `Descendre « ${E2} »` }).click(); - await expect(names).toHaveText([EF, E1, E2]); + await expect(names).toHaveText([EF, E1, EC, E2]); await expect(submit).toBeDisabled(); await expect(page.getByText(why)).toBeVisible(); - await items.nth(2).click(); - await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true'); + await items.nth(3).click(); + await expect(items.nth(3)).toHaveAttribute('aria-pressed', 'true'); await items.nth(1).click(); - await expect(names).toHaveText([EF, E2, E1]); + await expect(names).toHaveText([EF, E2, E1, EC]); // « Annuler » gives the section back as it read. await page.getByRole('button', { name: 'Annuler' }).click(); await expect(list).toHaveCount(0); await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(1); await line.getByRole('button', { name: 'Réordonner' }).click(); await list.getByRole('button', { name: `Monter « ${E2} »` }).click(); - await expect(names).toHaveText([EF, E2, E1]); + await list.getByRole('button', { name: `Monter « ${E2} »` }).click(); + await expect(names).toHaveText([EF, E2, E1, EC]); // Then the vote: the order goes to the vote, and its vote unfolds at once, in the same place. await submit.click(); await expect(page.getByText('Ordre proposé au vote.')).toBeVisible(); const fold = line.locator('section.fold'); await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${ORDER} »`); - await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([EF, E2, E1]); + await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([EF, E2, E1, EC]); await expect(line).toContainText('1 ordre proposé'); const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, ''); await fold.getByRole('button', { name: 'Pour', exact: true }).click(); @@ -257,7 +261,7 @@ test('C38 — l’en-tête compte ce que montre la liste ; un changement d’ine await page.goto(DOC); await unfold(page); const facts = page.locator('.facts'); - await expect.soft(facts).toContainText('0 article en vigueur · 3 Propositions seed · '); + await expect.soft(facts).toContainText('0 article en vigueur · 4 Propositions seed · '); await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`)); // A change of inertia on the order of a section and on the title (test group): each is found at diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index d7f3690..8ba1678 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -37,7 +37,7 @@ test('C2 — l’accueil avec session : le choix du groupe, ceux de la même cl await expect(page).toHaveURL((u) => u.pathname === '/'); }); -test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deux engagements, Propositions seed, ouvrent le texte', async ({ page }) => { +test('C3 — au départ, aucun texte en vigueur : la raison d’être et les trois engagements, Propositions seed, ouvrent le texte', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification"); @@ -45,20 +45,22 @@ test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deu // read whole at the head of the text (lot U), each marked: never the bare page of a text in force. await expect(page.getByText("Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition.")).toBeVisible(); const seeds = page.locator('section.text article.unit'); - await expect(seeds.locator('h2')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']); - await expect(seeds.locator('.tag', { hasText: 'Proposition seed' })).toHaveCount(3); - await expect(seeds.getByTitle("Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.")).toHaveCount(3); + // Lot T4 (Yvv, 2026-11-03): « Responsabilité civique » between the two engagements. + await expect(seeds.locator('h2')).toHaveText(["Raison d'être", 'Unicité du compte', 'Responsabilité civique', 'Réciprocité']); + await expect(seeds.locator('.tag', { hasText: 'Proposition seed' })).toHaveCount(4); + await expect(seeds.getByTitle("Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.")).toHaveCount(4); // At the low level they start at, how far each is from passing. for (const seed of await seeds.all()) await expect(seed).toContainText('0 pour · 0 contre · 9 oui sur 12'); for (const seed of await seeds.all()) await expect(seed).toContainText('inertie 2 / 5 · basse'); // Below, the open proposals, folded by section; the seed proposals are not said twice. await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); - await expect(page.locator('ul.deep li.section')).toHaveCount(8); + // Five sections: formule, inertie and ordonnancement are out of the document (ADR 0014). + await expect(page.locator('ul.deep li.section')).toHaveCount(5); await expect(page.locator('ul.deep button.row')).toHaveCount(0); await expect(page.locator('ul.deep li.section', { hasText: 'Engagements techniques' })).toContainText('11 articles'); await unfold(page); - // One line per article: the 33 less the 3 at the head, E3 and E7 holding their two variants inside. - await expect(page.locator('ul.deep button.row')).toHaveCount(30); + // One line per article: the 27 less the 4 at the head, E3 and E7 holding their two variants inside. + await expect(page.locator('ul.deep button.row')).toHaveCount(23); await expect(page.locator('ul.deep .tag', { hasText: 'Proposition seed' })).toHaveCount(0); }); @@ -172,7 +174,7 @@ test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => { await page.goto(DOC); await page.getByRole('radio', { name: 'Si tout passe' }).click(); await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible(); - await expect(page.locator('section.text article.unit')).toHaveCount(33); + await expect(page.locator('section.text article.unit')).toHaveCount(27); // Nothing in force in Monnaie libre, and nothing well rated yet (no « pour » there so far). await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click(); await expect(page.locator('section.text article.unit')).toHaveCount(0); @@ -267,8 +269,9 @@ test('C18 — la Raison d’être ouvre le texte, en trois langues', async ({ pa // The first sentence in italics, then two paragraphs. const paragraphs = first.locator('.prose p'); await expect(paragraphs).toHaveCount(3); - await expect(paragraphs.nth(0).locator('em')).toContainText("Derrière une clé il n'y a qu'un seul être humain vivant"); - await expect(paragraphs.nth(1)).toContainText("C'est le seul et unique propos de la certification"); + // The text of Yvv of 2026-11-03 (lot T4). + await expect(paragraphs.nth(0).locator('em')).toContainText('Derrière une clé il y a un et un seul être humain vivant'); + await expect(paragraphs.nth(1)).toContainText("C'est l'unique enjeu de la certification et de votre engagement"); await expect(paragraphs.nth(2)).toContainText('la première monnaie libre'); await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click(); await expect(first.locator('h2')).toHaveText('Purpose');