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};
19use crate::enums::{DecisionOutcome, ProposalCategory, RoundType};
20use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
21
22/// The `data` of a `council_decision` entry. See the [module docs](self).
23///
24/// The entry's tags are on the entry, not in `data`.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27#[cfg_attr(feature = "schemars", schemars(inline))]
28pub struct CouncilDecisionRecord {
29    /// This entry's own id
30    pub id: GovernanceLogId,
31    /// The sitting that decided it
32    pub meeting_id: CouncilMeetingId,
33    pub category: DecisionCategory,
34    /// The agenda item's title
35    pub title: Redactable<String>,
36    /// Every round of deliberation, in order. The last is the
37    /// `final_vote` round unless the item was tabled earlier.
38    pub rounds: Vec<CouncilRound>,
39    pub final_votes: FinalVotes,
40    /// Decided by `category`'s threshold over `final_votes`; a Steward
41    /// `veto` is always `rejected`, a tabled item `deferred`, and a
42    /// `Schedule` item `approved` once ranked
43    pub outcome: DecisionOutcome,
44    /// For display only. `"<yes>-<no>"` with `concur` counted as yes
45    /// (`"5-0"`, `"0-4"`); `"Deferred"` for a tabled item, optionally
46    /// followed by `": <why>"`; a sentence on a `Schedule` item.
47    pub vote_tally: Redactable<String>,
48    /// Flagged by the Steward as significant for readers and for
49    /// precedent weight. Changes nothing procedurally.
50    pub landmark: bool,
51    /// The Steward's rationale for a `veto` (Constitution Art. IV § 5).
52    /// Written since 2026-09-22; no veto had been cast before then.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub veto_rationale: Option<Redactable<String>>,
55    /// On a `Schedule` item, the seats' aggregated ranking of the docket
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub agenda_ranking: Option<AgendaRanking>,
58    /// Whether the item limits the Steward's powers, as the four seats
59    /// determined it before round 1 (Constitution Art. IV § 3,
60    /// GOV-2026-0009). Absent on items decided before it was recorded, and
61    /// on `Schedule` and `Emergency` items. (0.48)
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub steward_recusal: Option<StewardRecusal>,
64    /// The one-sentence rationale for each abstention in `final_votes`
65    /// (Art. IV § 3). (0.48)
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub abstentions: Vec<Abstention>,
68    /// Entries this decision retires as precedent. Only GOV-2026-0005
69    /// carries one, added by a migration rather than by the Council; an
70    /// amendment naming the same entry decides its `standing` instead.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub overrules: Vec<GovernanceLogId>,
73    /// What the seats were shown beyond the proposal: the Clerk's
74    /// summaries and everything a seat had read to it. (0.42)
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub attachments: Vec<CouncilAttachment>,
77    /// The model each seat ran on. Absent from entries before 0.64.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub seat_models: Option<SeatModels>,
80    /// The entry's blinding value (see [`blind_data`](super::blind_data)).
81    /// Absent from entries that predate blinding.
82    #[serde(
83        rename = "_blind",
84        default,
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub blind: Option<Blind>,
88}
89
90/// Material put before the Council, inline as markdown
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
93#[cfg_attr(feature = "schemars", schemars(inline))]
94pub struct CouncilAttachment {
95    /// A file name, unique within the record: `clerk-thread-summary.md`
96    pub name: String,
97    /// What it is and who saw it
98    pub note: String,
99    pub content: Redactable<String>,
100}
101
102/// An agenda item's category, which sets the vote it needs
103/// (Constitution v0.4 Art. IV § 3).
104///
105/// An `abstain` or `recused` leaves the denominator. A Steward `veto`
106/// rejects any item the Steward is not recused from; a recused Steward has
107/// no vote and no veto.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
110#[cfg_attr(feature = "schemars", schemars(inline))]
111pub enum DecisionCategory {
112    /// Three `yes`: an absolute count, found among the four seats when the
113    /// Steward is recused
114    Routine,
115    /// Four `yes` of five including the Steward (`yes` or `concur`); with
116    /// the Steward recused, all four seats `yes`
117    Policy,
118    /// Unanimous among those voting, with no fewer than four voting.
119    /// Before GOV-2026-0009 (2026-09-26), 5 of 5 `yes`
120    Constitutional,
121    /// The Steward alone, subject to 72-hour review
122    Emergency,
123    /// The Council's scheduling thread: the four seats rank the docket
124    /// (Borda count, see `agenda_ranking`) and the Steward executes the
125    /// order without voting
126    Schedule,
127}
128
129/// The two spell the same five categories (0.74)
130impl From<ProposalCategory> for DecisionCategory {
131    fn from(category: ProposalCategory) -> Self {
132        match category {
133            ProposalCategory::Routine => Self::Routine,
134            ProposalCategory::Policy => Self::Policy,
135            ProposalCategory::Constitutional => Self::Constitutional,
136            ProposalCategory::Emergency => Self::Emergency,
137            ProposalCategory::Schedule => Self::Schedule,
138        }
139    }
140}
141
142impl From<DecisionCategory> for ProposalCategory {
143    fn from(category: DecisionCategory) -> Self {
144        match category {
145            DecisionCategory::Routine => Self::Routine,
146            DecisionCategory::Policy => Self::Policy,
147            DecisionCategory::Constitutional => Self::Constitutional,
148            DecisionCategory::Emergency => Self::Emergency,
149            DecisionCategory::Schedule => Self::Schedule,
150        }
151    }
152}
153
154/// The tag a recused deferral carries on the record, which the next
155/// hearing's guard looks for (0.74)
156pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
157
158/// The tag every decision with the Steward recused carries (0.74)
159pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
160
161/// One round of deliberation
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
164#[cfg_attr(feature = "schemars", schemars(inline))]
165pub struct CouncilRound {
166    /// 1-indexed
167    pub number: u32,
168    /// `independent` (round 1: no seat sees another's response or any
169    /// Steward note), `deliberation` (seats see prior rounds), or
170    /// `final_vote`
171    pub round_type: RoundType,
172    /// One per seat that took its turn. A tabled round can hold fewer
173    /// than four.
174    pub responses: Vec<SeatResponse>,
175    /// The Steward's notes to the seats for this round, or the reason an
176    /// item was tabled
177    pub steward_contribution: Option<Redactable<String>>,
178    /// Present when `steward_contribution` came from a recused Steward
179    /// under emergency authority (Art. IV § 2), which puts the item under
180    /// mandatory Council review within 72 hours. (0.48)
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub steward_emergency: Option<StewardEmergency>,
183    /// The item was tabled here by a recused Steward, over an API refusal
184    /// on a seat's final vote: refusal handling, the one tabling open to
185    /// a recused Steward. `steward_contribution` says which refusal.
186    /// (0.48)
187    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
188    pub tabled_by_recused_steward: bool,
189}
190
191/// A recused Steward's note to the seats, made as an emergency act
192/// (Constitution Art. IV § 2)
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[cfg_attr(feature = "schemars", schemars(inline))]
196pub struct StewardEmergency {
197    /// The Steward's one-sentence reason, given before the note was read
198    pub reason: Redactable<String>,
199}
200
201/// The four seats' determination of whether an item limits the Steward's
202/// powers (Constitution Art. IV § 3, GOV-2026-0009)
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
205#[cfg_attr(feature = "schemars", schemars(inline))]
206pub struct StewardRecusal {
207    /// One per seat that answered in round 1; a seat whose turn the API
208    /// refused has none
209    pub votes: Vec<RecusalVote>,
210    /// Three of the four seats said it does. The Steward then has no
211    /// deliberation, vote or veto on the item, and `final_votes` records
212    /// `recused`.
213    pub recused: bool,
214}
215
216/// One seat's answer to whether an item limits the Steward's powers
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
219#[cfg_attr(feature = "schemars", schemars(inline))]
220pub struct RecusalVote {
221    pub seat: CouncilSeat,
222    /// The seat's explanation for the record; before 0.64, its reasoning,
223    /// written before its answer
224    pub reason: Redactable<String>,
225    /// The seat's thinking before it answered (0.64)
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub rationale: Option<Redactable<Rationale>>,
228    pub limits_steward_powers: bool,
229}
230
231/// An abstention and its one-sentence rationale (Art. IV § 3)
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
234#[cfg_attr(feature = "schemars", schemars(inline))]
235pub struct Abstention {
236    pub member: CouncilMember,
237    pub rationale: Redactable<String>,
238}
239
240/// Any of the five Council members: a [`CouncilSeat`] or the Steward
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
242#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
243#[cfg_attr(feature = "schemars", schemars(inline))]
244#[serde(rename_all = "snake_case")]
245pub enum CouncilMember {
246    Artist,
247    Philosopher,
248    Lawyer,
249    Engineer,
250    Steward,
251}
252
253impl From<CouncilSeat> for CouncilMember {
254    fn from(seat: CouncilSeat) -> Self {
255        match seat {
256            CouncilSeat::Artist => Self::Artist,
257            CouncilSeat::Philosopher => Self::Philosopher,
258            CouncilSeat::Lawyer => Self::Lawyer,
259            CouncilSeat::Engineer => Self::Engineer,
260        }
261    }
262}
263
264/// One seat's turn in a round
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
267#[cfg_attr(feature = "schemars", schemars(inline))]
268pub struct SeatResponse {
269    pub role: CouncilSeat,
270    /// The seat's statement for the record. On a turn the API refused,
271    /// `"No response: the API returned a refusal (…)."`
272    pub position: Redactable<String>,
273    /// The seat's vote as of this round; only the `final_vote` round's
274    /// counts. Absent only when the API returned a refusal for the turn:
275    /// a refusal is recorded as a fact, never as a vote.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub vote: Option<CouncilVote>,
278    /// Why the seat said it. Empty on a refused turn.
279    pub rationale: Redactable<Rationale>,
280    /// Questions the seat put to the others or the Steward. A redaction
281    /// can take one question or the whole list.
282    pub questions: Redactable<Vec<Redactable<String>>>,
283    /// Whether the seat said it was ready for the final vote
284    pub ready_to_vote: bool,
285    /// On a `Schedule` item's final round, the seat's ballot
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub ranking: Option<SeatRanking>,
288    /// The model's raw reply text. Only in entries signed before
289    /// 2026-09-18; from GOV-2026-0003 on it duplicates `rationale`.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub raw_text: Option<Redactable<String>>,
292}
293
294/// Why a seat said what it said.
295///
296/// Until 0.64 the seats wrote their reasoning into a `reasoning` field of
297/// the decision tool, which is not something a model should be asked to
298/// do: Anthropic's `reasoning_extraction` safeguard declines exactly that
299/// ([docs]). The seats now think before they answer, and the record keeps
300/// what the API returns for that thinking.
301///
302/// [docs]: https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#keep-reasoning-in-thinking-blocks
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
305#[cfg_attr(feature = "schemars", schemars(inline))]
306#[serde(untagged)]
307pub enum Rationale {
308    /// What the seat wrote in the decision tool's `reasoning` field
309    Legacy(String),
310    /// The thinking blocks of the seat's turn, in order and as the API
311    /// returned them. On Anthropic models each is a summary of the model's
312    /// reasoning, written by Anthropic, with a `signature` over the full
313    /// reasoning; the [`SeatModels`] entry says which model to replay it
314    /// against. Local models' blocks are their own reasoning, unsigned.
315    Thinking(Vec<Thought>),
316}
317
318impl Rationale {
319    /// The text of every thinking block, in order. A legacy rationale is
320    /// one item.
321    pub fn texts(&self) -> Vec<&str> {
322        match self {
323            Self::Legacy(text) => vec![text.as_str()],
324            Self::Thinking(thoughts) => {
325                thoughts.iter().filter_map(Thought::text).collect()
326            }
327        }
328    }
329
330    /// Thinking the API withheld: `redacted_thinking` blocks
331    pub fn redacted_blocks(&self) -> usize {
332        match self {
333            Self::Legacy(_) => 0,
334            Self::Thinking(thoughts) => {
335                thoughts.iter().filter(|t| t.text().is_none()).count()
336            }
337        }
338    }
339
340    /// No words: an empty legacy string, or no thinking at all
341    pub fn is_empty(&self) -> bool {
342        self.texts().iter().all(|t| t.trim().is_empty())
343            && self.redacted_blocks() == 0
344    }
345}
346
347/// The text of each block, a blank line apart; withheld blocks say so
348impl std::fmt::Display for Rationale {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        let Self::Thinking(thoughts) = self else {
351            return f.write_str(&self.texts().concat());
352        };
353        for (i, thought) in thoughts.iter().enumerate() {
354            if i > 0 {
355                f.write_str("\n\n")?;
356            }
357            f.write_str(
358                thought.text().unwrap_or("[reasoning withheld]").trim_end(),
359            )?;
360        }
361        Ok(())
362    }
363}
364
365impl From<String> for Rationale {
366    fn from(text: String) -> Self {
367        Self::Legacy(text)
368    }
369}
370
371impl From<&str> for Rationale {
372    fn from(text: &str) -> Self {
373        Self::Legacy(text.to_owned())
374    }
375}
376
377impl From<Vec<Thought>> for Rationale {
378    fn from(thoughts: Vec<Thought>) -> Self {
379        Self::Thinking(thoughts)
380    }
381}
382
383/// The thinking blocks of `content`; anything else in it is not thinking
384impl From<&Content> for Rationale {
385    fn from(content: &Content) -> Self {
386        Self::Thinking(content.thoughts().collect())
387    }
388}
389
390/// The model each seat ran on
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
393#[cfg_attr(feature = "schemars", schemars(inline))]
394pub struct SeatModels {
395    pub artist: Model,
396    pub philosopher: Model,
397    pub lawyer: Model,
398    pub engineer: Model,
399}
400
401impl SeatModels {
402    pub fn get(&self, seat: CouncilSeat) -> &Model {
403        match seat {
404            CouncilSeat::Artist => &self.artist,
405            CouncilSeat::Philosopher => &self.philosopher,
406            CouncilSeat::Lawyer => &self.lawyer,
407            CouncilSeat::Engineer => &self.engineer,
408        }
409    }
410}
411
412/// A voting Council seat. The fifth vote is the Steward's.
413// Ordered as declared (0.74). A plain comment: the doc comment is schema
414// text, which agora pins byte for byte.
415#[derive(
416    Debug,
417    Clone,
418    Copy,
419    PartialEq,
420    Eq,
421    PartialOrd,
422    Ord,
423    Hash,
424    Serialize,
425    Deserialize,
426)]
427#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
428#[cfg_attr(feature = "schemars", schemars(inline))]
429#[serde(rename_all = "snake_case")]
430pub enum CouncilSeat {
431    Artist,
432    Philosopher,
433    Lawyer,
434    Engineer,
435}
436
437impl CouncilSeat {
438    /// Every seat, in declaration order (0.74)
439    pub const ALL: [Self; 4] = [
440        Self::Artist,
441        Self::Philosopher,
442        Self::Lawyer,
443        Self::Engineer,
444    ];
445
446    /// The `snake_case` form, as serde spells it: `"artist"` (0.74)
447    pub const fn as_str(self) -> &'static str {
448        match self {
449            Self::Artist => "artist",
450            Self::Philosopher => "philosopher",
451            Self::Lawyer => "lawyer",
452            Self::Engineer => "engineer",
453        }
454    }
455
456    /// The seat's name as prose writes it: `"Artist"` (0.74)
457    pub const fn name(self) -> &'static str {
458        match self {
459            Self::Artist => "Artist",
460            Self::Philosopher => "Philosopher",
461            Self::Lawyer => "Lawyer",
462            Self::Engineer => "Engineer",
463        }
464    }
465}
466
467/// A vote cast by a seat or the Steward
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
469#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
470#[cfg_attr(feature = "schemars", schemars(inline))]
471#[serde(rename_all = "snake_case")]
472pub enum CouncilVote {
473    Yes,
474    No,
475    /// Also recorded for a seat with no final response, and for the
476    /// Steward on a `Schedule` item (who executes the order, not votes on
477    /// it)
478    Abstain,
479    /// Tabled: every vote is `defer` when the Steward tables an item
480    Defer,
481    /// The Steward's agreement with the seats' majority; counts as yes
482    Concur,
483    /// The Steward's veto; rejects whatever the others voted. See
484    /// `veto_rationale`.
485    Veto,
486    /// A seat ranked a `Schedule` item's docket instead of voting; see
487    /// `agenda_ranking`
488    Ranked,
489    /// The Steward, on an item the seats found limits the Steward's
490    /// powers: no vote and no veto. See `steward_recusal`. (0.48)
491    Recused,
492}
493
494/// The votes that decided the item
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
497#[cfg_attr(feature = "schemars", schemars(inline))]
498pub struct FinalVotes {
499    pub artist: CouncilVote,
500    pub philosopher: CouncilVote,
501    pub lawyer: CouncilVote,
502    pub engineer: CouncilVote,
503    pub steward: CouncilVote,
504}
505
506/// A seat's ballot on a `Schedule` item
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
509#[cfg_attr(feature = "schemars", schemars(inline))]
510pub struct SeatRanking {
511    /// How many items the seat judges this sitting can hear
512    pub sitting_capacity: u32,
513    /// Docket P-numbers (`P3` is `3`), most important first. May be
514    /// partial.
515    pub ranking: Vec<u32>,
516}
517
518/// The aggregated ranking of a `Schedule` item: a Borda count on the
519/// docket's scale, so a first choice scores `rankable` however many
520/// items the seat ranked, and an unranked item scores 0
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
523#[cfg_attr(feature = "schemars", schemars(inline))]
524pub struct AgendaRanking {
525    /// How many candidates could be ranked: the Borda scale
526    pub rankable: u32,
527    pub ballots: Vec<Ballot>,
528    /// Every candidate at least one seat ranked, best first: by Borda,
529    /// then more seats, then lower mean position, then P-number
530    pub order: Vec<PlacedProposal>,
531    /// How many of `order` make the docket: the median of the seats'
532    /// capacities (the lower middle one when even)
533    pub cut: u32,
534    /// The P-number that beats every other head to head, if any. `null`
535    /// with a non-empty `order` means a cycle or a tie at the top: the
536    /// Borda order is then a tiebreak, not a consensus.
537    pub condorcet_winner: Option<u32>,
538}
539
540/// One seat's ballot as aggregated
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543#[cfg_attr(feature = "schemars", schemars(inline))]
544pub struct Ballot {
545    pub seat: CouncilSeat,
546    /// See [`SeatRanking`]
547    pub sitting_capacity: u32,
548    /// See [`SeatRanking`]
549    pub ranking: Vec<u32>,
550}
551
552/// A proposal's place in an [`AgendaRanking`]
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
555#[cfg_attr(feature = "schemars", schemars(inline))]
556pub struct PlacedProposal {
557    /// The P-number the seats ranked it by
558    pub number: u32,
559    /// The proposal
560    pub post_id: PostId,
561    pub borda: u32,
562    /// How many seats ranked it at all
563    pub seats: u32,
564    /// Sum of its 1-based positions over those seats; the mean position
565    /// is `position_sum / seats`. Integers only, because the entry is
566    /// signed and a float has no canonical text form.
567    pub position_sum: u32,
568    /// Tied with the next entry on every criterion, so the order between
569    /// the two is by P-number and arbitrary
570    pub tied_with_next: bool,
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    /// Every `council_decision` signed on production, as stored
578    fn fixtures() -> Vec<(String, serde_json::Value)> {
579        let dir = concat!(
580            env!("CARGO_MANIFEST_DIR"),
581            "/tests/fixtures/council_decisions"
582        );
583        let mut out: Vec<_> = std::fs::read_dir(dir)
584            .unwrap()
585            .map(|e| e.unwrap().path())
586            .filter(|p| p.extension().is_some_and(|x| x == "json"))
587            .map(|p| {
588                let text = std::fs::read_to_string(&p).unwrap();
589                (
590                    p.file_name().unwrap().to_string_lossy().into_owned(),
591                    serde_json::from_str(&text).unwrap(),
592                )
593            })
594            .collect();
595        out.sort_by(|a, b| a.0.cmp(&b.0));
596        out
597    }
598
599    /// Parses `data` and checks re-serializing it gives back `data`
600    /// exactly: a key the types don't describe would be dropped here.
601    fn round_trips(
602        name: &str,
603        data: &serde_json::Value,
604    ) -> CouncilDecisionRecord {
605        let record: CouncilDecisionRecord =
606            serde_json::from_value(data.clone())
607                .unwrap_or_else(|e| panic!("{name}: {e}"));
608        assert_eq!(
609            &serde_json::to_value(&record).unwrap(),
610            data,
611            "{name}: the typed record loses or changes something"
612        );
613        record
614    }
615
616    #[test]
617    fn every_signed_decision_is_described_whole() {
618        let fixtures = fixtures();
619        assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
620        for (name, data) in &fixtures {
621            let record = round_trips(name, data);
622            assert_eq!(format!("{}.json", record.id), *name);
623        }
624    }
625
626    /// The field-by-field shape the Council writes today but no signed
627    /// entry has yet: a veto, a blind, a ranking, a refused turn.
628    fn synthetic(name: &str) -> serde_json::Value {
629        let base = serde_json::json!({
630            "id": "GOV-2026-0099",
631            "meeting_id": "00000000-0000-0000-0000-000000000001",
632            "category": "Policy",
633            "title": "t",
634            "rounds": [],
635            "final_votes": {
636                "artist": "yes", "philosopher": "yes", "lawyer": "yes",
637                "engineer": "yes", "steward": "veto"
638            },
639            "outcome": "rejected",
640            "vote_tally": "4-0",
641            "landmark": false,
642            "_blind": "00".repeat(32),
643        });
644        let mut data = base;
645        match name {
646            "veto" => {
647                data["veto_rationale"] = "because".into();
648            }
649            "refusal" => {
650                data["final_votes"] = serde_json::json!({
651                    "artist": "defer", "philosopher": "defer", "lawyer": "defer",
652                    "engineer": "defer", "steward": "defer"
653                });
654                data["outcome"] = "deferred".into();
655                data["vote_tally"] =
656                    "Deferred: tabled because the API returned a \
657                     refusal for the Artist's final vote"
658                        .into();
659                data["rounds"] = serde_json::json!([{
660                    "number": 3,
661                    "round_type": "final_vote",
662                    "responses": [{
663                        "role": "artist",
664                        "position": "No response: the API returned a refusal \
665                            (category: cyber; explanation: Flagged by a safety \
666                            classifier.).",
667                        "rationale": "",
668                        "questions": [],
669                        "ready_to_vote": false
670                    }],
671                    "steward_contribution": "Tabled by the Steward: the API \
672                        returned a refusal for the Artist's final vote (…)."
673                }]);
674            }
675            "schedule" => {
676                data["category"] = "Schedule".into();
677                data["outcome"] = "approved".into();
678                data["final_votes"] = serde_json::json!({
679                    "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
680                    "engineer": "abstain", "steward": "abstain"
681                });
682                data["vote_tally"] =
683                    "ranked 3/4; the Steward executes the order \
684                     and does not vote"
685                        .into();
686                data["rounds"] = serde_json::json!([{
687                    "number": 1,
688                    "round_type": "final_vote",
689                    "responses": [{
690                        "role": "lawyer",
691                        "position": "p",
692                        "vote": "ranked",
693                        "rationale": "r",
694                        "questions": [],
695                        "ready_to_vote": true,
696                        "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
697                    }],
698                    "steward_contribution": null
699                }]);
700                data["agenda_ranking"] = serde_json::json!({
701                    "rankable": 3,
702                    "ballots": [
703                        {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
704                    ],
705                    "order": [{
706                        "number": 3,
707                        "post_id": "00000000-0000-0000-0000-000000000003",
708                        "borda": 3, "seats": 1, "position_sum": 1,
709                        "tied_with_next": false
710                    }, {
711                        "number": 1,
712                        "post_id": "00000000-0000-0000-0000-000000000001",
713                        "borda": 2, "seats": 1, "position_sum": 2,
714                        "tied_with_next": false
715                    }],
716                    "cut": 2,
717                    "condorcet_winner": null
718                });
719            }
720            "attachments" => {
721                data["attachments"] = serde_json::json!([{
722                    "name": "clerk-thread-summary.md",
723                    "note": "The Clerk's summary of the thread, given to every seat",
724                    "content": "## Arguments\n\n[C1] argues for it."
725                }]);
726            }
727            "recusal" => {
728                data["final_votes"] = serde_json::json!({
729                    "artist": "yes", "philosopher": "yes", "lawyer": "yes",
730                    "engineer": "abstain", "steward": "recused"
731                });
732                data["outcome"] = "rejected".into();
733                data["vote_tally"] = "3-0 (Steward recused)".into();
734                data["rounds"] = serde_json::json!([{
735                    "number": 2,
736                    "round_type": "deliberation",
737                    "responses": [],
738                    "steward_contribution": "The quoted comment is forged.",
739                    "steward_emergency": {
740                        "reason": "An injected comment is steering the seats."
741                    }
742                }]);
743                data["steward_recusal"] = serde_json::json!({
744                    "votes": [
745                        {"seat": "artist", "reason": "r",
746                         "limits_steward_powers": true},
747                        {"seat": "philosopher", "reason": "r",
748                         "limits_steward_powers": true},
749                        {"seat": "lawyer", "reason": "r",
750                         "limits_steward_powers": true},
751                        {"seat": "engineer", "reason": "r",
752                         "limits_steward_powers": false}
753                    ],
754                    "recused": true
755                });
756                data["abstentions"] = serde_json::json!([
757                    {"member": "engineer", "rationale": "Conflicted."}
758                ]);
759            }
760            "thinking" => {
761                data["seat_models"] = serde_json::json!({
762                    "artist": "claude-opus-4-6",
763                    "philosopher": "claude-opus-4-6",
764                    "lawyer": "claude-opus-4-6",
765                    "engineer": "qwen3.8-35b-a3b"
766                });
767                data["rounds"] = serde_json::json!([{
768                    "number": 1,
769                    "round_type": "independent",
770                    "responses": [{
771                        "role": "lawyer",
772                        "position": "p",
773                        "vote": "yes",
774                        "rationale": [
775                            {"type": "thinking", "thinking": "First.",
776                             "signature": "EqQBCkgIARABGAIiQL"},
777                            {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
778                            {"type": "thinking", "thinking": "Then, after reading.",
779                             "signature": "EqQBCkgIARABGAIiQM"}
780                        ],
781                        "questions": [],
782                        "ready_to_vote": false
783                    }],
784                    "steward_contribution": null
785                }]);
786                data["steward_recusal"] = serde_json::json!({
787                    "votes": [
788                        {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
789                         "rationale": [{"type": "thinking",
790                            "thinking": "Unsigned, from a local model.",
791                            "signature": ""}],
792                         "limits_steward_powers": false}
793                    ],
794                    "recused": false
795                });
796            }
797            "recused_refusal" => {
798                data = synthetic("refusal");
799                data["final_votes"]["steward"] = "recused".into();
800                data["rounds"][0]["tabled_by_recused_steward"] = true.into();
801            }
802            _ => unreachable!(),
803        }
804        data
805    }
806
807    #[test]
808    fn newer_shapes_are_described_whole() {
809        for name in [
810            "veto",
811            "refusal",
812            "schedule",
813            "attachments",
814            "recusal",
815            "recused_refusal",
816            "thinking",
817        ] {
818            round_trips(name, &synthetic(name));
819        }
820        let tabled =
821            round_trips("recused_refusal", &synthetic("recused_refusal"));
822        assert!(tabled.rounds[0].tabled_by_recused_steward);
823        assert!(
824            !round_trips("refusal", &synthetic("refusal")).rounds[0]
825                .tabled_by_recused_steward
826        );
827        let refused = round_trips("refusal", &synthetic("refusal"));
828        assert_eq!(refused.rounds[0].responses[0].vote, None);
829    }
830
831    /// A seat's rationale is its turn's thinking blocks from 0.64, each
832    /// kept as the API returned it so a signed one can be replayed
833    #[test]
834    fn a_thinking_rationale_reads_back() {
835        let record = round_trips("thinking", &synthetic("thinking"));
836        let models = record.seat_models.as_ref().unwrap();
837        assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
838        assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
839
840        let response = &record.rounds[0].responses[0];
841        let rationale = response.rationale.value().unwrap();
842        assert!(matches!(rationale, Rationale::Thinking(_)));
843        assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
844        assert_eq!(rationale.redacted_blocks(), 1);
845
846        let vote = &record.steward_recusal.unwrap().votes[0];
847        let thought = vote.rationale.as_ref().unwrap().value().unwrap();
848        assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
849
850        assert_eq!(
851            rationale.to_string(),
852            "First.\n\n[reasoning withheld]\n\nThen, after reading."
853        );
854        assert!(!rationale.is_empty());
855        assert!(Rationale::Legacy(" ".into()).is_empty());
856
857        let legacy = round_trips("schedule", &synthetic("schedule"));
858        let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
859        assert_eq!(legacy, &Rationale::Legacy("r".into()));
860    }
861
862    #[test]
863    fn a_thinking_rationale_can_be_redacted() {
864        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
865        let redacted = super::super::redact_data(
866            &synthetic("thinking"),
867            &["/rounds/0/responses/0/rationale".into()],
868            &amd,
869            Blind::random(),
870        )
871        .unwrap();
872        let record = round_trips("thinking", &redacted);
873        assert_eq!(
874            record.rounds[0].responses[0].rationale.redacted_by(),
875            Some(&amd)
876        );
877    }
878
879    /// Recusal, abstention rationales and a recused Steward's emergency
880    /// note, as the Council writes them from 0.48
881    #[test]
882    fn a_recused_decision_reads_back() {
883        let record = round_trips("recusal", &synthetic("recusal"));
884        assert_eq!(record.final_votes.steward, CouncilVote::Recused);
885        let recusal = record.steward_recusal.unwrap();
886        assert!(recusal.recused);
887        assert_eq!(
888            recusal
889                .votes
890                .iter()
891                .filter(|v| v.limits_steward_powers)
892                .count(),
893            3
894        );
895        assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
896        let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
897        assert_eq!(
898            emergency.reason.value().map(String::as_str),
899            Some("An injected comment is steering the seats.")
900        );
901        // Absent everywhere they are not written: earlier records keep
902        // their bytes.
903        let veto = round_trips("veto", &synthetic("veto"));
904        assert_eq!(veto.steward_recusal, None);
905        assert!(veto.abstentions.is_empty());
906    }
907
908    #[test]
909    fn a_recused_decision_reads_in_order() {
910        let data = synthetic("recusal");
911        let keys = |v: &serde_json::Value| -> Vec<String> {
912            super::super::reading::ordered(v.as_object().unwrap())
913                .into_iter()
914                .map(|(k, _)| k.clone())
915                .collect()
916        };
917        let top = keys(&data);
918        let at = |k: &str| top.iter().position(|x| x == k).unwrap();
919        assert!(at("rounds") < at("steward_recusal"));
920        assert!(at("steward_recusal") < at("final_votes"));
921        assert!(at("final_votes") < at("abstentions"));
922        assert!(at("abstentions") < at("vote_tally"));
923        assert_eq!(
924            keys(&data["steward_recusal"]["votes"][0]),
925            ["seat", "reason", "limits_steward_powers"]
926        );
927        assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
928        assert_eq!(
929            keys(&data["rounds"][0]),
930            [
931                "number",
932                "round_type",
933                "steward_emergency",
934                "steward_contribution",
935                "responses"
936            ]
937        );
938        assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
939    }
940
941    /// GOV-2026-0001 through the real [`redact_data`](super::super::redact_data)
942    #[test]
943    fn a_redacted_record_parses_with_the_redactions_in_place() {
944        let (name, data) = fixtures().swap_remove(0);
945        assert_eq!(name, "GOV-2026-0001.json");
946        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
947        let fields = [
948            "/title",
949            "/vote_tally",
950            "/rounds/0/responses/0/position",
951            "/rounds/0/responses/0/rationale",
952            "/rounds/0/responses/0/raw_text",
953            "/rounds/0/responses/1/questions",
954            "/rounds/0/responses/2/questions/1",
955            "/rounds/1/steward_contribution",
956        ]
957        .map(String::from);
958        let redacted =
959            super::super::redact_data(&data, &fields, &amd, Blind::random())
960                .unwrap();
961        let record = round_trips(&name, &redacted);
962
963        let gone = Redactable::Redacted(amd.clone());
964        assert_eq!(record.title, gone);
965        assert_eq!(record.vote_tally, gone);
966        let [first, second, third, ..] = &record.rounds[0].responses[..] else {
967            panic!("round 1 has four responses");
968        };
969        assert_eq!(first.position, gone);
970        assert_eq!(first.rationale.redacted_by(), Some(&amd));
971        assert_eq!(first.raw_text, Some(gone.clone()));
972        assert!(!first.questions.is_redacted());
973        assert_eq!(second.questions.redacted_by(), Some(&amd));
974        let questions = third.questions.value().unwrap();
975        assert!(!questions[0].is_redacted());
976        assert_eq!(questions[1], gone);
977        assert_eq!(record.rounds[1].steward_contribution, Some(gone));
978        assert!(record.blind.is_some());
979    }
980
981    #[test]
982    fn an_attachment_can_be_redacted() {
983        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
984        let redacted = super::super::redact_data(
985            &synthetic("attachments"),
986            &["/attachments/0/content".into()],
987            &amd,
988            Blind::random(),
989        )
990        .unwrap();
991        let record = round_trips("attachments", &redacted);
992        assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
993    }
994
995    /// Structural fields stay plain: redacting one is a shape this
996    /// version doesn't describe, and says so rather than guessing
997    #[test]
998    fn a_redacted_vote_is_an_error() {
999        let (_, data) = fixtures().swap_remove(0);
1000        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1001        let redacted = super::super::redact_data(
1002            &data,
1003            &["/final_votes/artist".into()],
1004            &amd,
1005            Blind::random(),
1006        )
1007        .unwrap();
1008        assert!(
1009            serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1010        );
1011    }
1012
1013    #[cfg(feature = "schemars")]
1014    #[test]
1015    fn schema_is_ref_free() {
1016        let schema =
1017            crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1018        let text = schema.to_string();
1019        assert!(!text.contains("$ref"), "{text}");
1020        assert!(!text.contains("$defs"), "{text}");
1021        let plain = serde_json::to_string(&schemars::schema_for!(
1022            CouncilDecisionRecord
1023        ))
1024        .unwrap();
1025        assert!(!plain.contains("$ref"), "{plain}");
1026        assert!(!plain.contains("$defs"), "{plain}");
1027    }
1028}