//! 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, is no longer votable while the objection //! stands; 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, pub lifted_by: Option, pub kind: String, /// The version held back (not votable). pub held_id: Option, pub phase: String, pub cycle: i64, pub due_at: Option, pub answered_at: Option, pub answer: Option, pub answer_id: Option, pub lift: Option, } 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 { 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 { 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> { 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, HashMap)> { 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 = 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> { Ok(sqlx::query_scalar::<_, Option>("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 { 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 { 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, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { 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(); 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?; 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 { 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, User(user): User, Path(id): Path) -> AppResult> { 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, User(user): User, Path(id): Path, ) -> AppResult> { 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)> { 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, 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 { 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> { 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)>> { 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))) }