forked from yvv/decision
WIP lot S : Propositions seed (I1, E1, E2 ouvertes, cran 2), amendement gardé, publication suspendue sans article en vigueur
Travail en cours, sauvegardé à la demande d'orch : le moteur, le serveur et tests/seed_proposals.rs (verts) ; les autres tests et les e2e ne sont pas encore repris. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,10 @@ 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 (orch, 2026-11-02, while Yvv
|
||||
/// decides): 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
|
||||
@@ -110,6 +114,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| {
|
||||
|
||||
@@ -232,15 +232,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,
|
||||
@@ -659,7 +673,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),
|
||||
"evaluation": eval,
|
||||
"votedAtInertia": voted_at,
|
||||
@@ -797,10 +811,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"),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Floor amendments — the floor C of an existing document changed (ADR 0011).
|
||||
//!
|
||||
//! C is the minimal number of *for* votes below which nothing passes (ADR 0002):
|
||||
//! 50 in Monnaie libre, whatever the level of inertia. A low level for the seed
|
||||
//! proposals does not make a start with five people possible; whether C is
|
||||
//! lowered to start is Yvv's question (orch, 2026-11-02). **None is listed**:
|
||||
//! the floor stays. Should he lower it, one entry here does it, with the seed's
|
||||
//! `voteFloor` of the group set to the same value (new bases read the seed):
|
||||
//!
|
||||
//! - decided once per document, at the first start that knows the amendment;
|
||||
//! - only if the floor is still the one expected (`from`); already `to`: present;
|
||||
//! - kept in `floor_amendments` and in the logs; where the document publishes,
|
||||
//! the publication queue gets a row (`document.json` carries `voteFloor`).
|
||||
//!
|
||||
//! Every threshold of the document follows at the next pass of the sweeper, and
|
||||
//! so does the reference participation (2C/W) while fewer than five proposals
|
||||
//! are voted (ADR 0007).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::SeedDoc;
|
||||
use crate::util;
|
||||
|
||||
/// One floor to change, in the document a seed instantiated in a group.
|
||||
pub struct FloorAmendment {
|
||||
/// Stable identifier: the decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The group whose document it changes.
|
||||
pub group: &'static str,
|
||||
/// The floor it must still hold.
|
||||
pub from: f64,
|
||||
/// The floor it gets: the seed's today for the group, which must say so.
|
||||
pub to: f64,
|
||||
/// Reason given to the publication queue, and so to the texts repository.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// None while Yvv has not decided (2026-11-02).
|
||||
pub const AMENDMENTS: &[FloorAmendment] = &[];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// The floor is already there: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
fn same(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// The rule, apart from the database.
|
||||
pub fn verdict(found: Option<f64>, a: &FloorAmendment) -> Verdict {
|
||||
match found {
|
||||
None => Verdict::Refuse("no_document"),
|
||||
Some(c) if same(c, a.to) => Verdict::Present,
|
||||
Some(c) if !same(c, a.from) => Verdict::Refuse("floor_differs"),
|
||||
Some(_) => Verdict::Apply,
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[FloorAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
if !seed_says(seeds, a) {
|
||||
continue;
|
||||
}
|
||||
let doc: Option<(String, f64, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, vote_floor, publish_path FROM documents WHERE slug = ? AND group_slug = ?")
|
||||
.bind(a.doc)
|
||||
.bind(a.group)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
let doc_id = doc.as_ref().map(|(id, _, _)| id.clone());
|
||||
if let Some(id) = &doc_id {
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM floor_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut v = verdict(doc.as_ref().map(|(_, c, _)| *c), a);
|
||||
let now = util::now();
|
||||
if let (Verdict::Apply, Some((id, c, publish_path))) = (v, &doc) {
|
||||
// Written only over the floor that was judged.
|
||||
let done = sqlx::query("UPDATE documents SET vote_floor = ? WHERE id = ? AND vote_floor = ?")
|
||||
.bind(a.to)
|
||||
.bind(id)
|
||||
.bind(c)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
v = Verdict::Refuse("changed");
|
||||
} else if publish_path.is_some() {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let Some(id) = doc_id else {
|
||||
tracing::warn!(amendment = a.id, group = a.group, "floor amendment: no such document, nothing decided");
|
||||
continue;
|
||||
};
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO floor_amendments (amendment_id, doc_id, from_floor, to_floor, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(&id)
|
||||
.bind(doc.as_ref().map(|(_, c, _)| *c))
|
||||
.bind(a.to)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
match v {
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, group = a.group, reason = why, "floor amendment refused: the floor stays")
|
||||
}
|
||||
_ => tracing::info!(amendment = a.id, group = a.group, from = a.from, to = a.to, outcome, "floor amendment"),
|
||||
}
|
||||
if v == Verdict::Apply {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, verdict: v });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must give the group this floor: new bases read it there.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &FloorAmendment) -> bool {
|
||||
let floor = seeds
|
||||
.iter()
|
||||
.find(|s| s.slug == a.doc)
|
||||
.and_then(|s| s.instances.iter().find(|i| i.group == a.group))
|
||||
.map(|i| i.vote_floor);
|
||||
if floor.is_some_and(|c| same(c, a.to)) {
|
||||
return true;
|
||||
}
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
group = a.group,
|
||||
"floor amendment: the seed does not give this floor, nothing decided"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: FloorAmendment = FloorAmendment {
|
||||
id: "test",
|
||||
doc: "doc",
|
||||
group: "monnaie-libre",
|
||||
from: 50.0,
|
||||
to: 10.0,
|
||||
reason: "plancher C abaissé : 50 → 10",
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn none_is_listed_while_yvv_decides() {
|
||||
assert!(AMENDMENTS.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_floor_changes_only_from_the_one_expected() {
|
||||
assert_eq!(verdict(Some(50.0), &A), Verdict::Apply);
|
||||
assert_eq!(verdict(Some(10.0), &A), Verdict::Present);
|
||||
assert_eq!(verdict(Some(40.0), &A), Verdict::Refuse("floor_differs"));
|
||||
assert_eq!(verdict(None, &A), Verdict::Refuse("no_document"));
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod deadlines;
|
||||
pub mod doc_title;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod floor_amend;
|
||||
pub mod indexer;
|
||||
pub mod inertia_amend;
|
||||
pub mod messages;
|
||||
@@ -30,6 +31,7 @@ pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod seed_proposal_amend;
|
||||
pub mod team;
|
||||
pub mod typos;
|
||||
pub mod util;
|
||||
|
||||
@@ -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") })
|
||||
@@ -601,7 +603,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), "evaluation": eval, "votedAtInertia": voted_at,
|
||||
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
||||
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
||||
|
||||
@@ -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 the orch's (2026-11-02), while Yvv decides: 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");
|
||||
|
||||
@@ -9,18 +9,29 @@
|
||||
//! 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.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::inertia_amend;
|
||||
use crate::module_amend;
|
||||
use crate::modules;
|
||||
use crate::seed_amend;
|
||||
use crate::seed_proposal_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
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 »), or
|
||||
/// level 1 (« très basse »): his answer awaited, 2 by default. 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 {
|
||||
@@ -60,16 +71,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 +131,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?;
|
||||
crate::floor_amend::apply(state, &seeds, crate::floor_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -130,6 +166,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 +190,22 @@ 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.
|
||||
fn check(seed: &SeedDoc) -> anyhow::Result<()> {
|
||||
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();
|
||||
@@ -185,7 +238,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 +271,13 @@ 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)
|
||||
|
||||
@@ -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,8 @@ 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 +322,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 +368,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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user