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/// How many seats voting `defer` at the final vote defer the item, on a
158/// Routine, Policy or Constitutional item ([`FinalVotes::outcome`]; 0.97)
159pub const DEFERRING_SEATS: usize = 2;
160
161/// What a seat can vote on a voted item
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum SeatChoice {
165    Yes,
166    No,
167    /// Leaves the denominator (Art. IV § 3)
168    Abstain,
169    /// A vote to postpone: it participates, so it stays in the denominator
170    Defer,
171}
172
173impl From<SeatChoice> for CouncilVote {
174    fn from(choice: SeatChoice) -> Self {
175        match choice {
176            SeatChoice::Yes => Self::Yes,
177            SeatChoice::No => Self::No,
178            SeatChoice::Abstain => Self::Abstain,
179            SeatChoice::Defer => Self::Defer,
180        }
181    }
182}
183
184/// A [`CouncilVote`] that no seat casts on a voted item
185#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
186#[error("`{0:?}` is not a seat's vote on a voted item")]
187pub struct NotASeatChoice(pub CouncilVote);
188
189impl TryFrom<CouncilVote> for SeatChoice {
190    type Error = NotASeatChoice;
191
192    fn try_from(vote: CouncilVote) -> Result<Self, Self::Error> {
193        match vote {
194            CouncilVote::Yes => Ok(Self::Yes),
195            CouncilVote::No => Ok(Self::No),
196            CouncilVote::Abstain => Ok(Self::Abstain),
197            CouncilVote::Defer => Ok(Self::Defer),
198            other @ (CouncilVote::Concur
199            | CouncilVote::Veto
200            | CouncilVote::Ranked
201            | CouncilVote::Recused) => Err(NotASeatChoice(other)),
202        }
203    }
204}
205
206/// A seat's final vote, with its statement for the record. The statement
207/// is the seat's `position` in the final round, and an abstaining seat's
208/// rationale (Art. IV § 3).
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct SeatVote {
211    pub choice: SeatChoice,
212    pub statement: Statement,
213}
214
215/// One member's vote, seat or Steward: what [`Votes::iter`] yields
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Vote<'a> {
218    Seat(&'a SeatVote),
219    Steward(&'a StewardVote),
220}
221
222impl Vote<'_> {
223    /// The vote as a record holds it
224    pub fn cast(&self) -> CouncilVote {
225        match self {
226            Self::Seat(v) => v.choice.into(),
227            Self::Steward(v) => (*v).into(),
228        }
229    }
230
231    /// The rationale an abstention carries, if this is one
232    pub fn abstention(&self) -> Option<&Statement> {
233        match self {
234            Self::Seat(SeatVote {
235                choice: SeatChoice::Abstain,
236                statement,
237            }) => Some(statement),
238            Self::Steward(StewardVote::Abstain { rationale }) => {
239                Some(rationale)
240            }
241            Self::Seat(_) | Self::Steward(_) => None,
242        }
243    }
244}
245
246/// The four seats' final votes, each with its statement: what the
247/// Steward is shown before voting, and the seats' part of [`Votes`]
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct SeatVotes {
250    pub artist: SeatVote,
251    pub philosopher: SeatVote,
252    pub lawyer: SeatVote,
253    pub engineer: SeatVote,
254}
255
256impl SeatVotes {
257    /// One seat's vote
258    pub fn seat(&self, seat: CouncilSeat) -> &SeatVote {
259        match seat {
260            CouncilSeat::Artist => &self.artist,
261            CouncilSeat::Philosopher => &self.philosopher,
262            CouncilSeat::Lawyer => &self.lawyer,
263            CouncilSeat::Engineer => &self.engineer,
264        }
265    }
266
267    /// Every seat's vote, in seating order
268    pub fn iter(&self) -> impl Iterator<Item = (CouncilSeat, &SeatVote)> {
269        CouncilSeat::ALL
270            .into_iter()
271            .map(|seat| (seat, self.seat(seat)))
272    }
273
274    /// Seats voting yes
275    pub fn yes_count(&self) -> usize {
276        self.iter()
277            .filter(|(_, v)| v.choice == SeatChoice::Yes)
278            .count()
279    }
280
281    /// Seats voting no
282    pub fn no_count(&self) -> usize {
283        self.iter()
284            .filter(|(_, v)| v.choice == SeatChoice::No)
285            .count()
286    }
287}
288
289/// The five votes that decide a voted item, each with the words the
290/// Constitution requires of it. See the [module docs](self).
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct Votes {
293    #[serde(flatten)]
294    pub seats: SeatVotes,
295    pub steward: StewardVote,
296}
297
298impl Votes {
299    /// One seat's vote
300    pub fn seat(&self, seat: CouncilSeat) -> &SeatVote {
301        self.seats.seat(seat)
302    }
303
304    /// Every member's vote, the seats in seating order and the Steward
305    /// last
306    pub fn iter(&self) -> impl Iterator<Item = (CouncilMember, Vote<'_>)> {
307        self.seats
308            .iter()
309            .map(|(seat, vote)| (seat.into(), Vote::Seat(vote)))
310            .chain([(CouncilMember::Steward, Vote::Steward(&self.steward))])
311    }
312
313    /// The Steward's abstention with its rationale, when the Steward
314    /// abstained: all the record's `abstentions` hold. A seat's abstention
315    /// rationale is its statement in the final round, recorded there once;
316    /// a second copy would be one more place a lawful redaction has to
317    /// find it (Steward, 2026-10-13).
318    pub fn steward_abstention(&self) -> Option<Abstention> {
319        match &self.steward {
320            StewardVote::Abstain { rationale } => Some(Abstention {
321                member: CouncilMember::Steward,
322                rationale: rationale.to_string().into(),
323            }),
324            _ => None,
325        }
326    }
327
328    /// The Steward's rationale, when the Steward vetoed: the record's
329    /// `veto_rationale`
330    pub fn veto_rationale(&self) -> Option<&Statement> {
331        match &self.steward {
332            StewardVote::Veto { rationale } => Some(rationale),
333            _ => None,
334        }
335    }
336
337    /// The outcome under `category`'s threshold
338    /// ([`FinalVotes::outcome`])
339    pub fn outcome(&self, category: DecisionCategory) -> DecisionOutcome {
340        FinalVotes::from(self).outcome(category)
341    }
342
343    /// The record's `vote_tally` ([`FinalVotes::tally`])
344    pub fn tally(&self) -> String {
345        FinalVotes::from(self).tally()
346    }
347}
348
349impl From<&Votes> for FinalVotes {
350    fn from(votes: &Votes) -> Self {
351        Self {
352            artist: votes.seats.artist.choice.into(),
353            philosopher: votes.seats.philosopher.choice.into(),
354            lawyer: votes.seats.lawyer.choice.into(),
355            engineer: votes.seats.engineer.choice.into(),
356            steward: (&votes.steward).into(),
357        }
358    }
359}
360
361impl FinalVotes {
362    /// Every member's vote, in seating order and the Steward last
363    fn all(&self) -> [CouncilVote; 5] {
364        [
365            self.artist,
366            self.philosopher,
367            self.lawyer,
368            self.engineer,
369            self.steward,
370        ]
371    }
372
373    /// The four seats' votes
374    fn seats(&self) -> [CouncilVote; 4] {
375        [self.artist, self.philosopher, self.lawyer, self.engineer]
376    }
377
378    /// Yes votes, `concur` counted as yes
379    pub fn yes_count(&self) -> usize {
380        self.all()
381            .iter()
382            .filter(|v| matches!(v, CouncilVote::Yes | CouncilVote::Concur))
383            .count()
384    }
385
386    /// No votes
387    pub fn no_count(&self) -> usize {
388        self.all()
389            .iter()
390            .filter(|v| matches!(v, CouncilVote::No))
391            .count()
392    }
393
394    /// Whether the Steward is recused on the item
395    pub fn steward_recused(&self) -> bool {
396        self.steward == CouncilVote::Recused
397    }
398
399    /// Members voting: everyone but abstainers and a recused Steward.
400    ///
401    /// Constitution Art. IV § 3, "Abstention and Recusal" (GOV-2026-0009):
402    /// an abstention or recusal removes the member from the denominator;
403    /// it is neither a vote for nor against. A `defer` is a vote, a member
404    /// asking to postpone is participating, not standing aside, so it
405    /// stays in the denominator and blocks unanimity.
406    pub fn voting_count(&self) -> usize {
407        self.all()
408            .iter()
409            .filter(|v| {
410                !matches!(v, CouncilVote::Abstain | CouncilVote::Recused)
411            })
412            .count()
413    }
414
415    /// Seats voting `defer` (0.97)
416    pub fn defer_count(&self) -> usize {
417        self.seats()
418            .iter()
419            .filter(|v| **v == CouncilVote::Defer)
420            .count()
421    }
422
423    /// `"<yes>-<no>"`, noting a recused Steward and, from
424    /// [`DEFERRING_SEATS`] seats voting `defer`, how many did (0.97): the
425    /// record's `vote_tally` on a voted item, e.g. `"0-0, 2 defer (Steward
426    /// recused)"`
427    pub fn tally(&self) -> String {
428        let deferring = self.defer_count();
429        let defer = match deferring >= DEFERRING_SEATS {
430            true => format!(", {deferring} defer"),
431            false => String::new(),
432        };
433        if self.steward_recused() {
434            format!(
435                "{}-{}{defer} (Steward recused)",
436                self.yes_count(),
437                self.no_count()
438            )
439        } else {
440            format!("{}-{}{defer}", self.yes_count(), self.no_count())
441        }
442    }
443
444    /// The outcome under `category`'s threshold (Constitution Art. IV § 3,
445    /// as amended by GOV-2026-0009).
446    ///
447    /// A veto rejects first; then [`DEFERRING_SEATS`] or more seats voting
448    /// `defer` defer a Routine, Policy or Constitutional item (0.97). A
449    /// recused Steward puts the recused thresholds into effect, with no
450    /// separate vote on the arithmetic. A `Schedule` item is ranked, not
451    /// voted, and is recorded set without a tally; reaching this with one
452    /// means a caller routed it through the yes/no path, and it is
453    /// `Deferred`.
454    pub fn outcome(&self, category: DecisionCategory) -> DecisionOutcome {
455        let recused = self.steward_recused();
456        let passes = |pass: bool| match pass {
457            true => DecisionOutcome::Approved,
458            false => DecisionOutcome::Rejected,
459        };
460
461        // A veto rejects regardless of tally (Art. IV § 2). A recused
462        // Steward holds no veto (GOV-2026-0009), and recusal and veto are
463        // the same field, so a recused Steward's vote cannot also be one.
464        if self.steward == CouncilVote::Veto {
465            return DecisionOutcome::Rejected;
466        }
467
468        // Two or more seats voting `defer` at the final vote send the item
469        // to a later sitting, the Steward seated or recused, on any item
470        // the seats vote on (Steward, 2026-10-17; 0.97). Not an Emergency
471        // item, which is the Steward's alone. There is no limit on how
472        // often an item may be deferred so: whether to limit repeated
473        // deferrals is the Council's question. After the veto, which is
474        // absolute (Art. IV § 2).
475        if self.defer_count() >= DEFERRING_SEATS
476            && matches!(
477                category,
478                DecisionCategory::Routine
479                    | DecisionCategory::Policy
480                    | DecisionCategory::Constitutional
481            )
482        {
483            return DecisionOutcome::Deferred;
484        }
485
486        match category {
487            // Three affirmative votes, an absolute count. With the Steward
488            // recused they must be found among the four seats, which is
489            // the same count: a recusal is never a yes.
490            DecisionCategory::Routine => passes(self.yes_count() >= 3),
491            // "Unanimous among the four Council agents": all four yes. The
492            // row names its denominator, so a seat's abstention does not
493            // shrink it here.
494            DecisionCategory::Policy if recused => {
495                passes(self.seats().iter().all(|v| *v == CouncilVote::Yes))
496            }
497            // Four of five including the Steward. A Steward abstention
498            // still rejects (§ 4.4's pocket veto): an abstention is not a
499            // recusal, which only the seats' determination makes.
500            DecisionCategory::Policy => passes(
501                self.yes_count() >= 4
502                    && matches!(
503                        self.steward,
504                        CouncilVote::Yes | CouncilVote::Concur
505                    ),
506            ),
507            // GOV-2026-0009 ("Define unanimous"): unanimous among those
508            // voting, and no fewer than four voting. The floor keeps a
509            // shrunken denominator from hollowing out unanimity (the
510            // Council chose 4 so that one member may stand aside, and so a
511            // recused-Steward item tolerates no seat abstention).
512            DecisionCategory::Constitutional => {
513                let voting = self.voting_count();
514                passes(voting >= 4 && self.yes_count() == voting)
515            }
516            // The Steward alone
517            DecisionCategory::Emergency => passes(matches!(
518                self.steward,
519                CouncilVote::Yes | CouncilVote::Concur
520            )),
521            DecisionCategory::Schedule => DecisionOutcome::Deferred,
522        }
523    }
524}
525
526#[cfg(test)]
527mod tests;