forked from yvv/decision
WIP lot S : floor_amend retiré (le quorum le remplace), fixture de production sous tests/fixtures/seed-production-2026-11-02/, reprise des tests (api, inertia, ballots, objections, typos)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,9 @@ pub struct Publish {
|
||||
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).
|
||||
/// (`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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ 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;
|
||||
|
||||
@@ -125,8 +125,8 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> {
|
||||
|
||||
/// 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.
|
||||
/// 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);
|
||||
|
||||
@@ -26,8 +26,9 @@ 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:
|
||||
/// 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;
|
||||
@@ -133,7 +134,6 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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', ?)",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user