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