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};
19
20mod decided;
21mod ranking;
22mod votes;
23use crate::enums::{DecisionOutcome, ProposalCategory, RoundType};
24use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
25pub use decided::{Decided, FinalRoundError, RecordRuleError};
26pub use votes::{
27    BlankStatement, NotASeatChoice, SeatChoice, SeatVote, SeatVotes, Statement,
28    StewardVote, Vote, Votes,
29};
30
31/// The `data` of a `council_decision` entry. See the [module docs](self).
32///
33/// The entry's tags are on the entry, not in `data`.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
36#[cfg_attr(feature = "schemars", schemars(inline))]
37pub struct CouncilDecisionRecord {
38    /// This entry's own id
39    pub id: GovernanceLogId,
40    /// The sitting that decided it
41    pub meeting_id: CouncilMeetingId,
42    pub category: DecisionCategory,
43    /// The agenda item's title
44    pub title: Redactable<String>,
45    /// Every round of deliberation, in order. The last is the
46    /// `final_vote` round unless the item was tabled earlier.
47    pub rounds: Vec<CouncilRound>,
48    pub final_votes: FinalVotes,
49    /// Decided by `category`'s threshold over `final_votes`; a Steward
50    /// `veto` is always `rejected`, a tabled item `deferred`, and a
51    /// `Schedule` item `approved` once ranked
52    pub outcome: DecisionOutcome,
53    /// For display only. `"<yes>-<no>"` with `concur` counted as yes
54    /// (`"5-0"`, `"0-4"`); `"Deferred"` for a tabled item, optionally
55    /// followed by `": <why>"`; a sentence on a `Schedule` item.
56    pub vote_tally: Redactable<String>,
57    /// Flagged by the Steward as significant for readers and for
58    /// precedent weight. Changes nothing procedurally.
59    pub landmark: bool,
60    /// The Steward's rationale for a `veto` (Constitution Art. IV § 5).
61    /// Written since 2026-09-22; no veto had been cast before then.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub veto_rationale: Option<Redactable<String>>,
64    /// On a `Schedule` item, the seats' aggregated ranking of the docket
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub agenda_ranking: Option<AgendaRanking>,
67    /// Whether the item limits the Steward's powers, as the four seats
68    /// determined it before round 1 (Constitution Art. IV § 3,
69    /// GOV-2026-0009). Absent on items decided before it was recorded, and
70    /// on `Schedule` and `Emergency` items. (0.48)
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub steward_recusal: Option<StewardRecusal>,
73    /// The Steward's one-sentence rationale for an abstention (Art. IV
74    /// § 3). A seat's abstention rationale is its statement in the final
75    /// round, recorded there once.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub abstentions: Vec<Abstention>,
78    /// Entries this decision retires as precedent. Only GOV-2026-0005
79    /// carries one, added by a migration rather than by the Council; an
80    /// amendment naming the same entry decides its `standing` instead.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub overrules: Vec<GovernanceLogId>,
83    /// What the seats were shown beyond the proposal: the Clerk's
84    /// summaries and everything a seat had read to it. (0.42)
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub attachments: Vec<CouncilAttachment>,
87    /// The model each seat ran on. Absent from entries before 0.64.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub seat_models: Option<SeatModels>,
90    /// The entry's blinding value (see [`blind_data`](super::blind_data)).
91    /// Absent from entries that predate blinding.
92    #[serde(
93        rename = "_blind",
94        default,
95        skip_serializing_if = "Option::is_none"
96    )]
97    pub blind: Option<Blind>,
98}
99
100/// Material put before the Council, inline as markdown
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "schemars", schemars(inline))]
104pub struct CouncilAttachment {
105    /// A file name, unique within the record: `clerk-thread-summary.md`
106    pub name: String,
107    /// What it is and who saw it
108    pub note: String,
109    pub content: Redactable<String>,
110}
111
112/// An agenda item's category, which sets the vote it needs
113/// (Constitution v0.4 Art. IV § 3).
114///
115/// An `abstain` or `recused` leaves the denominator. A Steward `veto`
116/// rejects any item the Steward is not recused from; a recused Steward has
117/// no vote and no veto.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
120#[cfg_attr(feature = "schemars", schemars(inline))]
121pub enum DecisionCategory {
122    /// Three `yes`: an absolute count, found among the four seats when the
123    /// Steward is recused
124    Routine,
125    /// Four `yes` of five including the Steward (`yes` or `concur`); with
126    /// the Steward recused, all four seats `yes`
127    Policy,
128    /// Unanimous among those voting, with no fewer than four voting.
129    /// Before GOV-2026-0009 (2026-09-26), 5 of 5 `yes`
130    Constitutional,
131    /// The Steward alone, subject to 72-hour review
132    Emergency,
133    /// The Council's scheduling thread: the four seats rank the docket
134    /// (Borda count, see `agenda_ranking`) and the Steward executes the
135    /// order without voting
136    Schedule,
137}
138
139/// The two spell the same five categories (0.74)
140impl From<ProposalCategory> for DecisionCategory {
141    fn from(category: ProposalCategory) -> Self {
142        match category {
143            ProposalCategory::Routine => Self::Routine,
144            ProposalCategory::Policy => Self::Policy,
145            ProposalCategory::Constitutional => Self::Constitutional,
146            ProposalCategory::Emergency => Self::Emergency,
147            ProposalCategory::Schedule => Self::Schedule,
148        }
149    }
150}
151
152impl DecisionCategory {
153    /// The vote it needs, in words ([`ProposalCategory::threshold`]) (0.76)
154    pub const fn threshold(self) -> &'static str {
155        ProposalCategory::from_decision(self).threshold()
156    }
157
158    /// Whether an item of this category opens with the seats' recusal
159    /// determination ([`ProposalCategory::determines_recusal`]) (0.76)
160    pub const fn determines_recusal(self) -> bool {
161        ProposalCategory::from_decision(self).determines_recusal()
162    }
163
164    /// The vote it needs once the Steward is recused, if recusal is ever
165    /// determined ([`ProposalCategory::recused_threshold`]) (0.76)
166    pub const fn recused_threshold(self) -> Option<&'static str> {
167        ProposalCategory::from_decision(self).recused_threshold()
168    }
169
170    /// The category's name as the record and prose write it: `"Policy"`
171    /// (0.76)
172    pub const fn name(self) -> &'static str {
173        match self {
174            Self::Routine => "Routine",
175            Self::Policy => "Policy",
176            Self::Constitutional => "Constitutional",
177            Self::Emergency => "Emergency",
178            Self::Schedule => "Schedule",
179        }
180    }
181}
182
183/// `"Policy"`: [`DecisionCategory::name`], the record's spelling (0.76)
184impl std::fmt::Display for DecisionCategory {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.name())
187    }
188}
189
190impl ProposalCategory {
191    /// [`From<DecisionCategory>`], in a `const fn`
192    const fn from_decision(category: DecisionCategory) -> Self {
193        match category {
194            DecisionCategory::Routine => Self::Routine,
195            DecisionCategory::Policy => Self::Policy,
196            DecisionCategory::Constitutional => Self::Constitutional,
197            DecisionCategory::Emergency => Self::Emergency,
198            DecisionCategory::Schedule => Self::Schedule,
199        }
200    }
201}
202
203impl From<DecisionCategory> for ProposalCategory {
204    fn from(category: DecisionCategory) -> Self {
205        Self::from_decision(category)
206    }
207}
208
209/// The tag a recused deferral carries on the record, which the next
210/// hearing's guard looks for (0.74)
211pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
212
213/// The tag every decision with the Steward recused carries (0.74)
214pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
215
216/// One round of deliberation
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
219#[cfg_attr(feature = "schemars", schemars(inline))]
220pub struct CouncilRound {
221    /// 1-indexed
222    pub number: u32,
223    /// `independent` (round 1: no seat sees another's response or any
224    /// Steward note), `deliberation` (seats see prior rounds), or
225    /// `final_vote`
226    pub round_type: RoundType,
227    /// One per seat that took its turn. A tabled round can hold fewer
228    /// than four.
229    pub responses: Vec<SeatResponse>,
230    /// The Steward's notes to the seats for this round, or the reason an
231    /// item was tabled
232    pub steward_contribution: Option<Redactable<String>>,
233    /// Present when `steward_contribution` came from a recused Steward
234    /// under emergency authority (Art. IV § 2), which puts the item under
235    /// mandatory Council review within 72 hours. (0.48)
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub steward_emergency: Option<StewardEmergency>,
238    /// The item was tabled here by a recused Steward, over an API refusal
239    /// on a seat's final vote: refusal handling, the one tabling open to
240    /// a recused Steward. `steward_contribution` says which refusal.
241    /// (0.48)
242    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
243    pub tabled_by_recused_steward: bool,
244}
245
246/// A recused Steward's note to the seats, made as an emergency act
247/// (Constitution Art. IV § 2)
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
250#[cfg_attr(feature = "schemars", schemars(inline))]
251pub struct StewardEmergency {
252    /// The Steward's one-sentence reason, given before the note was read
253    pub reason: Redactable<String>,
254}
255
256/// The four seats' determination of whether an item limits the Steward's
257/// powers (Constitution Art. IV § 3, GOV-2026-0009)
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
260#[cfg_attr(feature = "schemars", schemars(inline))]
261pub struct StewardRecusal {
262    /// One per seat that answered in round 1; a seat whose turn the API
263    /// refused has none
264    pub votes: Vec<RecusalVote>,
265    /// Three of the four seats said it does. The Steward then has no
266    /// deliberation, vote or veto on the item, and `final_votes` records
267    /// `recused`.
268    pub recused: bool,
269}
270
271/// One seat's answer to whether an item limits the Steward's powers
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
274#[cfg_attr(feature = "schemars", schemars(inline))]
275pub struct RecusalVote {
276    pub seat: CouncilSeat,
277    /// The seat's explanation for the record; before 0.64, its reasoning,
278    /// written before its answer
279    pub reason: Redactable<String>,
280    /// The seat's thinking before it answered (0.64)
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub rationale: Option<Redactable<Rationale>>,
283    pub limits_steward_powers: bool,
284}
285
286/// An abstention and its one-sentence rationale (Art. IV § 3)
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
289#[cfg_attr(feature = "schemars", schemars(inline))]
290pub struct Abstention {
291    pub member: CouncilMember,
292    pub rationale: Redactable<String>,
293}
294
295/// Any of the five Council members: a [`CouncilSeat`] or the Steward
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298#[cfg_attr(feature = "schemars", schemars(inline))]
299#[serde(rename_all = "snake_case")]
300pub enum CouncilMember {
301    Artist,
302    Philosopher,
303    Lawyer,
304    Engineer,
305    Steward,
306}
307
308impl From<CouncilSeat> for CouncilMember {
309    fn from(seat: CouncilSeat) -> Self {
310        match seat {
311            CouncilSeat::Artist => Self::Artist,
312            CouncilSeat::Philosopher => Self::Philosopher,
313            CouncilSeat::Lawyer => Self::Lawyer,
314            CouncilSeat::Engineer => Self::Engineer,
315        }
316    }
317}
318
319/// One seat's turn in a round
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
322#[cfg_attr(feature = "schemars", schemars(inline))]
323pub struct SeatResponse {
324    pub role: CouncilSeat,
325    /// The seat's statement for the record. On a turn the API refused,
326    /// `"No response: the API returned a refusal (…)."`
327    pub position: Redactable<String>,
328    /// The seat's vote as of this round; only the `final_vote` round's
329    /// counts. Absent only when the API returned a refusal for the turn:
330    /// a refusal is recorded as a fact, never as a vote.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub vote: Option<CouncilVote>,
333    /// Why the seat said it. Empty on a refused turn.
334    pub rationale: Redactable<Rationale>,
335    /// Questions the seat put to the others or the Steward. A redaction
336    /// can take one question or the whole list.
337    pub questions: Redactable<Vec<Redactable<String>>>,
338    /// Whether the seat said it was ready for the final vote
339    pub ready_to_vote: bool,
340    /// On a `Schedule` item's final round, the seat's ballot
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub ranking: Option<SeatRanking>,
343    /// The model's raw reply text. Only in entries signed before
344    /// 2026-09-18; from GOV-2026-0003 on it duplicates `rationale`.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub raw_text: Option<Redactable<String>>,
347}
348
349/// Why a seat said what it said.
350///
351/// Until 0.64 the seats wrote their reasoning into a `reasoning` field of
352/// the decision tool, which is not something a model should be asked to
353/// do: Anthropic's `reasoning_extraction` safeguard declines exactly that
354/// ([docs]). The seats now think before they answer, and the record keeps
355/// what the API returns for that thinking.
356///
357/// [docs]: https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#keep-reasoning-in-thinking-blocks
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
360#[cfg_attr(feature = "schemars", schemars(inline))]
361#[serde(untagged)]
362pub enum Rationale {
363    /// What the seat wrote in the decision tool's `reasoning` field
364    Legacy(String),
365    /// The thinking blocks of the seat's turn, in order and as the API
366    /// returned them. On Anthropic models each is a summary of the model's
367    /// reasoning, written by Anthropic, with a `signature` over the full
368    /// reasoning; the [`SeatModels`] entry says which model to replay it
369    /// against. Local models' blocks are their own reasoning, unsigned.
370    Thinking(Vec<Thought>),
371}
372
373impl Rationale {
374    /// The text of every thinking block, in order. A legacy rationale is
375    /// one item.
376    pub fn texts(&self) -> Vec<&str> {
377        match self {
378            Self::Legacy(text) => vec![text.as_str()],
379            Self::Thinking(thoughts) => {
380                thoughts.iter().filter_map(Thought::text).collect()
381            }
382        }
383    }
384
385    /// Thinking the API withheld: `redacted_thinking` blocks
386    pub fn redacted_blocks(&self) -> usize {
387        match self {
388            Self::Legacy(_) => 0,
389            Self::Thinking(thoughts) => {
390                thoughts.iter().filter(|t| t.text().is_none()).count()
391            }
392        }
393    }
394
395    /// No words: an empty legacy string, or no thinking at all
396    pub fn is_empty(&self) -> bool {
397        self.texts().iter().all(|t| t.trim().is_empty())
398            && self.redacted_blocks() == 0
399    }
400}
401
402/// The text of each block, a blank line apart; withheld blocks say so
403impl std::fmt::Display for Rationale {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        let Self::Thinking(thoughts) = self else {
406            return f.write_str(&self.texts().concat());
407        };
408        for (i, thought) in thoughts.iter().enumerate() {
409            if i > 0 {
410                f.write_str("\n\n")?;
411            }
412            f.write_str(
413                thought.text().unwrap_or("[reasoning withheld]").trim_end(),
414            )?;
415        }
416        Ok(())
417    }
418}
419
420impl From<String> for Rationale {
421    fn from(text: String) -> Self {
422        Self::Legacy(text)
423    }
424}
425
426impl From<&str> for Rationale {
427    fn from(text: &str) -> Self {
428        Self::Legacy(text.to_owned())
429    }
430}
431
432impl From<Vec<Thought>> for Rationale {
433    fn from(thoughts: Vec<Thought>) -> Self {
434        Self::Thinking(thoughts)
435    }
436}
437
438/// The thinking blocks of `content`; anything else in it is not thinking
439impl From<&Content> for Rationale {
440    fn from(content: &Content) -> Self {
441        Self::Thinking(content.thoughts().collect())
442    }
443}
444
445/// The model each seat ran on
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 SeatModels {
450    pub artist: Model,
451    pub philosopher: Model,
452    pub lawyer: Model,
453    pub engineer: Model,
454}
455
456impl SeatModels {
457    pub fn get(&self, seat: CouncilSeat) -> &Model {
458        match seat {
459            CouncilSeat::Artist => &self.artist,
460            CouncilSeat::Philosopher => &self.philosopher,
461            CouncilSeat::Lawyer => &self.lawyer,
462            CouncilSeat::Engineer => &self.engineer,
463        }
464    }
465}
466
467/// A voting Council seat. The fifth vote is the Steward's.
468// Ordered as declared (0.74). A plain comment: the doc comment is schema
469// text, which agora pins byte for byte.
470#[derive(
471    Debug,
472    Clone,
473    Copy,
474    PartialEq,
475    Eq,
476    PartialOrd,
477    Ord,
478    Hash,
479    Serialize,
480    Deserialize,
481)]
482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
483#[cfg_attr(feature = "schemars", schemars(inline))]
484#[serde(rename_all = "snake_case")]
485pub enum CouncilSeat {
486    Artist,
487    Philosopher,
488    Lawyer,
489    Engineer,
490}
491
492impl CouncilSeat {
493    /// Every seat, in declaration order (0.74)
494    pub const ALL: [Self; 4] = [
495        Self::Artist,
496        Self::Philosopher,
497        Self::Lawyer,
498        Self::Engineer,
499    ];
500
501    /// The `snake_case` form, as serde spells it: `"artist"` (0.74)
502    pub const fn as_str(self) -> &'static str {
503        match self {
504            Self::Artist => "artist",
505            Self::Philosopher => "philosopher",
506            Self::Lawyer => "lawyer",
507            Self::Engineer => "engineer",
508        }
509    }
510
511    /// The seat's name as prose writes it: `"Artist"` (0.74). Also its
512    /// `Display` (0.76).
513    pub const fn name(self) -> &'static str {
514        match self {
515            Self::Artist => "Artist",
516            Self::Philosopher => "Philosopher",
517            Self::Lawyer => "Lawyer",
518            Self::Engineer => "Engineer",
519        }
520    }
521}
522
523/// `"Artist"`: [`CouncilSeat::name`] (0.76)
524impl std::fmt::Display for CouncilSeat {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        f.write_str(self.name())
527    }
528}
529
530/// A seat is the model role that speaks for it (0.76)
531impl From<CouncilSeat> for crate::enums::ModelRole {
532    fn from(seat: CouncilSeat) -> Self {
533        match seat {
534            CouncilSeat::Artist => Self::Artist,
535            CouncilSeat::Philosopher => Self::Philosopher,
536            CouncilSeat::Lawyer => Self::Lawyer,
537            CouncilSeat::Engineer => Self::Engineer,
538        }
539    }
540}
541
542/// A vote cast by a seat or the Steward
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
544#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
545#[cfg_attr(feature = "schemars", schemars(inline))]
546#[serde(rename_all = "snake_case")]
547pub enum CouncilVote {
548    Yes,
549    No,
550    /// Also recorded for a seat with no final response, and for the
551    /// Steward on a `Schedule` item (who executes the order, not votes on
552    /// it)
553    Abstain,
554    /// Tabled: every vote is `defer` when the Steward tables an item
555    Defer,
556    /// The Steward's agreement with the seats' majority; counts as yes
557    Concur,
558    /// The Steward's veto; rejects whatever the others voted. See
559    /// `veto_rationale`.
560    Veto,
561    /// A seat ranked a `Schedule` item's docket instead of voting; see
562    /// `agenda_ranking`
563    Ranked,
564    /// The Steward, on an item the seats found limits the Steward's
565    /// powers: no vote and no veto. See `steward_recusal`. (0.48)
566    Recused,
567}
568
569impl CouncilVote {
570    /// The vote as prose writes it: `"Yes"`, `"Abstain"` (0.76). Also its
571    /// `Display`, and how the Council stores a seat's vote in its
572    /// `deliberation_rounds` rows.
573    pub const fn name(self) -> &'static str {
574        match self {
575            Self::Yes => "Yes",
576            Self::No => "No",
577            Self::Abstain => "Abstain",
578            Self::Defer => "Defer",
579            Self::Concur => "Concur",
580            Self::Veto => "Veto",
581            Self::Ranked => "Ranked",
582            Self::Recused => "Recused",
583        }
584    }
585}
586
587/// `"Yes"`: [`CouncilVote::name`] (0.76)
588impl std::fmt::Display for CouncilVote {
589    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
590        f.write_str(self.name())
591    }
592}
593
594/// The votes that decided the item
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
596#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
597#[cfg_attr(feature = "schemars", schemars(inline))]
598pub struct FinalVotes {
599    pub artist: CouncilVote,
600    pub philosopher: CouncilVote,
601    pub lawyer: CouncilVote,
602    pub engineer: CouncilVote,
603    pub steward: CouncilVote,
604}
605
606/// A seat's ballot on a `Schedule` item
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
609#[cfg_attr(feature = "schemars", schemars(inline))]
610pub struct SeatRanking {
611    /// How many items the seat judges this sitting can hear
612    pub sitting_capacity: u32,
613    /// Docket P-numbers (`P3` is `3`), most important first. May be
614    /// partial.
615    pub ranking: Vec<u32>,
616}
617
618/// The aggregated ranking of a `Schedule` item: a Borda count on the
619/// docket's scale, so a first choice scores `rankable` however many
620/// items the seat ranked, and an unranked item scores 0
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
623#[cfg_attr(feature = "schemars", schemars(inline))]
624pub struct AgendaRanking {
625    /// How many candidates could be ranked: the Borda scale
626    pub rankable: u32,
627    pub ballots: Vec<Ballot>,
628    /// Every candidate at least one seat ranked, best first: by Borda,
629    /// then more seats, then lower mean position, then P-number
630    pub order: Vec<PlacedProposal>,
631    /// How many of `order` make the docket: the median of the seats'
632    /// capacities (the lower middle one when even)
633    pub cut: u32,
634    /// The P-number that beats every other head to head, if any. `null`
635    /// with a non-empty `order` means a cycle or a tie at the top: the
636    /// Borda order is then a tiebreak, not a consensus.
637    pub condorcet_winner: Option<u32>,
638}
639
640/// One seat's ballot as aggregated
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
643#[cfg_attr(feature = "schemars", schemars(inline))]
644pub struct Ballot {
645    pub seat: CouncilSeat,
646    /// See [`SeatRanking`]
647    pub sitting_capacity: u32,
648    /// See [`SeatRanking`]
649    pub ranking: Vec<u32>,
650}
651
652/// A proposal's place in an [`AgendaRanking`]
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
655#[cfg_attr(feature = "schemars", schemars(inline))]
656pub struct PlacedProposal {
657    /// The P-number the seats ranked it by
658    pub number: u32,
659    /// The proposal
660    pub post_id: PostId,
661    pub borda: u32,
662    /// How many seats ranked it at all
663    pub seats: u32,
664    /// Sum of its 1-based positions over those seats; the mean position
665    /// is `position_sum / seats`. Integers only, because the entry is
666    /// signed and a float has no canonical text form.
667    pub position_sum: u32,
668    /// Tied with the next entry on every criterion, so the order between
669    /// the two is by P-number and arbitrary
670    pub tied_with_next: bool,
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    /// Every `council_decision` signed on production, as signed
678    fn fixtures() -> Vec<(String, serde_json::Value)> {
679        fixtures_in(concat!(
680            env!("CARGO_MANIFEST_DIR"),
681            "/tests/fixtures/council_decisions"
682        ))
683    }
684
685    /// The latest version of each one a revision amended
686    fn revised_fixtures() -> Vec<(String, serde_json::Value)> {
687        fixtures_in(concat!(
688            env!("CARGO_MANIFEST_DIR"),
689            "/tests/fixtures/council_decisions/revised"
690        ))
691    }
692
693    /// Each signed entry's `data_hash`, and the `resulting_data_hash` of
694    /// its last revision when one revised it, as the production log has
695    /// them (2026-10-13): the fixtures are the signed bytes.
696    const SIGNED: [(&str, &str, Option<&str>); 13] = [
697        (
698            "GOV-2026-0001",
699            "fca67b28737d613e3a98cdb3b1ad686944b36f1654ad5c237da6ec0291ee4574",
700            None,
701        ),
702        (
703            "GOV-2026-0002",
704            "b1bb43d611d75e1d3b03df8ff64381781b3d75d934a1f9d7ec75fe2174716c47",
705            None,
706        ),
707        (
708            "GOV-2026-0003",
709            "7c75af86d9b3930b5d14cd9e1784c2ecb32483427ba7149dffb20d84510c1b09",
710            Some(
711                "e1db85357dbe3b865129a53836a16c33730c7ebb37a8412f84afe5fbac4aa64d",
712            ),
713        ),
714        (
715            "GOV-2026-0004",
716            "0004e84733236dca2ab4dfb16757236ad0d76e27efbf47bbec0e00c6d90a7ed0",
717            Some(
718                "b2a9ec584f49eebf0103a72986440a326fcb5f68c83a7f1eddba8228046c342f",
719            ),
720        ),
721        (
722            "GOV-2026-0005",
723            "8257faade7548f15c010f2a5bb7ff22e1e37f7034eac2c9726445002fa593f0e",
724            Some(
725                "514a020a95669da78dfadcee58729a99402f9b2c2f750a557a8b8ce8512f451d",
726            ),
727        ),
728        (
729            "GOV-2026-0006",
730            "6520bb4cba556c4ba251584d836d1efb8a33227b83573f74c34c32362762a6f7",
731            Some(
732                "58f3fafc031ca29c796ac08fa69bb9994bf40fef9114189d6f820d050c66eedc",
733            ),
734        ),
735        (
736            "GOV-2026-0007",
737            "39a273c711d3f5b4744bbfcb1a1b2978e26b5b48629fabd5d0f136241167f07c",
738            Some(
739                "56c1036a8ee1eb852d831082f36ef9c22bc2a103a9a0d56c778fa040f9c6917e",
740            ),
741        ),
742        (
743            "GOV-2026-0008",
744            "89ac0d32809a3baf07a28ab6a511c64aaf1a31741cb3c5630d23fde484cf5cf3",
745            None,
746        ),
747        (
748            "GOV-2026-0009",
749            "32a86a94646ed886085895ad92e76285acced67b76c1853bc0e8b04626b6487d",
750            None,
751        ),
752        (
753            "GOV-2026-0010",
754            "aa4031caaf9e2851f70ed83008954869388cacb03a8119a2996a281da9e4a0dd",
755            None,
756        ),
757        (
758            "GOV-2026-0011",
759            "590b29357ae1acd425f64abb8e2aeeee2eec71d36dbb9afc99ec33ce4e5791d7",
760            None,
761        ),
762        (
763            "GOV-2026-0012",
764            "625d7f8b7c504def1fa332db68b06518d34b247edd5f2225be370a392e1a0973",
765            None,
766        ),
767        (
768            "GOV-2026-0013",
769            "c3d0a7f2c157e15f73031621148c1f3ff543418e31684d12fc6048a6c657dcb7",
770            None,
771        ),
772    ];
773
774    fn fixtures_in(dir: &str) -> Vec<(String, serde_json::Value)> {
775        let mut out: Vec<_> = std::fs::read_dir(dir)
776            .unwrap()
777            .map(|e| e.unwrap().path())
778            .filter(|p| p.extension().is_some_and(|x| x == "json"))
779            .map(|p| {
780                let text = std::fs::read_to_string(&p).unwrap();
781                (
782                    p.file_name().unwrap().to_string_lossy().into_owned(),
783                    serde_json::from_str(&text).unwrap(),
784                )
785            })
786            .collect();
787        out.sort_by(|a, b| a.0.cmp(&b.0));
788        out
789    }
790
791    /// Parses `data` and checks re-serializing it gives back `data`
792    /// exactly: a key the types don't describe would be dropped here.
793    fn round_trips(
794        name: &str,
795        data: &serde_json::Value,
796    ) -> CouncilDecisionRecord {
797        let record: CouncilDecisionRecord =
798            serde_json::from_value(data.clone())
799                .unwrap_or_else(|e| panic!("{name}: {e}"));
800        assert_eq!(
801            &serde_json::to_value(&record).unwrap(),
802            data,
803            "{name}: the typed record loses or changes something"
804        );
805        record
806    }
807
808    #[test]
809    fn every_signed_decision_is_described_whole() {
810        let fixtures = fixtures();
811        assert_eq!(fixtures.len(), 13, "GOV-2026-0001..0013");
812        let revised = revised_fixtures();
813        assert_eq!(revised.len(), 5, "GOV-2026-0003..0007, revised");
814        for (name, data) in fixtures.iter().chain(&revised) {
815            let record = round_trips(name, data);
816            assert_eq!(format!("{}.json", record.id), *name);
817        }
818    }
819
820    /// Every signed outcome on a voted item is what the thresholds give
821    /// for its votes, as signed and as revised. Tabled items (every vote
822    /// `defer`, GOV-2026-0002) and `Schedule` items (0008, 0013) are
823    /// decided otherwise; 0001..0008 predate GOV-2026-0009's thresholds,
824    /// and agree with them anyway.
825    #[test]
826    fn every_signed_outcome_follows_from_its_votes() {
827        let check = |fixtures: Vec<(String, serde_json::Value)>| {
828            let mut checked = 0;
829            for (name, data) in fixtures {
830                let record: CouncilDecisionRecord =
831                    serde_json::from_value(data).unwrap();
832                let tabled = record.final_votes.steward == CouncilVote::Defer;
833                if tabled || record.category == DecisionCategory::Schedule {
834                    continue;
835                }
836                assert_eq!(
837                    record.final_votes.outcome(record.category),
838                    record.outcome,
839                    "{name}"
840                );
841                assert_eq!(
842                    record.vote_tally.value().map(String::as_str),
843                    Some(record.final_votes.tally().as_str()),
844                    "{name}"
845                );
846                checked += 1;
847            }
848            checked
849        };
850        assert_eq!(
851            check(fixtures()),
852            10,
853            "GOV-2026-0001, 0003..0007, 0009..0012"
854        );
855        assert_eq!(
856            check(revised_fixtures()),
857            5,
858            "GOV-2026-0003..0007, revised"
859        );
860    }
861
862    /// Aggregating a signed scheduling item's ballots again gives its
863    /// signed ranking: the order, the ties, the cut and the Condorcet
864    /// winner
865    #[test]
866    fn every_signed_ranking_follows_from_its_ballots() {
867        let mut checked = 0;
868        for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
869            let record: CouncilDecisionRecord =
870                serde_json::from_value(data).unwrap();
871            let Some(signed) = record.agenda_ranking else {
872                continue;
873            };
874            // The P-numbers run 1..=rankable; a candidate no seat ranked is
875            // not in `order`, and its proposal does not matter here
876            let candidates: std::collections::BTreeMap<u32, PostId> = (1
877                ..=signed.rankable)
878                .map(|n| {
879                    let post = signed
880                        .order
881                        .iter()
882                        .find(|p| p.number == n)
883                        .map(|p| p.post_id)
884                        .unwrap_or_else(|| {
885                            PostId::from(uuid::Uuid::from_u128(n.into()))
886                        });
887                    (n, post)
888                })
889                .collect();
890            let again =
891                AgendaRanking::aggregate(&candidates, signed.ballots.clone());
892            assert_eq!(again, signed, "{name}");
893            checked += 1;
894        }
895        assert_eq!(checked, 2, "GOV-2026-0008 and 0013");
896    }
897
898    /// What [`Decided`] writes is what the signed records say, for the
899    /// kinds the archive has: tabled (GOV-2026-0002) and ranked
900    /// (GOV-2026-0008, 0013). No voted record can be read back into
901    /// [`Votes`]: before GOV-2026-0013 the seats' words were in
902    /// `rationale` and `position` was empty, which is why there is no
903    /// conversion back.
904    #[test]
905    fn decided_writes_what_the_archive_says() {
906        let mut checked = 0;
907        for (name, data) in fixtures() {
908            let record: CouncilDecisionRecord =
909                serde_json::from_value(data).unwrap();
910            let decided =
911                match (&record.agenda_ranking, record.final_votes.steward) {
912                    (Some(ranking), _) => Decided::Ranked(ranking.clone()),
913                    (None, CouncilVote::Defer) => Decided::Tabled,
914                    (None, _) => {
915                        let last = record.rounds.last().unwrap();
916                        let votes = SeatVotes::from_final_round(last);
917                        assert!(
918                            matches!(
919                                votes,
920                                Err(FinalRoundError::NoStatement(_))
921                            ),
922                            "{name}: {votes:?}"
923                        );
924                        continue;
925                    }
926                };
927            let again = record.clone().decided(decided);
928            assert_eq!(again, record, "{name}");
929            checked += 1;
930        }
931        assert_eq!(checked, 3, "GOV-2026-0002, 0008, 0013");
932    }
933
934    /// The prose names: a category's is its serde form; a seat's and a
935    /// vote's are their serde forms capitalized (0.76)
936    #[test]
937    fn the_prose_names() {
938        use DecisionCategory as C;
939        for c in [
940            C::Routine,
941            C::Policy,
942            C::Constitutional,
943            C::Emergency,
944            C::Schedule,
945        ] {
946            assert_eq!(
947                serde_json::to_value(c).unwrap(),
948                c.to_string().as_str()
949            );
950            assert_eq!(c.threshold(), ProposalCategory::from(c).threshold());
951        }
952        let capitalized = |s: &str| {
953            let mut c = s.chars();
954            c.next()
955                .unwrap()
956                .to_uppercase()
957                .chain(c)
958                .collect::<String>()
959        };
960        for seat in CouncilSeat::ALL {
961            assert_eq!(seat.to_string(), capitalized(seat.as_str()));
962        }
963        use CouncilVote as V;
964        for v in [
965            V::Yes,
966            V::No,
967            V::Abstain,
968            V::Defer,
969            V::Concur,
970            V::Veto,
971            V::Ranked,
972            V::Recused,
973        ] {
974            let serde = serde_json::to_value(v).unwrap();
975            assert_eq!(v.to_string(), capitalized(serde.as_str().unwrap()));
976        }
977    }
978
979    /// Every signed record keeps the rules a record is checked against
980    /// before signing
981    #[test]
982    fn every_signed_record_keeps_the_record_rules() {
983        for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
984            let record: CouncilDecisionRecord =
985                serde_json::from_value(data).unwrap();
986            assert_eq!(record.check(), Ok(()), "{name}");
987        }
988    }
989
990    /// The fixtures are what was signed: each hashes to its entry's
991    /// `data_hash`, and each revised one to its last revision's
992    /// `resulting_data_hash`
993    #[test]
994    fn the_fixtures_are_the_signed_bytes() {
995        let hex = |data: &serde_json::Value| {
996            serde_json::to_value(super::super::data_hash(data))
997                .unwrap()
998                .as_str()
999                .unwrap()
1000                .to_string()
1001        };
1002        let fixtures = fixtures();
1003        let revised = revised_fixtures();
1004        for (id, signed, latest) in SIGNED {
1005            let name = format!("{id}.json");
1006            let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1007            assert_eq!(hex(data), signed, "{id} as signed");
1008            let found = revised.iter().find(|(n, _)| *n == name);
1009            assert_eq!(
1010                found.map(|(_, d)| hex(d)).as_deref(),
1011                latest,
1012                "{id} as revised"
1013            );
1014        }
1015    }
1016
1017    /// The field-by-field shape the Council writes today but no signed
1018    /// entry has yet: a veto, a blind, a ranking, a refused turn.
1019    fn synthetic(name: &str) -> serde_json::Value {
1020        let base = serde_json::json!({
1021            "id": "GOV-2026-0099",
1022            "meeting_id": "00000000-0000-0000-0000-000000000001",
1023            "category": "Policy",
1024            "title": "t",
1025            "rounds": [],
1026            "final_votes": {
1027                "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1028                "engineer": "yes", "steward": "veto"
1029            },
1030            "outcome": "rejected",
1031            "vote_tally": "4-0",
1032            "landmark": false,
1033            "_blind": "00".repeat(32),
1034        });
1035        let mut data = base;
1036        match name {
1037            "veto" => {
1038                data["veto_rationale"] = "because".into();
1039            }
1040            "refusal" => {
1041                data["final_votes"] = serde_json::json!({
1042                    "artist": "defer", "philosopher": "defer", "lawyer": "defer",
1043                    "engineer": "defer", "steward": "defer"
1044                });
1045                data["outcome"] = "deferred".into();
1046                data["vote_tally"] =
1047                    "Deferred: tabled because the API returned a \
1048                     refusal for the Artist's final vote"
1049                        .into();
1050                data["rounds"] = serde_json::json!([{
1051                    "number": 3,
1052                    "round_type": "final_vote",
1053                    "responses": [{
1054                        "role": "artist",
1055                        "position": "No response: the API returned a refusal \
1056                            (category: cyber; explanation: Flagged by a safety \
1057                            classifier.).",
1058                        "rationale": "",
1059                        "questions": [],
1060                        "ready_to_vote": false
1061                    }],
1062                    "steward_contribution": "Tabled by the Steward: the API \
1063                        returned a refusal for the Artist's final vote (…)."
1064                }]);
1065            }
1066            "schedule" => {
1067                data["category"] = "Schedule".into();
1068                data["outcome"] = "approved".into();
1069                data["final_votes"] = serde_json::json!({
1070                    "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
1071                    "engineer": "abstain", "steward": "abstain"
1072                });
1073                data["vote_tally"] =
1074                    "ranked 3/4; the Steward executes the order \
1075                     and does not vote"
1076                        .into();
1077                data["rounds"] = serde_json::json!([{
1078                    "number": 1,
1079                    "round_type": "final_vote",
1080                    "responses": [{
1081                        "role": "lawyer",
1082                        "position": "p",
1083                        "vote": "ranked",
1084                        "rationale": "r",
1085                        "questions": [],
1086                        "ready_to_vote": true,
1087                        "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
1088                    }],
1089                    "steward_contribution": null
1090                }]);
1091                data["agenda_ranking"] = serde_json::json!({
1092                    "rankable": 3,
1093                    "ballots": [
1094                        {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
1095                    ],
1096                    "order": [{
1097                        "number": 3,
1098                        "post_id": "00000000-0000-0000-0000-000000000003",
1099                        "borda": 3, "seats": 1, "position_sum": 1,
1100                        "tied_with_next": false
1101                    }, {
1102                        "number": 1,
1103                        "post_id": "00000000-0000-0000-0000-000000000001",
1104                        "borda": 2, "seats": 1, "position_sum": 2,
1105                        "tied_with_next": false
1106                    }],
1107                    "cut": 2,
1108                    "condorcet_winner": null
1109                });
1110            }
1111            "attachments" => {
1112                data["attachments"] = serde_json::json!([{
1113                    "name": "clerk-thread-summary.md",
1114                    "note": "The Clerk's summary of the thread, given to every seat",
1115                    "content": "## Arguments\n\n[C1] argues for it."
1116                }]);
1117            }
1118            "recusal" => {
1119                data["final_votes"] = serde_json::json!({
1120                    "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1121                    "engineer": "abstain", "steward": "recused"
1122                });
1123                data["outcome"] = "rejected".into();
1124                data["vote_tally"] = "3-0 (Steward recused)".into();
1125                data["rounds"] = serde_json::json!([{
1126                    "number": 2,
1127                    "round_type": "deliberation",
1128                    "responses": [],
1129                    "steward_contribution": "The quoted comment is forged.",
1130                    "steward_emergency": {
1131                        "reason": "An injected comment is steering the seats."
1132                    }
1133                }]);
1134                data["steward_recusal"] = serde_json::json!({
1135                    "votes": [
1136                        {"seat": "artist", "reason": "r",
1137                         "limits_steward_powers": true},
1138                        {"seat": "philosopher", "reason": "r",
1139                         "limits_steward_powers": true},
1140                        {"seat": "lawyer", "reason": "r",
1141                         "limits_steward_powers": true},
1142                        {"seat": "engineer", "reason": "r",
1143                         "limits_steward_powers": false}
1144                    ],
1145                    "recused": true
1146                });
1147                data["abstentions"] = serde_json::json!([
1148                    {"member": "engineer", "rationale": "Conflicted."}
1149                ]);
1150            }
1151            "thinking" => {
1152                data["seat_models"] = serde_json::json!({
1153                    "artist": "claude-opus-4-6",
1154                    "philosopher": "claude-opus-4-6",
1155                    "lawyer": "claude-opus-4-6",
1156                    "engineer": "qwen3.8-35b-a3b"
1157                });
1158                data["rounds"] = serde_json::json!([{
1159                    "number": 1,
1160                    "round_type": "independent",
1161                    "responses": [{
1162                        "role": "lawyer",
1163                        "position": "p",
1164                        "vote": "yes",
1165                        "rationale": [
1166                            {"type": "thinking", "thinking": "First.",
1167                             "signature": "EqQBCkgIARABGAIiQL"},
1168                            {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
1169                            {"type": "thinking", "thinking": "Then, after reading.",
1170                             "signature": "EqQBCkgIARABGAIiQM"}
1171                        ],
1172                        "questions": [],
1173                        "ready_to_vote": false
1174                    }],
1175                    "steward_contribution": null
1176                }]);
1177                data["steward_recusal"] = serde_json::json!({
1178                    "votes": [
1179                        {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
1180                         "rationale": [{"type": "thinking",
1181                            "thinking": "Unsigned, from a local model.",
1182                            "signature": ""}],
1183                         "limits_steward_powers": false}
1184                    ],
1185                    "recused": false
1186                });
1187            }
1188            "recused_refusal" => {
1189                data = synthetic("refusal");
1190                data["final_votes"]["steward"] = "recused".into();
1191                data["rounds"][0]["tabled_by_recused_steward"] = true.into();
1192            }
1193            _ => unreachable!(),
1194        }
1195        data
1196    }
1197
1198    #[test]
1199    fn newer_shapes_are_described_whole() {
1200        for name in [
1201            "veto",
1202            "refusal",
1203            "schedule",
1204            "attachments",
1205            "recusal",
1206            "recused_refusal",
1207            "thinking",
1208        ] {
1209            round_trips(name, &synthetic(name));
1210        }
1211        let tabled =
1212            round_trips("recused_refusal", &synthetic("recused_refusal"));
1213        assert!(tabled.rounds[0].tabled_by_recused_steward);
1214        assert!(
1215            !round_trips("refusal", &synthetic("refusal")).rounds[0]
1216                .tabled_by_recused_steward
1217        );
1218        let refused = round_trips("refusal", &synthetic("refusal"));
1219        assert_eq!(refused.rounds[0].responses[0].vote, None);
1220    }
1221
1222    /// A seat's rationale is its turn's thinking blocks from 0.64, each
1223    /// kept as the API returned it so a signed one can be replayed
1224    #[test]
1225    fn a_thinking_rationale_reads_back() {
1226        let record = round_trips("thinking", &synthetic("thinking"));
1227        let models = record.seat_models.as_ref().unwrap();
1228        assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
1229        assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
1230
1231        let response = &record.rounds[0].responses[0];
1232        let rationale = response.rationale.value().unwrap();
1233        assert!(matches!(rationale, Rationale::Thinking(_)));
1234        assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
1235        assert_eq!(rationale.redacted_blocks(), 1);
1236
1237        let vote = &record.steward_recusal.unwrap().votes[0];
1238        let thought = vote.rationale.as_ref().unwrap().value().unwrap();
1239        assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
1240
1241        assert_eq!(
1242            rationale.to_string(),
1243            "First.\n\n[reasoning withheld]\n\nThen, after reading."
1244        );
1245        assert!(!rationale.is_empty());
1246        assert!(Rationale::Legacy(" ".into()).is_empty());
1247
1248        let legacy = round_trips("schedule", &synthetic("schedule"));
1249        let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
1250        assert_eq!(legacy, &Rationale::Legacy("r".into()));
1251    }
1252
1253    #[test]
1254    fn a_thinking_rationale_can_be_redacted() {
1255        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1256        let redacted = super::super::redact_data(
1257            &synthetic("thinking"),
1258            &["/rounds/0/responses/0/rationale".into()],
1259            &amd,
1260            Blind::random(),
1261        )
1262        .unwrap();
1263        let record = round_trips("thinking", &redacted);
1264        assert_eq!(
1265            record.rounds[0].responses[0].rationale.redacted_by(),
1266            Some(&amd)
1267        );
1268    }
1269
1270    /// Recusal, abstention rationales and a recused Steward's emergency
1271    /// note, as the Council writes them from 0.48
1272    #[test]
1273    fn a_recused_decision_reads_back() {
1274        let record = round_trips("recusal", &synthetic("recusal"));
1275        assert_eq!(record.final_votes.steward, CouncilVote::Recused);
1276        let recusal = record.steward_recusal.unwrap();
1277        assert!(recusal.recused);
1278        assert_eq!(
1279            recusal
1280                .votes
1281                .iter()
1282                .filter(|v| v.limits_steward_powers)
1283                .count(),
1284            3
1285        );
1286        assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
1287        let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
1288        assert_eq!(
1289            emergency.reason.value().map(String::as_str),
1290            Some("An injected comment is steering the seats.")
1291        );
1292        // Absent everywhere they are not written: earlier records keep
1293        // their bytes.
1294        let veto = round_trips("veto", &synthetic("veto"));
1295        assert_eq!(veto.steward_recusal, None);
1296        assert!(veto.abstentions.is_empty());
1297    }
1298
1299    #[test]
1300    fn a_recused_decision_reads_in_order() {
1301        let data = synthetic("recusal");
1302        let keys = |v: &serde_json::Value| -> Vec<String> {
1303            super::super::reading::ordered(v.as_object().unwrap())
1304                .into_iter()
1305                .map(|(k, _)| k.clone())
1306                .collect()
1307        };
1308        let top = keys(&data);
1309        let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1310        assert!(at("rounds") < at("steward_recusal"));
1311        assert!(at("steward_recusal") < at("final_votes"));
1312        assert!(at("final_votes") < at("abstentions"));
1313        assert!(at("abstentions") < at("vote_tally"));
1314        assert_eq!(
1315            keys(&data["steward_recusal"]["votes"][0]),
1316            ["seat", "reason", "limits_steward_powers"]
1317        );
1318        assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
1319        assert_eq!(
1320            keys(&data["rounds"][0]),
1321            [
1322                "number",
1323                "round_type",
1324                "steward_emergency",
1325                "steward_contribution",
1326                "responses"
1327            ]
1328        );
1329        assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
1330    }
1331
1332    /// GOV-2026-0001 through the real [`redact_data`](super::super::redact_data)
1333    #[test]
1334    fn a_redacted_record_parses_with_the_redactions_in_place() {
1335        let (name, data) = fixtures().swap_remove(0);
1336        assert_eq!(name, "GOV-2026-0001.json");
1337        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1338        let fields = [
1339            "/title",
1340            "/vote_tally",
1341            "/rounds/0/responses/0/position",
1342            "/rounds/0/responses/0/rationale",
1343            "/rounds/0/responses/0/raw_text",
1344            "/rounds/0/responses/1/questions",
1345            "/rounds/0/responses/2/questions/1",
1346            "/rounds/1/steward_contribution",
1347        ]
1348        .map(String::from);
1349        let redacted =
1350            super::super::redact_data(&data, &fields, &amd, Blind::random())
1351                .unwrap();
1352        let record = round_trips(&name, &redacted);
1353
1354        let gone = Redactable::Redacted(amd.clone());
1355        assert_eq!(record.title, gone);
1356        assert_eq!(record.vote_tally, gone);
1357        let [first, second, third, ..] = &record.rounds[0].responses[..] else {
1358            panic!("round 1 has four responses");
1359        };
1360        assert_eq!(first.position, gone);
1361        assert_eq!(first.rationale.redacted_by(), Some(&amd));
1362        assert_eq!(first.raw_text, Some(gone.clone()));
1363        assert!(!first.questions.is_redacted());
1364        assert_eq!(second.questions.redacted_by(), Some(&amd));
1365        let questions = third.questions.value().unwrap();
1366        assert!(!questions[0].is_redacted());
1367        assert_eq!(questions[1], gone);
1368        assert_eq!(record.rounds[1].steward_contribution, Some(gone));
1369        assert!(record.blind.is_some());
1370    }
1371
1372    #[test]
1373    fn an_attachment_can_be_redacted() {
1374        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1375        let redacted = super::super::redact_data(
1376            &synthetic("attachments"),
1377            &["/attachments/0/content".into()],
1378            &amd,
1379            Blind::random(),
1380        )
1381        .unwrap();
1382        let record = round_trips("attachments", &redacted);
1383        assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
1384    }
1385
1386    /// Structural fields stay plain: redacting one is a shape this
1387    /// version doesn't describe, and says so rather than guessing
1388    #[test]
1389    fn a_redacted_vote_is_an_error() {
1390        let (_, data) = fixtures().swap_remove(0);
1391        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1392        let redacted = super::super::redact_data(
1393            &data,
1394            &["/final_votes/artist".into()],
1395            &amd,
1396            Blind::random(),
1397        )
1398        .unwrap();
1399        assert!(
1400            serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1401        );
1402    }
1403
1404    #[cfg(feature = "schemars")]
1405    #[test]
1406    fn schema_is_ref_free() {
1407        let schema =
1408            crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1409        let text = schema.to_string();
1410        assert!(!text.contains("$ref"), "{text}");
1411        assert!(!text.contains("$defs"), "{text}");
1412        let plain = serde_json::to_string(&schemars::schema_for!(
1413            CouncilDecisionRecord
1414        ))
1415        .unwrap();
1416        assert!(!plain.contains("$ref"), "{plain}");
1417        assert!(!plain.contains("$defs"), "{plain}");
1418    }
1419}