Fusion de chantier/acte-certification (lots H et S) dans le lot V : le tampon et ses états, avec le quorum et les Propositions seed

- conflits résolus des deux côtés : kept_params garde le quorum (S) et la part simulée, la version déplacée, le cran d'avant (V) ; proposals.rs garde seedMinimal / seedProposal (S) et tallyPending / tallySimulated / myVoteState (V) ;
- simulation::reexamine juge sur le quorum gardé par l'adoption (params.quorum, à défaut celui de l'article) ;
- l'acte répété n'examine plus une adoption qui tient (params.reexamined) ;
- tests V repris sans texte en vigueur au départ (I1, E1 : la Proposition seed) et sur un quorum 2 (small_web) ; le test overview d'une personne invitée dans Monnaie libre suit la simulation (elle vote, chaîne fermée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 05:32:51 +01:00
co-authored by Claude Opus 5.5
66 changed files with 5134 additions and 701 deletions
+8 -8
View File
@@ -190,18 +190,18 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
Ok(entered)
}
/// What an adoption keeps of the formula it was judged by. The four letters
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
/// `constant`: read as they were written); the level and the scale join them —
/// and, for the act that ends the simulation (ADR 0012, `simulation::discard`),
/// the simulated share of the votes it entered with, the version it displaced
/// and the level of inertia of before.
/// What an adoption keeps of the formula it was judged by. The letters stay where
/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before
/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum
/// (ADR 0013), the level and the scale join them — and, for the act that ends the
/// simulation (ADR 0012, `simulation::discard`), the simulated share of the votes it
/// entered with, the version it displaced and the level of inertia of before.
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
let level = st.voting_level(unit, p);
let f = st.scale().params(level);
let f = st.params(unit, p);
let simulated = st.simulated_counted(&p.id);
serde_json::json!({
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant,
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
"level": level, "share_pct": level.share_pct(),
"participation": st.participation.ratio, "participation_source": st.participation.source,
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
+17 -7
View File
@@ -322,7 +322,10 @@ pub struct DevChainBody {
/// `POST /api/dev/vote-chain` — tests only, with the dev login: closes (or opens
/// again) the chain of this server, to try the simulation (ADR 0012).
pub async fn dev_chain(State(state): State<AppState>, Json(b): Json<DevChainBody>) -> AppResult<Json<serde_json::Value>> {
pub async fn dev_chain(
State(state): State<AppState>,
Json(b): Json<DevChainBody>,
) -> AppResult<Json<serde_json::Value>> {
if !state.cfg.dev_login {
return Err(AppError::NotFound);
}
@@ -746,8 +749,11 @@ pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
match verdict {
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
let proof =
Proof { voter: format!("idty:{}", identity.index), position: Some(found.comment.position), found: Some(found) };
let proof = Proof {
voter: format!("idty:{}", identity.index),
position: Some(found.comment.position),
found: Some(found),
};
settle(state, row, proof).await.map(|_| ())
}
Verdict::Paid { .. } => refuse(state, row, found, "not_member").await,
@@ -921,9 +927,10 @@ pub async fn reconcile(conn: &mut SqliteConnection, voter: &str, proposal_id: &s
.await?;
let mut kept: Vec<(&Row, Proven)> = Vec::with_capacity(rows.len());
for r in &rows {
let position = r.block.zip(r.event_index).and_then(|(b, e)| {
Some(ChainPosition { block: u32::try_from(b).ok()?, event: u32::try_from(e).ok()? })
});
let position = r
.block
.zip(r.event_index)
.and_then(|(b, e)| Some(ChainPosition { block: u32::try_from(b).ok()?, event: u32::try_from(e).ok()? }));
let (Some(position), Some(choice)) = (position, Choice::parse(&r.choice)) else {
tracing::warn!(ballot = %r.ballot_id, "a proven line that does not read back");
continue;
@@ -1218,7 +1225,10 @@ pub async fn intents(state: &AppState, doc_id: &str) -> AppResult<Vec<Intent>> {
for r in rows {
let Some(choice) = Choice::parse(&r.choice) else { continue };
let key = (r.requested_by.clone(), r.proposal_id.clone());
latest.insert(key, Intent { requester: r.requested_by, proposal_id: r.proposal_id, revision: r.revision, choice });
latest.insert(
key,
Intent { requester: r.requested_by, proposal_id: r.proposal_id, revision: r.revision, choice },
);
}
Ok(latest.into_values().collect())
}
+6
View File
@@ -19,6 +19,11 @@ pub struct Publish {
pub key: PathBuf,
pub author_name: String,
pub author_email: String,
/// Publish a document even when none of its articles is in force
/// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default — Yvv, 2026-11-02: « Suspendre
/// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it
/// (ADR 0011).
pub without_articles: bool,
}
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
@@ -113,6 +118,7 @@ impl Config {
key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")),
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"),
});
let dev_login = flag("LD_DEV_LOGIN");
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
+101 -20
View File
@@ -16,7 +16,7 @@ use ld_engine::ballot::Choice;
use ld_engine::buffer::{self, Rules, Stage};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally};
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -197,8 +197,21 @@ pub struct DocRow {
pub title: String,
pub summary: String,
pub source_lang: String,
/// The reference quorum Q₀ (C before 2026-11-03): read for the reference
/// participation p0 = 2Q₀/W only, and as every level's quorum while `quorums` is NULL.
pub vote_floor: f64,
pub publish_path: Option<String>,
/// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum
/// amendment (`quorum_amend`).
pub quorums: Option<String>,
}
impl DocRow {
/// The quorum of each level, 1 to 5: the document's, or, in a base the quorum
/// amendment has not reached yet, its reference quorum at every level — the C of before.
pub fn quorums(&self) -> [u32; 5] {
self.quorums.as_deref().and_then(|raw| serde_json::from_str(raw).ok()).unwrap_or([whole(self.vote_floor); 5])
}
}
#[derive(Clone, Debug, sqlx::FromRow)]
@@ -209,6 +222,16 @@ pub struct UnitRow {
pub position: f64,
pub inertia: String,
pub in_force_id: Option<String>,
/// Its own quorum, set by the author of its first version before the first vote
/// (`quorum`); NULL: the quorum of its level.
pub quorum: Option<i64>,
}
impl UnitRow {
/// Its own quorum, if its author set one.
pub fn own_quorum(&self) -> Option<u32> {
self.quorum.and_then(|q| u32::try_from(q).ok())
}
}
#[derive(Clone, Debug, sqlx::FromRow)]
@@ -233,15 +256,29 @@ pub struct ProposalRow {
pub submitted_at: Option<String>,
pub entered_at: Option<String>,
pub confirmed_at: Option<String>,
/// Put forward by the seed to start the vote (ADR 0011): see `seed_proposal`.
pub seed_featured: bool,
}
pub const PROPOSAL_SELECT: &str =
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin,
p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at,
p.entered_at, p.confirmed_at
p.entered_at, p.confirmed_at, p.seed_featured
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
impl ProposalRow {
/// A seed a minima: in force from the seed, never voted into force nor confirmed
/// (the title and the orders of a new base; a text a seed proposal amendment left).
pub fn seed_minimal(&self) -> bool {
self.status == "in_force" && self.entered_at.is_none() && self.confirmed_at.is_none() && self.origin == "seed"
}
/// A seed proposal (ADR 0011): open, from the seed, put forward to start the vote,
/// never voted into force. Not in force: it enters like any proposal.
pub fn seed_proposal(&self) -> bool {
self.seed_featured && self.status == "open" && self.origin == "seed" && self.entered_at.is_none()
}
pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() {
"remove" => ProposalKind::Remove,
@@ -295,7 +332,8 @@ pub struct DocState {
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
sqlx::query_as::<_, DocRow>(
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?",
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, quorums
FROM documents WHERE id = ?",
)
.bind(doc_id)
.fetch_optional(&state.db)
@@ -322,7 +360,7 @@ 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?;
let units = sqlx::query_as::<_, UnitRow>(
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
"SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id",
)
.bind(doc_id)
.fetch_all(&state.db)
@@ -443,9 +481,25 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
}
impl DocState {
/// What this document's votes are judged by: W, C and p̃.
/// What this document's votes are judged by: W, p̃ and the quorum of each level.
pub fn scale(&self) -> Scale {
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
Scale { electorate: self.electorate, participation: self.participation.ratio, quorums: self.doc.quorums() }
}
/// The quorum an article is judged on, and its versions with it: its own, else its
/// level's (`Scale::quorum`).
pub fn quorum_of(&self, unit: &UnitRow) -> u32 {
self.scale().quorum(inertia_of(unit), unit.own_quorum())
}
/// The quorum of the article's level: what it is judged on without one of its own.
pub fn quorum_default(&self, unit: &UnitRow) -> u32 {
self.scale().quorum(inertia_of(unit), None)
}
/// The letters a proposal is judged by: its level and its article's quorum.
pub fn params(&self, unit: &UnitRow, p: &ProposalRow) -> FormulaParams {
compose::params_for(&bare(unit), p.kind(), &self.scale())
}
/// p̃ · W, as a whole number of voters.
@@ -489,7 +543,7 @@ impl DocState {
/// How far the threshold is, as shown: on `shown_tally`.
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
threshold::evaluate(self.shown_tally(&p.id), self.electorate, &self.params(unit, p)).ok()
}
/// Open to a vote: open or in force, and not held back by an objection.
@@ -578,6 +632,7 @@ impl DocState {
id: u.id.clone(),
position: u.position,
inertia: inertia_of(u),
quorum: u.own_quorum(),
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) {
Some(order) => VersionText {
id: p.id.clone(),
@@ -630,13 +685,12 @@ impl DocState {
}
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
threshold::evaluate(self.tally(&p.id), self.electorate, &self.params(unit, p)).ok()
}
/// The level a proposal is voted at — a change of inertia inherits its article's.
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
compose::voting_level(&us, p.kind())
compose::voting_level(&bare(unit), p.kind())
}
/// The usual participation, as the screen tells it.
@@ -650,22 +704,36 @@ impl DocState {
})
}
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
/// The five levels on this scale: M, G, s_k, the quorum, and the *for* votes asked at
/// `cast` votes — on an article's own quorum if it has one (`own`), else each level's.
pub fn levels_json(&self, cast: u32, own: Option<u32>) -> Vec<serde_json::Value> {
let scale = self.scale();
InertiaLevel::ALL
.iter()
.map(|k| {
let params = scale.params(*k);
let quorum = scale.quorum(*k, own);
let params = scale.params(*k, quorum);
json!({
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
"sharePct": k.share_pct(), "quorum": quorum, "cast": cast,
"required": scale.required(cast, *k, quorum).ok(),
})
})
.collect()
}
}
/// A unit as the engine sees it, without its text: its level and its own quorum.
fn bare(unit: &UnitRow) -> UnitState {
UnitState {
id: unit.id.clone(),
position: unit.position,
inertia: inertia_of(unit),
quorum: unit.own_quorum(),
in_force: None,
}
}
/// A non-negative count, rounded to the nearest whole.
fn whole(x: f64) -> u32 {
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
@@ -741,7 +809,7 @@ fn proposal_json(
"mine": p.author_id.as_deref() == Some(me),
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
"tally": st.shown_tally(&p.id),
"tallyPending": st.pending_tally(&p.id),
"tallySimulated": st.simulated_tally(&p.id),
@@ -776,6 +844,7 @@ pub async fn document(
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
let changes = inertia_changes(&state, &id).await?;
let rights = crate::quorum::Rights::of(&state, &id).await?;
let unit_json = |u: &UnitRow| {
let of_unit: Vec<_> = visible
.iter()
@@ -794,11 +863,18 @@ pub async fn document(
.as_ref()
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
let mut j = json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals,
})
});
// Its quorum (ADR 0013): its own or `null`, its level's, and who may set it.
if let (Some(o), serde_json::Value::Object(q)) =
(j.as_object_mut(), crate::quorum::unit_json(&st, &rights, &user, u))
{
o.extend(q);
}
j
};
let units: Vec<_> = st.articles().map(unit_json).collect();
// The modules (ADR 0009): the title of the document, the order of each section.
@@ -828,7 +904,7 @@ pub async fn document(
"doc": {
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(),
"electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"participation": st.participation_json(),
@@ -837,7 +913,7 @@ pub async fn document(
"units": units,
"modules": { "title": title, "orders": orders },
// The five levels, with the *for* votes asked at the usual participation.
"levels": st.levels_json(st.usual_voters()),
"levels": st.levels_json(st.usual_voters(), None),
})))
}
@@ -882,10 +958,15 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<ser
.bind(doc_id)
.fetch_optional(&state.db)
.await?;
// Waiting, not failing: no article in force, nothing goes out (ADR 0011).
let suspended = match &last {
Some(r) if r.done_at.is_none() => crate::publish::waits_for_a_text(state, doc_id).await?,
_ => false,
};
Ok(match last {
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
Some(r) => json!({
"state": if r.done_at.is_some() { "done" } else { "pending" },
"state": if r.done_at.is_some() { "done" } else if suspended { "suspended" } else { "pending" },
"configured": state.cfg.publish.is_some(),
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
+30 -3
View File
@@ -22,6 +22,11 @@
//! logs. Applied, the level is written in one transaction with a line in
//! `inertia_changes` (the article says so) and, where the document publishes,
//! a row in the publication queue (`document.json` carries the levels).
//!
//! Since 2026-11-02 the seed gives E1 and E2 another starting level: put forward
//! as seed proposals, they start low (ADR 0011, `seed_proposal_amend`). The two
//! amendments of 30/10 are then superseded: decided in production, they decide
//! nothing more, and say so only in the debug logs.
use crate::AppState;
use crate::seed::SeedDoc;
@@ -43,6 +48,9 @@ pub struct InertiaAmendment {
pub to: u8,
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
/// The later decision whose level the seed gives instead, if any: this one then
/// decides nothing more, without an error.
pub superseded_by: Option<&'static str>,
}
pub const AMENDMENTS: &[InertiaAmendment] = &[
@@ -53,6 +61,7 @@ pub const AMENDMENTS: &[InertiaAmendment] = &[
from: 3,
to: 4,
reason: "inertie de départ relevée : E1, 3 → 4",
superseded_by: Some("2026-11-02-e1-proposition-seed"),
},
InertiaAmendment {
id: "2026-10-30-e2-inertie-4",
@@ -61,6 +70,7 @@ pub const AMENDMENTS: &[InertiaAmendment] = &[
from: 3,
to: 4,
reason: "inertie de départ relevée : E2, 3 → 4",
superseded_by: Some("2026-11-02-e2-proposition-seed"),
},
];
@@ -148,7 +158,17 @@ pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment
fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool {
let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit));
match level {
Some(u) if u.inertia.get() == a.to => true,
Some(u) if u.level().get() == a.to => true,
Some(u) if a.superseded_by.is_some() => {
tracing::debug!(
amendment = a.id,
unit = a.unit,
seed_level = u.level().get(),
superseded_by = a.superseded_by,
"inertia amendment superseded: nothing decided"
);
false
}
_ => {
tracing::error!(
amendment = a.id,
@@ -267,8 +287,15 @@ async fn decide(
mod tests {
use super::*;
const A: InertiaAmendment =
InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée" };
const A: InertiaAmendment = InertiaAmendment {
id: "test",
doc: "doc",
unit: "E1",
from: 3,
to: 4,
reason: "inertie de départ relevée",
superseded_by: None,
};
fn at(raw: &str) -> Facts {
Facts { raw: raw.into(), ..Facts::default() }
+4
View File
@@ -29,8 +29,11 @@ pub mod overview;
pub mod participation;
pub mod proposals;
pub mod publish;
pub mod quorum;
pub mod quorum_amend;
pub mod seed;
pub mod seed_amend;
pub mod seed_proposal_amend;
pub mod simulation;
pub mod team;
pub mod typos;
@@ -179,6 +182,7 @@ pub fn api_router() -> Router<AppState> {
.route("/dev/clock", post(deadlines::dev_clock))
.route("/dev/vote-chain", post(ballots::dev_chain))
.route("/units/{id}/inertia", post(proposals::propose_inertia))
.route("/units/{id}/quorum", put(quorum::set))
.route("/units/{id}/order", post(order::propose))
.route("/units/{id}/title", post(doc_title::propose))
.route("/feedback", post(access::send_feedback))
+4 -2
View File
@@ -570,7 +570,9 @@ pub async fn detail(
let mut history = Vec::new();
let mut previous: Option<&Rev> = None;
for r in &revs {
// A change of status (a seed proposal, ADR 0011) changes no text: no diff.
let diff = previous
.filter(|_| r.kind != "seed_proposal")
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
let typo = r.typo_id.as_ref().map(|t| {
json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") })
@@ -602,7 +604,7 @@ pub async fn detail(
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
"tally": st.shown_tally(&p.id), "tallyPending": st.pending_tally(&p.id),
"tallySimulated": st.simulated_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
@@ -621,7 +623,7 @@ pub async fn detail(
"voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
"levels": st.levels_json(st.shown_tally(&p.id).cast(), unit.own_quorum()),
})))
}
+32 -1
View File
@@ -83,6 +83,20 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> {
let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect();
let last = rows.iter().map(|d| d.id).max().unwrap_or(0);
let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0);
if waits_for_a_text(state, &doc_id).await? {
// Not a failure: no attempt counted, no error. The queue stays; the first
// text that enters queues a row due now, and everything goes out with it.
sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL")
.bind(util::in_seconds(MAX_BACKOFF_SECS))
.bind(&doc_id)
.execute(&state.db)
.await?;
tracing::info!(
doc = %doc_id, waiting = rows.len(),
"publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)"
);
continue;
}
match publish_doc(state, &doc_id).await {
Ok(sha) => {
sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?")
@@ -109,6 +123,23 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> {
Ok(())
}
/// Does the document wait for a text before it is published? Yes when none of its
/// articles is in force — the seed proposals are not (ADR 0011) — unless the
/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default is
/// Yvv's (2026-11-02, « Suspendre jusqu'au 1er texte »): never a document without a text.
pub async fn waits_for_a_text(state: &AppState, doc_id: &str) -> anyhow::Result<bool> {
if state.cfg.publish.as_ref().is_none_or(|p| p.without_articles) {
return Ok(false);
}
let in_force: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND in_force_id IS NOT NULL",
)
.bind(doc_id)
.fetch_one(&state.db)
.await?;
Ok(in_force == 0)
}
async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<String> {
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
let known_hosts = state.cfg.data_dir.join("keys/known_hosts");
@@ -361,7 +392,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
let json = json!({
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
"voteFloor": st.doc.vote_floor, "units": units, "modules": modules,
"voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(), "units": units, "modules": modules,
});
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
Ok(files)
+176
View File
@@ -0,0 +1,176 @@
//! The quorum of an article (ADR 0013).
//!
//! Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition par défaut, variable
//! modifiable par l'auteur initial du texte » — « L'auteur, avant le 1er vote ». Each
//! level has its quorum (`documents.quorums`); an article judged on another one holds
//! it in `units.quorum`, set here by the author of its first version (an administrator
//! for a text of the seed, `trustin_access::sets_quorum`), and only while no vote
//! exists on it (`voted_units`). From 2 to W; from 1 in the test group
//! (`ld_engine::inertia::quorum_bounds`). Each change is journaled (`quorum_changes`).
//! An article's own quorum is not published: `document.json` carries the quorums by level.
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::docs::{self, DocState, UnitRow};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::inertia::quorum_bounds;
use serde::Deserialize;
use serde_json::json;
use sqlx::SqliteConnection;
use std::collections::{HashMap, HashSet};
/// **« Un vote existe »** — the units of a document on which a vote exists: the one
/// place this is asked (ADR 0013; lot V extends it here, nowhere else).
///
/// A vote exists on an article when any version of it (its texts, a removal, a change
/// of its inertia; any revision) holds
/// - a vote of the server (`votes`: a vote of a managed group, or the line of a ballot
/// of the chain once confirmed, written there by the confirmation);
/// - a line of a ballot of the chain, **even pending** — signed, not yet read on the
/// chain (Yvv, 2026-11-03) — or confirmed; a refused or expired ballot is no vote;
/// - an adoption: a version that entered into force by the vote keeps that trace once
/// its votes are withdrawn.
///
/// A vote of the server withdrawn before any adoption leaves no row (`votes` keeps the
/// last choice only): from then on, no vote exists (a known limit, ADR 0013).
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
let ids: Vec<String> = sqlx::query_scalar(
"SELECT p.unit_id FROM votes v
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ?1
UNION
SELECT p.unit_id FROM ballot_lines l
JOIN ballots b ON b.id = l.ballot_id
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ?1 AND b.status IN ('pending', 'confirmed')
UNION
SELECT a.unit_id FROM adoptions a WHERE a.doc_id = ?1",
)
.bind(doc_id)
.fetch_all(&mut *conn)
.await?;
Ok(ids.into_iter().collect())
}
/// Does a vote exist on this unit (`voted_units`)?
pub async fn a_vote_exists(state: &AppState, unit_id: &str) -> AppResult<bool> {
let doc_id = doc_of_unit(state, unit_id).await?;
let mut conn = state.db.acquire().await?;
Ok(voted_units(&mut conn, &doc_id).await?.contains(unit_id))
}
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
.bind(unit_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// The author of each unit's first version (`None`: a text of the seed, without author).
/// A first version is a text — of an article or a module —, never a removal or a change
/// of inertia, whatever became of it since.
pub async fn first_authors(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Option<String>>> {
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
"SELECT p.unit_id, p.author_id FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND p.kind NOT IN ('remove', 'inertia') ORDER BY p.created_at, p.rowid",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut out = HashMap::new();
for (unit, author) in rows {
out.entry(unit).or_insert(author);
}
Ok(out)
}
/// What a person may do with the quorums of a document, read once for its view.
pub struct Rights {
first_authors: HashMap<String, Option<String>>,
voted: HashSet<String>,
}
impl Rights {
pub async fn of(state: &AppState, doc_id: &str) -> AppResult<Self> {
let first_authors = first_authors(state, doc_id).await?;
let mut conn = state.db.acquire().await?;
let voted = voted_units(&mut conn, doc_id).await?;
Ok(Self { first_authors, voted })
}
fn first_author(&self, unit: &UnitRow) -> Option<&str> {
self.first_authors.get(&unit.id).and_then(|a| a.as_deref())
}
/// May `user` set the quorum of `unit` now (`trustin_access::sets_quorum`)?
pub fn sets(&self, user: &IdentityRow, unit: &UnitRow) -> bool {
trustin_access::sets_quorum(&user.to_identity(), self.first_author(unit), self.voted.contains(&unit.id))
}
}
/// The quorum of a unit, as its view says it: its own (`null`: its level's), its level's,
/// and whether `user` may set it.
pub fn unit_json(st: &DocState, rights: &Rights, user: &IdentityRow, unit: &UnitRow) -> serde_json::Value {
json!({
"quorum": unit.own_quorum(), "quorumDefault": st.quorum_default(unit), "canSetQuorum": rights.sets(user, unit),
})
}
#[derive(Deserialize)]
pub struct QuorumBody {
/// A whole number of voters; `null`: back to the quorum of its level.
quorum: Option<i64>,
}
/// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`.
pub async fn set(
State(state): State<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<QuorumBody>,
) -> AppResult<Json<serde_json::Value>> {
let doc_id = doc_of_unit(&state, &unit_id).await?;
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
let authors = first_authors(&state, &doc_id).await?;
let first = authors.get(&unit.id).and_then(|a| a.as_deref());
let identity = user.to_identity();
if !trustin_access::sets_quorum(&identity, first, false) {
return Err(AppError::Forbidden);
}
let (lowest, highest) = quorum_bounds(st.electorate, st.group.slug == trustin_access::TEST_GROUP);
let to = match b.quorum {
None => None,
Some(q) if q >= i64::from(lowest) && q <= i64::from(highest) => Some(q),
Some(_) => return Err(AppError::Invalid("quorum")),
};
let mut tx = state.db.begin().await?;
// Asked within the writing transaction: a vote that arrives first wins.
let voted = voted_units(&mut tx, &doc_id).await?.contains(&unit.id);
if !trustin_access::sets_quorum(&identity, first, voted) {
return Err(AppError::Conflict("voted"));
}
if unit.quorum != to {
let now = util::now();
sqlx::query("UPDATE units SET quorum = ? WHERE id = ?").bind(to).bind(&unit.id).execute(&mut *tx).await?;
sqlx::query(
"INSERT INTO quorum_changes (unit_id, doc_id, from_quorum, to_quorum, identity_id, at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&unit.id)
.bind(&doc_id)
.bind(unit.quorum)
.bind(to)
.bind(&user.id)
.bind(&now)
.execute(&mut *tx)
.await?;
tracing::info!(doc = %doc_id, unit = %unit.code, from = ?unit.quorum, to = ?to, by = %user.id, "quorum set");
}
tx.commit().await?;
Ok(Json(json!({ "quorum": to, "quorumDefault": st.quorum_default(unit), "canSetQuorum": true })))
}
+195
View File
@@ -0,0 +1,195 @@
//! Quorum amendments — the quorums by level a seed now gives a document, written into
//! a document born without them (ADR 0013).
//!
//! Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, the test group at 1
//! everywhere. A new base gets them from the seed (`quorums` of each instance). An
//! existing base — production holds two instances of the certification act — reads
//! `vote_floor` at every level until then (the C of before: 50 and 1), and gets them
//! through an amendment listed here: written **only where the document holds none**;
//! nothing else is touched (no article's own quorum, no vote, no text).
//!
//! Like the other amendments of the seed, each document is decided once, at the first
//! start that knows the amendment, and the decision is kept in `quorum_amendments`
//! (applied, refused and why, or present) and in the logs. Applied where the document
//! publishes, the publication queue gets a row (`document.json` carries the quorums).
use crate::AppState;
use crate::seed::{SeedDoc, quorums_json};
use crate::util;
/// The quorums by level to write into every document instantiated from a seed.
pub struct QuorumAmendment {
/// Stable identifier: each document's decision is kept under it.
pub id: &'static str,
/// Slug of the seed document.
pub doc: &'static str,
/// The quorums of each group's instance: the seed's today, which must say so.
pub quorums: &'static [(&'static str, [u32; 5])],
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
}
pub const AMENDMENTS: &[QuorumAmendment] = &[QuorumAmendment {
id: "2026-11-03-quorums-par-cran",
doc: "acte-engagement-certification",
quorums: &[("monnaie-libre", [12, 12, 50, 50, 50]), ("essai", [1, 1, 1, 1, 1])],
reason: "quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013)",
}];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// Born with these quorums: nothing to do.
Present,
Refuse(&'static str),
}
/// The rule, apart from the database: `found` is what the document holds.
pub fn verdict(found: Option<&str>, wanted: &[u32; 5]) -> Verdict {
match found {
None => Verdict::Apply,
Some(raw) if serde_json::from_str::<[u32; 5]>(raw).ok().as_ref() == Some(wanted) => Verdict::Present,
// Written otherwise: kept as it is.
Some(_) => Verdict::Refuse("quorums_set"),
}
}
/// 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: &[QuorumAmendment]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
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(wanted) = seed_says(seeds, a, &group) else { continue };
let Some(v) = decide(state, a, &wanted, &doc_id, publish_path.is_some()).await? else { continue };
match v {
Verdict::Apply => tracing::info!(amendment = a.id, %group, quorums = ?wanted, "quorums written"),
Verdict::Present => tracing::info!(amendment = a.id, %group, "quorums: already there"),
Verdict::Refuse(why) => {
tracing::warn!(amendment = a.id, %group, reason = why, "quorums refused: the document keeps its own")
}
}
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 must give the group's instance the quorums the amendment writes: a seed
/// edited later never reaches a base by this way, and a seed without them decides nothing.
fn seed_says(seeds: &[SeedDoc], a: &QuorumAmendment, group: &str) -> Option<[u32; 5]> {
let wanted = a.quorums.iter().find(|(g, _)| *g == group).map(|(_, q)| *q)?;
let declared = seeds
.iter()
.find(|s| s.slug == a.doc)
.and_then(|s| s.instances.iter().find(|i| i.group == group))
.and_then(|i| i.quorums);
if declared == Some(wanted) {
Some(wanted)
} else {
tracing::warn!(amendment = a.id, doc = a.doc, %group, "quorum amendment: the seed does not give these quorums, nothing decided");
None
}
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &QuorumAmendment,
wanted: &[u32; 5],
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 quorum_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 found: Option<String> =
sqlx::query_scalar("SELECT quorums FROM documents WHERE id = ?").bind(doc_id).fetch_one(&mut *tx).await?;
let v = verdict(found.as_deref(), wanted);
let now = util::now();
let to = quorums_json(wanted);
if v == Verdict::Apply {
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ? AND quorums IS NULL")
.bind(&to)
.bind(doc_id)
.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?;
}
}
let (outcome, reason) = match v {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
};
sqlx::query(
"INSERT INTO quorum_amendments (amendment_id, doc_id, from_quorums, to_quorums, outcome, reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(&found)
.bind(&to)
.bind(outcome)
.bind(reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(v))
}
#[cfg(test)]
mod tests {
use super::*;
const ML: [u32; 5] = [12, 12, 50, 50, 50];
#[test]
fn written_where_none_kept_where_other() {
assert_eq!(verdict(None, &ML), Verdict::Apply);
assert_eq!(verdict(Some("[12,12,50,50,50]"), &ML), Verdict::Present);
assert_eq!(verdict(Some("[12, 12, 50, 50, 50]"), &ML), Verdict::Present, "the same numbers");
assert_eq!(verdict(Some("[3,3,3,3,3]"), &ML), Verdict::Refuse("quorums_set"));
assert_eq!(verdict(Some("not json"), &ML), Verdict::Refuse("quorums_set"));
}
#[test]
fn the_amendment_says_what_the_seed_says() {
let seed: serde_json::Value =
serde_json::from_str(include_str!("../../../seeds/acte-engagement-certification.json")).expect("seed");
for (group, quorums) in AMENDMENTS[0].quorums {
let inst = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == *group);
assert_eq!(inst.map(|i| i["quorums"].clone()), Some(serde_json::json!(quorums)), "{group}");
}
}
}
+93 -6
View File
@@ -9,18 +9,32 @@
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
//! no vote changed; modules the seed declares (the title, the order of each
//! section, ADR 0009), a module amendment (`module_amend`), built from what the
//! document holds.
//! document holds; a starting text in force the seed now puts forward as a seed
//! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an
//! article nobody has touched; the quorums by level the seed now gives a document
//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none.
use crate::AppState;
use crate::inertia_amend;
use crate::module_amend;
use crate::modules;
use crate::quorum_amend;
use crate::seed_amend;
use crate::seed_proposal_amend;
use crate::util::{self, LangMap};
use ld_engine::inertia::InertiaLevel;
use ld_engine::inertia::{InertiaLevel, quorum_bounds};
use serde::Deserialize;
use std::path::Path;
/// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « on
/// pouvait l'amorcer avec une inertie basse, pour que le nombre de votes initiaux soit
/// assez petit pour commencer l'expérience en mode prod » — level 2 (« basse »),
/// confirmed by Yvv the same day (level 1, « très basse », was the other choice). The
/// one place to change:
/// the seed gives no level to an article it puts forward, and the seed proposal
/// amendment (`seed_proposal_amend`) writes this one.
pub const SEED_PROPOSAL_LEVEL: u8 = 2;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedDoc {
@@ -51,8 +65,13 @@ pub struct SeedModule {
#[serde(rename_all = "camelCase")]
pub struct SeedInstance {
pub group: String,
/// The reference quorum Q₀ (the C of before): p0 = 2Q₀/W while too few proposals are
/// voted on; every level's quorum where `quorums` is missing.
pub vote_floor: f64,
pub publish_path: Option<String>,
/// The quorum of each level, 1 to 5 (ADR 0013): the default of an article at that level.
#[serde(default)]
pub quorums: Option<[u32; 5]>,
}
#[derive(Deserialize)]
@@ -60,16 +79,38 @@ pub struct SeedUnit {
pub code: String,
pub section: Option<String>,
pub position: f64,
/// A level, 1 to 5 (a word of before is read as its level).
pub inertia: InertiaLevel,
/// A level, 1 to 5 (a word of before is read as its level). None for an article
/// the seed puts forward: it starts at `SEED_PROPOSAL_LEVEL`.
#[serde(default)]
pub inertia: Option<InertiaLevel>,
pub versions: Vec<SeedVersion>,
}
impl SeedUnit {
/// Holds a seed proposal (ADR 0011).
pub fn puts_forward(&self) -> bool {
self.versions.iter().any(|v| v.seed_proposal)
}
/// The level it starts at: its own, or the one of seed proposals.
pub fn level(&self) -> InertiaLevel {
match self.inertia {
Some(level) => level,
None => InertiaLevel::new(SEED_PROPOSAL_LEVEL).unwrap_or_default(),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedVersion {
pub label: Option<String>,
/// `in_force` (seed a minima) or `open` (proposal at zero votes).
/// `in_force` (seed a minima: the title and the orders) or `open` (a proposal at
/// zero votes).
pub status: String,
/// A seed proposal (ADR 0011): open, put forward to start the vote, not in force.
#[serde(default)]
pub seed_proposal: bool,
pub title: LangMap,
pub body: LangMap,
}
@@ -98,6 +139,9 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?;
// After the text amendments: a starting text replaced first, then put forward.
seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?;
quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?;
Ok(())
}
@@ -130,6 +174,7 @@ pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
let raw = tokio::fs::read_to_string(path).await?;
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
check(&seed).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
for inst in &seed.instances {
let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?")
.bind(&inst.group)
@@ -153,6 +198,31 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
Ok(seed)
}
/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL`
/// alone; every other article says its level. A quorum is never under the lowest an
/// author may set (`quorum_bounds`): 2, or 1 in the test group.
fn check(seed: &SeedDoc) -> anyhow::Result<()> {
for inst in &seed.instances {
let (lowest, _) = quorum_bounds(1, inst.group == trustin_access::TEST_GROUP);
if inst.quorums.is_some_and(|q| q.iter().any(|q| *q < lowest)) {
anyhow::bail!("{}: a quorum under {lowest}", inst.group);
}
}
for u in &seed.units {
if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") {
anyhow::bail!("{}: a seed proposal is open, never in force", u.code);
}
match (u.puts_forward(), u.inertia.is_some()) {
(true, true) => {
anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code)
}
(false, false) => anyhow::bail!("{}: no level of inertia", u.code),
_ => {}
}
}
Ok(())
}
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
let src = seed.source_lang.as_str();
let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default();
@@ -174,6 +244,14 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.bind(&now)
.execute(&mut *tx)
.await?;
// The quorums by level, written apart: only where the seed gives them (ADR 0013).
if let Some(q) = &inst.quorums {
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ?")
.bind(quorums_json(q))
.bind(&doc_id)
.execute(&mut *tx)
.await?;
}
for unit in &seed.units {
let unit_id = util::id();
sqlx::query(
@@ -185,7 +263,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.bind(&unit.code)
.bind(&unit.section)
.bind(unit.position)
.bind(unit.inertia.as_db())
.bind(unit.level().as_db())
.bind(&now)
.execute(&mut *tx)
.await?;
@@ -218,6 +296,10 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.bind(&now)
.execute(&mut *tx)
.await?;
// The mark, written apart: only where the seed puts the version forward.
if v.seed_proposal && status == "open" {
sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?").bind(&pid).execute(&mut *tx).await?;
}
if status == "in_force" {
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
.bind(&pid)
@@ -260,3 +342,8 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
tx.commit().await?;
Ok(())
}
/// 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())
}
+39 -36
View File
@@ -3,7 +3,9 @@
//! The text replaced is an article's version in force (a seed a minima), or, since
//! 2026-11-02, one of its open proposals from the seed, named by its label: the
//! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv).
//! The guards are the same.
//! The guards are the same. Since the same day, a starting text may be a seed
//! proposal instead of a text in force (ADR 0011): where an article has no text
//! in force, `Version::InForce` examines its seed proposal (a new base: present).
//!
//! A seeded document is never instantiated again: once in the database, it is
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
@@ -33,21 +35,12 @@ use sqlx::SqliteConnection;
/// Which version of the article an amendment replaces.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Version {
/// Its text in force, a seed a minima.
/// Its starting text: in force (a seed a minima), or its seed proposal (ADR 0011).
InForce,
/// An open proposal of the seed, at zero votes, by its label.
Open(&'static str),
}
impl Version {
fn status(self) -> &'static str {
match self {
Version::InForce => "in_force",
Version::Open(_) => "open",
}
}
}
/// One starting text to replace, in every document instantiated from a seed.
pub struct Amendment {
/// Stable identifier: each document's decision is kept under it.
@@ -232,7 +225,8 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'
};
let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| {
u.versions.iter().find(|v| match a.version {
Version::InForce => v.status == "in_force",
// The starting text: in force, or since 2026-11-02 put forward (ADR 0011).
Version::InForce => v.status == "in_force" || v.seed_proposal,
Version::Open(label) => v.status == "open" && v.label.as_deref() == Some(label),
})
});
@@ -252,7 +246,12 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'
}
}
async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result<Option<Facts>> {
/// 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,
@@ -327,35 +326,39 @@ async fn decide(
if decided > 0 {
return Ok(None);
}
let target_id: Option<String> = match a.version {
Version::InForce => {
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?
.flatten()
}
Version::Open(label) => {
sqlx::query_scalar(
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = ?
ORDER BY p.rowid LIMIT 1",
)
let in_force = || {
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.bind(label)
.fetch_optional(&mut *tx)
.await?
}
};
let found = match &target_id {
Some(id) => facts(&mut tx, id, a.version.status()).await?,
let open = |label: Option<&'static str>| {
sqlx::query_scalar::<_, String>(
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed'
AND (p.label = ?3 OR (?3 IS NULL AND p.seed_featured = 1))
ORDER BY p.rowid LIMIT 1",
)
.bind(doc_id)
.bind(a.unit)
.bind(label)
};
// The version examined, and the status it holds.
let examined: Option<(String, &'static str)> = match a.version {
Version::InForce => match in_force().fetch_optional(&mut *tx).await?.flatten() {
Some(id) => Some((id, "in_force")),
// No text in force: the starting text may be a seed proposal (ADR 0011).
None => open(None).fetch_optional(&mut *tx).await?.map(|id| (id, "open")),
},
Version::Open(label) => open(Some(label)).fetch_optional(&mut *tx).await?.map(|id| (id, "open")),
};
let target_id = examined.as_ref().map(|(id, _)| id.clone());
let found = match &examined {
Some((id, status)) => facts(&mut tx, id, status).await?,
None => None,
};
let mut v = verdict(found.as_ref(), a);
let now = util::now();
if let (Verdict::Apply, Some(pid)) = (v, &target_id) {
if let (Verdict::Apply, Some((pid, status))) = (v, &examined) {
let src = seed.source_lang.as_str();
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
let (title, body) = (pick(&target.title), pick(&target.body));
@@ -369,7 +372,7 @@ async fn decide(
.bind(&body)
.bind(&now)
.bind(pid)
.bind(a.version.status())
.bind(*status)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
+571
View File
@@ -0,0 +1,571 @@
//! Seed proposal amendments — a starting text in force made a seed proposal in
//! an existing base (ADR 0011).
//!
//! Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les
//! 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition
//! seed" » — then: « on pouvait l'amorcer avec une inertie basse, pour que le
//! nombre de votes initiaux soit assez petit pour commencer l'expérience en mode
//! prod ». New bases get I1, E1 and E2 from the seed as open proposals put
//! forward, at `seed::SEED_PROPOSAL_LEVEL`. An existing base — production holds
//! them in force, seeds a minima, in Monnaie libre and in the test group — gets
//! them through an amendment listed here, decided once per document and article.
//!
//! **The status**, only if nobody has touched the text in force:
//!
//! - it is the seed's own (origin `seed`), never voted into force, never
//! confirmed (`entered_at`, `confirmed_at` empty);
//! - nobody ever voted on it, on the server or in a ballot of the chain;
//! - no typo fix (applied, waiting or refused), no member translation, no
//! objection names it; its history is the seed's alone: its initial text, and
//! at most the seed amendment that replaced it (I1, the « Raison d'être »);
//! - its text is the seed proposal's of today (fingerprint).
//!
//! Applied, the same version stays — its text, translations, history and votes
//! (none) — and becomes open and marked; the article has no text in force any
//! more; its history gets a line (kind `seed_proposal`).
//!
//! **The level**, with the status only, and only if nobody has touched it: no
//! change of inertia proposed by a member that is open, applied or voted on, no
//! vote that changed it, and a level the seed gave the article before (I1: 3;
//! E1 and E2: 3, then 4 since 30/10). Applied, the article says so
//! (`inertia_changes`, « réglage de départ du texte »). Refused, it keeps its
//! level and the status still changes.
//!
//! Each decision is kept in `seed_proposal_amendments` (status and level:
//! applied, refused and why, or present) and in the logs. Where the document
//! publishes, the publication queue gets a row — and waits while no article is
//! in force (`publish`, ADR 0011).
use crate::AppState;
use crate::seed::{self, SeedDoc};
use crate::seed_amend::{self, fingerprint};
use crate::util;
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// One starting text in force to put forward, in every document instantiated from a seed.
pub struct SeedProposalAmendment {
/// Stable identifier: each document's decision is kept under it.
pub id: &'static str,
/// Slug of the seed document.
pub doc: &'static str,
/// Code of the article.
pub unit: &'static str,
/// Fingerprint of the text (source language): the seed proposal of the seed
/// today, which must hash to it, and the text in force it expects.
pub text: &'static str,
/// The levels the article may still hold for its level to go low: those the seed
/// gave it before.
pub levels: &'static [u8],
/// Reason given to the publication queue, and so to the texts repository.
pub reason: &'static str,
}
pub const AMENDMENTS: &[SeedProposalAmendment] = &[
SeedProposalAmendment {
id: "2026-11-02-i1-proposition-seed",
doc: "acte-engagement-certification",
unit: "I1",
text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
levels: &[3],
reason: "Proposition seed : I1 sort de la vigueur",
},
SeedProposalAmendment {
id: "2026-11-02-e1-proposition-seed",
doc: "acte-engagement-certification",
unit: "E1",
text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd",
levels: &[3, 4],
reason: "Proposition seed : E1 sort de la vigueur",
},
SeedProposalAmendment {
id: "2026-11-02-e2-proposition-seed",
doc: "acte-engagement-certification",
unit: "E2",
text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0",
levels: &[3, 4],
reason: "Proposition seed : E2 sort de la vigueur",
},
];
/// What the database says of the version in force.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
/// As a seed amendment reads it.
pub text: seed_amend::Facts,
/// Lines of ballots of the chain naming it, whatever became of them.
pub ballots: i64,
}
/// What the article holds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Found {
/// A version in force.
InForce(Facts),
/// No text in force, and its seed proposal: born so, or already amended.
SeedProposal,
/// Neither.
Nothing,
}
/// What the database says of the article's level.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LevelFacts {
/// `units.inertia` as written.
pub raw: String,
/// Changes of inertia proposed on it that are open or applied, or hold a vote.
pub inertia_proposals: i64,
/// Changes of its inertia that a vote brought into force.
pub voted_changes: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// Already so: nothing to do.
Present,
Refuse(&'static str),
}
impl Verdict {
fn outcome(self) -> (&'static str, Option<&'static str>) {
match self {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
}
}
}
/// The status, apart from the database: any trace of a person keeps the text in force.
pub fn verdict(found: &Found, a: &SeedProposalAmendment) -> Verdict {
let f = match found {
Found::Nothing => return Verdict::Refuse("no_text_in_force"),
Found::SeedProposal => return Verdict::Present,
Found::InForce(f) => f,
};
let t = &f.text;
let kinds: Vec<&str> = t.revisions.iter().map(String::as_str).collect();
let seed_history = matches!((kinds.as_slice(), t.revision), (["initial"], 1) | (["initial", "amendment"], 2));
let refusal = if t.origin != "seed" {
"not_a_seed"
} else if t.entered {
"voted_into_force"
} else if t.confirmed {
"confirmed"
} else if t.votes > 0 {
"votes"
} else if f.ballots > 0 {
"ballots"
} else if t.typos > 0 || kinds.iter().any(|k| matches!(*k, "typo" | "revert")) {
"corrected"
} else if !seed_history {
"revised"
} else if t.member_translations > 0 {
"translation_edited"
} else if t.signals > 0 {
"signalled"
} else if t.fingerprint != a.text {
"text_differs"
} else {
return Verdict::Apply;
};
Verdict::Refuse(refusal)
}
/// The level, apart from the database: it goes low with the status only, and any
/// change of inertia a person proposed or voted keeps it.
pub fn level_verdict(status: Verdict, facts: Option<&LevelFacts>, a: &SeedProposalAmendment, to: u8) -> Verdict {
if let Verdict::Refuse(_) = status {
return Verdict::Refuse("not_a_seed_proposal");
}
let Some(f) = facts else { return Verdict::Refuse("no_article") };
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
if level.get() == to {
Verdict::Present
} else if f.voted_changes > 0 {
Verdict::Refuse("changed_by_vote")
} else if f.inertia_proposals > 0 {
Verdict::Refuse("inertia_proposal")
} else if !a.levels.contains(&level.get()) {
Verdict::Refuse("level_differs")
} else {
Verdict::Apply
}
}
/// A decision taken at this start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub amendment: &'static str,
pub group: String,
pub status: Verdict,
pub level: Verdict,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(
state: &AppState,
seeds: &[SeedDoc],
list: &[SeedProposalAmendment],
) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
let to = seed::SEED_PROPOSAL_LEVEL;
for a in list {
if !seed_says(seeds, a) {
continue;
}
let docs: Vec<(String, String, Option<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((status, level)) = decide(state, a, to, &doc_id, publish_path.is_some()).await? else {
continue;
};
let (s, sr) = status.outcome();
let (l, lr) = level.outcome();
if matches!(status, Verdict::Refuse(_)) || matches!(level, Verdict::Refuse(_)) {
tracing::warn!(
amendment = a.id, %group, unit = a.unit, status = s, reason = sr, level = l, level_reason = lr,
"seed proposal amendment: refused where the article was touched, it keeps what it had"
);
} else {
tracing::info!(
amendment = a.id, %group, unit = a.unit, status = s, level = l, to_level = to,
"seed proposal amendment"
);
}
if (status == Verdict::Apply || level == Verdict::Apply) && publish_path.is_some() {
state.publish_wake.notify_one();
}
out.push(Outcome { amendment: a.id, group, status, level });
}
}
Ok(out)
}
/// The seed must put the article forward, with the text the amendment names and
/// no level of its own: a seed edited later never reaches a base by this way.
fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool {
let unit = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| {
let src = s.source_lang.as_str();
let u = s.units.iter().find(|u| u.code == a.unit)?;
let v = u.versions.iter().find(|v| v.seed_proposal)?;
Some((u, fingerprint(v.title.get(src)?, v.body.get(src)?)))
});
match unit {
Some((u, print)) if print == a.text && u.inertia.is_none() => true,
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
unit = a.unit,
"seed proposal amendment: the seed does not put this text forward, nothing decided"
);
false
}
}
}
/// What the article holds, and the version examined: in force, or its seed proposal.
async fn found(
conn: &mut SqliteConnection,
unit_id: &str,
in_force: Option<&str>,
) -> anyhow::Result<(Found, Option<String>)> {
if let Some(id) = in_force {
let Some(text) = seed_amend::facts(&mut *conn, id, "in_force").await? else {
return Ok((Found::Nothing, Some(id.to_owned())));
};
let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?")
.bind(id)
.fetch_one(&mut *conn)
.await?;
return Ok((Found::InForce(Facts { text, ballots }), Some(id.to_owned())));
}
let featured: Option<String> = sqlx::query_scalar(
"SELECT id FROM proposals WHERE unit_id = ? AND status = 'open' AND origin = 'seed' AND seed_featured = 1
ORDER BY rowid LIMIT 1",
)
.bind(unit_id)
.fetch_optional(&mut *conn)
.await?;
Ok(match featured {
Some(id) => (Found::SeedProposal, Some(id)),
None => (Found::Nothing, None),
})
}
async fn level_facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<LevelFacts> {
let inertia_proposals: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM proposals p WHERE p.unit_id = ? AND p.kind = 'inertia'
AND (p.status IN ('open', 'applied') OR EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id))",
)
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
let voted_changes: i64 = sqlx::query_scalar(
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
)
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
Ok(LevelFacts { raw, inertia_proposals, voted_changes })
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &SeedProposalAmendment,
to: u8,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<(Verdict, Verdict)>> {
let mut tx = state.db.begin().await?;
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM seed_proposal_amendments WHERE amendment_id = ? AND doc_id = ?")
.bind(a.id)
.bind(doc_id)
.fetch_one(&mut *tx)
.await?;
if decided > 0 {
return Ok(None);
}
let unit: Option<(String, String, Option<String>)> =
sqlx::query_as("SELECT id, inertia, in_force_id FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?;
let ((status_found, examined), level_found) = match &unit {
Some((id, raw, in_force)) => {
(found(&mut tx, id, in_force.as_deref()).await?, Some(level_facts(&mut tx, id, raw.clone()).await?))
}
None => ((Found::Nothing, None), None),
};
let mut status = verdict(&status_found, a);
let mut level = level_verdict(status, level_found.as_ref(), a, to);
let now = util::now();
let to_db = InertiaLevel::new(to).map(InertiaLevel::as_db).unwrap_or_default();
if let (Verdict::Apply, Some((unit_id, _, Some(pid)))) = (status, &unit) {
// Written only over the version that was judged, still untouched and in force.
let revision = match &status_found {
Found::InForce(f) => f.text.revision,
_ => 0,
};
let left = sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ? AND in_force_id = ?")
.bind(unit_id)
.bind(pid)
.execute(&mut *tx)
.await?;
let opened = sqlx::query(
"UPDATE proposals SET status = 'open', seed_featured = 1, left_at = ?, updated_at = ?
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
AND confirmed_at IS NULL AND revision = ?",
)
.bind(&now)
.bind(&now)
.bind(pid)
.bind(revision)
.execute(&mut *tx)
.await?;
if left.rows_affected() == 1 && opened.rows_affected() == 1 {
// The history says it: the same text, at the same revision, now a seed proposal.
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
SELECT id, revision, 'seed_proposal', title, body, NULL, ? FROM proposals WHERE id = ?",
)
.bind(&now)
.bind(pid)
.execute(&mut *tx)
.await?;
} else {
// Nothing is written: what was examined changed under us.
tx.rollback().await?;
tx = state.db.begin().await?;
status = Verdict::Refuse("changed");
level = level_verdict(status, level_found.as_ref(), a, to);
}
}
if let (Verdict::Apply, Some((unit_id, raw, _))) = (level, &unit) {
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
.bind(&to_db)
.bind(unit_id)
.bind(raw)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
let from = InertiaLevel::parse(raw).map_or_else(|| raw.clone(), InertiaLevel::as_db);
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
)
.bind(unit_id)
.bind(doc_id)
.bind(from)
.bind(&to_db)
.bind(a.id)
.bind(&now)
.execute(&mut *tx)
.await?;
} else {
level = Verdict::Refuse("changed");
}
}
if publishes && (status == Verdict::Apply || level == Verdict::Apply) {
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
.bind(doc_id)
.bind(a.reason)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
let (outcome, reason) = status.outcome();
let (level_outcome, level_reason) = level.outcome();
sqlx::query(
"INSERT INTO seed_proposal_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason,
from_level, to_level, level_outcome, level_reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(examined)
.bind(outcome)
.bind(reason)
.bind(unit.as_ref().map(|(_, raw, _)| raw.clone()))
.bind(&to_db)
.bind(level_outcome)
.bind(level_reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((status, level)))
}
#[cfg(test)]
mod tests {
use super::*;
const A: SeedProposalAmendment = SeedProposalAmendment {
id: "test",
doc: "doc",
unit: "E1",
text: "seed",
levels: &[3, 4],
reason: "Proposition seed : E1 sort de la vigueur",
};
fn intact() -> Facts {
Facts {
text: seed_amend::Facts {
origin: "seed".into(),
revision: 1,
revisions: vec!["initial".into()],
fingerprint: "seed".into(),
..seed_amend::Facts::default()
},
ballots: 0,
}
}
fn with(f: impl FnOnce(&mut seed_amend::Facts)) -> Found {
let mut facts = intact();
f(&mut facts.text);
Found::InForce(facts)
}
fn at(raw: &str) -> LevelFacts {
LevelFacts { raw: raw.into(), ..LevelFacts::default() }
}
#[test]
fn an_untouched_text_in_force_is_put_forward() {
assert_eq!(verdict(&Found::InForce(intact()), &A), Verdict::Apply);
// I1: its initial text, then the seed amendment of 30/10.
let amended = with(|t| {
t.revision = 2;
t.revisions = vec!["initial".into(), "amendment".into()];
});
assert_eq!(verdict(&amended, &A), Verdict::Apply);
}
#[test]
fn a_seed_proposal_already_there_is_left_as_it_is() {
assert_eq!(verdict(&Found::SeedProposal, &A), Verdict::Present);
}
#[test]
fn any_trace_of_a_person_keeps_the_text_in_force() {
let cases = [
(Found::Nothing, "no_text_in_force"),
(with(|t| t.origin = "member".into()), "not_a_seed"),
(with(|t| t.entered = true), "voted_into_force"),
(with(|t| t.confirmed = true), "confirmed"),
(with(|t| t.votes = 1), "votes"),
(Found::InForce(Facts { ballots: 1, ..intact() }), "ballots"),
(with(|t| t.typos = 1), "corrected"),
(
with(|t| {
t.revision = 2;
t.revisions = vec!["initial".into(), "typo".into()];
}),
"corrected",
),
(
with(|t| {
t.revision = 2;
t.revisions = vec!["initial".into(), "substantive".into()];
}),
"revised",
),
(with(|t| t.revision = 2), "revised"),
(with(|t| t.revisions = vec![]), "revised"),
(with(|t| t.member_translations = 1), "translation_edited"),
(with(|t| t.signals = 1), "signalled"),
(with(|t| t.fingerprint = "other".into()), "text_differs"),
];
for (found, reason) in cases {
assert_eq!(verdict(&found, &A), Verdict::Refuse(reason), "{found:?}");
}
}
#[test]
fn the_level_goes_low_with_the_status_where_nobody_touched_it() {
assert_eq!(level_verdict(Verdict::Apply, Some(&at("4")), &A, 2), Verdict::Apply);
assert_eq!(level_verdict(Verdict::Apply, Some(&at("3")), &A, 2), Verdict::Apply);
// The word of before, if an older image wrote it.
assert_eq!(level_verdict(Verdict::Apply, Some(&at("standard")), &A, 2), Verdict::Apply);
assert_eq!(level_verdict(Verdict::Present, Some(&at("2")), &A, 2), Verdict::Present);
assert_eq!(level_verdict(Verdict::Apply, Some(&at("1")), &A, 1), Verdict::Present, "level 1, if Yvv says so");
}
#[test]
fn a_change_of_inertia_a_person_proposed_or_voted_keeps_the_level() {
let cases = [
(Verdict::Refuse("votes"), Some(at("4")), "not_a_seed_proposal"),
(Verdict::Apply, None, "no_article"),
(Verdict::Apply, Some(at("haute")), "level_unreadable"),
(Verdict::Apply, Some(LevelFacts { voted_changes: 1, ..at("4") }), "changed_by_vote"),
(Verdict::Apply, Some(LevelFacts { inertia_proposals: 1, ..at("4") }), "inertia_proposal"),
(Verdict::Apply, Some(at("5")), "level_differs"),
(Verdict::Apply, Some(at("1")), "level_differs"),
];
for (status, facts, reason) in cases {
assert_eq!(level_verdict(status, facts.as_ref(), &A, 2), Verdict::Refuse(reason), "{facts:?}");
}
}
#[test]
fn the_shipped_amendments_name_the_three_first_modules_and_their_levels_of_before() {
let units: Vec<&str> = AMENDMENTS.iter().map(|a| a.unit).collect();
assert_eq!(units, ["I1", "E1", "E2"]);
assert!(AMENDMENTS.iter().all(|a| !a.levels.contains(&seed::SEED_PROPOSAL_LEVEL)));
assert!(InertiaLevel::new(seed::SEED_PROPOSAL_LEVEL).is_some_and(|l| l.get() <= 2), "a low level");
}
}
+30 -6
View File
@@ -16,7 +16,12 @@
//! its threshold, it **returns among the proposals** and the version it had
//! displaced comes back into force (a removed text comes back, a level of
//! inertia goes back, the order and the title of before apply again); its
//! adoption is void. At or above, it stays.
//! adoption is void. At or above, it stays, and its adoption says so
//! (`params.reexamined`): the act run again examines it no more.
//!
//! Told only (no `--apply`), the re-examination sets the simulated votes apart
//! without removing them: an entry that would return only once a more recent one
//! returned before it is told as no longer in effect.
use crate::AppState;
use crate::adoption;
@@ -141,7 +146,8 @@ pub async fn discard(state: &AppState, apply: bool) -> AppResult<Discarded> {
for a in adoptions {
let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null);
let simulated = tally_of(&params["simulated"]);
if simulated.cast() == 0 {
// Without simulated votes, or examined by an act already: nothing to examine.
if simulated.cast() == 0 || !params["reexamined"].is_null() {
continue;
}
if let Some(r) = reexamine(state, &a, &params, simulated, apply).await? {
@@ -164,14 +170,19 @@ async fn reexamine(
let in_effect = match a.kind.as_str() {
"remove" => p.status == "applied" && unit.in_force_id.is_none(),
"inertia" => {
p.status == "applied" && p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(docs::inertia_of(unit))
p.status == "applied"
&& p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(docs::inertia_of(unit))
}
_ => unit.in_force_id.as_deref() == Some(p.id.as_str()),
};
// Applied: the simulated votes are gone. Told only: they still count, set them apart.
let proven = if apply { st.tally(&p.id) } else { minus(st.tally(&p.id), st.simulated_counted(&p.id)) };
let level = serde_json::from_value::<InertiaLevel>(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p));
let eval = st.scale().evaluate(proven, level).ok();
// At the level and on the quorum it was voted at (ADR 0013; an adoption of before
// holds the C of before, `constant`, which is no quorum: the article's quorum now).
let level =
serde_json::from_value::<InertiaLevel>(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p));
let quorum = params["quorum"].as_u64().and_then(|q| u32::try_from(q).ok()).unwrap_or_else(|| st.quorum_of(unit));
let eval = st.scale().evaluate(proven, level, quorum).ok();
let holds = eval.is_some_and(|e| e.reached);
let displaced = params["displaced"].as_str().map(str::to_owned);
let mut r = Reexamined {
@@ -196,6 +207,15 @@ async fn reexamine(
if apply && in_effect && !holds {
give_back(state, &st, unit, &p, a, params, displaced.as_deref()).await?;
r.returned = true;
} else if apply {
// It stays (or something else took its place since): examined once, never again.
let mut kept = params.clone();
kept["reexamined"] = serde_json::json!({ "at": util::now(), "proven": r.proven, "required": r.required });
sqlx::query("UPDATE adoptions SET params = ? WHERE id = ?")
.bind(kept.to_string())
.bind(a.id)
.execute(&state.db)
.await?;
}
Ok(Some(r))
}
@@ -225,7 +245,11 @@ async fn give_back(
.await?;
if a.kind == "inertia" {
let from = serde_json::from_value::<InertiaLevel>(params["from_inertia"].clone()).unwrap_or_default();
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?").bind(from.as_db()).bind(&unit.id).execute(&mut *tx).await?;
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(from.as_db())
.bind(&unit.id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM inertia_changes WHERE proposal_id = ? AND cause = 'vote'")
.bind(&p.id)
.execute(&mut *tx)
+14 -2
View File
@@ -491,13 +491,25 @@ mod tests {
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let now = "2026-10-31T00:00:00Z";
// A seed proposal (ADR 0011) the vote has just put in force.
let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as(
"SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE p.status = 'in_force' AND p.kind = 'text' LIMIT 1",
WHERE p.status = 'open' AND p.seed_featured = 1 AND p.kind = 'text' LIMIT 1",
)
.fetch_one(&state.db)
.await
.expect("a version in force");
.expect("a seed proposal");
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ? WHERE id = ?")
.bind(now)
.bind(&id)
.execute(&state.db)
.await
.expect("in force");
sqlx::query("UPDATE units SET in_force_id = ?1 WHERE id = (SELECT unit_id FROM proposals WHERE id = ?1)")
.bind(&id)
.execute(&state.db)
.await
.expect("unit");
sqlx::query(
"INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)",
)