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