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