diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index ecd5c92..41fd27a 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -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| { diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 41d830f..23327be 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -958,15 +958,17 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult 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"), diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index 82b886c..19a4faa 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -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> { + 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 { + 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 diff --git a/crates/ld-server/src/simulation.rs b/crates/ld-server/src/simulation.rs index 0ea6d1c..c1bfc73 100644 --- a/crates/ld-server/src/simulation.rs +++ b/crates/ld-server/src/simulation.rs @@ -137,23 +137,56 @@ pub async fn discard(state: &AppState, apply: bool) -> AppResult { } // 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(¶ms["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, ¶ms, 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> { + 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(¶ms["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> { + let mut out = Vec::new(); + for (a, params, simulated) in to_examine(state, Some(doc_id)).await? { + if let Some(r) = reexamine(state, &a, ¶ms, simulated, false).await? + && r.in_effect + && !r.holds + { + out.push(r); + } + } Ok(out) } diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 1c3ebdf..e83a155 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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"); diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index cec3b1f..2a93b39 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -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 { + 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}"); +} diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index 281d4cf..6b4f77e 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -564,6 +564,7 @@ impl Remote { author_name: "libreDecision".into(), author_email: "decision@example.org".into(), without_articles, + simulated: false, }); cfg }