Lot T4 : les textes et la structure de l'Acte d'engagement (Yvv, 03/11) — I1, E1, E2 prennent les textes d'Yvv (fr, en, es ; « il y a une et une seule clé » lu comme la coquille de « il n'y a une et une seule ») ; E12 « Responsabilité civique », Proposition seed au cran 2, entre E1 et E2 ; les sections formule, inertie, ordonnancement sortent du document (ADR 0014). Migration 0012 additive (units.retired_at et retired_reason, retirement_amendments, article_amendments) ; une unité retirée n'est ni lue avec son document, ni listée, ni votée, ni proposée, corrigée, traduite ou objectée (404), ni comptée, ni publiée ; rien d'effacé. Amendements gardés, une fois par document : 2026-11-03-i1-amorce, -e1-amorce, -e2-amorce (Version::SeedProposal ; la ligne seed_proposal admise quand l'amendement de Proposition seed l'a écrite), 2026-11-03-e12-responsabilite-civique (article_amend : entre E1 et E2 tant que l'ordre de la section n'a pas de vote, sinon en fin de section), 2026-11-03-fonctionnement-hors-vote (retire : tous ou aucun, refus à la moindre trace d'une personne, avec sa raison et l'unité) ; les amendements dont le seed ne donne plus le texte, dépassés (superseded_by) ; seed::write_unit partagé

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 21:09:00 +01:00
co-authored by Claude Opus 5.5
parent 4365ed607a
commit dda1ca85dd
14 changed files with 1067 additions and 265 deletions
@@ -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)
);
+380
View File
@@ -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<bool>,
/// 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<Placement>,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ArticleAmendment]) -> anyhow::Result<Vec<Outcome>> {
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<String>)> =
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
.bind(a.doc)
.fetch_all(&state.db)
.await?;
for (doc_id, group, publish_path) in docs {
let Some((v, 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<Facts> {
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<String>, 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<String> = 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<f64> = 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<Option<(Verdict, Option<Placement>)>> {
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, &section).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, &section).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"));
}
}
+12 -7
View File
@@ -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<UnitRow>,
/// The units that are modules (ADR 0009), by unit id; any other is an article.
pub module_kinds: HashMap<String, ModuleKind>,
@@ -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<DocState> {
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<DocState> {
// 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)
+2
View File
@@ -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;
+4 -2
View File
@@ -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)
+4 -2
View File
@@ -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<Vec<Module>> {
@@ -169,7 +170,8 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
WHERE b.requested_by = ?1 AND b.status = 'pending' AND b.mode = 'chain'
AND l.proposal_id = p.id AND l.revision = p.revision AND l.choice <> '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)
+5
View File
@@ -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))
}
+6 -3
View File
@@ -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<String> {
let at: Option<String> = 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)
+365
View File
@@ -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<String>,
}
/// Decides every retirement not yet decided, document by document.
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Retirement]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
if !seed_says(seeds, a) {
continue;
}
let docs: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
.bind(a.doc)
.fetch_all(&state.db)
.await?;
for (doc_id, group, publish_path) in docs {
let Some((v, 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<UnitFacts> {
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<Option<(Verdict, Vec<String>)>> {
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"]);
}
}
+102 -75
View File
@@ -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<String> {
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())
+78 -8
View File
@@ -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 {
@@ -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 {
+11 -4
View File
@@ -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<String, i64>| {
t.iter().filter(|(k, _)| !matches!(k.as_str(), "identities" | "sessions" | "memberships")).map(|(k, n)| (k.clone(), *n)).collect::<Vec<_>>()
};
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")]);