v3 : F1.2, N1, N1.1 et O1 réécrits selon les règles appliquées (Yvv, 02/11)
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful

- Le seed porte les nouveaux textes (fr, en, es), ceux du brouillon relu
  (textes-regles-seed.md), pour les bases neuves.
- L'amendement de seed vise aussi une proposition ouverte du seed, par son
  étiquette (Version::Open) ; mêmes gardes : rien d'une personne, 0 vote,
  révision 1, empreinte attendue. Quatre amendements, de l'empreinte du seed du
  21/10 (inchangée depuis 67ddee2) à celle d'aujourd'hui.
- Tests vus rouges : remplacement une fois, un vote garde le texte, republication
  de Monnaie libre seule, base neuve « present ».

Boucle : 237 tests Rust, svelte-check 0, 55 vitest, e2e 35 / 35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-02 01:56:34 +01:00
co-authored by Claude Opus 5.5
parent 0919b467c9
commit 3f10a0e860
8 changed files with 405 additions and 42 deletions
+117 -21
View File
@@ -1,5 +1,10 @@
//! Seed amendments — a starting text replaced by a newer text of the seed.
//!
//! The text replaced is an article's version in force (a seed a minima), or, since
//! 2026-11-02, one of its open proposals from the seed, named by its label: the
//! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv).
//! The guards are the same.
//!
//! 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;
@@ -25,6 +30,24 @@ use crate::seed::{SeedDoc, SeedVersion};
use crate::util;
use sqlx::SqliteConnection;
/// Which version of the article an amendment replaces.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Version {
/// Its text in force, a seed a minima.
InForce,
/// An open proposal of the seed, at zero votes, by its label.
Open(&'static str),
}
impl Version {
fn status(self) -> &'static str {
match self {
Version::InForce => "in_force",
Version::Open(_) => "open",
}
}
}
/// 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.
@@ -33,6 +56,8 @@ pub struct Amendment {
pub doc: &'static str,
/// Code of the article.
pub unit: &'static str,
/// The version replaced.
pub version: Version,
/// 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
@@ -42,14 +67,54 @@ pub struct Amendment {
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",
}];
pub const AMENDMENTS: &[Amendment] = &[
Amendment {
id: "2026-10-30-i1-raison-d-etre",
doc: "acte-engagement-certification",
unit: "I1",
version: Version::InForce,
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
reason: "texte de départ remplacé : I1",
},
// The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02).
Amendment {
id: "2026-11-02-f1-2-regles",
doc: "acte-engagement-certification",
unit: "F1.2",
version: Version::Open("1.0.0"),
from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6",
to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60",
reason: "proposition de départ réécrite : F1.2",
},
Amendment {
id: "2026-11-02-n1-regles",
doc: "acte-engagement-certification",
unit: "N1",
version: Version::Open("1.0.0"),
from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142",
to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e",
reason: "proposition de départ réécrite : N1",
},
Amendment {
id: "2026-11-02-n1-1-regles",
doc: "acte-engagement-certification",
unit: "N1.1",
version: Version::Open("1.0.0"),
from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee",
to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0",
reason: "proposition de départ réécrite : N1.1",
},
Amendment {
id: "2026-11-02-o1-regles",
doc: "acte-engagement-certification",
unit: "O1",
version: Version::Open("1.0.0"),
from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642",
to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537",
reason: "proposition de départ réécrite : O1",
},
];
/// The fingerprint of a text: the hash translations are pinned to.
pub fn fingerprint(title: &str, body: &str) -> String {
@@ -88,7 +153,12 @@ pub enum Verdict {
/// 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") };
let Some(f) = facts else {
return Verdict::Refuse(match a.version {
Version::InForce => "no_text_in_force",
Version::Open(_) => "no_open_version",
});
};
if f.fingerprint == a.to {
return Verdict::Present;
}
@@ -160,8 +230,12 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'
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 version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| {
u.versions.iter().find(|v| match a.version {
Version::InForce => v.status == "in_force",
Version::Open(label) => v.status == "open" && v.label.as_deref() == Some(label),
})
});
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) {
@@ -178,7 +252,7 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'
}
}
async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result<Option<Facts>> {
async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result<Option<Facts>> {
#[derive(sqlx::FromRow)]
struct Row {
origin: String,
@@ -189,9 +263,10 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
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'",
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = ?",
)
.bind(proposal_id)
.bind(status)
.fetch_optional(&mut *conn)
.await?
else {
@@ -252,33 +327,49 @@ async fn decide(
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 = ?")
let target_id: Option<String> = match a.version {
Version::InForce => {
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()
}
Version::Open(label) => {
sqlx::query_scalar(
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = ?
ORDER BY p.rowid LIMIT 1",
)
.bind(doc_id)
.bind(a.unit)
.bind(label)
.fetch_optional(&mut *tx)
.await?
.flatten();
let found = match &in_force {
Some(id) => facts(&mut tx, id).await?,
}
};
let found = match &target_id {
Some(id) => facts(&mut tx, id, a.version.status()).await?,
None => None,
};
let mut v = verdict(found.as_ref(), a);
let now = util::now();
if let (Verdict::Apply, Some(pid)) = (v, &in_force) {
if let (Verdict::Apply, Some(pid)) = (v, &target_id) {
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
WHERE id = ? AND status = ? AND origin = 'seed' AND entered_at IS NULL
AND confirmed_at IS NULL AND revision = 1",
)
.bind(&title)
.bind(&body)
.bind(&now)
.bind(pid)
.bind(a.version.status())
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
@@ -335,7 +426,7 @@ async fn decide(
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(&in_force)
.bind(&target_id)
.bind(outcome)
.bind(reason)
.bind(&now)
@@ -353,6 +444,7 @@ mod tests {
id: "test",
doc: "doc",
unit: "I1",
version: Version::InForce,
from: "old",
to: "new",
reason: "texte de départ remplacé : I1",
@@ -398,5 +490,9 @@ mod tests {
for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
}
// An open proposal of the seed that is no longer there (withdrawn, never seeded).
let open = Amendment { version: Version::Open("1.0.0"), ..A };
assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version"));
assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply);
}
}