Skip to main content

agora_agentkit/govlog/council/
decided.rs

1//! How a Council item was decided, as one value, so the parts of a record
2//! that say so cannot disagree (0.76).
3//!
4//! A record states its decision six times over: `final_votes`, `outcome`,
5//! `vote_tally`, `veto_rationale`, `abstentions` and `agenda_ranking`. A
6//! writer that fills each by hand can sign a veto with no rationale, an
7//! abstention with none, or an outcome its votes do not give. A writer
8//! that builds a [`Decided`] and lets
9//! [`CouncilDecisionRecord::decided`] fill them cannot.
10
11use super::{
12    AgendaRanking, CouncilDecisionRecord, CouncilRound, CouncilSeat,
13    CouncilVote, DecisionCategory, FinalVotes, Redactable, SeatChoice,
14    SeatVote, SeatVotes, Statement, Votes,
15};
16use crate::enums::{DecisionOutcome, RoundType};
17
18/// How an item was decided.
19///
20/// Not every shape a record can hold is a `Decided`: a round tabled by a
21/// recused Steward over an API refusal (`tabled_by_recused_steward`) has
22/// none, because the Council program does not produce it (a refusal
23/// aborts the sitting, 2026-09-29). Should it again, it needs a variant
24/// here first.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Decided {
27    /// Voted, under the category's threshold
28    Voted(Votes),
29    /// A `Schedule` item: the seats' rankings, aggregated. The Steward
30    /// executes the order and does not vote; a seat that submitted no
31    /// ballot is recorded `abstain`, as is the Steward, as every scheduling
32    /// record has been.
33    Ranked(AgendaRanking),
34    /// Tabled by the Steward: every vote `defer`
35    Tabled,
36    /// The Steward recused, and `deferring` seats still deferring at the
37    /// round cap: it returns to a later sitting, and may not be deferred
38    /// again
39    RecusedDeferral { deferring: u32 },
40}
41
42impl Decided {
43    /// The record's `final_votes`
44    pub fn final_votes(&self) -> FinalVotes {
45        match self {
46            Self::Voted(votes) => votes.into(),
47            Self::Ranked(ranking) => {
48                let ranked = |seat: CouncilSeat| match ranking
49                    .ballots
50                    .iter()
51                    .any(|b| b.seat == seat)
52                {
53                    true => CouncilVote::Ranked,
54                    false => CouncilVote::Abstain,
55                };
56                FinalVotes {
57                    artist: ranked(CouncilSeat::Artist),
58                    philosopher: ranked(CouncilSeat::Philosopher),
59                    lawyer: ranked(CouncilSeat::Lawyer),
60                    engineer: ranked(CouncilSeat::Engineer),
61                    steward: CouncilVote::Abstain,
62                }
63            }
64            Self::Tabled => FinalVotes {
65                artist: CouncilVote::Defer,
66                philosopher: CouncilVote::Defer,
67                lawyer: CouncilVote::Defer,
68                engineer: CouncilVote::Defer,
69                steward: CouncilVote::Defer,
70            },
71            Self::RecusedDeferral { .. } => FinalVotes {
72                artist: CouncilVote::Defer,
73                philosopher: CouncilVote::Defer,
74                lawyer: CouncilVote::Defer,
75                engineer: CouncilVote::Defer,
76                steward: CouncilVote::Recused,
77            },
78        }
79    }
80
81    /// The record's `outcome`
82    pub fn outcome(&self, category: DecisionCategory) -> DecisionOutcome {
83        match self {
84            Self::Voted(votes) => votes.outcome(category),
85            Self::Ranked(_) => DecisionOutcome::Approved,
86            Self::Tabled | Self::RecusedDeferral { .. } => {
87                DecisionOutcome::Deferred
88            }
89        }
90    }
91
92    /// The record's `vote_tally`
93    pub fn tally(&self) -> String {
94        match self {
95            Self::Voted(votes) => votes.tally(),
96            Self::Ranked(ranking) => format!(
97                "ranked {}/{}; the Steward executes the order and does not vote",
98                ranking.ballots.len(),
99                CouncilSeat::ALL.len()
100            ),
101            Self::Tabled => "Deferred".to_string(),
102            Self::RecusedDeferral { deferring } => format!(
103                "Deferred: {deferring} seat(s) still deferring at the round cap, \
104                 Steward recused; it returns to a later sitting and may not be \
105                 deferred again"
106            ),
107        }
108    }
109}
110
111impl CouncilDecisionRecord {
112    /// Set every part of the record that states the decision from
113    /// `decided`: `final_votes`, `outcome`, `vote_tally`,
114    /// `veto_rationale`, `abstentions` and `agenda_ranking`
115    pub fn decided(mut self, decided: Decided) -> Self {
116        self.final_votes = decided.final_votes();
117        self.outcome = decided.outcome(self.category);
118        self.vote_tally = decided.tally().into();
119        (self.veto_rationale, self.abstentions, self.agenda_ranking) =
120            match decided {
121                Decided::Voted(votes) => (
122                    votes.veto_rationale().map(|r| r.to_string().into()),
123                    votes.steward_abstention().into_iter().collect(),
124                    None,
125                ),
126                Decided::Ranked(ranking) => (None, Vec::new(), Some(ranking)),
127                Decided::Tabled | Decided::RecusedDeferral { .. } => {
128                    (None, Vec::new(), None)
129                }
130            };
131        self
132    }
133
134    /// The rules a record must keep whoever wrote it, checked before it is
135    /// signed and on every revision of it: a Steward veto carries its
136    /// rationale (Constitution Art. IV § 5), and a rationale belongs to a
137    /// veto.
138    ///
139    /// [`StewardVote::Veto`] already cannot be built without one; this is
140    /// the same rule for a record that did not come from [`Decided`]. No
141    /// signed entry breaks it: no veto has ever been cast.
142    pub fn check(&self) -> Result<(), RecordRuleError> {
143        veto_rule(Some(self.final_votes.steward), self.veto_rationale.as_ref())
144    }
145
146    /// [`check`](Self::check) on a record's raw `data`, reading only what
147    /// the rule reads (`final_votes.steward` and `veto_rationale`), for a
148    /// writer that holds `data` rather than a record. Absent fields are
149    /// absent: data with no `final_votes` has no veto.
150    pub fn check_data(data: &serde_json::Value) -> Result<(), RecordRuleError> {
151        #[derive(serde::Deserialize)]
152        struct Steward {
153            steward: CouncilVote,
154        }
155        #[derive(serde::Deserialize)]
156        struct Part {
157            final_votes: Option<Steward>,
158            veto_rationale: Option<Redactable<String>>,
159        }
160        let part: Part = serde_json::from_value(data.clone())
161            .map_err(|_| RecordRuleError::Unreadable)?;
162        veto_rule(
163            part.final_votes.map(|v| v.steward),
164            part.veto_rationale.as_ref(),
165        )
166    }
167}
168
169/// A Steward veto carries its rationale, and a rationale belongs to a veto
170fn veto_rule(
171    steward: Option<CouncilVote>,
172    rationale: Option<&Redactable<String>>,
173) -> Result<(), RecordRuleError> {
174    let vetoed = steward == Some(CouncilVote::Veto);
175    let rationale = rationale.map(|r| match r {
176        Redactable::Value(text) => !text.trim().is_empty(),
177        // Lawfully redacted after it was signed: it was there
178        Redactable::Redacted(_) => true,
179    });
180    match (vetoed, rationale) {
181        (true, Some(true)) | (false, None) => Ok(()),
182        (true, None | Some(false)) => {
183            Err(RecordRuleError::VetoWithoutRationale)
184        }
185        (false, Some(_)) => Err(RecordRuleError::RationaleWithoutVeto),
186    }
187}
188
189/// A record that breaks a rule every record must keep
190#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
191pub enum RecordRuleError {
192    #[error(
193        "the Steward vetoed with no rationale; a veto is recorded with its \
194         rationale (Constitution Art. IV § 5)"
195    )]
196    VetoWithoutRationale,
197    #[error("the record carries a veto rationale but the Steward did not veto")]
198    RationaleWithoutVeto,
199    #[error("the record's `final_votes` or `veto_rationale` does not read")]
200    Unreadable,
201}
202
203/// Why a final round's responses are not a seat's votes
204#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
205pub enum FinalRoundError {
206    #[error("round {0} is not a final vote")]
207    NotFinal(u32),
208    #[error("the {} has no response in the final round", .0.name())]
209    Missing(CouncilSeat),
210    #[error("the {} answered more than once in the final round", .0.name())]
211    Twice(CouncilSeat),
212    #[error("the {} cast no vote in the final round", .0.name())]
213    NoVote(CouncilSeat),
214    #[error("the {name}'s final vote, `{vote:?}`, is not a seat's vote", name = .0.name(), vote = .1)]
215    NotASeatChoice(CouncilSeat, CouncilVote),
216    #[error(
217        "the {}'s final-vote statement is blank or withheld; it is the \
218         record's words for the vote, and an abstention's rationale (Art. \
219         IV § 3)",
220        .0.name()
221    )]
222    NoStatement(CouncilSeat),
223}
224
225impl SeatVotes {
226    /// The votes the seats cast in `round`, a `final_vote` round, each
227    /// with its statement (its `position`)
228    pub fn from_final_round(
229        round: &CouncilRound,
230    ) -> Result<Self, FinalRoundError> {
231        if round.round_type != RoundType::FinalVote {
232            return Err(FinalRoundError::NotFinal(round.number));
233        }
234        let seat = |seat: CouncilSeat| -> Result<SeatVote, FinalRoundError> {
235            let mut responses =
236                round.responses.iter().filter(|r| r.role == seat);
237            let response =
238                responses.next().ok_or(FinalRoundError::Missing(seat))?;
239            if responses.next().is_some() {
240                return Err(FinalRoundError::Twice(seat));
241            }
242            let vote = response.vote.ok_or(FinalRoundError::NoVote(seat))?;
243            let choice = SeatChoice::try_from(vote)
244                .map_err(|_| FinalRoundError::NotASeatChoice(seat, vote))?;
245            let statement = response
246                .position
247                .value()
248                .and_then(|p| Statement::try_from(p.as_str()).ok())
249                .ok_or(FinalRoundError::NoStatement(seat))?;
250            Ok(SeatVote { choice, statement })
251        };
252        Ok(Self {
253            artist: seat(CouncilSeat::Artist)?,
254            philosopher: seat(CouncilSeat::Philosopher)?,
255            lawyer: seat(CouncilSeat::Lawyer)?,
256            engineer: seat(CouncilSeat::Engineer)?,
257        })
258    }
259}
260
261#[cfg(test)]
262mod tests;