Lot V : « Pas avant des votes prouvés » (Yvv, 03/11) — un texte entré par des votes simulés est en vigueur sur le site, la publication attend que tout ce qui est en vigueur tienne sur les seuls votes prouvés (publish::waits_for_proven_votes, simulation::unproven), LD_PUBLISH_SIMULATED=1 publie quand même ; publication.waitsFor ; vu rouge puis vert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 05:42:13 +01:00
co-authored by Claude Opus 5.5
parent 0cde5bdbd0
commit 9b7345346e
7 changed files with 258 additions and 25 deletions
+7
View File
@@ -24,6 +24,12 @@ pub struct Publish {
/// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it
/// (ADR 0011).
pub without_articles: bool,
/// Publish a document even while a text, an order, a title or a level of inertia
/// in effect entered by simulated votes does not hold on the proven votes alone
/// (`LD_PUBLISH_SIMULATED`). Off — Yvv, 2026-11-03: « Pas avant des votes
/// prouvés »: in force on the site, never published before the proven votes carry
/// it (ADR 0012, `publish::waits_for_proven_votes`).
pub simulated: bool,
}
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
@@ -119,6 +125,7 @@ impl Config {
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"),
simulated: flag("LD_PUBLISH_SIMULATED"),
});
let dev_login = flag("LD_DEV_LOGIN");
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
+7 -5
View File
@@ -958,15 +958,17 @@ 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,
// Waiting, not failing: no article in force (ADR 0011), or something in effect that
// does not hold on the proven votes yet (ADR 0012) — nothing goes out.
let waits = match &last {
Some(r) if r.done_at.is_none() => crate::publish::waits(state, doc_id).await?,
_ => None,
};
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 if suspended { "suspended" } else { "pending" },
"state": if r.done_at.is_some() { "done" } else if waits.is_some() { "suspended" } else { "pending" },
"waitsFor": waits.map(crate::publish::Wait::as_str),
"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"),
+70 -7
View File
@@ -83,18 +83,29 @@ 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? {
if let Some(wait) = waits(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.
// text that enters queues a row due now, and everything goes out with it —
// or, waiting for proven votes, it is asked again a while later.
let again = match wait {
Wait::Text => MAX_BACKOFF_SECS,
Wait::Proven => PROVEN_AGAIN_SECS,
};
sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL")
.bind(util::in_seconds(MAX_BACKOFF_SECS))
.bind(util::in_seconds(again))
.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)"
);
match wait {
Wait::Text => 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)"
),
Wait::Proven => tracing::info!(
doc = %doc_id, waiting = rows.len(),
"publication suspended: something in effect entered by simulated votes does not hold on the proven votes (LD_PUBLISH_SIMULATED=1 publishes anyway)"
),
}
continue;
}
match publish_doc(state, &doc_id).await {
@@ -123,6 +134,58 @@ pub async fn pass(state: &AppState) -> anyhow::Result<()> {
Ok(())
}
/// Why a document waits before it is published.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Wait {
/// None of its articles is in force (ADR 0011).
Text,
/// Something in effect entered by simulated votes and does not hold on the
/// proven votes alone (ADR 0012).
Proven,
}
impl Wait {
/// As the API says it (`publication.waitsFor`).
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Proven => "proven",
}
}
}
/// Waiting for proven votes, the queue is asked again this often: a proven vote that
/// carries an entry already in force queues nothing by itself.
const PROVEN_AGAIN_SECS: i64 = 600;
/// Does the document wait before it is published, and for what: a first text
/// (`waits_for_a_text`), then proven votes (`waits_for_proven_votes`).
pub async fn waits(state: &AppState, doc_id: &str) -> anyhow::Result<Option<Wait>> {
if waits_for_a_text(state, doc_id).await? {
return Ok(Some(Wait::Text));
}
if waits_for_proven_votes(state, doc_id).await? {
return Ok(Some(Wait::Proven));
}
Ok(None)
}
/// Does the document wait for proven votes before it is published? Yvv, 2026-11-03:
/// « Pas avant des votes prouvés ». A text — an order, a title, a level of inertia, a
/// removal — that entered by simulated votes is in force on the site, and sends
/// nothing to the repository of the texts while anything in effect that entered by
/// simulated votes does not hold on the proven votes alone, by the re-examination of
/// the act « supprime » (`simulation::unproven`, told only). The chain being closed,
/// nothing goes out of Monnaie libre before it opens. The one place of this rule;
/// `LD_PUBLISH_SIMULATED=1` publishes anyway (`Publish::simulated`).
pub async fn waits_for_proven_votes(state: &AppState, doc_id: &str) -> anyhow::Result<bool> {
if state.cfg.publish.as_ref().is_none_or(|p| p.simulated) {
return Ok(false);
}
let unproven = crate::simulation::unproven(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(!unproven.is_empty())
}
/// 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
+46 -13
View File
@@ -137,23 +137,56 @@ pub async fn discard(state: &AppState, apply: bool) -> AppResult<Discarded> {
}
// The re-examination, the most recent entry first.
let adoptions = sqlx::query_as::<_, AdoptionRow>(
"SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions
ORDER BY at DESC, id DESC",
)
.fetch_all(&state.db)
.await?;
for a in adoptions {
let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null);
let simulated = tally_of(&params["simulated"]);
// Without simulated votes, or examined by an act already: nothing to examine.
if simulated.cast() == 0 || !params["reexamined"].is_null() {
continue;
}
for (a, params, simulated) in to_examine(state, None).await? {
if let Some(r) = reexamine(state, &a, &params, simulated, apply).await? {
out.reexamined.push(r);
}
}
if apply {
// What waited for the proven votes may go out now (`publish::waits_for_proven_votes`).
sqlx::query("UPDATE outbox SET next_at = ? WHERE done_at IS NULL").bind(util::now()).execute(&state.db).await?;
state.publish_wake.notify_one();
}
Ok(out)
}
/// The entries into force still to examine, the most recent first: those that
/// counted simulated votes, not yet examined by an act — of one document, or all.
async fn to_examine(state: &AppState, doc_id: Option<&str>) -> AppResult<Vec<(AdoptionRow, Value, Tally)>> {
let adoptions = sqlx::query_as::<_, AdoptionRow>(
"SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions
WHERE ?1 IS NULL OR doc_id = ?1
ORDER BY at DESC, id DESC",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
Ok(adoptions
.into_iter()
.filter_map(|a| {
let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null);
let simulated = tally_of(&params["simulated"]);
// Without simulated votes, or examined by an act already: nothing to examine.
(simulated.cast() > 0 && params["reexamined"].is_null()).then_some((a, params, simulated))
})
.collect())
}
/// What, in effect in a document, entered by simulated votes and does not hold on
/// the proven votes alone: the re-examination of the act, told only — nothing is
/// written. Empty: everything in effect holds on the proven votes. The publication
/// waits on it (Yvv, 2026-11-03: « Pas avant des votes prouvés »,
/// `publish::waits_for_proven_votes`).
pub async fn unproven(state: &AppState, doc_id: &str) -> AppResult<Vec<Reexamined>> {
let mut out = Vec::new();
for (a, params, simulated) in to_examine(state, Some(doc_id)).await? {
if let Some(r) = reexamine(state, &a, &params, simulated, false).await?
&& r.in_effect
&& !r.holds
{
out.push(r);
}
}
Ok(out)
}
+3
View File
@@ -798,6 +798,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
simulated: false,
});
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
@@ -872,6 +873,7 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
simulated: false,
});
cfg
};
@@ -1067,6 +1069,7 @@ async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_i
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
simulated: false,
});
let git = |args: &[&str]| {
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
+124
View File
@@ -2075,3 +2075,127 @@ async fn a_vote_exists_for_every_state_of_the_buffer() {
assert!(exists(unit_id("E1")).await, "a proven trace stays");
let _ = doc_id;
}
/// A bare repository of the texts, for the publication (ADR 0011, 0012).
struct Texts {
dir: std::path::PathBuf,
bare: std::path::PathBuf,
}
impl Texts {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("ld-v-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let bare = dir.join("textes.git");
let ok = std::process::Command::new("git").args(["init", "-q", "--bare"]).arg(&bare).status().expect("git");
assert!(ok.success());
Self { dir, bare }
}
/// A configuration that publishes there; `simulated`: `LD_PUBLISH_SIMULATED`.
fn config(&self, mut cfg: Config, simulated: bool) -> Config {
cfg.data_dir = self.dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: self.bare.display().to_string(),
branch: "main".into(),
key: self.dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles: false,
simulated,
});
cfg
}
/// The French text of the Acte as the repository has it; `None`: nothing published.
fn french(&self) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&self.bare)
.args(["show", "main:monnaie-libre/acte-engagement-certification/fr.md"])
.output()
.expect("git show");
out.status.success().then(|| String::from_utf8(out.stdout).expect("utf8"))
}
}
impl Drop for Texts {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// A body of a version, as the document shows it.
fn body_of(doc: &Value, id: &str) -> String {
find(doc, id)["body"].as_str().expect("body").to_owned()
}
/// Yvv, 2026-11-03: « Pas avant des votes prouvés ». A text that enters by simulated
/// votes is in force on the site, but sends nothing to the repository of the texts:
/// the publication waits until everything in effect holds on the proven votes alone —
/// the re-examination of the act « supprime » (`simulation`), told only.
#[tokio::test]
async fn a_text_entered_by_simulated_votes_is_in_force_but_never_published_before_proven_votes() {
let indexer = Indexer::start().await;
let texts = Texts::new("proven");
let (mut c, state) = Client::new(texts.config(chain_config(&indexer), false)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
small_web(&state, &doc_id).await;
let (e4, e5) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"));
// The chain closed: two simulated « pour », E4 enters — in force on the site.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": false }))).await;
for name in ["Luna", "Bruno"] {
let mut s = c.other();
s.login(name).await;
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
}
assert_eq!(unit(&c.doc("monnaie-libre").await, "E4")["inForce"]["id"], e4.as_str());
ld_server::publish::pass(&state).await.expect("pass");
assert!(texts.french().is_none(), "not before proven votes");
let publication = &c.doc("monnaie-libre").await["doc"]["publication"];
assert_eq!((publication["state"].as_str(), publication["waitsFor"].as_str()), (Some("suspended"), Some("proven")));
// The chain open: E5 enters on two proven votes — E4 still does not hold on them: nothing goes out.
c.ok("POST", "/api/dev/vote-chain", Some(json!({ "open": true }))).await;
let charlie = Signer { account: CHARLIE, identity: Some((9003, "Charlie", true)) };
for (block, signer) in [(100, alice()), (101, charlie)] {
let b = c.ballot(&doc, &[(&e5, Some("for"))], None).await;
indexer.pay(block, comment_of(&b), signer);
}
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "E5")["inForce"]["id"], e5.as_str());
ld_server::publish::pass(&state).await.expect("pass");
assert!(texts.french().is_none(), "E4 does not hold on the proven votes yet");
// The act « supprime »: E4 returns among the proposals; what is in force holds on the
// proven votes alone — it goes out.
ld_server::simulation::discard(&state, true).await.expect("act");
ld_server::publish::pass(&state).await.expect("pass");
let fr = texts.french().expect("published");
let (b4, b5) = (body_of(&d, &e4), body_of(&d, &e5));
assert!(fr.contains(b5.lines().next().expect("line")), "{fr}");
assert!(!fr.contains(b4.lines().next().expect("line")), "{fr}");
assert_eq!(c.doc("monnaie-libre").await["doc"]["publication"]["state"], "done");
}
/// Yvv may choose the other way (`LD_PUBLISH_SIMULATED=1`): published anyway.
#[tokio::test]
async fn with_the_switch_a_text_entered_by_simulated_votes_is_published() {
let texts = Texts::new("switch");
let (mut c, state) = Client::new(texts.config(Config::for_tests(), true)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
small_web(&state, &doc_id).await;
let e4 = version(&doc, "E4", "0");
for name in ["Luna", "Bruno"] {
let mut s = c.other();
s.login(name).await;
s.ok("POST", &format!("/api/proposals/{e4}/vote"), Some(json!({ "choice": "for" }))).await;
}
ld_server::publish::pass(&state).await.expect("pass");
let fr = texts.french().expect("published");
assert!(fr.contains(body_of(&doc, &e4).lines().next().expect("line")), "{fr}");
}
+1
View File
@@ -564,6 +564,7 @@ impl Remote {
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
without_articles,
simulated: false,
});
cfg
}