forked from yvv/decision
v3 : la Raison d'être remplace le Préambule (L7), amendement de seed gardé
- seed : I1 devient « Raison d'être », texte d'Yvv (fr), traduit en « Purpose » et « Razón de ser » ; apostrophes droites comme le reste du seed, insécables avant « ; » et dans les guillemets. - seed_amend.rs : un amendement de seed remplace un texte de départ dans une base existante, décidé une fois par document et seulement si personne n'a touché l'article (seed a minima d'origine, révision 1, aucun vote, aucune correction, aucune traduction de membre, aucun signalement, empreinte attendue). Décision consignée (migration 0004, table seed_amendments), ligne d'historique « texte de départ remplacé » avec son diff, republication mise en file là où le document publie. - Une coquille se mesure désormais aussi contre le texte posé par l'amendement. - Tests : règle pure (3), scénarios d'API (base neuve, amendement appliqué et republié une seule fois, cinq refus), e2e C13 ; C3, C6 et l'API suivent le nouveau texte (C6 : les apostrophes typographiques d'Unicité du compte, « latransmission » ayant disparu). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ pub mod error;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod team;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
|
||||
@@ -71,10 +71,11 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text the votes were cast on: its last initial or substantive revision.
|
||||
/// The text the votes were cast on: its last initial or substantive revision, or the
|
||||
/// starting text a seed amendment put in place (`seed_amend`).
|
||||
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive', 'amendment')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(&p.id)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//!
|
||||
//! On start, missing groups are created and every `seeds/*.json` document is
|
||||
//! instantiated in each group it names, **only if that group does not hold it
|
||||
//! yet**. An existing document is never touched: from its first vote on, it
|
||||
//! is runtime data.
|
||||
//! yet**. An existing document is never instantiated again: from its first
|
||||
//! vote on, it is runtime data. The one way a newer seed text reaches an
|
||||
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
|
||||
//! and applied only to an article nobody has touched.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
@@ -64,14 +67,23 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On start: the groups, the documents missing from them, then the seed
|
||||
/// amendments (each decided once per document).
|
||||
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
let seeds = documents(state).await?;
|
||||
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The groups and every seed document missing from its groups; returns the seeds read.
|
||||
pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
|
||||
ensure_groups(state).await?;
|
||||
let dir = state.cfg.seed_dir.clone();
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
|
||||
return Ok(());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
@@ -82,13 +94,14 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut seeds = Vec::new();
|
||||
for path in files {
|
||||
load_file(state, &path).await?;
|
||||
seeds.push(load_file(state, &path).await?);
|
||||
}
|
||||
Ok(())
|
||||
Ok(seeds)
|
||||
}
|
||||
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
let raw = tokio::fs::read_to_string(path).await?;
|
||||
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||
for inst in &seed.instances {
|
||||
@@ -111,7 +124,7 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
instantiate(state, &seed, inst).await?;
|
||||
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
|
||||
}
|
||||
Ok(())
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Seed amendments — a starting text replaced by a newer text of the seed.
|
||||
//!
|
||||
//! A seeded document is never instantiated again: once in the database, it is
|
||||
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
|
||||
//! the Préambule becomes « Raison d'être »), new bases get it from the seed;
|
||||
//! an existing base gets it through an amendment listed here, and **only if
|
||||
//! nobody has touched the article**:
|
||||
//!
|
||||
//! - its version in force is the seed's own, a *seed a minima* (never voted
|
||||
//! into force, never confirmed), at revision 1;
|
||||
//! - nobody ever voted on it, corrected it, edited one of its translations or
|
||||
//! named it in a contradiction signal;
|
||||
//! - its text is exactly the one the amendment replaces (fingerprint).
|
||||
//!
|
||||
//! Each document is decided once, at the first start that knows the
|
||||
//! amendment, and the decision is kept in `seed_amendments`: applied, refused
|
||||
//! (and why — also in the logs), or present (the base was born with the new
|
||||
//! text). Applied, the text and its seed translations are replaced in one
|
||||
//! transaction, the article's history gets a line of its own (kind
|
||||
//! `amendment`, with its diff) and, where the document publishes, the
|
||||
//! publication queue a row.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, SeedVersion};
|
||||
use crate::util;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// One starting text to replace, in every document instantiated from a seed.
|
||||
pub struct Amendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// Code of the article.
|
||||
pub unit: &'static str,
|
||||
/// Fingerprint of the starting text expected in force (source language).
|
||||
pub from: &'static str,
|
||||
/// Fingerprint of the new text: the version in force of the seed today, which
|
||||
/// must hash to it (a seed edited later never reaches a base by this way).
|
||||
pub to: &'static str,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[Amendment] = &[Amendment {
|
||||
id: "2026-10-30-i1-raison-d-etre",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "I1",
|
||||
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
|
||||
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
}];
|
||||
|
||||
/// The fingerprint of a text: the hash translations are pinned to.
|
||||
pub fn fingerprint(title: &str, body: &str) -> String {
|
||||
util::source_hash(title, body)
|
||||
}
|
||||
|
||||
/// What the database says of the version in force of the article.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
pub origin: String,
|
||||
/// Voted into force (`entered_at`).
|
||||
pub entered: bool,
|
||||
/// A seed a minima that later reached its threshold (`confirmed_at`).
|
||||
pub confirmed: bool,
|
||||
pub revision: i64,
|
||||
/// Votes cast on it, whatever their revision.
|
||||
pub votes: i64,
|
||||
/// Kinds of its revisions, in order.
|
||||
pub revisions: Vec<String>,
|
||||
/// Translations written or corrected by a member.
|
||||
pub member_translations: i64,
|
||||
/// Contradiction signals naming it, standing or lifted.
|
||||
pub signals: i64,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// The new text is already in force: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: any trace of a person keeps the text.
|
||||
pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_text_in_force") };
|
||||
if f.fingerprint == a.to {
|
||||
return Verdict::Present;
|
||||
}
|
||||
let refusal = if f.origin != "seed" {
|
||||
"not_a_seed"
|
||||
} else if f.entered {
|
||||
"voted_into_force"
|
||||
} else if f.confirmed {
|
||||
"confirmed"
|
||||
} else if f.revision != 1 {
|
||||
"revised"
|
||||
} else if f.votes > 0 {
|
||||
"votes"
|
||||
} else if f.revisions != ["initial"] {
|
||||
"corrected"
|
||||
} else if f.member_translations > 0 {
|
||||
"translation_edited"
|
||||
} else if f.signals > 0 {
|
||||
"signalled"
|
||||
} else if f.fingerprint != a.from {
|
||||
"text_differs"
|
||||
} else {
|
||||
return Verdict::Apply;
|
||||
};
|
||||
Verdict::Refuse(refusal)
|
||||
}
|
||||
|
||||
/// 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: &[Amendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let Some((seed, target)) = target_of(seeds, a) else { continue };
|
||||
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(v) = decide(state, a, seed, target, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"),
|
||||
Verdict::Present => {
|
||||
tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment: text already there")
|
||||
}
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "seed amendment refused: the article was touched, it keeps its text")
|
||||
}
|
||||
}
|
||||
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 document and its version in force for the article, checked against `to`.
|
||||
fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'a SeedVersion)> {
|
||||
let Some(seed) = seeds.iter().find(|s| s.slug == a.doc) else {
|
||||
tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided");
|
||||
return None;
|
||||
};
|
||||
let version =
|
||||
seed.units.iter().find(|u| u.code == a.unit).and_then(|u| u.versions.iter().find(|v| v.status == "in_force"));
|
||||
let src = seed.source_lang.as_str();
|
||||
let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?)));
|
||||
match (version, text) {
|
||||
(Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)),
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
unit = a.unit,
|
||||
"seed amendment: the seed text is not the one it names, nothing decided"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result<Option<Facts>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
origin: String,
|
||||
entered_at: Option<String>,
|
||||
confirmed_at: Option<String>,
|
||||
revision: i64,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
let Some(p) = sqlx::query_as::<_, Row>(
|
||||
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = 'in_force'",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE proposal_id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let revisions: Vec<String> = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let member_translations: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM translations WHERE proposal_id = ? AND (origin <> 'seed' OR updated_by IS NOT NULL)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let signals: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE proposal_id = ?1 OR target_proposal_id = ?1")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts {
|
||||
origin: p.origin,
|
||||
entered: p.entered_at.is_some(),
|
||||
confirmed: p.confirmed_at.is_some(),
|
||||
revision: p.revision,
|
||||
votes,
|
||||
revisions,
|
||||
member_translations,
|
||||
signals,
|
||||
fingerprint: fingerprint(&p.title, &p.body),
|
||||
}))
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &Amendment,
|
||||
seed: &SeedDoc,
|
||||
target: &SeedVersion,
|
||||
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 seed_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 in_force: Option<String> =
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
let found = match &in_force {
|
||||
Some(id) => facts(&mut tx, id).await?,
|
||||
None => None,
|
||||
};
|
||||
let mut v = verdict(found.as_ref(), a);
|
||||
let now = util::now();
|
||||
if let (Verdict::Apply, Some(pid)) = (v, &in_force) {
|
||||
let src = seed.source_lang.as_str();
|
||||
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
|
||||
let (title, body) = (pick(&target.title), pick(&target.body));
|
||||
// Written only over the text that was judged, still untouched.
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
|
||||
AND confirmed_at IS NULL AND revision = 1",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
||||
VALUES (?, 2, 'amendment', ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let hash = fingerprint(&title, &body);
|
||||
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||
let (Some(t), Some(b)) = (target.title.get(*lang), target.body.get(*lang)) else { continue };
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)
|
||||
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
||||
source_hash = excluded.source_hash, origin = 'seed', updated_by = NULL, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(lang)
|
||||
.bind(t)
|
||||
.bind(b)
|
||||
.bind(&hash)
|
||||
.bind(&now)
|
||||
.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?;
|
||||
}
|
||||
} else {
|
||||
v = Verdict::Refuse("changed");
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO seed_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.bind(&in_force)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: Amendment = Amendment {
|
||||
id: "test",
|
||||
doc: "doc",
|
||||
unit: "I1",
|
||||
from: "old",
|
||||
to: "new",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
};
|
||||
|
||||
fn intact() -> Facts {
|
||||
Facts {
|
||||
origin: "seed".into(),
|
||||
revision: 1,
|
||||
revisions: vec!["initial".into()],
|
||||
fingerprint: "old".into(),
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_starting_text_is_replaced() {
|
||||
assert_eq!(verdict(Some(&intact()), &A), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_text_already_there_is_left_as_it_is() {
|
||||
let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() };
|
||||
assert_eq!(verdict(Some(&f), &A), Verdict::Present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_trace_of_a_person_keeps_the_text() {
|
||||
let cases = [
|
||||
(None, "no_text_in_force"),
|
||||
(Some(Facts { origin: "member".into(), ..intact() }), "not_a_seed"),
|
||||
(Some(Facts { entered: true, ..intact() }), "voted_into_force"),
|
||||
(Some(Facts { confirmed: true, ..intact() }), "confirmed"),
|
||||
(Some(Facts { revision: 2, ..intact() }), "revised"),
|
||||
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
||||
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
||||
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
||||
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
||||
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
||||
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
||||
];
|
||||
for (facts, reason) in cases {
|
||||
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user