Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
//! Quorum amendments — the quorums by level a seed now gives a document, written into
|
||||
//! a document born without them (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, the test group at 1
|
||||
//! everywhere. A new base gets them from the seed (`quorums` of each instance). An
|
||||
//! existing base — production holds two instances of the certification act — reads
|
||||
//! `vote_floor` at every level until then (the C of before: 50 and 1), and gets them
|
||||
//! through an amendment listed here: written **only where the document holds none**;
|
||||
//! nothing else is touched (no article's own quorum, no vote, no text).
|
||||
//!
|
||||
//! Like the other amendments of the seed, each document is decided once, at the first
|
||||
//! start that knows the amendment, and the decision is kept in `quorum_amendments`
|
||||
//! (applied, refused and why, or present) and in the logs. Applied where the document
|
||||
//! publishes, the publication queue gets a row (`document.json` carries the quorums).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, quorums_json};
|
||||
use crate::util;
|
||||
|
||||
/// The quorums by level to write into every document instantiated from a seed.
|
||||
pub struct QuorumAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The quorums of each group's instance: the seed's today, which must say so.
|
||||
pub quorums: &'static [(&'static str, [u32; 5])],
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[QuorumAmendment] = &[QuorumAmendment {
|
||||
id: "2026-11-03-quorums-par-cran",
|
||||
doc: "acte-engagement-certification",
|
||||
quorums: &[("monnaie-libre", [12, 12, 50, 50, 50]), ("essai", [1, 1, 1, 1, 1])],
|
||||
reason: "quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013)",
|
||||
}];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with these quorums: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: `found` is what the document holds.
|
||||
pub fn verdict(found: Option<&str>, wanted: &[u32; 5]) -> Verdict {
|
||||
match found {
|
||||
None => Verdict::Apply,
|
||||
Some(raw) if serde_json::from_str::<[u32; 5]>(raw).ok().as_ref() == Some(wanted) => Verdict::Present,
|
||||
// Written otherwise: kept as it is.
|
||||
Some(_) => Verdict::Refuse("quorums_set"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[QuorumAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some(wanted) = seed_says(seeds, a, &group) else { continue };
|
||||
let Some(v) = decide(state, a, &wanted, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, quorums = ?wanted, "quorums written"),
|
||||
Verdict::Present => tracing::info!(amendment = a.id, %group, "quorums: already there"),
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, reason = why, "quorums refused: the document keeps its own")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must give the group's instance the quorums the amendment writes: a seed
|
||||
/// edited later never reaches a base by this way, and a seed without them decides nothing.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &QuorumAmendment, group: &str) -> Option<[u32; 5]> {
|
||||
let wanted = a.quorums.iter().find(|(g, _)| *g == group).map(|(_, q)| *q)?;
|
||||
let declared = seeds
|
||||
.iter()
|
||||
.find(|s| s.slug == a.doc)
|
||||
.and_then(|s| s.instances.iter().find(|i| i.group == group))
|
||||
.and_then(|i| i.quorums);
|
||||
if declared == Some(wanted) {
|
||||
Some(wanted)
|
||||
} else {
|
||||
tracing::warn!(amendment = a.id, doc = a.doc, %group, "quorum amendment: the seed does not give these quorums, nothing decided");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &QuorumAmendment,
|
||||
wanted: &[u32; 5],
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<Verdict>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM quorum_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let found: Option<String> =
|
||||
sqlx::query_scalar("SELECT quorums FROM documents WHERE id = ?").bind(doc_id).fetch_one(&mut *tx).await?;
|
||||
let v = verdict(found.as_deref(), wanted);
|
||||
let now = util::now();
|
||||
let to = quorums_json(wanted);
|
||||
if v == Verdict::Apply {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ? AND quorums IS NULL")
|
||||
.bind(&to)
|
||||
.bind(doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_amendments (amendment_id, doc_id, from_quorums, to_quorums, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(&found)
|
||||
.bind(&to)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ML: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
#[test]
|
||||
fn written_where_none_kept_where_other() {
|
||||
assert_eq!(verdict(None, &ML), Verdict::Apply);
|
||||
assert_eq!(verdict(Some("[12,12,50,50,50]"), &ML), Verdict::Present);
|
||||
assert_eq!(verdict(Some("[12, 12, 50, 50, 50]"), &ML), Verdict::Present, "the same numbers");
|
||||
assert_eq!(verdict(Some("[3,3,3,3,3]"), &ML), Verdict::Refuse("quorums_set"));
|
||||
assert_eq!(verdict(Some("not json"), &ML), Verdict::Refuse("quorums_set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amendment_says_what_the_seed_says() {
|
||||
let seed: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../seeds/acte-engagement-certification.json")).expect("seed");
|
||||
for (group, quorums) in AMENDMENTS[0].quorums {
|
||||
let inst = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == *group);
|
||||
assert_eq!(inst.map(|i| i["quorums"].clone()), Some(serde_json::json!(quorums)), "{group}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user