Files
decision/crates/ld-server/src/objections.rs
T
YvvandClaude Opus 5.5 238c7db93f v3 : sur une version retenue, « contre » et retrait oui, « pour » non (moteur, serveur, chaîne)
Décision d'Yvv du 01/11/2026 : « Retirer ou "contre" ». Le moteur dit la règle
(objection::may_cast) ; le vote direct, le vote groupé et le bulletin de la chaîne
l'appliquent par DocState::admits : « pour » 409 objection, « contre » et retrait
acceptés, une ligne refusée refuse tout le vote. Un bulletin scellé avant
l'objection compte tel qu'il est signé ; une version retenue n'entre jamais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:03:45 +01:00

594 lines
23 KiB
Rust

//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
//!
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of
//! the pair, never a version in force, takes no new *for* while the objection
//! stands — against or a withdrawal only (`may_cast`, Yvv 2026-11-01) — and
//! cannot enter into force; its author answers (a substantive revision, or a proposal linked to
//! the objection — a revision of the other side answers too); the objector is
//! then asked, by a message, to lift or reiterate; without an answer within 3
//! days the objection is lifted (`due`, run by the sweeper and at start).
//!
//! Objections live in the table of the contradiction signals of before
//! (`signals`, migration 0008): an older image still reads them as signals, and
//! every lifting writes `lifted_at` / `lifted_by` as it did.
use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::docs::{self, DocState, UnitRow};
use crate::error::{AppError, AppResult};
use crate::messages::{self, Topic};
use crate::proposals;
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::edit;
use ld_engine::objection::{
self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who,
};
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
pub const MIN_REASON: usize = 10;
pub const MAX_REASON: usize = 600;
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct ObjectionRow {
pub id: String,
/// The version the objection was raised from.
pub proposal_id: String,
/// The version it was raised against.
pub target_proposal_id: String,
pub reason: String,
/// The objector.
pub author_id: String,
pub created_at: String,
pub lifted_at: Option<String>,
pub lifted_by: Option<String>,
pub kind: String,
/// The version held back (not votable).
pub held_id: Option<String>,
pub phase: String,
pub cycle: i64,
pub due_at: Option<String>,
pub answered_at: Option<String>,
pub answer: Option<String>,
pub answer_id: Option<String>,
pub lift: Option<String>,
}
const SELECT: &str = "SELECT id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by,
kind, held_id, phase, cycle, due_at, answered_at, answer, answer_id, lift FROM signals";
impl ObjectionRow {
pub fn phase(&self) -> Phase {
let cycle = u32::try_from(self.cycle).unwrap_or(1);
match self.phase.as_str() {
"objector" => Phase::Objector { cycle, due: self.due_at.clone().unwrap_or_default() },
"lifted" => Phase::Lifted(self.lift.as_deref().and_then(Lift::parse).unwrap_or(Lift::Objector)),
_ => Phase::Author { cycle },
}
}
pub fn kind(&self) -> ObjectionKind {
ObjectionKind::parse(&self.kind).unwrap_or(ObjectionKind::Contredit)
}
/// The version the held one is said to repeat or contradict.
pub fn other_id(&self) -> &str {
if self.held_id.as_deref() == Some(self.proposal_id.as_str()) {
&self.target_proposal_id
} else {
&self.proposal_id
}
}
fn touches(&self, id: &str) -> Option<Touched> {
if self.held_id.as_deref() == Some(id) {
Some(Touched::Target)
} else if self.proposal_id == id || self.target_proposal_id == id {
Some(Touched::Other)
} else {
None
}
}
}
async fn load(state: &AppState, id: &str) -> AppResult<ObjectionRow> {
sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Standing objections touching one version, either side.
async fn standing_around(state: &AppState, proposal_id: &str) -> AppResult<Vec<ObjectionRow>> {
Ok(sqlx::query_as::<_, ObjectionRow>(&format!(
"{SELECT} WHERE phase <> 'lifted' AND (proposal_id = ?1 OR target_proposal_id = ?1) ORDER BY created_at, rowid"
))
.bind(proposal_id)
.fetch_all(&state.db)
.await?)
}
/// For a document: the versions held back by a standing objection (never one in
/// force), and how many standing objections touch each version.
pub async fn held(
state: &AppState,
doc_id: &str,
units: &[UnitRow],
) -> AppResult<(HashSet<String>, HashMap<String, i64>)> {
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
"{SELECT} WHERE phase <> 'lifted' AND proposal_id IN
(SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ?)"
))
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
let mut held = HashSet::new();
let mut counts: HashMap<String, i64> = HashMap::new();
for o in &rows {
*counts.entry(o.proposal_id.clone()).or_default() += 1;
*counts.entry(o.target_proposal_id.clone()).or_default() += 1;
if let Some(h) = &o.held_id
&& !objection::votable(h, in_force.contains(h.as_str()), &[h.as_str()])
{
held.insert(h.clone());
}
}
Ok((held, counts))
}
/// The order a version arrived in: its submission (or creation), then the order it was written in.
async fn arrival(state: &AppState, p: &docs::ProposalRow) -> AppResult<(String, i64)> {
let seq: i64 =
sqlx::query_scalar("SELECT rowid FROM proposals WHERE id = ?").bind(&p.id).fetch_one(&state.db).await?;
Ok((p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), seq))
}
fn is_in_force(st: &DocState, id: &str) -> bool {
st.units.iter().any(|u| u.in_force_id.as_deref() == Some(id))
}
fn who(user: &IdentityRow, objector: &str) -> Who {
let me = user.to_identity();
if trustin_access::reiterates_objection(&me, objector) {
Who::Objector
} else if trustin_access::lifts_objection(&me, objector) {
Who::Admin
} else {
Who::Other
}
}
fn refused(r: Refusal) -> AppError {
match r {
Refusal::Lifted => AppError::Conflict("lifted"),
Refusal::NotYours => AppError::Forbidden,
Refusal::NothingToReiterate => AppError::Conflict("nothing_to_reiterate"),
}
}
async fn author_of(state: &AppState, proposal_id: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar::<_, Option<String>>("SELECT author_id FROM proposals WHERE id = ?")
.bind(proposal_id)
.fetch_optional(&state.db)
.await?
.flatten())
}
async fn doc_of(state: &AppState, proposal_id: &str) -> AppResult<String> {
sqlx::query_scalar("SELECT u.doc_id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.id = ?")
.bind(proposal_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Stores a step, only over the phase it was computed from (two people acting at
/// once: the second gets a conflict), then does its effects. Returns false when
/// the phase had changed meanwhile.
async fn store(
state: &AppState,
o: &ObjectionRow,
step: &Step,
by: &str,
extra: Option<(&str, &str)>,
) -> AppResult<bool> {
let now = state.clock.now();
let (phase, cycle, due, lift) = match &step.phase {
Phase::Author { cycle } => ("author", i64::from(*cycle), None, None),
Phase::Objector { cycle, due } => ("objector", i64::from(*cycle), Some(due.clone()), None),
Phase::Lifted(l) => ("lifted", o.cycle, None, Some(*l)),
};
let (answer, answer_id) =
extra.map_or((o.answer.clone(), o.answer_id.clone()), |(a, id)| (Some(a.into()), Some(id.into())));
let answered_at = if extra.is_some() { Some(now.clone()) } else { o.answered_at.clone() };
let done = sqlx::query(
"UPDATE signals SET phase = ?, cycle = ?, due_at = ?, lift = ?, answer = ?, answer_id = ?, answered_at = ?,
lifted_at = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_at END,
lifted_by = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_by END
WHERE id = ? AND phase = ? AND cycle = ?",
)
.bind(phase)
.bind(cycle)
.bind(&due)
.bind(lift.map(Lift::as_str))
.bind(&answer)
.bind(&answer_id)
.bind(&answered_at)
.bind(phase)
.bind(&now)
.bind(phase)
.bind(by)
.bind(&o.id)
.bind(&o.phase)
.bind(o.cycle)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Ok(false);
}
let doc_id = doc_of(state, &o.proposal_id).await?;
let held_author = match &o.held_id {
Some(h) => author_of(state, h).await?,
None => None,
};
for effect in &step.effects {
match effect {
Effect::AskObjector => {
// The author's question of this cycle is answered.
messages::close(
state,
Topic::Objection,
&o.id,
Some(&["objection_raised", "objection_reiterated"]),
"answered",
)
.await?;
if held_author.as_deref() != Some(o.author_id.as_str()) {
messages::notify(
state,
&o.author_id,
&doc_id,
Topic::Objection,
&o.id,
"objection_answered",
cycle,
due.as_deref(),
)
.await?;
}
}
Effect::TellAuthor => {
messages::close(state, Topic::Objection, &o.id, Some(&["objection_answered"]), "reiterated").await?;
if let Some(author) = held_author.as_deref().filter(|a| *a != o.author_id) {
messages::notify(
state,
author,
&doc_id,
Topic::Objection,
&o.id,
"objection_reiterated",
cycle,
None,
)
.await?;
}
}
Effect::Ended => {
let outcome = match lift {
Some(Lift::Admin) => "admin",
Some(Lift::Deadline) => "deadline",
Some(Lift::Withdrawn) => "withdrawn",
_ => "lifted",
};
messages::close(state, Topic::Objection, &o.id, None, outcome).await?;
}
}
}
Ok(true)
}
// ---------------------------------------------------------------------------
// Raise, lift, reiterate
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RaiseBody {
other_id: String,
kind: String,
reason: String,
}
/// `POST /api/proposals/{id}/objections` — « this version repeats (or
/// contradicts) that one ». The latest arrival of the two is held back.
pub async fn raise(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<RaiseBody>,
) -> AppResult<Json<Value>> {
let (p, st) = proposals::guarded(&state, &user, &id).await?;
let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason"));
}
let kind = ObjectionKind::parse(&b.kind).ok_or(AppError::Invalid("kind"))?;
let other = st.proposals.iter().find(|x| x.id == b.other_id).ok_or(AppError::Invalid("other"))?;
let voted = |x: &docs::ProposalRow| matches!(x.status.as_str(), "open" | "in_force") && x.kind == "text";
if other.id == p.id || !voted(other) || !voted(&p) {
return Err(AppError::Invalid("other"));
}
if other.unit_id == p.unit_id {
// Rivals on one article never stand together anyway.
return Err(AppError::Invalid("same_unit"));
}
let (pa, oa) = (arrival(&state, &p).await?, arrival(&state, other).await?);
let held = objection::target(
&Side { id: &p.id, arrived_at: &pa.0, seq: pa.1, in_force: is_in_force(&st, &p.id) },
&Side { id: &other.id, arrived_at: &oa.0, seq: oa.1, in_force: is_in_force(&st, &other.id) },
)
.map_err(|e| match e {
objection::PairError::BothInForce => AppError::Invalid("both_in_force"),
objection::PairError::Same => AppError::Invalid("other"),
})?
.to_owned();
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM signals WHERE phase <> 'lifted' AND author_id = ?
AND ((proposal_id = ?2 AND target_proposal_id = ?3) OR (proposal_id = ?3 AND target_proposal_id = ?2))",
)
.bind(&user.id)
.bind(&p.id)
.bind(&other.id)
.fetch_one(&state.db)
.await?;
if exists > 0 {
return Err(AppError::Conflict("objection_exists"));
}
let oid = util::id();
let inserted = sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
)
.bind(&oid)
.bind(&p.id)
.bind(&other.id)
.bind(reason)
.bind(&user.id)
.bind(state.clock.now())
.bind(kind.as_str())
.bind(&held)
.execute(&state.db)
.await;
match inserted {
// The same one sent at the same instant: it stands already (`signals_one_standing`).
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Err(AppError::Conflict("objection_exists")),
other => other?,
};
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
}
tracing::info!(objection = %oid, held = %held, kind = kind.as_str(), "objection raised");
Ok(Json(json!({ "id": oid, "heldId": held })))
}
async fn act(state: &AppState, user: &IdentityRow, id: &str, lift: bool) -> AppResult<ObjectionRow> {
let o = load(state, id).await?;
proposals::guarded(state, user, &o.proposal_id).await?;
let event = if lift { Event::Lift(who(user, &o.author_id)) } else { Event::Reiterate(who(user, &o.author_id)) };
let step = objection::step(&o.phase(), &event).map_err(refused)?.ok_or(AppError::Conflict("lifted"))?;
if !store(state, &o, &step, &user.id, None).await? {
return Err(AppError::Conflict("changed"));
}
messages::read_subject(state, &user.id, Topic::Objection, &o.id).await?;
tracing::info!(objection = %o.id, lift, by = %user.id, "objection lifted or reiterated");
Ok(o)
}
/// `POST /api/objections/{id}/lift` — by the objector, or an administrator.
pub async fn lift(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
let o = act(&state, &user, &id, true).await?;
let entered = adoption::run(&state, &doc_of(&state, &o.proposal_id).await?).await?;
Ok(Json(json!({ "entered": entered })))
}
/// `POST /api/objections/{id}/reiterate` — by the objector only, once the author answered.
pub async fn reiterate(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
act(&state, &user, &id, false).await?;
Ok(Json(json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// What answers an objection
// ---------------------------------------------------------------------------
/// A version changed (by `by`): the objections around it whose author it
/// answers move on; the messages of the one who answered are read.
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> {
for o in standing_around(state, proposal_id).await? {
let Some(side) = o.touches(proposal_id) else { continue };
let change = if substantive { Change::Revision(side) } else { Change::Typo(side) };
if !objection::answers(change) {
continue;
}
let due = state.clock.in_days(objection::ANSWER_DAYS);
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
let answer = if side == Touched::Target { "revision" } else { "other" };
if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? {
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
}
}
}
Ok(())
}
/// May this person write a proposal linked to this objection, on this article?
pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> {
let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?;
let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?;
let unit_of = |id: String| async move {
sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?")
.bind(id)
.fetch_one(&state.db)
.await
};
let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?);
let author = author_of(state, held).await?;
if !o.phase().standing()
|| !objection::may_link(author.as_deref(), &user.id)
|| !objection::link_fits(unit_id, &held_unit, &other_unit)
{
return Err(AppError::Invalid("link"));
}
Ok(())
}
/// A proposal linked to an objection was submitted (by `by`): it answers the objection.
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> {
let o = load(state, objection_id).await?;
if !objection::answers(Change::LinkedProposal) {
return Ok(());
}
let due = state.clock.in_days(objection::ANSWER_DAYS);
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)?
&& store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?
{
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
}
Ok(())
}
/// A version was withdrawn: the objections around it end.
pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> {
for o in standing_around(state, proposal_id).await? {
if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? {
store(state, &o, &step, by, None).await?;
}
}
Ok(())
}
/// The objections whose deadline passed are lifted by themselves; returns how
/// many, and the documents touched.
pub async fn due(state: &AppState) -> AppResult<(usize, Vec<String>)> {
let now = state.clock.now();
let rows = sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE phase = 'objector' AND due_at <= ?"))
.bind(&now)
.fetch_all(&state.db)
.await?;
let mut docs = Vec::new();
let mut lifted = 0;
for o in rows {
if let Some(step) = objection::step(&o.phase(), &Event::Tick { now: &now }).map_err(refused)?
&& store(state, &o, &step, "deadline", None).await?
{
tracing::info!(objection = %o.id, "objection lifted at its deadline");
lifted += 1;
let doc = doc_of(state, &o.proposal_id).await?;
if !docs.contains(&doc) {
docs.push(doc);
}
}
}
Ok((lifted, docs))
}
// ---------------------------------------------------------------------------
// What the screen shows
// ---------------------------------------------------------------------------
fn version_json(st: &DocState, id: &str) -> Value {
let p = st.proposals.iter().find(|p| p.id == id);
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
json!({
"id": id, "title": p.map(|p| &p.title), "status": p.map(|p| &p.status), "unitId": p.map(|p| &p.unit_id),
"code": unit.map(|u| &u.code), "author": p.and_then(|p| p.author_name.as_ref()),
"authorId": p.and_then(|p| p.author_id.as_ref()),
})
}
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
let row: Option<(String, bool)> =
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?;
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
}
/// One objection, as `viewer` sees it: who raised it, what it holds back, where
/// it stands, and what the viewer may do.
pub async fn view(state: &AppState, st: &DocState, viewer: &IdentityRow, o: &ObjectionRow) -> AppResult<Value> {
let (objector, invited) = name_of(state, &o.author_id).await?;
let phase = o.phase();
let held_author = match &o.held_id {
Some(h) => author_of(state, h).await?,
None => None,
};
let me = viewer.to_identity();
let standing = phase.standing();
let i_answer = standing && o.held_id.is_some() && objection::may_link(held_author.as_deref(), &viewer.id);
let asked = matches!(phase, Phase::Objector { .. });
Ok(json!({
"id": o.id, "kind": o.kind(), "reason": o.reason, "objector": objector, "objectorInvited": invited,
"mine": o.author_id == viewer.id, "at": o.created_at,
"phase": o.phase, "cycle": o.cycle, "dueAt": o.due_at, "answer": o.answer, "answerId": o.answer_id,
"answeredAt": o.answered_at, "lift": o.lift, "liftedAt": o.lifted_at,
"heldVersion": o.held_id.as_ref().map(|h| version_json(st, h)),
"other": version_json(st, o.other_id()),
"iAnswer": i_answer,
"canLift": standing && trustin_access::lifts_objection(&me, &o.author_id),
"canReiterate": asked && trustin_access::reiterates_objection(&me, &o.author_id),
}))
}
/// The objections around one version, newest first, each as `viewer` sees it;
/// `held` tells whether this version is the one held back.
pub async fn around(state: &AppState, st: &DocState, viewer: &IdentityRow, proposal_id: &str) -> AppResult<Vec<Value>> {
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
"{SELECT} WHERE proposal_id = ?1 OR target_proposal_id = ?1 ORDER BY created_at DESC, rowid DESC"
))
.bind(proposal_id)
.fetch_all(&state.db)
.await?;
let mut out = Vec::with_capacity(rows.len());
for o in rows {
let mut v = view(state, st, viewer, &o).await?;
v["held"] = json!(o.held_id.as_deref() == Some(proposal_id));
out.push(v);
}
Ok(out)
}
/// An objection, for a message of its box: what it is about, and the gestures
/// the message offers now (none once dealt with).
pub async fn for_message(
state: &AppState,
viewer: &IdentityRow,
subject_id: &str,
kind: &str,
cycle: i64,
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
let Ok(o) = load(state, subject_id).await else { return Ok(None) };
let st = docs::load_state(state, &doc_of(state, &o.proposal_id).await?).await?;
let v = view(state, &st, viewer, &o).await?;
let current = o.cycle == cycle;
let gestures = match (o.phase(), kind) {
(Phase::Author { .. }, "objection_raised" | "objection_reiterated") if current => vec!["respond"],
(Phase::Objector { .. }, "objection_answered") if current && o.author_id == viewer.id => {
vec!["lift", "reiterate"]
}
_ => vec![],
};
Ok(Some((v, gestures)))
}