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. A [`summary`](Self::summary) is the Lawyer's, not blank, and
138    /// accepted by the Steward, or by [`RECUSED_ACCEPTORS`] when the
139    /// Steward is recused (0.96).
140    ///
141    /// [`RECUSED_ACCEPTORS`]: super::RECUSED_ACCEPTORS
142    ///
143    /// [`StewardVote::Veto`] already cannot be built without one; this is
144    /// the same rule for a record that did not come from [`Decided`]. No
145    /// signed entry breaks it: no veto has ever been cast.
146    pub fn check(&self) -> Result<(), RecordRuleError> {
147        let steward = Some(self.final_votes.steward);
148        veto_rule(steward, self.veto_rationale.as_ref())?;
149        self.summary.as_ref().map_or(Ok(()), |s| s.check(steward))
150    }
151
152    /// Sign `text` as the Lawyer's summary, accepted by whoever accepts on
153    /// this item: the Steward, or with `final_votes` recording the Steward
154    /// `recused`, the other three seats (0.96)
155    pub fn with_summary(mut self, text: impl Into<String>) -> Self {
156        let recused = self.final_votes.steward == CouncilVote::Recused;
157        self.summary = Some(super::DecisionSummary::by_lawyer(text, recused));
158        self
159    }
160
161    /// [`check`](Self::check) on a record's raw `data`, reading only what
162    /// the rules read (`final_votes.steward`, `veto_rationale` and
163    /// `summary`), for a
164    /// writer that holds `data` rather than a record. Absent fields are
165    /// absent: data with no `final_votes` has no veto.
166    pub fn check_data(data: &serde_json::Value) -> Result<(), RecordRuleError> {
167        #[derive(serde::Deserialize)]
168        struct Steward {
169            steward: CouncilVote,
170        }
171        #[derive(serde::Deserialize)]
172        struct Part {
173            final_votes: Option<Steward>,
174            veto_rationale: Option<Redactable<String>>,
175            summary: Option<super::DecisionSummary>,
176        }
177        let part: Part = serde_json::from_value(data.clone())
178            .map_err(|_| RecordRuleError::Unreadable)?;
179        let steward = part.final_votes.map(|v| v.steward);
180        veto_rule(steward, part.veto_rationale.as_ref())?;
181        part.summary.as_ref().map_or(Ok(()), |s| s.check(steward))
182    }
183}
184
185/// A Steward veto carries its rationale, and a rationale belongs to a veto
186fn veto_rule(
187    steward: Option<CouncilVote>,
188    rationale: Option<&Redactable<String>>,
189) -> Result<(), RecordRuleError> {
190    let vetoed = steward == Some(CouncilVote::Veto);
191    let rationale = rationale.map(|r| match r {
192        Redactable::Value(text) => !text.trim().is_empty(),
193        // Lawfully redacted after it was signed: it was there
194        Redactable::Redacted(_) => true,
195    });
196    match (vetoed, rationale) {
197        (true, Some(true)) | (false, None) => Ok(()),
198        (true, None | Some(false)) => {
199            Err(RecordRuleError::VetoWithoutRationale)
200        }
201        (false, Some(_)) => Err(RecordRuleError::RationaleWithoutVeto),
202    }
203}
204
205/// A record that breaks a rule every record must keep
206#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
207pub enum RecordRuleError {
208    #[error(
209        "the Steward vetoed with no rationale; a veto is recorded with its \
210         rationale (Constitution Art. IV § 5)"
211    )]
212    VetoWithoutRationale,
213    #[error("the record carries a veto rationale but the Steward did not veto")]
214    RationaleWithoutVeto,
215    #[error(
216        "the record's `final_votes`, `veto_rationale` or `summary` does not read"
217    )]
218    Unreadable,
219    /// (0.96)
220    #[error("the summary is the {}'s; the Lawyer writes it", .0.name())]
221    SummaryWriter(CouncilSeat),
222    /// (0.96)
223    #[error("the summary is blank")]
224    BlankSummary,
225    /// (0.96)
226    #[error(
227        "the summary's acceptance is not {}",
228        if *recused {
229            "the Artist's, Philosopher's and Engineer's, as on an item the \
230             Steward is recused from"
231        } else {
232            "the Steward's"
233        }
234    )]
235    SummaryAcceptance { recused: bool },
236}
237
238/// Why a final round's responses are not a seat's votes
239#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
240pub enum FinalRoundError {
241    #[error("round {0} is not a final vote")]
242    NotFinal(u32),
243    #[error("the {} has no response in the final round", .0.name())]
244    Missing(CouncilSeat),
245    #[error("the {} answered more than once in the final round", .0.name())]
246    Twice(CouncilSeat),
247    #[error("the {} cast no vote in the final round", .0.name())]
248    NoVote(CouncilSeat),
249    #[error("the {name}'s final vote, `{vote:?}`, is not a seat's vote", name = .0.name(), vote = .1)]
250    NotASeatChoice(CouncilSeat, CouncilVote),
251    #[error(
252        "the {}'s final-vote statement is blank or withheld; it is the \
253         record's words for the vote, and an abstention's rationale (Art. \
254         IV § 3)",
255        .0.name()
256    )]
257    NoStatement(CouncilSeat),
258}
259
260impl SeatVotes {
261    /// The votes the seats cast in `round`, a `final_vote` round, each
262    /// with its statement (its `position`)
263    pub fn from_final_round(
264        round: &CouncilRound,
265    ) -> Result<Self, FinalRoundError> {
266        if round.round_type != RoundType::FinalVote {
267            return Err(FinalRoundError::NotFinal(round.number));
268        }
269        let seat = |seat: CouncilSeat| -> Result<SeatVote, FinalRoundError> {
270            let mut responses =
271                round.responses.iter().filter(|r| r.role == seat);
272            let response =
273                responses.next().ok_or(FinalRoundError::Missing(seat))?;
274            if responses.next().is_some() {
275                return Err(FinalRoundError::Twice(seat));
276            }
277            let vote = response.vote.ok_or(FinalRoundError::NoVote(seat))?;
278            let choice = SeatChoice::try_from(vote)
279                .map_err(|_| FinalRoundError::NotASeatChoice(seat, vote))?;
280            let statement = response
281                .position
282                .value()
283                .and_then(|p| Statement::try_from(p.as_str()).ok())
284                .ok_or(FinalRoundError::NoStatement(seat))?;
285            Ok(SeatVote { choice, statement })
286        };
287        Ok(Self {
288            artist: seat(CouncilSeat::Artist)?,
289            philosopher: seat(CouncilSeat::Philosopher)?,
290            lawyer: seat(CouncilSeat::Lawyer)?,
291            engineer: seat(CouncilSeat::Engineer)?,
292        })
293    }
294}
295
296#[cfg(test)]
297mod tests;