Skip to main content

agora_agentkit/govlog/council/
votes.rs

1//! The votes that decide a Council item, and the arithmetic that decides
2//! it (0.76).
3//!
4//! Two forms, for two jobs:
5//!
6//! - [`FinalVotes`] is what a record holds: five bare [`CouncilVote`]s,
7//!   with a veto's rationale in `veto_rationale` and the Steward's
8//!   abstention's in `abstentions` beside it (a seat's is its final-round
9//!   statement, recorded there once). It reads every entry ever signed, including
10//!   those from before a rationale was required, so it cannot require one.
11//!   The thresholds ([`FinalVotes::outcome`]) are computed on it, so a
12//!   reader can check any signed outcome.
13//! - [`Votes`] is what a Council decides with: a [`StewardVote`] that
14//!   carries its rationale when it is a veto or an abstention, and a
15//!   [`SeatVote`] per seat that carries the seat's statement. A decision
16//!   built from it cannot leave out what the Constitution requires (Art.
17//!   IV § 3, each abstention's rationale; § 5, each veto's). It converts
18//!   into the record's fields with `From`, which cannot fail.
19//!
20//! There is no conversion back: an entry signed before GOV-2026-0009 may
21//! hold an `abstain` with no rationale anywhere, and nothing should
22//! pretend it had one.
23
24use std::fmt;
25use std::ops::Deref;
26
27use serde::{Deserialize, Serialize};
28
29use super::{
30    Abstention, CouncilMember, CouncilSeat, CouncilVote, DecisionCategory,
31    FinalVotes,
32};
33use crate::enums::DecisionOutcome;
34
35/// Words for the record that are never blank: a seat's statement with its
36/// final vote, the Steward's rationale for a veto or an abstention.
37///
38/// Built only by `TryFrom`, which refuses text that is empty once
39/// whitespace is trimmed; the text is kept as given. Serialized as a plain
40/// string, and its schema is a plain string with no `minLength` or
41/// `pattern` (neither is enforced under `strict`; see agora's CLAUDE.md),
42/// so a blank one is caught when parsed, not by the grammar.
43#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44#[serde(try_from = "String", into = "String")]
45pub struct Statement(String);
46
47/// A [`Statement`] that would have been blank
48#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
49#[error("words for the record may not be blank")]
50pub struct BlankStatement;
51
52impl TryFrom<String> for Statement {
53    type Error = BlankStatement;
54
55    fn try_from(text: String) -> Result<Self, Self::Error> {
56        if text.trim().is_empty() {
57            Err(BlankStatement)
58        } else {
59            Ok(Self(text))
60        }
61    }
62}
63
64impl TryFrom<&str> for Statement {
65    type Error = BlankStatement;
66
67    fn try_from(text: &str) -> Result<Self, Self::Error> {
68        text.to_string().try_into()
69    }
70}
71
72impl From<Statement> for String {
73    fn from(statement: Statement) -> Self {
74        statement.0
75    }
76}
77
78impl Deref for Statement {
79    type Target = str;
80
81    fn deref(&self) -> &str {
82        &self.0
83    }
84}
85
86impl AsRef<str> for Statement {
87    fn as_ref(&self) -> &str {
88        &self.0
89    }
90}
91
92impl fmt::Display for Statement {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(&self.0)
95    }
96}
97
98impl fmt::Debug for Statement {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        fmt::Debug::fmt(&self.0, f)
101    }
102}
103
104#[cfg(feature = "schemars")]
105impl schemars::JsonSchema for Statement {
106    fn inline_schema() -> bool {
107        true
108    }
109
110    fn schema_name() -> std::borrow::Cow<'static, str> {
111        std::borrow::Cow::Borrowed("Statement")
112    }
113
114    fn json_schema(
115        generator: &mut schemars::SchemaGenerator,
116    ) -> schemars::Schema {
117        String::json_schema(generator)
118    }
119}
120
121/// The Steward's vote on an item, with the rationale the Constitution
122/// requires when it is a veto (Art. IV § 5) or an abstention (Art. IV
123/// § 3).
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(tag = "vote", rename_all = "snake_case")]
126pub enum StewardVote {
127    Yes,
128    No,
129    /// Agreement with the seats' majority, on a Policy item; counts as yes
130    Concur,
131    Abstain {
132        rationale: Statement,
133    },
134    /// Rejects the item whatever the others voted
135    Veto {
136        rationale: Statement,
137    },
138    /// On an item the four seats determined limits the Steward's powers:
139    /// no vote and no veto (Art. IV § 3, GOV-2026-0009). Written only from
140    /// a recorded determination.
141    Recused,
142}
143
144impl From<&StewardVote> for CouncilVote {
145    fn from(vote: &StewardVote) -> Self {
146        match vote {
147            StewardVote::Yes => Self::Yes,
148            StewardVote::No => Self::No,
149            StewardVote::Concur => Self::Concur,
150            StewardVote::Abstain { .. } => Self::Abstain,
151            StewardVote::Veto { .. } => Self::Veto,
152            StewardVote::Recused => Self::Recused,
153        }
154    }
155}
156
157/// What a seat can vote on a voted item
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum SeatChoice {
161    Yes,
162    No,
163    /// Leaves the denominator (Art. IV § 3)
164    Abstain,
165    /// A vote to postpone: it participates, so it stays in the denominator
166    Defer,
167}
168
169impl From<SeatChoice> for CouncilVote {
170    fn from(choice: SeatChoice) -> Self {
171        match choice {
172            SeatChoice::Yes => Self::Yes,
173            SeatChoice::No => Self::No,
174            SeatChoice::Abstain => Self::Abstain,
175            SeatChoice::Defer => Self::Defer,
176        }
177    }
178}
179
180/// A [`CouncilVote`] that no seat casts on a voted item
181#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
182#[error("`{0:?}` is not a seat's vote on a voted item")]
183pub struct NotASeatChoice(pub CouncilVote);
184
185impl TryFrom<CouncilVote> for SeatChoice {
186    type Error = NotASeatChoice;
187
188    fn try_from(vote: CouncilVote) -> Result<Self, Self::Error> {
189        match vote {
190            CouncilVote::Yes => Ok(Self::Yes),
191            CouncilVote::No => Ok(Self::No),
192            CouncilVote::Abstain => Ok(Self::Abstain),
193            CouncilVote::Defer => Ok(Self::Defer),
194            other @ (CouncilVote::Concur
195            | CouncilVote::Veto
196            | CouncilVote::Ranked
197            | CouncilVote::Recused) => Err(NotASeatChoice(other)),
198        }
199    }
200}
201
202/// A seat's final vote, with its statement for the record. The statement
203/// is the seat's `position` in the final round, and an abstaining seat's
204/// rationale (Art. IV § 3).
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct SeatVote {
207    pub choice: SeatChoice,
208    pub statement: Statement,
209}
210
211/// One member's vote, seat or Steward: what [`Votes::iter`] yields
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum Vote<'a> {
214    Seat(&'a SeatVote),
215    Steward(&'a StewardVote),
216}
217
218impl Vote<'_> {
219    /// The vote as a record holds it
220    pub fn cast(&self) -> CouncilVote {
221        match self {
222            Self::Seat(v) => v.choice.into(),
223            Self::Steward(v) => (*v).into(),
224        }
225    }
226
227    /// The rationale an abstention carries, if this is one
228    pub fn abstention(&self) -> Option<&Statement> {
229        match self {
230            Self::Seat(SeatVote {
231                choice: SeatChoice::Abstain,
232                statement,
233            }) => Some(statement),
234            Self::Steward(StewardVote::Abstain { rationale }) => {
235                Some(rationale)
236            }
237            Self::Seat(_) | Self::Steward(_) => None,
238        }
239    }
240}
241
242/// The four seats' final votes, each with its statement: what the
243/// Steward is shown before voting, and the seats' part of [`Votes`]
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct SeatVotes {
246    pub artist: SeatVote,
247    pub philosopher: SeatVote,
248    pub lawyer: SeatVote,
249    pub engineer: SeatVote,
250}
251
252impl SeatVotes {
253    /// One seat's vote
254    pub fn seat(&self, seat: CouncilSeat) -> &SeatVote {
255        match seat {
256            CouncilSeat::Artist => &self.artist,
257            CouncilSeat::Philosopher => &self.philosopher,
258            CouncilSeat::Lawyer => &self.lawyer,
259            CouncilSeat::Engineer => &self.engineer,
260        }
261    }
262
263    /// Every seat's vote, in seating order
264    pub fn iter(&self) -> impl Iterator<Item = (CouncilSeat, &SeatVote)> {
265        CouncilSeat::ALL
266            .into_iter()
267            .map(|seat| (seat, self.seat(seat)))
268    }
269
270    /// Seats voting yes
271    pub fn yes_count(&self) -> usize {
272        self.iter()
273            .filter(|(_, v)| v.choice == SeatChoice::Yes)
274            .count()
275    }
276
277    /// Seats voting no
278    pub fn no_count(&self) -> usize {
279        self.iter()
280            .filter(|(_, v)| v.choice == SeatChoice::No)
281            .count()
282    }
283}
284
285/// The five votes that decide a voted item, each with the words the
286/// Constitution requires of it. See the [module docs](self).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct Votes {
289    #[serde(flatten)]
290    pub seats: SeatVotes,
291    pub steward: StewardVote,
292}
293
294impl Votes {
295    /// One seat's vote
296    pub fn seat(&self, seat: CouncilSeat) -> &SeatVote {
297        self.seats.seat(seat)
298    }
299
300    /// Every member's vote, the seats in seating order and the Steward
301    /// last
302    pub fn iter(&self) -> impl Iterator<Item = (CouncilMember, Vote<'_>)> {
303        self.seats
304            .iter()
305            .map(|(seat, vote)| (seat.into(), Vote::Seat(vote)))
306            .chain([(CouncilMember::Steward, Vote::Steward(&self.steward))])
307    }
308
309    /// The Steward's abstention with its rationale, when the Steward
310    /// abstained: all the record's `abstentions` hold. A seat's abstention
311    /// rationale is its statement in the final round, recorded there once;
312    /// a second copy would be one more place a lawful redaction has to
313    /// find it (Steward, 2026-10-13).
314    pub fn steward_abstention(&self) -> Option<Abstention> {
315        match &self.steward {
316            StewardVote::Abstain { rationale } => Some(Abstention {
317                member: CouncilMember::Steward,
318                rationale: rationale.to_string().into(),
319            }),
320            _ => None,
321        }
322    }
323
324    /// The Steward's rationale, when the Steward vetoed: the record's
325    /// `veto_rationale`
326    pub fn veto_rationale(&self) -> Option<&Statement> {
327        match &self.steward {
328            StewardVote::Veto { rationale } => Some(rationale),
329            _ => None,
330        }
331    }
332
333    /// The outcome under `category`'s threshold
334    /// ([`FinalVotes::outcome`])
335    pub fn outcome(&self, category: DecisionCategory) -> DecisionOutcome {
336        FinalVotes::from(self).outcome(category)
337    }
338
339    /// The record's `vote_tally` ([`FinalVotes::tally`])
340    pub fn tally(&self) -> String {
341        FinalVotes::from(self).tally()
342    }
343}
344
345impl From<&Votes> for FinalVotes {
346    fn from(votes: &Votes) -> Self {
347        Self {
348            artist: votes.seats.artist.choice.into(),
349            philosopher: votes.seats.philosopher.choice.into(),
350            lawyer: votes.seats.lawyer.choice.into(),
351            engineer: votes.seats.engineer.choice.into(),
352            steward: (&votes.steward).into(),
353        }
354    }
355}
356
357impl FinalVotes {
358    /// Every member's vote, in seating order and the Steward last
359    fn all(&self) -> [CouncilVote; 5] {
360        [
361            self.artist,
362            self.philosopher,
363            self.lawyer,
364            self.engineer,
365            self.steward,
366        ]
367    }
368
369    /// The four seats' votes
370    fn seats(&self) -> [CouncilVote; 4] {
371        [self.artist, self.philosopher, self.lawyer, self.engineer]
372    }
373
374    /// Yes votes, `concur` counted as yes
375    pub fn yes_count(&self) -> usize {
376        self.all()
377            .iter()
378            .filter(|v| matches!(v, CouncilVote::Yes | CouncilVote::Concur))
379            .count()
380    }
381
382    /// No votes
383    pub fn no_count(&self) -> usize {
384        self.all()
385            .iter()
386            .filter(|v| matches!(v, CouncilVote::No))
387            .count()
388    }
389
390    /// Whether the Steward is recused on the item
391    pub fn steward_recused(&self) -> bool {
392        self.steward == CouncilVote::Recused
393    }
394
395    /// Members voting: everyone but abstainers and a recused Steward.
396    ///
397    /// Constitution Art. IV § 3, "Abstention and Recusal" (GOV-2026-0009):
398    /// an abstention or recusal removes the member from the denominator;
399    /// it is neither a vote for nor against. A `defer` is a vote, a member
400    /// asking to postpone is participating, not standing aside, so it
401    /// stays in the denominator and blocks unanimity.
402    pub fn voting_count(&self) -> usize {
403        self.all()
404            .iter()
405            .filter(|v| {
406                !matches!(v, CouncilVote::Abstain | CouncilVote::Recused)
407            })
408            .count()
409    }
410
411    /// `"<yes>-<no>"`, noting a recused Steward: the record's `vote_tally`
412    /// on a voted item
413    pub fn tally(&self) -> String {
414        if self.steward_recused() {
415            format!(
416                "{}-{} (Steward recused)",
417                self.yes_count(),
418                self.no_count()
419            )
420        } else {
421            format!("{}-{}", self.yes_count(), self.no_count())
422        }
423    }
424
425    /// The outcome under `category`'s threshold (Constitution Art. IV § 3,
426    /// as amended by GOV-2026-0009).
427    ///
428    /// A recused Steward puts the recused thresholds into effect, with no
429    /// separate vote on the arithmetic. A `Schedule` item is ranked, not
430    /// voted, and is recorded set without a tally; reaching this with one
431    /// means a caller routed it through the yes/no path, and it is
432    /// `Deferred`.
433    pub fn outcome(&self, category: DecisionCategory) -> DecisionOutcome {
434        let recused = self.steward_recused();
435        let passes = |pass: bool| match pass {
436            true => DecisionOutcome::Approved,
437            false => DecisionOutcome::Rejected,
438        };
439
440        // A veto rejects regardless of tally (Art. IV § 2). A recused
441        // Steward holds no veto (GOV-2026-0009), and recusal and veto are
442        // the same field, so a recused Steward's vote cannot also be one.
443        if self.steward == CouncilVote::Veto {
444            return DecisionOutcome::Rejected;
445        }
446
447        match category {
448            // Three affirmative votes, an absolute count. With the Steward
449            // recused they must be found among the four seats, which is
450            // the same count: a recusal is never a yes.
451            DecisionCategory::Routine => passes(self.yes_count() >= 3),
452            // "Unanimous among the four Council agents": all four yes. The
453            // row names its denominator, so a seat's abstention does not
454            // shrink it here.
455            DecisionCategory::Policy if recused => {
456                passes(self.seats().iter().all(|v| *v == CouncilVote::Yes))
457            }
458            // Four of five including the Steward. A Steward abstention
459            // still rejects (§ 4.4's pocket veto): an abstention is not a
460            // recusal, which only the seats' determination makes.
461            DecisionCategory::Policy => passes(
462                self.yes_count() >= 4
463                    && matches!(
464                        self.steward,
465                        CouncilVote::Yes | CouncilVote::Concur
466                    ),
467            ),
468            // GOV-2026-0009 ("Define unanimous"): unanimous among those
469            // voting, and no fewer than four voting. The floor keeps a
470            // shrunken denominator from hollowing out unanimity (the
471            // Council chose 4 so that one member may stand aside, and so a
472            // recused-Steward item tolerates no seat abstention).
473            DecisionCategory::Constitutional => {
474                let voting = self.voting_count();
475                passes(voting >= 4 && self.yes_count() == voting)
476            }
477            // The Steward alone
478            DecisionCategory::Emergency => passes(matches!(
479                self.steward,
480                CouncilVote::Yes | CouncilVote::Concur
481            )),
482            DecisionCategory::Schedule => DecisionOutcome::Deferred,
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests;