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 misanthropic::Model;
15use misanthropic::prompt::message::{Content, Thought};
16use serde::{Deserialize, Serialize};
17
18use super::{Blind, Redactable};
19use crate::enums::{DecisionOutcome, RoundType};
20use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
21
22/// The `data` of a `council_decision` entry. See the [module docs](self).
23///
24/// The entry's tags are on the entry, not in `data`.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27#[cfg_attr(feature = "schemars", schemars(inline))]
28pub struct CouncilDecisionRecord {
29    /// This entry's own id
30    pub id: GovernanceLogId,
31    /// The sitting that decided it
32    pub meeting_id: CouncilMeetingId,
33    pub category: DecisionCategory,
34    /// The agenda item's title
35    pub title: Redactable<String>,
36    /// Every round of deliberation, in order. The last is the
37    /// `final_vote` round unless the item was tabled earlier.
38    pub rounds: Vec<CouncilRound>,
39    pub final_votes: FinalVotes,
40    /// Decided by `category`'s threshold over `final_votes`; a Steward
41    /// `veto` is always `rejected`, a tabled item `deferred`, and a
42    /// `Schedule` item `approved` once ranked
43    pub outcome: DecisionOutcome,
44    /// For display only. `"<yes>-<no>"` with `concur` counted as yes
45    /// (`"5-0"`, `"0-4"`); `"Deferred"` for a tabled item, optionally
46    /// followed by `": <why>"`; a sentence on a `Schedule` item.
47    pub vote_tally: Redactable<String>,
48    /// Flagged by the Steward as significant for readers and for
49    /// precedent weight. Changes nothing procedurally.
50    pub landmark: bool,
51    /// The Steward's rationale for a `veto` (Constitution Art. IV § 5).
52    /// Written since 2026-09-22; no veto had been cast before then.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub veto_rationale: Option<Redactable<String>>,
55    /// On a `Schedule` item, the seats' aggregated ranking of the docket
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub agenda_ranking: Option<AgendaRanking>,
58    /// Whether the item limits the Steward's powers, as the four seats
59    /// determined it before round 1 (Constitution Art. IV § 3,
60    /// GOV-2026-0009). Absent on items decided before it was recorded, and
61    /// on `Schedule` and `Emergency` items. (0.48)
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub steward_recusal: Option<StewardRecusal>,
64    /// The one-sentence rationale for each abstention in `final_votes`
65    /// (Art. IV § 3). (0.48)
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub abstentions: Vec<Abstention>,
68    /// Entries this decision retires as precedent. Only GOV-2026-0005
69    /// carries one, added by a migration rather than by the Council; an
70    /// amendment naming the same entry decides its `standing` instead.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub overrules: Vec<GovernanceLogId>,
73    /// What the seats were shown beyond the proposal: the Clerk's
74    /// summaries and everything a seat had read to it. (0.42)
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub attachments: Vec<CouncilAttachment>,
77    /// The model each seat ran on. Absent from entries before 0.64.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub seat_models: Option<SeatModels>,
80    /// The entry's blinding value (see [`blind_data`](super::blind_data)).
81    /// Absent from entries that predate blinding.
82    #[serde(
83        rename = "_blind",
84        default,
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub blind: Option<Blind>,
88}
89
90/// Material put before the Council, inline as markdown
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
93#[cfg_attr(feature = "schemars", schemars(inline))]
94pub struct CouncilAttachment {
95    /// A file name, unique within the record: `clerk-thread-summary.md`
96    pub name: String,
97    /// What it is and who saw it
98    pub note: String,
99    pub content: Redactable<String>,
100}
101
102/// An agenda item's category, which sets the vote it needs
103/// (Constitution v0.4 Art. IV § 3).
104///
105/// An `abstain` or `recused` leaves the denominator. A Steward `veto`
106/// rejects any item the Steward is not recused from; a recused Steward has
107/// no vote and no veto.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
110#[cfg_attr(feature = "schemars", schemars(inline))]
111pub enum DecisionCategory {
112    /// Three `yes`: an absolute count, found among the four seats when the
113    /// Steward is recused
114    Routine,
115    /// Four `yes` of five including the Steward (`yes` or `concur`); with
116    /// the Steward recused, all four seats `yes`
117    Policy,
118    /// Unanimous among those voting, with no fewer than four voting.
119    /// Before GOV-2026-0009 (2026-09-26), 5 of 5 `yes`
120    Constitutional,
121    /// The Steward alone, subject to 72-hour review
122    Emergency,
123    /// The Council's scheduling thread: the four seats rank the docket
124    /// (Borda count, see `agenda_ranking`) and the Steward executes the
125    /// order without voting
126    Schedule,
127}
128
129/// One round of deliberation
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
132#[cfg_attr(feature = "schemars", schemars(inline))]
133pub struct CouncilRound {
134    /// 1-indexed
135    pub number: u32,
136    /// `independent` (round 1: no seat sees another's response or any
137    /// Steward note), `deliberation` (seats see prior rounds), or
138    /// `final_vote`
139    pub round_type: RoundType,
140    /// One per seat that took its turn. A tabled round can hold fewer
141    /// than four.
142    pub responses: Vec<SeatResponse>,
143    /// The Steward's notes to the seats for this round, or the reason an
144    /// item was tabled
145    pub steward_contribution: Option<Redactable<String>>,
146    /// Present when `steward_contribution` came from a recused Steward
147    /// under emergency authority (Art. IV § 2), which puts the item under
148    /// mandatory Council review within 72 hours. (0.48)
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub steward_emergency: Option<StewardEmergency>,
151    /// The item was tabled here by a recused Steward, over an API refusal
152    /// on a seat's final vote: refusal handling, the one tabling open to
153    /// a recused Steward. `steward_contribution` says which refusal.
154    /// (0.48)
155    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156    pub tabled_by_recused_steward: bool,
157}
158
159/// A recused Steward's note to the seats, made as an emergency act
160/// (Constitution Art. IV § 2)
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
163#[cfg_attr(feature = "schemars", schemars(inline))]
164pub struct StewardEmergency {
165    /// The Steward's one-sentence reason, given before the note was read
166    pub reason: Redactable<String>,
167}
168
169/// The four seats' determination of whether an item limits the Steward's
170/// powers (Constitution Art. IV § 3, GOV-2026-0009)
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
173#[cfg_attr(feature = "schemars", schemars(inline))]
174pub struct StewardRecusal {
175    /// One per seat that answered in round 1; a seat whose turn the API
176    /// refused has none
177    pub votes: Vec<RecusalVote>,
178    /// Three of the four seats said it does. The Steward then has no
179    /// deliberation, vote or veto on the item, and `final_votes` records
180    /// `recused`.
181    pub recused: bool,
182}
183
184/// One seat's answer to whether an item limits the Steward's powers
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
187#[cfg_attr(feature = "schemars", schemars(inline))]
188pub struct RecusalVote {
189    pub seat: CouncilSeat,
190    /// The seat's explanation for the record; before 0.64, its reasoning,
191    /// written before its answer
192    pub reason: Redactable<String>,
193    /// The seat's thinking before it answered (0.64)
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub rationale: Option<Redactable<Rationale>>,
196    pub limits_steward_powers: bool,
197}
198
199/// An abstention and its one-sentence rationale (Art. IV § 3)
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 Abstention {
204    pub member: CouncilMember,
205    pub rationale: Redactable<String>,
206}
207
208/// Any of the five Council members: a [`CouncilSeat`] or the Steward
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211#[cfg_attr(feature = "schemars", schemars(inline))]
212#[serde(rename_all = "snake_case")]
213pub enum CouncilMember {
214    Artist,
215    Philosopher,
216    Lawyer,
217    Engineer,
218    Steward,
219}
220
221impl From<CouncilSeat> for CouncilMember {
222    fn from(seat: CouncilSeat) -> Self {
223        match seat {
224            CouncilSeat::Artist => Self::Artist,
225            CouncilSeat::Philosopher => Self::Philosopher,
226            CouncilSeat::Lawyer => Self::Lawyer,
227            CouncilSeat::Engineer => Self::Engineer,
228        }
229    }
230}
231
232/// One seat's turn in a round
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
235#[cfg_attr(feature = "schemars", schemars(inline))]
236pub struct SeatResponse {
237    pub role: CouncilSeat,
238    /// The seat's statement for the record. On a turn the API refused,
239    /// `"No response: the API returned a refusal (…)."`
240    pub position: Redactable<String>,
241    /// The seat's vote as of this round; only the `final_vote` round's
242    /// counts. Absent only when the API returned a refusal for the turn:
243    /// a refusal is recorded as a fact, never as a vote.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub vote: Option<CouncilVote>,
246    /// Why the seat said it. Empty on a refused turn.
247    pub rationale: Redactable<Rationale>,
248    /// Questions the seat put to the others or the Steward. A redaction
249    /// can take one question or the whole list.
250    pub questions: Redactable<Vec<Redactable<String>>>,
251    /// Whether the seat said it was ready for the final vote
252    pub ready_to_vote: bool,
253    /// On a `Schedule` item's final round, the seat's ballot
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub ranking: Option<SeatRanking>,
256    /// The model's raw reply text. Only in entries signed before
257    /// 2026-09-18; from GOV-2026-0003 on it duplicates `rationale`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub raw_text: Option<Redactable<String>>,
260}
261
262/// Why a seat said what it said.
263///
264/// Until 0.64 the seats wrote their reasoning into a `reasoning` field of
265/// the decision tool, which is not something a model should be asked to
266/// do: Anthropic's `reasoning_extraction` safeguard declines exactly that
267/// ([docs]). The seats now think before they answer, and the record keeps
268/// what the API returns for that thinking.
269///
270/// [docs]: https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#keep-reasoning-in-thinking-blocks
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
273#[cfg_attr(feature = "schemars", schemars(inline))]
274#[serde(untagged)]
275pub enum Rationale {
276    /// What the seat wrote in the decision tool's `reasoning` field
277    Legacy(String),
278    /// The thinking blocks of the seat's turn, in order and as the API
279    /// returned them. On Anthropic models each is a summary of the model's
280    /// reasoning, written by Anthropic, with a `signature` over the full
281    /// reasoning; the [`SeatModels`] entry says which model to replay it
282    /// against. Local models' blocks are their own reasoning, unsigned.
283    Thinking(Vec<Thought>),
284}
285
286impl Rationale {
287    /// The text of every thinking block, in order. A legacy rationale is
288    /// one item.
289    pub fn texts(&self) -> Vec<&str> {
290        match self {
291            Self::Legacy(text) => vec![text.as_str()],
292            Self::Thinking(thoughts) => {
293                thoughts.iter().filter_map(Thought::text).collect()
294            }
295        }
296    }
297
298    /// Thinking the API withheld: `redacted_thinking` blocks
299    pub fn redacted_blocks(&self) -> usize {
300        match self {
301            Self::Legacy(_) => 0,
302            Self::Thinking(thoughts) => {
303                thoughts.iter().filter(|t| t.text().is_none()).count()
304            }
305        }
306    }
307
308    /// No words: an empty legacy string, or no thinking at all
309    pub fn is_empty(&self) -> bool {
310        self.texts().iter().all(|t| t.trim().is_empty())
311            && self.redacted_blocks() == 0
312    }
313}
314
315/// The text of each block, a blank line apart; withheld blocks say so
316impl std::fmt::Display for Rationale {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        let Self::Thinking(thoughts) = self else {
319            return f.write_str(&self.texts().concat());
320        };
321        for (i, thought) in thoughts.iter().enumerate() {
322            if i > 0 {
323                f.write_str("\n\n")?;
324            }
325            f.write_str(
326                thought.text().unwrap_or("[reasoning withheld]").trim_end(),
327            )?;
328        }
329        Ok(())
330    }
331}
332
333impl From<String> for Rationale {
334    fn from(text: String) -> Self {
335        Self::Legacy(text)
336    }
337}
338
339impl From<&str> for Rationale {
340    fn from(text: &str) -> Self {
341        Self::Legacy(text.to_owned())
342    }
343}
344
345impl From<Vec<Thought>> for Rationale {
346    fn from(thoughts: Vec<Thought>) -> Self {
347        Self::Thinking(thoughts)
348    }
349}
350
351/// The thinking blocks of `content`; anything else in it is not thinking
352impl From<&Content> for Rationale {
353    fn from(content: &Content) -> Self {
354        Self::Thinking(content.thoughts().collect())
355    }
356}
357
358/// The model each seat ran on
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
361#[cfg_attr(feature = "schemars", schemars(inline))]
362pub struct SeatModels {
363    pub artist: Model,
364    pub philosopher: Model,
365    pub lawyer: Model,
366    pub engineer: Model,
367}
368
369impl SeatModels {
370    pub fn get(&self, seat: CouncilSeat) -> &Model {
371        match seat {
372            CouncilSeat::Artist => &self.artist,
373            CouncilSeat::Philosopher => &self.philosopher,
374            CouncilSeat::Lawyer => &self.lawyer,
375            CouncilSeat::Engineer => &self.engineer,
376        }
377    }
378}
379
380/// A voting Council seat. The fifth vote is the Steward's.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
382#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
383#[cfg_attr(feature = "schemars", schemars(inline))]
384#[serde(rename_all = "snake_case")]
385pub enum CouncilSeat {
386    Artist,
387    Philosopher,
388    Lawyer,
389    Engineer,
390}
391
392/// A vote cast by a seat or the Steward
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
394#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
395#[cfg_attr(feature = "schemars", schemars(inline))]
396#[serde(rename_all = "snake_case")]
397pub enum CouncilVote {
398    Yes,
399    No,
400    /// Also recorded for a seat with no final response, and for the
401    /// Steward on a `Schedule` item (who executes the order, not votes on
402    /// it)
403    Abstain,
404    /// Tabled: every vote is `defer` when the Steward tables an item
405    Defer,
406    /// The Steward's agreement with the seats' majority; counts as yes
407    Concur,
408    /// The Steward's veto; rejects whatever the others voted. See
409    /// `veto_rationale`.
410    Veto,
411    /// A seat ranked a `Schedule` item's docket instead of voting; see
412    /// `agenda_ranking`
413    Ranked,
414    /// The Steward, on an item the seats found limits the Steward's
415    /// powers: no vote and no veto. See `steward_recusal`. (0.48)
416    Recused,
417}
418
419/// The votes that decided the item
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
422#[cfg_attr(feature = "schemars", schemars(inline))]
423pub struct FinalVotes {
424    pub artist: CouncilVote,
425    pub philosopher: CouncilVote,
426    pub lawyer: CouncilVote,
427    pub engineer: CouncilVote,
428    pub steward: CouncilVote,
429}
430
431/// A seat's ballot on a `Schedule` item
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
434#[cfg_attr(feature = "schemars", schemars(inline))]
435pub struct SeatRanking {
436    /// How many items the seat judges this sitting can hear
437    pub sitting_capacity: u32,
438    /// Docket P-numbers (`P3` is `3`), most important first. May be
439    /// partial.
440    pub ranking: Vec<u32>,
441}
442
443/// The aggregated ranking of a `Schedule` item: a Borda count on the
444/// docket's scale, so a first choice scores `rankable` however many
445/// items the seat ranked, and an unranked item scores 0
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
448#[cfg_attr(feature = "schemars", schemars(inline))]
449pub struct AgendaRanking {
450    /// How many candidates could be ranked: the Borda scale
451    pub rankable: u32,
452    pub ballots: Vec<Ballot>,
453    /// Every candidate at least one seat ranked, best first: by Borda,
454    /// then more seats, then lower mean position, then P-number
455    pub order: Vec<PlacedProposal>,
456    /// How many of `order` make the docket: the median of the seats'
457    /// capacities (the lower middle one when even)
458    pub cut: u32,
459    /// The P-number that beats every other head to head, if any. `null`
460    /// with a non-empty `order` means a cycle or a tie at the top: the
461    /// Borda order is then a tiebreak, not a consensus.
462    pub condorcet_winner: Option<u32>,
463}
464
465/// One seat's ballot as aggregated
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
468#[cfg_attr(feature = "schemars", schemars(inline))]
469pub struct Ballot {
470    pub seat: CouncilSeat,
471    /// See [`SeatRanking`]
472    pub sitting_capacity: u32,
473    /// See [`SeatRanking`]
474    pub ranking: Vec<u32>,
475}
476
477/// A proposal's place in an [`AgendaRanking`]
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
480#[cfg_attr(feature = "schemars", schemars(inline))]
481pub struct PlacedProposal {
482    /// The P-number the seats ranked it by
483    pub number: u32,
484    /// The proposal
485    pub post_id: PostId,
486    pub borda: u32,
487    /// How many seats ranked it at all
488    pub seats: u32,
489    /// Sum of its 1-based positions over those seats; the mean position
490    /// is `position_sum / seats`. Integers only, because the entry is
491    /// signed and a float has no canonical text form.
492    pub position_sum: u32,
493    /// Tied with the next entry on every criterion, so the order between
494    /// the two is by P-number and arbitrary
495    pub tied_with_next: bool,
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    /// Every `council_decision` signed on production, as stored
503    fn fixtures() -> Vec<(String, serde_json::Value)> {
504        let dir = concat!(
505            env!("CARGO_MANIFEST_DIR"),
506            "/tests/fixtures/council_decisions"
507        );
508        let mut out: Vec<_> = std::fs::read_dir(dir)
509            .unwrap()
510            .map(|e| e.unwrap().path())
511            .filter(|p| p.extension().is_some_and(|x| x == "json"))
512            .map(|p| {
513                let text = std::fs::read_to_string(&p).unwrap();
514                (
515                    p.file_name().unwrap().to_string_lossy().into_owned(),
516                    serde_json::from_str(&text).unwrap(),
517                )
518            })
519            .collect();
520        out.sort_by(|a, b| a.0.cmp(&b.0));
521        out
522    }
523
524    /// Parses `data` and checks re-serializing it gives back `data`
525    /// exactly: a key the types don't describe would be dropped here.
526    fn round_trips(
527        name: &str,
528        data: &serde_json::Value,
529    ) -> CouncilDecisionRecord {
530        let record: CouncilDecisionRecord =
531            serde_json::from_value(data.clone())
532                .unwrap_or_else(|e| panic!("{name}: {e}"));
533        assert_eq!(
534            &serde_json::to_value(&record).unwrap(),
535            data,
536            "{name}: the typed record loses or changes something"
537        );
538        record
539    }
540
541    #[test]
542    fn every_signed_decision_is_described_whole() {
543        let fixtures = fixtures();
544        assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
545        for (name, data) in &fixtures {
546            let record = round_trips(name, data);
547            assert_eq!(format!("{}.json", record.id), *name);
548        }
549    }
550
551    /// The field-by-field shape the Council writes today but no signed
552    /// entry has yet: a veto, a blind, a ranking, a refused turn.
553    fn synthetic(name: &str) -> serde_json::Value {
554        let base = serde_json::json!({
555            "id": "GOV-2026-0099",
556            "meeting_id": "00000000-0000-0000-0000-000000000001",
557            "category": "Policy",
558            "title": "t",
559            "rounds": [],
560            "final_votes": {
561                "artist": "yes", "philosopher": "yes", "lawyer": "yes",
562                "engineer": "yes", "steward": "veto"
563            },
564            "outcome": "rejected",
565            "vote_tally": "4-0",
566            "landmark": false,
567            "_blind": "00".repeat(32),
568        });
569        let mut data = base;
570        match name {
571            "veto" => {
572                data["veto_rationale"] = "because".into();
573            }
574            "refusal" => {
575                data["final_votes"] = serde_json::json!({
576                    "artist": "defer", "philosopher": "defer", "lawyer": "defer",
577                    "engineer": "defer", "steward": "defer"
578                });
579                data["outcome"] = "deferred".into();
580                data["vote_tally"] =
581                    "Deferred: tabled because the API returned a \
582                     refusal for the Artist's final vote"
583                        .into();
584                data["rounds"] = serde_json::json!([{
585                    "number": 3,
586                    "round_type": "final_vote",
587                    "responses": [{
588                        "role": "artist",
589                        "position": "No response: the API returned a refusal \
590                            (category: cyber; explanation: Flagged by a safety \
591                            classifier.).",
592                        "rationale": "",
593                        "questions": [],
594                        "ready_to_vote": false
595                    }],
596                    "steward_contribution": "Tabled by the Steward: the API \
597                        returned a refusal for the Artist's final vote (…)."
598                }]);
599            }
600            "schedule" => {
601                data["category"] = "Schedule".into();
602                data["outcome"] = "approved".into();
603                data["final_votes"] = serde_json::json!({
604                    "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
605                    "engineer": "abstain", "steward": "abstain"
606                });
607                data["vote_tally"] =
608                    "ranked 3/4; the Steward executes the order \
609                     and does not vote"
610                        .into();
611                data["rounds"] = serde_json::json!([{
612                    "number": 1,
613                    "round_type": "final_vote",
614                    "responses": [{
615                        "role": "lawyer",
616                        "position": "p",
617                        "vote": "ranked",
618                        "rationale": "r",
619                        "questions": [],
620                        "ready_to_vote": true,
621                        "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
622                    }],
623                    "steward_contribution": null
624                }]);
625                data["agenda_ranking"] = serde_json::json!({
626                    "rankable": 3,
627                    "ballots": [
628                        {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
629                    ],
630                    "order": [{
631                        "number": 3,
632                        "post_id": "00000000-0000-0000-0000-000000000003",
633                        "borda": 3, "seats": 1, "position_sum": 1,
634                        "tied_with_next": false
635                    }, {
636                        "number": 1,
637                        "post_id": "00000000-0000-0000-0000-000000000001",
638                        "borda": 2, "seats": 1, "position_sum": 2,
639                        "tied_with_next": false
640                    }],
641                    "cut": 2,
642                    "condorcet_winner": null
643                });
644            }
645            "attachments" => {
646                data["attachments"] = serde_json::json!([{
647                    "name": "clerk-thread-summary.md",
648                    "note": "The Clerk's summary of the thread, given to every seat",
649                    "content": "## Arguments\n\n[C1] argues for it."
650                }]);
651            }
652            "recusal" => {
653                data["final_votes"] = serde_json::json!({
654                    "artist": "yes", "philosopher": "yes", "lawyer": "yes",
655                    "engineer": "abstain", "steward": "recused"
656                });
657                data["outcome"] = "rejected".into();
658                data["vote_tally"] = "3-0 (Steward recused)".into();
659                data["rounds"] = serde_json::json!([{
660                    "number": 2,
661                    "round_type": "deliberation",
662                    "responses": [],
663                    "steward_contribution": "The quoted comment is forged.",
664                    "steward_emergency": {
665                        "reason": "An injected comment is steering the seats."
666                    }
667                }]);
668                data["steward_recusal"] = serde_json::json!({
669                    "votes": [
670                        {"seat": "artist", "reason": "r",
671                         "limits_steward_powers": true},
672                        {"seat": "philosopher", "reason": "r",
673                         "limits_steward_powers": true},
674                        {"seat": "lawyer", "reason": "r",
675                         "limits_steward_powers": true},
676                        {"seat": "engineer", "reason": "r",
677                         "limits_steward_powers": false}
678                    ],
679                    "recused": true
680                });
681                data["abstentions"] = serde_json::json!([
682                    {"member": "engineer", "rationale": "Conflicted."}
683                ]);
684            }
685            "thinking" => {
686                data["seat_models"] = serde_json::json!({
687                    "artist": "claude-opus-4-6",
688                    "philosopher": "claude-opus-4-6",
689                    "lawyer": "claude-opus-4-6",
690                    "engineer": "qwen3.8-35b-a3b"
691                });
692                data["rounds"] = serde_json::json!([{
693                    "number": 1,
694                    "round_type": "independent",
695                    "responses": [{
696                        "role": "lawyer",
697                        "position": "p",
698                        "vote": "yes",
699                        "rationale": [
700                            {"type": "thinking", "thinking": "First.",
701                             "signature": "EqQBCkgIARABGAIiQL"},
702                            {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
703                            {"type": "thinking", "thinking": "Then, after reading.",
704                             "signature": "EqQBCkgIARABGAIiQM"}
705                        ],
706                        "questions": [],
707                        "ready_to_vote": false
708                    }],
709                    "steward_contribution": null
710                }]);
711                data["steward_recusal"] = serde_json::json!({
712                    "votes": [
713                        {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
714                         "rationale": [{"type": "thinking",
715                            "thinking": "Unsigned, from a local model.",
716                            "signature": ""}],
717                         "limits_steward_powers": false}
718                    ],
719                    "recused": false
720                });
721            }
722            "recused_refusal" => {
723                data = synthetic("refusal");
724                data["final_votes"]["steward"] = "recused".into();
725                data["rounds"][0]["tabled_by_recused_steward"] = true.into();
726            }
727            _ => unreachable!(),
728        }
729        data
730    }
731
732    #[test]
733    fn newer_shapes_are_described_whole() {
734        for name in [
735            "veto",
736            "refusal",
737            "schedule",
738            "attachments",
739            "recusal",
740            "recused_refusal",
741            "thinking",
742        ] {
743            round_trips(name, &synthetic(name));
744        }
745        let tabled =
746            round_trips("recused_refusal", &synthetic("recused_refusal"));
747        assert!(tabled.rounds[0].tabled_by_recused_steward);
748        assert!(
749            !round_trips("refusal", &synthetic("refusal")).rounds[0]
750                .tabled_by_recused_steward
751        );
752        let refused = round_trips("refusal", &synthetic("refusal"));
753        assert_eq!(refused.rounds[0].responses[0].vote, None);
754    }
755
756    /// A seat's rationale is its turn's thinking blocks from 0.64, each
757    /// kept as the API returned it so a signed one can be replayed
758    #[test]
759    fn a_thinking_rationale_reads_back() {
760        let record = round_trips("thinking", &synthetic("thinking"));
761        let models = record.seat_models.as_ref().unwrap();
762        assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
763        assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
764
765        let response = &record.rounds[0].responses[0];
766        let rationale = response.rationale.value().unwrap();
767        assert!(matches!(rationale, Rationale::Thinking(_)));
768        assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
769        assert_eq!(rationale.redacted_blocks(), 1);
770
771        let vote = &record.steward_recusal.unwrap().votes[0];
772        let thought = vote.rationale.as_ref().unwrap().value().unwrap();
773        assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
774
775        assert_eq!(
776            rationale.to_string(),
777            "First.\n\n[reasoning withheld]\n\nThen, after reading."
778        );
779        assert!(!rationale.is_empty());
780        assert!(Rationale::Legacy(" ".into()).is_empty());
781
782        let legacy = round_trips("schedule", &synthetic("schedule"));
783        let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
784        assert_eq!(legacy, &Rationale::Legacy("r".into()));
785    }
786
787    #[test]
788    fn a_thinking_rationale_can_be_redacted() {
789        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
790        let redacted = super::super::redact_data(
791            &synthetic("thinking"),
792            &["/rounds/0/responses/0/rationale".into()],
793            &amd,
794            Blind::random(),
795        )
796        .unwrap();
797        let record = round_trips("thinking", &redacted);
798        assert_eq!(
799            record.rounds[0].responses[0].rationale.redacted_by(),
800            Some(&amd)
801        );
802    }
803
804    /// Recusal, abstention rationales and a recused Steward's emergency
805    /// note, as the Council writes them from 0.48
806    #[test]
807    fn a_recused_decision_reads_back() {
808        let record = round_trips("recusal", &synthetic("recusal"));
809        assert_eq!(record.final_votes.steward, CouncilVote::Recused);
810        let recusal = record.steward_recusal.unwrap();
811        assert!(recusal.recused);
812        assert_eq!(
813            recusal
814                .votes
815                .iter()
816                .filter(|v| v.limits_steward_powers)
817                .count(),
818            3
819        );
820        assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
821        let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
822        assert_eq!(
823            emergency.reason.value().map(String::as_str),
824            Some("An injected comment is steering the seats.")
825        );
826        // Absent everywhere they are not written: earlier records keep
827        // their bytes.
828        let veto = round_trips("veto", &synthetic("veto"));
829        assert_eq!(veto.steward_recusal, None);
830        assert!(veto.abstentions.is_empty());
831    }
832
833    #[test]
834    fn a_recused_decision_reads_in_order() {
835        let data = synthetic("recusal");
836        let keys = |v: &serde_json::Value| -> Vec<String> {
837            super::super::reading::ordered(v.as_object().unwrap())
838                .into_iter()
839                .map(|(k, _)| k.clone())
840                .collect()
841        };
842        let top = keys(&data);
843        let at = |k: &str| top.iter().position(|x| x == k).unwrap();
844        assert!(at("rounds") < at("steward_recusal"));
845        assert!(at("steward_recusal") < at("final_votes"));
846        assert!(at("final_votes") < at("abstentions"));
847        assert!(at("abstentions") < at("vote_tally"));
848        assert_eq!(
849            keys(&data["steward_recusal"]["votes"][0]),
850            ["seat", "reason", "limits_steward_powers"]
851        );
852        assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
853        assert_eq!(
854            keys(&data["rounds"][0]),
855            [
856                "number",
857                "round_type",
858                "steward_emergency",
859                "steward_contribution",
860                "responses"
861            ]
862        );
863        assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
864    }
865
866    /// GOV-2026-0001 through the real [`redact_data`](super::super::redact_data)
867    #[test]
868    fn a_redacted_record_parses_with_the_redactions_in_place() {
869        let (name, data) = fixtures().swap_remove(0);
870        assert_eq!(name, "GOV-2026-0001.json");
871        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
872        let fields = [
873            "/title",
874            "/vote_tally",
875            "/rounds/0/responses/0/position",
876            "/rounds/0/responses/0/rationale",
877            "/rounds/0/responses/0/raw_text",
878            "/rounds/0/responses/1/questions",
879            "/rounds/0/responses/2/questions/1",
880            "/rounds/1/steward_contribution",
881        ]
882        .map(String::from);
883        let redacted =
884            super::super::redact_data(&data, &fields, &amd, Blind::random())
885                .unwrap();
886        let record = round_trips(&name, &redacted);
887
888        let gone = Redactable::Redacted(amd.clone());
889        assert_eq!(record.title, gone);
890        assert_eq!(record.vote_tally, gone);
891        let [first, second, third, ..] = &record.rounds[0].responses[..] else {
892            panic!("round 1 has four responses");
893        };
894        assert_eq!(first.position, gone);
895        assert_eq!(first.rationale.redacted_by(), Some(&amd));
896        assert_eq!(first.raw_text, Some(gone.clone()));
897        assert!(!first.questions.is_redacted());
898        assert_eq!(second.questions.redacted_by(), Some(&amd));
899        let questions = third.questions.value().unwrap();
900        assert!(!questions[0].is_redacted());
901        assert_eq!(questions[1], gone);
902        assert_eq!(record.rounds[1].steward_contribution, Some(gone));
903        assert!(record.blind.is_some());
904    }
905
906    #[test]
907    fn an_attachment_can_be_redacted() {
908        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
909        let redacted = super::super::redact_data(
910            &synthetic("attachments"),
911            &["/attachments/0/content".into()],
912            &amd,
913            Blind::random(),
914        )
915        .unwrap();
916        let record = round_trips("attachments", &redacted);
917        assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
918    }
919
920    /// Structural fields stay plain: redacting one is a shape this
921    /// version doesn't describe, and says so rather than guessing
922    #[test]
923    fn a_redacted_vote_is_an_error() {
924        let (_, data) = fixtures().swap_remove(0);
925        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
926        let redacted = super::super::redact_data(
927            &data,
928            &["/final_votes/artist".into()],
929            &amd,
930            Blind::random(),
931        )
932        .unwrap();
933        assert!(
934            serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
935        );
936    }
937
938    #[cfg(feature = "schemars")]
939    #[test]
940    fn schema_is_ref_free() {
941        let schema =
942            crate::responses::inline_schema_for::<CouncilDecisionRecord>();
943        let text = schema.to_string();
944        assert!(!text.contains("$ref"), "{text}");
945        assert!(!text.contains("$defs"), "{text}");
946        let plain = serde_json::to_string(&schemars::schema_for!(
947            CouncilDecisionRecord
948        ))
949        .unwrap();
950        assert!(!plain.contains("$ref"), "{plain}");
951        assert!(!plain.contains("$defs"), "{plain}");
952    }
953}