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