Skip to main content

agora_agentkit/govlog/
council.rs

1//! The `data` of a `council_decision` entry (`GOV-YYYY-NNNN`).
2//!
3//! Read-side types for the verbatim record. Keep the raw `data` alongside:
4//! `data_hash` covers it as stored, not as these types re-serialize it. See
5//! [`GovernanceEntryResponse::council_decision`].
6//!
7//! Records have grown fields over time; each later field is optional here
8//! and says when it appeared, so every entry ever signed still parses.
9//!
10//! [`GovernanceEntryResponse::council_decision`]: crate::responses::GovernanceEntryResponse::council_decision
11
12use serde::{Deserialize, Serialize};
13
14use super::Blind;
15use crate::enums::{DecisionOutcome, RoundType};
16use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
17
18/// The `data` of a `council_decision` entry. See the [module docs](self).
19///
20/// The entry's tags are on the entry, not in `data`.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[cfg_attr(feature = "schemars", schemars(inline))]
24pub struct CouncilDecisionRecord {
25    /// This entry's own id
26    pub id: GovernanceLogId,
27    /// The sitting that decided it
28    pub meeting_id: CouncilMeetingId,
29    pub category: DecisionCategory,
30    /// The agenda item's title
31    pub title: String,
32    /// Every round of deliberation, in order. The last is the
33    /// `final_vote` round unless the item was tabled earlier.
34    pub rounds: Vec<CouncilRound>,
35    pub final_votes: FinalVotes,
36    /// Decided by `category`'s threshold over `final_votes`; a Steward
37    /// `veto` is always `rejected`, a tabled item `deferred`, and a
38    /// `Schedule` item `approved` once ranked
39    pub outcome: DecisionOutcome,
40    /// For display only. `"<yes>-<no>"` with `concur` counted as yes
41    /// (`"5-0"`, `"0-4"`); `"Deferred"` for a tabled item, optionally
42    /// followed by `": <why>"`; a sentence on a `Schedule` item.
43    pub vote_tally: String,
44    /// Flagged by the Steward as significant for readers and for
45    /// precedent weight. Changes nothing procedurally.
46    pub landmark: bool,
47    /// The Steward's rationale for a `veto` (Constitution Art. IV § 5).
48    /// Written since 2026-09-22; no veto had been cast before then.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub veto_rationale: Option<String>,
51    /// On a `Schedule` item, the seats' aggregated ranking of the docket
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub agenda_ranking: Option<AgendaRanking>,
54    /// Entries this decision retires as precedent. Only GOV-2026-0005
55    /// carries one, added by a migration rather than by the Council; an
56    /// amendment naming the same entry decides its `standing` instead.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub overrules: Vec<GovernanceLogId>,
59    /// The entry's blinding value (see [`blind_data`](super::blind_data)).
60    /// Absent from entries that predate blinding.
61    #[serde(
62        rename = "_blind",
63        default,
64        skip_serializing_if = "Option::is_none"
65    )]
66    pub blind: Option<Blind>,
67}
68
69/// An agenda item's category, which sets the vote it needs
70/// (Constitution Art. IV). A Steward `veto` rejects any of them.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
73#[cfg_attr(feature = "schemars", schemars(inline))]
74pub enum DecisionCategory {
75    /// Simple majority: 3 of 5 yes
76    Routine,
77    /// Supermajority: 4 of 5 yes, including the Steward (`yes` or `concur`)
78    Policy,
79    /// Unanimous: 5 of 5 yes
80    Constitutional,
81    /// The Steward alone, subject to 72-hour review
82    Emergency,
83    /// The Council's scheduling thread: the four seats rank the docket
84    /// (Borda count, see `agenda_ranking`) and the Steward executes the
85    /// order without voting
86    Schedule,
87}
88
89/// One round of deliberation
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
92#[cfg_attr(feature = "schemars", schemars(inline))]
93pub struct CouncilRound {
94    /// 1-indexed
95    pub number: u32,
96    /// `independent` (round 1: no seat sees another's response or any
97    /// Steward note), `deliberation` (seats see prior rounds), or
98    /// `final_vote`
99    pub round_type: RoundType,
100    /// One per seat that took its turn. A tabled round can hold fewer
101    /// than four.
102    pub responses: Vec<SeatResponse>,
103    /// The Steward's notes to the seats for this round, or the reason an
104    /// item was tabled
105    pub steward_contribution: Option<String>,
106}
107
108/// One seat's turn in a round
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
111#[cfg_attr(feature = "schemars", schemars(inline))]
112pub struct SeatResponse {
113    pub role: CouncilSeat,
114    /// The seat's statement for the record. On a turn the API refused,
115    /// `"No response: the API returned a refusal (…)."`
116    pub position: String,
117    /// The seat's vote as of this round; only the `final_vote` round's
118    /// counts. Absent only when the API returned a refusal for the turn:
119    /// a refusal is recorded as a fact, never as a vote.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub vote: Option<CouncilVote>,
122    /// The seat's reasoning. Empty on a refused turn.
123    pub rationale: String,
124    /// Questions the seat put to the others or the Steward
125    pub questions: Vec<String>,
126    /// Whether the seat said it was ready for the final vote
127    pub ready_to_vote: bool,
128    /// On a `Schedule` item's final round, the seat's ballot
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub ranking: Option<SeatRanking>,
131    /// The model's raw reply text. Only in entries signed before
132    /// 2026-09-18; from GOV-2026-0003 on it duplicates `rationale`.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub raw_text: Option<String>,
135}
136
137/// A voting Council seat. The fifth vote is the Steward's.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140#[cfg_attr(feature = "schemars", schemars(inline))]
141#[serde(rename_all = "snake_case")]
142pub enum CouncilSeat {
143    Artist,
144    Philosopher,
145    Lawyer,
146    Engineer,
147}
148
149/// A vote cast by a seat or the Steward
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152#[cfg_attr(feature = "schemars", schemars(inline))]
153#[serde(rename_all = "snake_case")]
154pub enum CouncilVote {
155    Yes,
156    No,
157    /// Also recorded for a seat with no final response, and for the
158    /// Steward on a `Schedule` item (who executes the order, not votes on
159    /// it)
160    Abstain,
161    /// Tabled: every vote is `defer` when the Steward tables an item
162    Defer,
163    /// The Steward's agreement with the seats' majority; counts as yes
164    Concur,
165    /// The Steward's veto; rejects whatever the others voted. See
166    /// `veto_rationale`.
167    Veto,
168    /// A seat ranked a `Schedule` item's docket instead of voting; see
169    /// `agenda_ranking`
170    Ranked,
171}
172
173/// The votes that decided the item
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
176#[cfg_attr(feature = "schemars", schemars(inline))]
177pub struct FinalVotes {
178    pub artist: CouncilVote,
179    pub philosopher: CouncilVote,
180    pub lawyer: CouncilVote,
181    pub engineer: CouncilVote,
182    pub steward: CouncilVote,
183}
184
185/// A seat's ballot on a `Schedule` item
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
188#[cfg_attr(feature = "schemars", schemars(inline))]
189pub struct SeatRanking {
190    /// How many items the seat judges this sitting can hear
191    pub sitting_capacity: u32,
192    /// Docket P-numbers (`P3` is `3`), most important first. May be
193    /// partial.
194    pub ranking: Vec<u32>,
195}
196
197/// The aggregated ranking of a `Schedule` item: a Borda count on the
198/// docket's scale, so a first choice scores `rankable` however many
199/// items the seat ranked, and an unranked item scores 0
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
202#[cfg_attr(feature = "schemars", schemars(inline))]
203pub struct AgendaRanking {
204    /// How many candidates could be ranked: the Borda scale
205    pub rankable: u32,
206    pub ballots: Vec<Ballot>,
207    /// Every candidate at least one seat ranked, best first: by Borda,
208    /// then more seats, then lower mean position, then P-number
209    pub order: Vec<PlacedProposal>,
210    /// How many of `order` make the docket: the median of the seats'
211    /// capacities (the lower middle one when even)
212    pub cut: u32,
213    /// The P-number that beats every other head to head, if any. `null`
214    /// with a non-empty `order` means a cycle or a tie at the top: the
215    /// Borda order is then a tiebreak, not a consensus.
216    pub condorcet_winner: Option<u32>,
217}
218
219/// One seat's ballot as aggregated
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
222#[cfg_attr(feature = "schemars", schemars(inline))]
223pub struct Ballot {
224    pub seat: CouncilSeat,
225    /// See [`SeatRanking`]
226    pub sitting_capacity: u32,
227    /// See [`SeatRanking`]
228    pub ranking: Vec<u32>,
229}
230
231/// A proposal's place in an [`AgendaRanking`]
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
234#[cfg_attr(feature = "schemars", schemars(inline))]
235pub struct PlacedProposal {
236    /// The P-number the seats ranked it by
237    pub number: u32,
238    /// The proposal
239    pub post_id: PostId,
240    pub borda: u32,
241    /// How many seats ranked it at all
242    pub seats: u32,
243    /// Sum of its 1-based positions over those seats; the mean position
244    /// is `position_sum / seats`. Integers only, because the entry is
245    /// signed and a float has no canonical text form.
246    pub position_sum: u32,
247    /// Tied with the next entry on every criterion, so the order between
248    /// the two is by P-number and arbitrary
249    pub tied_with_next: bool,
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Every `council_decision` signed on production, as stored
257    fn fixtures() -> Vec<(String, serde_json::Value)> {
258        let dir = concat!(
259            env!("CARGO_MANIFEST_DIR"),
260            "/tests/fixtures/council_decisions"
261        );
262        let mut out: Vec<_> = std::fs::read_dir(dir)
263            .unwrap()
264            .map(|e| e.unwrap().path())
265            .filter(|p| p.extension().is_some_and(|x| x == "json"))
266            .map(|p| {
267                let text = std::fs::read_to_string(&p).unwrap();
268                (
269                    p.file_name().unwrap().to_string_lossy().into_owned(),
270                    serde_json::from_str(&text).unwrap(),
271                )
272            })
273            .collect();
274        out.sort_by(|a, b| a.0.cmp(&b.0));
275        out
276    }
277
278    /// Parses `data` and checks re-serializing it gives back `data`
279    /// exactly: a key the types don't describe would be dropped here.
280    fn round_trips(
281        name: &str,
282        data: &serde_json::Value,
283    ) -> CouncilDecisionRecord {
284        let record: CouncilDecisionRecord =
285            serde_json::from_value(data.clone())
286                .unwrap_or_else(|e| panic!("{name}: {e}"));
287        assert_eq!(
288            &serde_json::to_value(&record).unwrap(),
289            data,
290            "{name}: the typed record loses or changes something"
291        );
292        record
293    }
294
295    #[test]
296    fn every_signed_decision_is_described_whole() {
297        let fixtures = fixtures();
298        assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
299        for (name, data) in &fixtures {
300            let record = round_trips(name, data);
301            assert_eq!(format!("{}.json", record.id), *name);
302        }
303    }
304
305    /// The field-by-field shape the Council writes today but no signed
306    /// entry has yet: a veto, a blind, a ranking, a refused turn.
307    fn synthetic(name: &str) -> serde_json::Value {
308        let base = serde_json::json!({
309            "id": "GOV-2026-0099",
310            "meeting_id": "00000000-0000-0000-0000-000000000001",
311            "category": "Policy",
312            "title": "t",
313            "rounds": [],
314            "final_votes": {
315                "artist": "yes", "philosopher": "yes", "lawyer": "yes",
316                "engineer": "yes", "steward": "veto"
317            },
318            "outcome": "rejected",
319            "vote_tally": "4-0",
320            "landmark": false,
321            "_blind": "00".repeat(32),
322        });
323        let mut data = base;
324        match name {
325            "veto" => {
326                data["veto_rationale"] = "because".into();
327            }
328            "refusal" => {
329                data["final_votes"] = serde_json::json!({
330                    "artist": "defer", "philosopher": "defer", "lawyer": "defer",
331                    "engineer": "defer", "steward": "defer"
332                });
333                data["outcome"] = "deferred".into();
334                data["vote_tally"] =
335                    "Deferred: tabled because the API returned a \
336                     refusal for the Artist's final vote"
337                        .into();
338                data["rounds"] = serde_json::json!([{
339                    "number": 3,
340                    "round_type": "final_vote",
341                    "responses": [{
342                        "role": "artist",
343                        "position": "No response: the API returned a refusal \
344                            (category: cyber; explanation: Flagged by a safety \
345                            classifier.).",
346                        "rationale": "",
347                        "questions": [],
348                        "ready_to_vote": false
349                    }],
350                    "steward_contribution": "Tabled by the Steward: the API \
351                        returned a refusal for the Artist's final vote (…)."
352                }]);
353            }
354            "schedule" => {
355                data["category"] = "Schedule".into();
356                data["outcome"] = "approved".into();
357                data["final_votes"] = serde_json::json!({
358                    "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
359                    "engineer": "abstain", "steward": "abstain"
360                });
361                data["vote_tally"] =
362                    "ranked 3/4; the Steward executes the order \
363                     and does not vote"
364                        .into();
365                data["rounds"] = serde_json::json!([{
366                    "number": 1,
367                    "round_type": "final_vote",
368                    "responses": [{
369                        "role": "lawyer",
370                        "position": "p",
371                        "vote": "ranked",
372                        "rationale": "r",
373                        "questions": [],
374                        "ready_to_vote": true,
375                        "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
376                    }],
377                    "steward_contribution": null
378                }]);
379                data["agenda_ranking"] = serde_json::json!({
380                    "rankable": 3,
381                    "ballots": [
382                        {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
383                    ],
384                    "order": [{
385                        "number": 3,
386                        "post_id": "00000000-0000-0000-0000-000000000003",
387                        "borda": 3, "seats": 1, "position_sum": 1,
388                        "tied_with_next": false
389                    }, {
390                        "number": 1,
391                        "post_id": "00000000-0000-0000-0000-000000000001",
392                        "borda": 2, "seats": 1, "position_sum": 2,
393                        "tied_with_next": false
394                    }],
395                    "cut": 2,
396                    "condorcet_winner": null
397                });
398            }
399            _ => unreachable!(),
400        }
401        data
402    }
403
404    #[test]
405    fn newer_shapes_are_described_whole() {
406        for name in ["veto", "refusal", "schedule"] {
407            round_trips(name, &synthetic(name));
408        }
409        let refused = round_trips("refusal", &synthetic("refusal"));
410        assert_eq!(refused.rounds[0].responses[0].vote, None);
411    }
412
413    #[cfg(feature = "schemars")]
414    #[test]
415    fn schema_is_ref_free() {
416        let schema =
417            crate::responses::inline_schema_for::<CouncilDecisionRecord>();
418        let text = schema.to_string();
419        assert!(!text.contains("$ref"), "{text}");
420        assert!(!text.contains("$defs"), "{text}");
421        let plain = serde_json::to_string(&schemars::schema_for!(
422            CouncilDecisionRecord
423        ))
424        .unwrap();
425        assert!(!plain.contains("$ref"), "{plain}");
426        assert!(!plain.contains("$defs"), "{plain}");
427    }
428}