forked from yvv/decision
901 lines
40 KiB
Rust
901 lines
40 KiB
Rust
//! Seed amendments — a starting text replaced by a newer text of the seed.
|
|
//!
|
|
//! The text replaced is an article's version in force (a seed a minima), or, since
|
|
//! 2026-11-02, one of its open proposals from the seed, named by its label: the
|
|
//! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv).
|
|
//! The guards are the same. Since the same day, a starting text may be a seed
|
|
//! proposal instead of a text in force (ADR 0011): where an article has no text
|
|
//! in force, `Version::InForce` examines its seed proposal (a new base: present).
|
|
//!
|
|
//! Since 2026-11-03 (lot T), a text may be amended twice: F1.1, F1.2 and N1.1 say
|
|
//! the quorum by level (ADR 0013) instead of the floor C, and F1.2 and N1.1 had
|
|
//! been rewritten by the amendments of 02/11. An amendment that `follows` an
|
|
//! earlier one replaces the text that one wrote. The same day (lot T2, after the
|
|
//! review of M6b), three phrases of those texts are written true by amendments that
|
|
//! follow lot T's: F1.1 is then at its revision 2, F1.2 and N1.1 at their revision 3.
|
|
//! Still the same day (lot T3, Yvv: no dated figure in a voted text), F1.2 and N1.1 are
|
|
//! written without a figure that depends on the web of trust, and true for every quorum,
|
|
//! by amendments that follow lot T2's: they are then at their revision 4; F1.1 is left.
|
|
//! So an amendment admits, besides the seed's own revision 1, the revision written
|
|
//! by the amendments it follows (`earlier`: the one it follows, the one that one
|
|
//! follows…), applied in that order, the last being the one it follows, and nothing
|
|
//! else between them: a revision n + 1 whose history is « initial » then n times
|
|
//! « amendment », on which those n amendments, and they alone, were applied
|
|
//! (`seed_amendments`), its text the one the amendment replaces (fingerprint).
|
|
//! 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).
|
|
//!
|
|
//! A seeded document is never instantiated again: once in the database, it is
|
|
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
|
|
//! the Préambule becomes « Raison d'être »), new bases get it from the seed;
|
|
//! an existing base gets it through an amendment listed here, and **only if
|
|
//! nobody has touched the article**:
|
|
//!
|
|
//! - its version in force is the seed's own, a *seed a minima* (never voted
|
|
//! into force, never confirmed), at revision 1 — or further, written by the
|
|
//! amendments it follows;
|
|
//! - no trace of a vote is on it — a vote that counts (`votes`: a vote of the server,
|
|
//! proven by the chain, or simulated), or a line of a ballot of the buffer, pending
|
|
//! or proven, whatever became of it (ADR 0012) — and nobody corrected it, edited
|
|
//! one of its translations or named it in a contradiction signal (an objection).
|
|
//! Two votes leave no trace, and so do not keep the text: in a group without the
|
|
//! buffer (the test group), a vote of the server withdrawn (`votes` keeps the last
|
|
//! choice only); and a simulated vote once the act « supprime » took it away
|
|
//! (`simulation::discard`);
|
|
//! - its text is exactly the one the amendment replaces (fingerprint).
|
|
//!
|
|
//! Each document is decided once, at the first start that knows the
|
|
//! amendment, and the decision is kept in `seed_amendments`: applied, refused
|
|
//! (and why — also in the logs), or present (the base was born with the new
|
|
//! text). Applied, the text and its seed translations are replaced in one
|
|
//! transaction, the article's history gets a line of its own (kind
|
|
//! `amendment`, with its diff) and, where the document publishes, the
|
|
//! publication queue a row.
|
|
|
|
use crate::AppState;
|
|
use crate::seed::{SeedDoc, SeedVersion};
|
|
use crate::util;
|
|
use sqlx::SqliteConnection;
|
|
|
|
/// Which version of the article an amendment replaces.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Version {
|
|
/// Its starting text: in force (a seed a minima), or its seed proposal (ADR 0011).
|
|
InForce,
|
|
/// An open proposal of the seed, at zero votes, by its label.
|
|
Open(&'static str),
|
|
}
|
|
|
|
/// One starting text to replace, in every document instantiated from a seed.
|
|
pub struct Amendment {
|
|
/// Stable identifier: each document's decision is kept under it.
|
|
pub id: &'static str,
|
|
/// Slug of the seed document.
|
|
pub doc: &'static str,
|
|
/// Code of the article.
|
|
pub unit: &'static str,
|
|
/// The version replaced.
|
|
pub version: Version,
|
|
/// Fingerprint of the starting text expected in force (source language).
|
|
pub from: &'static str,
|
|
/// Fingerprint of the new text: the version in force of the seed today, which
|
|
/// must hash to it (a seed edited later never reaches a base by this way).
|
|
pub to: &'static str,
|
|
/// Reason given to the publication queue, and so to the commit of the texts.
|
|
pub reason: &'static str,
|
|
/// 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.
|
|
pub superseded_by: Option<&'static str>,
|
|
}
|
|
|
|
pub const AMENDMENTS: &[Amendment] = &[
|
|
Amendment {
|
|
id: "2026-10-30-i1-raison-d-etre",
|
|
doc: "acte-engagement-certification",
|
|
unit: "I1",
|
|
version: Version::InForce,
|
|
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
|
|
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
|
reason: "texte de départ remplacé : I1",
|
|
follows: None,
|
|
superseded_by: None,
|
|
},
|
|
// The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02).
|
|
Amendment {
|
|
id: "2026-11-02-f1-2-regles",
|
|
doc: "acte-engagement-certification",
|
|
unit: "F1.2",
|
|
version: Version::Open("1.0.0"),
|
|
from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6",
|
|
to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60",
|
|
reason: "proposition de départ réécrite : F1.2",
|
|
follows: None,
|
|
superseded_by: Some("2026-11-03-f1-2-quorum"),
|
|
},
|
|
Amendment {
|
|
id: "2026-11-02-n1-regles",
|
|
doc: "acte-engagement-certification",
|
|
unit: "N1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142",
|
|
to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e",
|
|
reason: "proposition de départ réécrite : N1",
|
|
follows: None,
|
|
superseded_by: None,
|
|
},
|
|
Amendment {
|
|
id: "2026-11-02-n1-1-regles",
|
|
doc: "acte-engagement-certification",
|
|
unit: "N1.1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee",
|
|
to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0",
|
|
reason: "proposition de départ réécrite : N1.1",
|
|
follows: None,
|
|
superseded_by: Some("2026-11-03-n1-1-quorum"),
|
|
},
|
|
Amendment {
|
|
id: "2026-11-02-o1-regles",
|
|
doc: "acte-engagement-certification",
|
|
unit: "O1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642",
|
|
to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537",
|
|
reason: "proposition de départ réécrite : O1",
|
|
follows: None,
|
|
superseded_by: None,
|
|
},
|
|
// 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
|
|
// text the amendments of 02/11 wrote.
|
|
Amendment {
|
|
id: "2026-11-03-f1-1-quorum",
|
|
doc: "acte-engagement-certification",
|
|
unit: "F1.1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "258980be190192fe3c7b32f6319584b682621d01329d880b7056c28c84e9db0a",
|
|
to: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa",
|
|
reason: "proposition de départ réécrite : F1.1, le quorum par cran",
|
|
follows: None,
|
|
superseded_by: Some("2026-11-03-f1-1-phrases"),
|
|
},
|
|
Amendment {
|
|
id: "2026-11-03-f1-2-quorum",
|
|
doc: "acte-engagement-certification",
|
|
unit: "F1.2",
|
|
version: Version::Open("1.0.0"),
|
|
from: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60",
|
|
to: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9",
|
|
reason: "proposition de départ réécrite : F1.2, le quorum par cran",
|
|
follows: Some("2026-11-02-f1-2-regles"),
|
|
superseded_by: Some("2026-11-03-f1-2-phrases"),
|
|
},
|
|
Amendment {
|
|
id: "2026-11-03-n1-1-quorum",
|
|
doc: "acte-engagement-certification",
|
|
unit: "N1.1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0",
|
|
to: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48",
|
|
reason: "proposition de départ réécrite : N1.1, le quorum par cran",
|
|
follows: Some("2026-11-02-n1-1-regles"),
|
|
superseded_by: Some("2026-11-03-n1-1-phrases"),
|
|
},
|
|
// The three phrases the review of M6b left inexact (lot T2, 2026-11-03): the share of the
|
|
// level is highest at the quorum (F1.1); the author sets the quorum while the article keeps
|
|
// no trace of a vote (F1.2); the quorum is no minimum of voters (N1.1). Each replaces the
|
|
// text lot T wrote: F1.1 at its revision 2, F1.2 and N1.1 at their revision 3.
|
|
Amendment {
|
|
id: "2026-11-03-f1-1-phrases",
|
|
doc: "acte-engagement-certification",
|
|
unit: "F1.1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa",
|
|
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,
|
|
},
|
|
Amendment {
|
|
id: "2026-11-03-f1-2-phrases",
|
|
doc: "acte-engagement-certification",
|
|
unit: "F1.2",
|
|
version: Version::Open("1.0.0"),
|
|
from: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9",
|
|
to: "96caf353b33805f2894593d72c5b70fce2e751039cd7f4651d8842c986729a14",
|
|
reason: "proposition de départ réécrite : F1.2, le quorum fixé tant qu'aucun vote n'a laissé de trace",
|
|
follows: Some("2026-11-03-f1-2-quorum"),
|
|
superseded_by: Some("2026-11-03-f1-2-chiffres"),
|
|
},
|
|
Amendment {
|
|
id: "2026-11-03-n1-1-phrases",
|
|
doc: "acte-engagement-certification",
|
|
unit: "N1.1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48",
|
|
to: "7fbedcb8750c208f8790c4f87dc21d8bd1096a27a1a0044af6b9b8f2057f2aa8",
|
|
reason: "proposition de départ réécrite : N1.1, le quorum n'est pas un minimum de votants",
|
|
follows: Some("2026-11-03-n1-1-quorum"),
|
|
superseded_by: Some("2026-11-03-n1-1-chiffres"),
|
|
},
|
|
// No dated figure in a voted text (lot T3, Yvv, 2026-11-03: « pas de chiffre datés dans un
|
|
// texte voté »), every sentence true for every W and every quorum: N1.1 loses its example
|
|
// (7 224 members, 120 voters) and says that under the quorum it takes as many « pour » as if
|
|
// the quorum had voted; N1.1 and F1.2 ask the share of a level « comme d'habitude » beyond the
|
|
// quorum. Each replaces the text lot T2 wrote, at its revision 4. F1.1 is not touched.
|
|
Amendment {
|
|
id: "2026-11-03-f1-2-chiffres",
|
|
doc: "acte-engagement-certification",
|
|
unit: "F1.2",
|
|
version: Version::Open("1.0.0"),
|
|
from: "96caf353b33805f2894593d72c5b70fce2e751039cd7f4651d8842c986729a14",
|
|
to: "ddce5aa5fcd13dfc3e374c37509a1bb8cfafddefb6e6add5a06b130b43216d62",
|
|
reason: "proposition de départ réécrite : F1.2, la part du cran par défaut au-delà du quorum",
|
|
follows: Some("2026-11-03-f1-2-phrases"),
|
|
superseded_by: None,
|
|
},
|
|
Amendment {
|
|
id: "2026-11-03-n1-1-chiffres",
|
|
doc: "acte-engagement-certification",
|
|
unit: "N1.1",
|
|
version: Version::Open("1.0.0"),
|
|
from: "7fbedcb8750c208f8790c4f87dc21d8bd1096a27a1a0044af6b9b8f2057f2aa8",
|
|
to: "4289e3579ee747f6023162339ed60601b810d33462617b42a9f8b5b3735c3238",
|
|
reason: "proposition de départ réécrite : N1.1, sans chiffre daté ; sous le quorum, autant de voix pour que si le quorum avait voté",
|
|
follows: Some("2026-11-03-n1-1-phrases"),
|
|
superseded_by: None,
|
|
},
|
|
];
|
|
|
|
/// The fingerprint of a text: the hash translations are pinned to.
|
|
pub fn fingerprint(title: &str, body: &str) -> String {
|
|
util::source_hash(title, body)
|
|
}
|
|
|
|
/// What the database says of the version in force of the article.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Facts {
|
|
pub origin: String,
|
|
/// Voted into force (`entered_at`).
|
|
pub entered: bool,
|
|
/// A seed a minima that later reached its threshold (`confirmed_at`).
|
|
pub confirmed: bool,
|
|
pub revision: i64,
|
|
/// Votes cast on it, whatever their revision.
|
|
pub votes: i64,
|
|
/// Lines of ballots of the buffer naming it, pending or proven, whatever became of them.
|
|
pub ballots: i64,
|
|
/// The seed amendments applied on it, in order.
|
|
pub amendments: Vec<String>,
|
|
/// Kinds of its revisions, in order.
|
|
pub revisions: Vec<String>,
|
|
/// Translations written or corrected by a member.
|
|
pub member_translations: i64,
|
|
/// Contradiction signals (objections since 2026-10-31) naming it, standing or lifted.
|
|
pub signals: i64,
|
|
/// Typo fixes someone else wrote on it, whatever became of them (2026-10-31).
|
|
pub typos: i64,
|
|
pub fingerprint: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Verdict {
|
|
Apply,
|
|
/// The new text is already in force: nothing to do.
|
|
Present,
|
|
Refuse(&'static str),
|
|
}
|
|
|
|
/// The amendments whose text `a` replaces, oldest first: the one it follows, the one that
|
|
/// one follows, and so on, through `list`. Empty when it follows none.
|
|
pub fn earlier(list: &[Amendment], a: &Amendment) -> Vec<&'static str> {
|
|
let mut out = Vec::new();
|
|
let mut at = a.follows;
|
|
while let Some(id) = at {
|
|
if out.contains(&id) {
|
|
break;
|
|
}
|
|
out.insert(0, id);
|
|
at = list.iter().find(|e| e.id == id).and_then(|e| e.follows);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The rule, apart from the database: any trace of a person keeps the text. `earlier`:
|
|
/// the amendments `a` follows, oldest first (`earlier`).
|
|
pub fn verdict(facts: Option<&Facts>, a: &Amendment, earlier: &[&str]) -> Verdict {
|
|
let Some(f) = facts else {
|
|
return Verdict::Refuse(match a.version {
|
|
Version::InForce => "no_text_in_force",
|
|
Version::Open(_) => "no_open_version",
|
|
});
|
|
};
|
|
if f.fingerprint == a.to {
|
|
return Verdict::Present;
|
|
}
|
|
// 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.
|
|
let n = f.amendments.len();
|
|
let after_earlier = n > 0
|
|
&& f.revision == 1 + n as i64
|
|
&& earlier.len() >= n
|
|
&& earlier[earlier.len() - n..].iter().zip(&f.amendments).all(|(e, applied)| e == applied);
|
|
let history: Vec<&str> =
|
|
std::iter::once("initial").chain(std::iter::repeat_n("amendment", if after_earlier { n } else { 0 })).collect();
|
|
let refusal = if f.origin != "seed" {
|
|
"not_a_seed"
|
|
} else if f.entered {
|
|
"voted_into_force"
|
|
} else if f.confirmed {
|
|
"confirmed"
|
|
} else if f.revision != 1 && !after_earlier {
|
|
"revised"
|
|
} else if f.votes > 0 {
|
|
"votes"
|
|
} else if f.ballots > 0 {
|
|
"ballots"
|
|
} else if f.revisions != history || f.typos > 0 {
|
|
"corrected"
|
|
} else if f.member_translations > 0 {
|
|
"translation_edited"
|
|
} else if f.signals > 0 {
|
|
"signalled"
|
|
} else if f.fingerprint != a.from {
|
|
"text_differs"
|
|
} else {
|
|
return Verdict::Apply;
|
|
};
|
|
Verdict::Refuse(refusal)
|
|
}
|
|
|
|
/// A decision taken at this start.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Outcome {
|
|
pub amendment: &'static str,
|
|
pub group: String,
|
|
pub verdict: Verdict,
|
|
}
|
|
|
|
/// Decides every amendment not yet decided, document by document.
|
|
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Amendment]) -> anyhow::Result<Vec<Outcome>> {
|
|
let mut out = Vec::new();
|
|
for a in list {
|
|
let Some((seed, target)) = target_of(seeds, a) else { continue };
|
|
let before = earlier(list, a);
|
|
let docs: Vec<(String, String, Option<String>)> =
|
|
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
|
.bind(a.doc)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
for (doc_id, group, publish_path) in docs {
|
|
let Some(v) = decide(state, a, &before, seed, target, &doc_id, publish_path.is_some()).await? else {
|
|
continue;
|
|
};
|
|
match v {
|
|
Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"),
|
|
Verdict::Present => {
|
|
tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment: text already there")
|
|
}
|
|
Verdict::Refuse(why) => {
|
|
tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "seed amendment refused: the article was touched, it keeps its text")
|
|
}
|
|
}
|
|
if v == Verdict::Apply && publish_path.is_some() {
|
|
state.publish_wake.notify_one();
|
|
}
|
|
out.push(Outcome { amendment: a.id, group, verdict: v });
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The seed document and its version in force for the article, checked against `to`.
|
|
fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'a SeedVersion)> {
|
|
let Some(seed) = seeds.iter().find(|s| s.slug == a.doc) else {
|
|
tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided");
|
|
return None;
|
|
};
|
|
let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| {
|
|
u.versions.iter().find(|v| match a.version {
|
|
// 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),
|
|
})
|
|
});
|
|
let src = seed.source_lang.as_str();
|
|
let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?)));
|
|
match (version, text) {
|
|
(Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)),
|
|
_ if a.superseded_by.is_some() => {
|
|
tracing::debug!(
|
|
amendment = a.id,
|
|
unit = a.unit,
|
|
superseded_by = a.superseded_by,
|
|
"seed amendment superseded: nothing decided"
|
|
);
|
|
None
|
|
}
|
|
_ => {
|
|
tracing::error!(
|
|
amendment = a.id,
|
|
doc = a.doc,
|
|
unit = a.unit,
|
|
"seed amendment: the seed text is not the one it names, nothing decided"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the database says of a version holding `status` (`None`: it does not).
|
|
pub(crate) async fn facts(
|
|
conn: &mut SqliteConnection,
|
|
proposal_id: &str,
|
|
status: &str,
|
|
) -> anyhow::Result<Option<Facts>> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
origin: String,
|
|
entered_at: Option<String>,
|
|
confirmed_at: Option<String>,
|
|
revision: i64,
|
|
title: String,
|
|
body: String,
|
|
}
|
|
let Some(p) = sqlx::query_as::<_, Row>(
|
|
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = ?",
|
|
)
|
|
.bind(proposal_id)
|
|
.bind(status)
|
|
.fetch_optional(&mut *conn)
|
|
.await?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE proposal_id = ?")
|
|
.bind(proposal_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
let revisions: Vec<String> = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
|
|
.bind(proposal_id)
|
|
.fetch_all(&mut *conn)
|
|
.await?;
|
|
let member_translations: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM translations WHERE proposal_id = ? AND (origin <> 'seed' OR updated_by IS NOT NULL)",
|
|
)
|
|
.bind(proposal_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
let signals: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE proposal_id = ?1 OR target_proposal_id = ?1")
|
|
.bind(proposal_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?")
|
|
.bind(proposal_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
// Every line of a ballot, chain or simulated, whatever its fate: a vote was cast.
|
|
let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?")
|
|
.bind(proposal_id)
|
|
.fetch_one(&mut *conn)
|
|
.await?;
|
|
let amendments: Vec<String> = sqlx::query_scalar(
|
|
"SELECT amendment_id FROM seed_amendments WHERE proposal_id = ? AND outcome = 'applied' ORDER BY at, rowid",
|
|
)
|
|
.bind(proposal_id)
|
|
.fetch_all(&mut *conn)
|
|
.await?;
|
|
Ok(Some(Facts {
|
|
origin: p.origin,
|
|
entered: p.entered_at.is_some(),
|
|
confirmed: p.confirmed_at.is_some(),
|
|
revision: p.revision,
|
|
votes,
|
|
ballots,
|
|
amendments,
|
|
revisions,
|
|
member_translations,
|
|
signals,
|
|
typos,
|
|
fingerprint: fingerprint(&p.title, &p.body),
|
|
}))
|
|
}
|
|
|
|
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
|
async fn decide(
|
|
state: &AppState,
|
|
a: &Amendment,
|
|
earlier: &[&str],
|
|
seed: &SeedDoc,
|
|
target: &SeedVersion,
|
|
doc_id: &str,
|
|
publishes: bool,
|
|
) -> anyhow::Result<Option<Verdict>> {
|
|
let mut tx = state.db.begin().await?;
|
|
let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seed_amendments WHERE amendment_id = ? AND doc_id = ?")
|
|
.bind(a.id)
|
|
.bind(doc_id)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
if decided > 0 {
|
|
return Ok(None);
|
|
}
|
|
let in_force = || {
|
|
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
|
.bind(doc_id)
|
|
.bind(a.unit)
|
|
};
|
|
let open = |label: Option<&'static str>| {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id
|
|
WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed'
|
|
AND (p.label = ?3 OR (?3 IS NULL AND p.seed_featured = 1))
|
|
ORDER BY p.rowid LIMIT 1",
|
|
)
|
|
.bind(doc_id)
|
|
.bind(a.unit)
|
|
.bind(label)
|
|
};
|
|
// The version examined, and the status it holds.
|
|
let examined: Option<(String, &'static str)> = match a.version {
|
|
Version::InForce => match in_force().fetch_optional(&mut *tx).await?.flatten() {
|
|
Some(id) => Some((id, "in_force")),
|
|
// No text in force: the starting text may be a seed proposal (ADR 0011).
|
|
None => open(None).fetch_optional(&mut *tx).await?.map(|id| (id, "open")),
|
|
},
|
|
Version::Open(label) => open(Some(label)).fetch_optional(&mut *tx).await?.map(|id| (id, "open")),
|
|
};
|
|
let target_id = examined.as_ref().map(|(id, _)| id.clone());
|
|
let found = match &examined {
|
|
Some((id, status)) => facts(&mut tx, id, status).await?,
|
|
None => None,
|
|
};
|
|
let mut v = verdict(found.as_ref(), a, earlier);
|
|
let now = util::now();
|
|
let judged = found.as_ref().map_or(1, |f| f.revision);
|
|
if let (Verdict::Apply, Some((pid, status))) = (v, &examined) {
|
|
let src = seed.source_lang.as_str();
|
|
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
|
|
let (title, body) = (pick(&target.title), pick(&target.body));
|
|
// Written only over the text that was judged, still untouched.
|
|
let done = sqlx::query(
|
|
"UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ?
|
|
WHERE id = ? AND status = ? AND origin = 'seed' AND entered_at IS NULL
|
|
AND confirmed_at IS NULL AND revision = ?",
|
|
)
|
|
.bind(&title)
|
|
.bind(&body)
|
|
.bind(&now)
|
|
.bind(pid)
|
|
.bind(*status)
|
|
.bind(judged)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if done.rows_affected() == 1 {
|
|
sqlx::query(
|
|
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
|
VALUES (?, ?, 'amendment', ?, ?, NULL, ?)",
|
|
)
|
|
.bind(pid)
|
|
.bind(judged + 1)
|
|
.bind(&title)
|
|
.bind(&body)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let hash = fingerprint(&title, &body);
|
|
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
|
let (Some(t), Some(b)) = (target.title.get(*lang), target.body.get(*lang)) else { continue };
|
|
sqlx::query(
|
|
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)
|
|
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
|
source_hash = excluded.source_hash, origin = 'seed', updated_by = NULL, updated_at = excluded.updated_at",
|
|
)
|
|
.bind(pid)
|
|
.bind(lang)
|
|
.bind(t)
|
|
.bind(b)
|
|
.bind(&hash)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
if publishes {
|
|
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
|
.bind(doc_id)
|
|
.bind(a.reason)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
} else {
|
|
v = Verdict::Refuse("changed");
|
|
}
|
|
}
|
|
let (outcome, reason) = match v {
|
|
Verdict::Apply => ("applied", None),
|
|
Verdict::Present => ("present", None),
|
|
Verdict::Refuse(why) => ("refused", Some(why)),
|
|
};
|
|
sqlx::query(
|
|
"INSERT INTO seed_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(a.id)
|
|
.bind(doc_id)
|
|
.bind(a.unit)
|
|
.bind(&target_id)
|
|
.bind(outcome)
|
|
.bind(reason)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(Some(v))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const A: Amendment = Amendment {
|
|
id: "test",
|
|
doc: "doc",
|
|
unit: "I1",
|
|
version: Version::InForce,
|
|
from: "old",
|
|
to: "new",
|
|
reason: "texte de départ remplacé : I1",
|
|
follows: None,
|
|
superseded_by: None,
|
|
};
|
|
|
|
fn intact() -> Facts {
|
|
Facts {
|
|
origin: "seed".into(),
|
|
revision: 1,
|
|
revisions: vec!["initial".into()],
|
|
fingerprint: "old".into(),
|
|
..Facts::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_untouched_starting_text_is_replaced() {
|
|
assert_eq!(verdict(Some(&intact()), &A, &[]), Verdict::Apply);
|
|
}
|
|
|
|
#[test]
|
|
fn the_new_text_already_there_is_left_as_it_is() {
|
|
let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() };
|
|
assert_eq!(verdict(Some(&f), &A, &[]), Verdict::Present);
|
|
}
|
|
|
|
#[test]
|
|
fn any_trace_of_a_person_keeps_the_text() {
|
|
let cases = [
|
|
(None, "no_text_in_force"),
|
|
(Some(Facts { origin: "member".into(), ..intact() }), "not_a_seed"),
|
|
(Some(Facts { entered: true, ..intact() }), "voted_into_force"),
|
|
(Some(Facts { confirmed: true, ..intact() }), "confirmed"),
|
|
(Some(Facts { revision: 2, ..intact() }), "revised"),
|
|
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
|
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
|
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
|
(Some(Facts { typos: 1, ..intact() }), "corrected"),
|
|
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
|
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
|
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
|
];
|
|
for (facts, reason) in cases {
|
|
assert_eq!(verdict(facts.as_ref(), &A, &[]), Verdict::Refuse(reason), "{facts:?}");
|
|
}
|
|
// An open proposal of the seed that is no longer there (withdrawn, never seeded).
|
|
let open = Amendment { version: Version::Open("1.0.0"), ..A };
|
|
assert_eq!(verdict(None, &open, &[]), Verdict::Refuse("no_open_version"));
|
|
assert_eq!(verdict(Some(&intact()), &open, &[]), Verdict::Apply);
|
|
// A ballot of the buffer names it, pending or proven, whatever became of it (ADR 0012).
|
|
assert_eq!(verdict(Some(&Facts { ballots: 1, ..intact() }), &A, &[]), Verdict::Refuse("ballots"));
|
|
}
|
|
|
|
/// The text an earlier amendment wrote, as production holds F1.2 and N1.1 since M5.
|
|
fn amended_by(earlier: &str) -> Facts {
|
|
Facts {
|
|
revision: 2,
|
|
revisions: vec!["initial".into(), "amendment".into()],
|
|
amendments: vec![earlier.into()],
|
|
..intact()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_amendment_admits_the_text_the_first_wrote_and_nothing_else() {
|
|
// Lot T (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2); the quorum
|
|
// texts replace them, after that amendment only.
|
|
let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A };
|
|
assert_eq!(verdict(Some(&amended_by("m5")), &second, &["m5"]), Verdict::Apply);
|
|
// A base born with the text of M5: its revision 1, as the seed made it.
|
|
assert_eq!(verdict(Some(&intact()), &second, &["m5"]), Verdict::Apply);
|
|
// Not the amendment it follows, or none: a revision 2 of something else.
|
|
assert_eq!(verdict(Some(&amended_by("other")), &second, &["m5"]), Verdict::Refuse("revised"));
|
|
let typo = Facts { revisions: vec!["initial".into(), "typo".into()], amendments: vec![], ..amended_by("m5") };
|
|
assert_eq!(verdict(Some(&typo), &second, &["m5"]), Verdict::Refuse("revised"));
|
|
// A history that is not « initial then amendment ».
|
|
let odd = Facts { revisions: vec!["initial".into(), "substantive".into()], ..amended_by("m5") };
|
|
assert_eq!(verdict(Some(&odd), &second, &["m5"]), Verdict::Refuse("corrected"));
|
|
// Further on, nothing is admitted.
|
|
let third = Facts {
|
|
revision: 3,
|
|
revisions: vec!["initial".into(), "amendment".into(), "typo".into()],
|
|
..amended_by("m5")
|
|
};
|
|
assert_eq!(verdict(Some(&third), &second, &["m5"]), Verdict::Refuse("revised"));
|
|
// The same traces of a person keep it.
|
|
for (f, why) in [
|
|
(Facts { votes: 1, ..amended_by("m5") }, "votes"),
|
|
(Facts { ballots: 2, ..amended_by("m5") }, "ballots"),
|
|
(Facts { typos: 1, ..amended_by("m5") }, "corrected"),
|
|
(Facts { member_translations: 1, ..amended_by("m5") }, "translation_edited"),
|
|
(Facts { signals: 1, ..amended_by("m5") }, "signalled"),
|
|
(Facts { fingerprint: "other".into(), ..amended_by("m5") }, "text_differs"),
|
|
] {
|
|
assert_eq!(verdict(Some(&f), &second, &["m5"]), Verdict::Refuse(why), "{f:?}");
|
|
}
|
|
// An amendment that follows none never admits a revision 2.
|
|
assert_eq!(verdict(Some(&amended_by("m5")), &A, &[]), Verdict::Refuse("revised"));
|
|
}
|
|
|
|
/// The text written by `applied`, in order, one revision each over the seed's.
|
|
fn written_by(applied: &[&str]) -> Facts {
|
|
let mut revisions = vec!["initial".to_owned()];
|
|
revisions.extend(applied.iter().map(|_| "amendment".to_owned()));
|
|
Facts {
|
|
revision: 1 + applied.len() as i64,
|
|
revisions,
|
|
amendments: applied.iter().map(|a| (*a).to_owned()).collect(),
|
|
..intact()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_third_amendment_admits_the_texts_the_two_before_wrote_and_nothing_else() {
|
|
// Lot T2 (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2), then by M6b
|
|
// (revision 3); the true phrases replace the text of M6b, after it only.
|
|
let third = Amendment { version: Version::Open("1.0.0"), follows: Some("t"), ..A };
|
|
let before: &[&str] = &["m5", "t"];
|
|
// Production: M5 then M6b, each its revision.
|
|
assert_eq!(verdict(Some(&written_by(&["m5", "t"])), &third, before), Verdict::Apply);
|
|
// A base born with the text of M5 (02/11): M6b alone wrote its revision 2.
|
|
assert_eq!(verdict(Some(&written_by(&["t"])), &third, before), Verdict::Apply);
|
|
// A base born with the text of M6b: its revision 1.
|
|
assert_eq!(verdict(Some(&intact()), &third, before), Verdict::Apply);
|
|
// The last revision is not M6b's, or not M6b's alone, or out of order: refused.
|
|
for applied in [&["m5"][..], &["t", "m5"], &["m5", "other"], &["other", "t"], &["m5", "t", "t"]] {
|
|
assert_eq!(verdict(Some(&written_by(applied)), &third, before), Verdict::Refuse("revised"), "{applied:?}");
|
|
}
|
|
// A revision that no amendment wrote: a typo after M6b, a revision count off by one.
|
|
let typo = Facts {
|
|
revision: 4,
|
|
revisions: vec!["initial".into(), "amendment".into(), "amendment".into(), "typo".into()],
|
|
..written_by(&["m5", "t"])
|
|
};
|
|
assert_eq!(verdict(Some(&typo), &third, before), Verdict::Refuse("revised"));
|
|
let short = Facts { revision: 2, ..written_by(&["m5", "t"]) };
|
|
assert_eq!(verdict(Some(&short), &third, before), Verdict::Refuse("revised"));
|
|
// The history is not « initial » then twice « amendment ».
|
|
let odd = Facts {
|
|
revisions: vec!["initial".into(), "amendment".into(), "substantive".into()],
|
|
..written_by(&["m5", "t"])
|
|
};
|
|
assert_eq!(verdict(Some(&odd), &third, before), Verdict::Refuse("corrected"));
|
|
// Any trace of a person keeps the text, with its reason.
|
|
for (f, why) in [
|
|
(Facts { votes: 1, ..written_by(&["m5", "t"]) }, "votes"),
|
|
(Facts { ballots: 1, ..written_by(&["m5", "t"]) }, "ballots"),
|
|
(Facts { typos: 1, ..written_by(&["m5", "t"]) }, "corrected"),
|
|
(Facts { member_translations: 1, ..written_by(&["m5", "t"]) }, "translation_edited"),
|
|
(Facts { signals: 1, ..written_by(&["m5", "t"]) }, "signalled"),
|
|
(Facts { fingerprint: "other".into(), ..written_by(&["m5", "t"]) }, "text_differs"),
|
|
(Facts { entered: true, ..written_by(&["m5", "t"]) }, "voted_into_force"),
|
|
] {
|
|
assert_eq!(verdict(Some(&f), &third, before), Verdict::Refuse(why), "{f:?}");
|
|
}
|
|
// Lot T's own amendment, which follows M5 alone, never admits a revision 3.
|
|
let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A };
|
|
assert_eq!(verdict(Some(&written_by(&["m5", "t"])), &second, &["m5"]), Verdict::Refuse("revised"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_fourth_amendment_admits_the_texts_the_three_before_wrote_and_nothing_else() {
|
|
// Lot T3 (2026-11-03): F1.2 and N1.1 were amended by M5, M6b and M6c (revision 4 in
|
|
// production); the texts without dated figures replace the text of M6c, after it only.
|
|
let fourth = Amendment { version: Version::Open("1.0.0"), follows: Some("t2"), ..A };
|
|
let before: &[&str] = &["m5", "t", "t2"];
|
|
// Production: M5, M6b, M6c, each its revision.
|
|
assert_eq!(verdict(Some(&written_by(&["m5", "t", "t2"])), &fourth, before), Verdict::Apply);
|
|
// A base born with the text of M5 (02/11): M6b and M6c wrote its revisions 2 and 3.
|
|
assert_eq!(verdict(Some(&written_by(&["t", "t2"])), &fourth, before), Verdict::Apply);
|
|
// A base born with the text of M6b, then of M6c: its revision 2, its revision 1.
|
|
assert_eq!(verdict(Some(&written_by(&["t2"])), &fourth, before), Verdict::Apply);
|
|
assert_eq!(verdict(Some(&intact()), &fourth, before), Verdict::Apply);
|
|
// The last revision is not M6c's, or not by them alone, or out of order: refused.
|
|
for applied in [
|
|
&["m5", "t"][..],
|
|
&["t"],
|
|
&["m5"],
|
|
&["t2", "t"],
|
|
&["m5", "t2", "t"],
|
|
&["other", "t", "t2"],
|
|
&["m5", "t", "t2", "t2"],
|
|
] {
|
|
assert_eq!(verdict(Some(&written_by(applied)), &fourth, before), Verdict::Refuse("revised"), "{applied:?}");
|
|
}
|
|
// A typo after M6c: a revision no amendment wrote.
|
|
let typo = Facts {
|
|
revision: 5,
|
|
revisions: vec![
|
|
"initial".into(),
|
|
"amendment".into(),
|
|
"amendment".into(),
|
|
"amendment".into(),
|
|
"typo".into(),
|
|
],
|
|
..written_by(&["m5", "t", "t2"])
|
|
};
|
|
assert_eq!(verdict(Some(&typo), &fourth, before), Verdict::Refuse("revised"));
|
|
// Any trace of a person keeps the text, with its reason.
|
|
for (f, why) in [
|
|
(Facts { votes: 1, ..written_by(&["m5", "t", "t2"]) }, "votes"),
|
|
(Facts { ballots: 1, ..written_by(&["m5", "t", "t2"]) }, "ballots"),
|
|
(Facts { typos: 1, ..written_by(&["m5", "t", "t2"]) }, "corrected"),
|
|
(Facts { member_translations: 1, ..written_by(&["m5", "t", "t2"]) }, "translation_edited"),
|
|
(Facts { signals: 1, ..written_by(&["m5", "t", "t2"]) }, "signalled"),
|
|
(Facts { fingerprint: "other".into(), ..written_by(&["m5", "t", "t2"]) }, "text_differs"),
|
|
(Facts { confirmed: true, ..written_by(&["m5", "t", "t2"]) }, "confirmed"),
|
|
] {
|
|
assert_eq!(verdict(Some(&f), &fourth, before), Verdict::Refuse(why), "{f:?}");
|
|
}
|
|
// Lot T2's own amendment never admits a revision 4.
|
|
let third = Amendment { version: Version::Open("1.0.0"), follows: Some("t"), ..A };
|
|
assert_eq!(verdict(Some(&written_by(&["m5", "t", "t2"])), &third, &["m5", "t"]), Verdict::Refuse("revised"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_amendments_followed_are_read_through_the_list_oldest_first() {
|
|
let by_id = |id: &str| AMENDMENTS.iter().find(|a| a.id == id).expect("amendment");
|
|
assert_eq!(
|
|
earlier(AMENDMENTS, by_id("2026-11-03-f1-2-phrases")),
|
|
["2026-11-02-f1-2-regles", "2026-11-03-f1-2-quorum"]
|
|
);
|
|
assert_eq!(
|
|
earlier(AMENDMENTS, by_id("2026-11-03-n1-1-phrases")),
|
|
["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum"]
|
|
);
|
|
assert_eq!(earlier(AMENDMENTS, by_id("2026-11-03-f1-1-phrases")), ["2026-11-03-f1-1-quorum"]);
|
|
assert_eq!(
|
|
earlier(AMENDMENTS, by_id("2026-11-03-f1-2-chiffres")),
|
|
["2026-11-02-f1-2-regles", "2026-11-03-f1-2-quorum", "2026-11-03-f1-2-phrases"]
|
|
);
|
|
assert_eq!(
|
|
earlier(AMENDMENTS, by_id("2026-11-03-n1-1-chiffres")),
|
|
["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum", "2026-11-03-n1-1-phrases"]
|
|
);
|
|
assert!(earlier(AMENDMENTS, by_id("2026-10-30-i1-raison-d-etre")).is_empty());
|
|
// Each one followed wrote the text the next replaces, and is superseded by it.
|
|
for a in AMENDMENTS.iter().filter(|a| a.follows.is_some()) {
|
|
let e = by_id(a.follows.expect("follows"));
|
|
assert_eq!((e.unit, e.doc, e.to, e.superseded_by), (a.unit, a.doc, a.from, Some(a.id)), "{}", a.id);
|
|
}
|
|
// A list that loops never hangs.
|
|
let looped = [Amendment { id: "x", follows: Some("y"), ..A }, Amendment { id: "y", follows: Some("x"), ..A }];
|
|
assert_eq!(earlier(&looped, &looped[0]), ["x", "y"]);
|
|
}
|
|
}
|