From dda1ca85dd11267aa0bdacfefd0dc774a0186d2f Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 21:09:00 +0100 Subject: [PATCH] =?UTF-8?q?Lot=20T4=20:=20les=20textes=20et=20la=20structu?= =?UTF-8?q?re=20de=20l'Acte=20d'engagement=20(Yvv,=2003/11)=20=E2=80=94=20?= =?UTF-8?q?I1,=20E1,=20E2=20prennent=20les=20textes=20d'Yvv=20(fr,=20en,?= =?UTF-8?q?=20es=20;=20=C2=AB=20il=20y=20a=20une=20et=20une=20seule=20cl?= =?UTF-8?q?=C3=A9=20=C2=BB=20lu=20comme=20la=20coquille=20de=20=C2=AB=20il?= =?UTF-8?q?=20n'y=20a=20une=20et=20une=20seule=20=C2=BB)=20;=20E12=20?= =?UTF-8?q?=C2=AB=20Responsabilit=C3=A9=20civique=20=C2=BB,=20Proposition?= =?UTF-8?q?=20seed=20au=20cran=202,=20entre=20E1=20et=20E2=20;=20les=20sec?= =?UTF-8?q?tions=20formule,=20inertie,=20ordonnancement=20sortent=20du=20d?= =?UTF-8?q?ocument=20(ADR=200014).=20Migration=200012=20additive=20(units.?= =?UTF-8?q?retired=5Fat=20et=20retired=5Freason,=20retirement=5Famendments?= =?UTF-8?q?,=20article=5Famendments)=20;=20une=20unit=C3=A9=20retir=C3=A9e?= =?UTF-8?q?=20n'est=20ni=20lue=20avec=20son=20document,=20ni=20list=C3=A9e?= =?UTF-8?q?,=20ni=20vot=C3=A9e,=20ni=20propos=C3=A9e,=20corrig=C3=A9e,=20t?= =?UTF-8?q?raduite=20ou=20object=C3=A9e=20(404),=20ni=20compt=C3=A9e,=20ni?= =?UTF-8?q?=20publi=C3=A9e=20;=20rien=20d'effac=C3=A9.=20Amendements=20gar?= =?UTF-8?q?d=C3=A9s,=20une=20fois=20par=20document=20:=202026-11-03-i1-amo?= =?UTF-8?q?rce,=20-e1-amorce,=20-e2-amorce=20(Version::SeedProposal=20;=20?= =?UTF-8?q?la=20ligne=20seed=5Fproposal=20admise=20quand=20l'amendement=20?= =?UTF-8?q?de=20Proposition=20seed=20l'a=20=C3=A9crite),=202026-11-03-e12-?= =?UTF-8?q?responsabilite-civique=20(article=5Famend=20:=20entre=20E1=20et?= =?UTF-8?q?=20E2=20tant=20que=20l'ordre=20de=20la=20section=20n'a=20pas=20?= =?UTF-8?q?de=20vote,=20sinon=20en=20fin=20de=20section),=202026-11-03-fon?= =?UTF-8?q?ctionnement-hors-vote=20(retire=20:=20tous=20ou=20aucun,=20refu?= =?UTF-8?q?s=20=C3=A0=20la=20moindre=20trace=20d'une=20personne,=20avec=20?= =?UTF-8?q?sa=20raison=20et=20l'unit=C3=A9)=20;=20les=20amendements=20dont?= =?UTF-8?q?=20le=20seed=20ne=20donne=20plus=20le=20texte,=20d=C3=A9pass?= =?UTF-8?q?=C3=A9s=20(superseded=5Fby)=20;=20seed::write=5Funit=20partag?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/migrations/0012_retired.sql | 49 +++ crates/ld-server/src/article_amend.rs | 380 +++++++++++++++++++ crates/ld-server/src/docs.rs | 19 +- crates/ld-server/src/lib.rs | 2 + crates/ld-server/src/order.rs | 6 +- crates/ld-server/src/overview.rs | 6 +- crates/ld-server/src/proposals.rs | 5 + crates/ld-server/src/publish.rs | 9 +- crates/ld-server/src/retire.rs | 365 ++++++++++++++++++ crates/ld-server/src/seed.rs | 177 +++++---- crates/ld-server/src/seed_amend.rs | 86 ++++- crates/ld-server/src/seed_proposal_amend.rs | 17 + crates/ld-server/tests/amorce.rs | 15 +- seeds/acte-engagement-certification.json | 196 ++-------- 14 files changed, 1067 insertions(+), 265 deletions(-) create mode 100644 crates/ld-server/migrations/0012_retired.sql create mode 100644 crates/ld-server/src/article_amend.rs create mode 100644 crates/ld-server/src/retire.rs 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..ccf0348 --- /dev/null +++ b/crates/ld-server/src/article_amend.rs @@ -0,0 +1,380 @@ +//! 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..f3d941e 100644 --- a/crates/ld-server/src/order.rs +++ b/crates/ld-server/src/order.rs @@ -37,12 +37,14 @@ 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); never a + // unit out of the document (ADR 0014). 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 WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible - FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id", + FROM units u WHERE u.doc_id = ? AND u.kind = 'article' AND u.retired_at IS NULL + ORDER BY u.position, u.id", ) .bind(doc_id) .fetch_all(&mut *conn) 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..4829360 --- /dev/null +++ b/crates/ld-server/src/retire.rs @@ -0,0 +1,365 @@ +//! 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 (`order::sections_now`, `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..19cd53b 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 { 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 index 4dc3e4c..fad03da 100644 --- a/crates/ld-server/tests/amorce.rs +++ b/crates/ld-server/tests/amorce.rs @@ -526,6 +526,9 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) { 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(); @@ -609,8 +612,6 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) { // Nothing erased, anywhere, the publication queue included: every row of before is // there, and each table holds exactly what lot T4 adds. - let after = tables(&state).await; - let after_rows = rowids(&state).await; for (t, ids) in &before_rows { let missing: Vec<&i64> = ids.difference(&after_rows[t]).collect(); assert!(missing.is_empty(), "{t}: rows erased {missing:?}"); @@ -650,9 +651,15 @@ async fn production_gets_lot_t4_once(base: AppState, rules_revision: i64) { 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. + // 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, after); + 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")]); 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." - } - } - ] } ] }