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:
Yvv
2026-11-03 04:06:07 +01:00
co-authored by Claude Opus 5.5
parent 6228d3913a
commit e2445f6467
14 changed files with 185 additions and 312 deletions
+3 -2
View File
@@ -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,
}
-199
View File
@@ -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"));
}
}
-1
View File
@@ -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;
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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(())
}
+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', ?)",
)