forked from yvv/decision
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:
@@ -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| {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶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<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(¶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<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, ¶ms, simulated, false).await?
|
||||
&& r.in_effect
|
||||
&& !r.holds
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -564,6 +564,7 @@ impl Remote {
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
without_articles,
|
||||
simulated: false,
|
||||
});
|
||||
cfg
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user