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