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