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