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 serde::{Deserialize, Serialize};
15
16use super::{Blind, Redactable};
17use crate::enums::{DecisionOutcome, RoundType};
18use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
19
20/// The `data` of a `council_decision` entry. See the [module docs](self).
21///
22/// The entry's tags are on the entry, not in `data`.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
25#[cfg_attr(feature = "schemars", schemars(inline))]
26pub struct CouncilDecisionRecord {
27    /// This entry's own id
28    pub id: GovernanceLogId,
29    /// The sitting that decided it
30    pub meeting_id: CouncilMeetingId,
31    pub category: DecisionCategory,
32    /// The agenda item's title
33    pub title: Redactable<String>,
34    /// Every round of deliberation, in order. The last is the
35    /// `final_vote` round unless the item was tabled earlier.
36    pub rounds: Vec<CouncilRound>,
37    pub final_votes: FinalVotes,
38    /// Decided by `category`'s threshold over `final_votes`; a Steward
39    /// `veto` is always `rejected`, a tabled item `deferred`, and a
40    /// `Schedule` item `approved` once ranked
41    pub outcome: DecisionOutcome,
42    /// For display only. `"<yes>-<no>"` with `concur` counted as yes
43    /// (`"5-0"`, `"0-4"`); `"Deferred"` for a tabled item, optionally
44    /// followed by `": <why>"`; a sentence on a `Schedule` item.
45    pub vote_tally: Redactable<String>,
46    /// Flagged by the Steward as significant for readers and for
47    /// precedent weight. Changes nothing procedurally.
48    pub landmark: bool,
49    /// The Steward's rationale for a `veto` (Constitution Art. IV § 5).
50    /// Written since 2026-09-22; no veto had been cast before then.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub veto_rationale: Option<Redactable<String>>,
53    /// On a `Schedule` item, the seats' aggregated ranking of the docket
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub agenda_ranking: Option<AgendaRanking>,
56    /// Whether the item limits the Steward's powers, as the four seats
57    /// determined it after round 1 (Constitution Art. IV § 3,
58    /// GOV-2026-0009). Absent on items decided before it was recorded, and
59    /// on `Schedule` and `Emergency` items. (0.48)
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub steward_recusal: Option<StewardRecusal>,
62    /// The one-sentence rationale for each abstention in `final_votes`
63    /// (Art. IV § 3). (0.48)
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub abstentions: Vec<Abstention>,
66    /// Entries this decision retires as precedent. Only GOV-2026-0005
67    /// carries one, added by a migration rather than by the Council; an
68    /// amendment naming the same entry decides its `standing` instead.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub overrules: Vec<GovernanceLogId>,
71    /// What the seats were shown beyond the proposal: the Clerk's
72    /// summaries and everything a seat had read to it. (0.42)
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub attachments: Vec<CouncilAttachment>,
75    /// The entry's blinding value (see [`blind_data`](super::blind_data)).
76    /// Absent from entries that predate blinding.
77    #[serde(
78        rename = "_blind",
79        default,
80        skip_serializing_if = "Option::is_none"
81    )]
82    pub blind: Option<Blind>,
83}
84
85/// Material put before the Council, inline as markdown
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "schemars", schemars(inline))]
89pub struct CouncilAttachment {
90    /// A file name, unique within the record: `clerk-thread-summary.md`
91    pub name: String,
92    /// What it is and who saw it
93    pub note: String,
94    pub content: Redactable<String>,
95}
96
97/// An agenda item's category, which sets the vote it needs
98/// (Constitution v0.4 Art. IV § 3).
99///
100/// An `abstain` or `recused` leaves the denominator. A Steward `veto`
101/// rejects any item the Steward is not recused from; a recused Steward has
102/// no vote and no veto.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105#[cfg_attr(feature = "schemars", schemars(inline))]
106pub enum DecisionCategory {
107    /// Three `yes`: an absolute count, found among the four seats when the
108    /// Steward is recused
109    Routine,
110    /// Four `yes` of five including the Steward (`yes` or `concur`); with
111    /// the Steward recused, all four seats `yes`
112    Policy,
113    /// Unanimous among those voting, with no fewer than four voting.
114    /// Before GOV-2026-0009 (2026-09-26), 5 of 5 `yes`
115    Constitutional,
116    /// The Steward alone, subject to 72-hour review
117    Emergency,
118    /// The Council's scheduling thread: the four seats rank the docket
119    /// (Borda count, see `agenda_ranking`) and the Steward executes the
120    /// order without voting
121    Schedule,
122}
123
124/// One round of deliberation
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
127#[cfg_attr(feature = "schemars", schemars(inline))]
128pub struct CouncilRound {
129    /// 1-indexed
130    pub number: u32,
131    /// `independent` (round 1: no seat sees another's response or any
132    /// Steward note), `deliberation` (seats see prior rounds), or
133    /// `final_vote`
134    pub round_type: RoundType,
135    /// One per seat that took its turn. A tabled round can hold fewer
136    /// than four.
137    pub responses: Vec<SeatResponse>,
138    /// The Steward's notes to the seats for this round, or the reason an
139    /// item was tabled
140    pub steward_contribution: Option<Redactable<String>>,
141    /// Present when `steward_contribution` came from a recused Steward
142    /// under emergency authority (Art. IV § 2), which puts the item under
143    /// mandatory Council review within 72 hours. (0.48)
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub steward_emergency: Option<StewardEmergency>,
146    /// The item was tabled here by a recused Steward, over an API refusal
147    /// on a seat's final vote: refusal handling, the one tabling open to
148    /// a recused Steward. `steward_contribution` says which refusal.
149    /// (0.48)
150    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
151    pub tabled_by_recused_steward: bool,
152}
153
154/// A recused Steward's note to the seats, made as an emergency act
155/// (Constitution Art. IV § 2)
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
158#[cfg_attr(feature = "schemars", schemars(inline))]
159pub struct StewardEmergency {
160    /// The Steward's one-sentence reason, given before the note was read
161    pub reason: Redactable<String>,
162}
163
164/// The four seats' determination of whether an item limits the Steward's
165/// powers (Constitution Art. IV § 3, GOV-2026-0009)
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
168#[cfg_attr(feature = "schemars", schemars(inline))]
169pub struct StewardRecusal {
170    /// One per seat that answered in round 1; a seat whose turn the API
171    /// refused has none
172    pub votes: Vec<RecusalVote>,
173    /// Three of the four seats said it does. The Steward then has no
174    /// deliberation, vote or veto on the item, and `final_votes` records
175    /// `recused`.
176    pub recused: bool,
177}
178
179/// One seat's answer to whether an item limits the Steward's powers
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
182#[cfg_attr(feature = "schemars", schemars(inline))]
183pub struct RecusalVote {
184    pub seat: CouncilSeat,
185    /// The seat's reasoning, written before its answer
186    pub reason: Redactable<String>,
187    pub limits_steward_powers: bool,
188}
189
190/// An abstention and its one-sentence rationale (Art. IV § 3)
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
193#[cfg_attr(feature = "schemars", schemars(inline))]
194pub struct Abstention {
195    pub member: CouncilMember,
196    pub rationale: Redactable<String>,
197}
198
199/// Any of the five Council members: a [`CouncilSeat`] or the Steward
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
202#[cfg_attr(feature = "schemars", schemars(inline))]
203#[serde(rename_all = "snake_case")]
204pub enum CouncilMember {
205    Artist,
206    Philosopher,
207    Lawyer,
208    Engineer,
209    Steward,
210}
211
212impl From<CouncilSeat> for CouncilMember {
213    fn from(seat: CouncilSeat) -> Self {
214        match seat {
215            CouncilSeat::Artist => Self::Artist,
216            CouncilSeat::Philosopher => Self::Philosopher,
217            CouncilSeat::Lawyer => Self::Lawyer,
218            CouncilSeat::Engineer => Self::Engineer,
219        }
220    }
221}
222
223/// One seat's turn in a round
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
226#[cfg_attr(feature = "schemars", schemars(inline))]
227pub struct SeatResponse {
228    pub role: CouncilSeat,
229    /// The seat's statement for the record. On a turn the API refused,
230    /// `"No response: the API returned a refusal (…)."`
231    pub position: Redactable<String>,
232    /// The seat's vote as of this round; only the `final_vote` round's
233    /// counts. Absent only when the API returned a refusal for the turn:
234    /// a refusal is recorded as a fact, never as a vote.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub vote: Option<CouncilVote>,
237    /// The seat's reasoning. Empty on a refused turn.
238    pub rationale: Redactable<String>,
239    /// Questions the seat put to the others or the Steward. A redaction
240    /// can take one question or the whole list.
241    pub questions: Redactable<Vec<Redactable<String>>>,
242    /// Whether the seat said it was ready for the final vote
243    pub ready_to_vote: bool,
244    /// On a `Schedule` item's final round, the seat's ballot
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub ranking: Option<SeatRanking>,
247    /// The model's raw reply text. Only in entries signed before
248    /// 2026-09-18; from GOV-2026-0003 on it duplicates `rationale`.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub raw_text: Option<Redactable<String>>,
251}
252
253/// A voting Council seat. The fifth vote is the Steward's.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
255#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
256#[cfg_attr(feature = "schemars", schemars(inline))]
257#[serde(rename_all = "snake_case")]
258pub enum CouncilSeat {
259    Artist,
260    Philosopher,
261    Lawyer,
262    Engineer,
263}
264
265/// A vote cast by a seat or the Steward
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
268#[cfg_attr(feature = "schemars", schemars(inline))]
269#[serde(rename_all = "snake_case")]
270pub enum CouncilVote {
271    Yes,
272    No,
273    /// Also recorded for a seat with no final response, and for the
274    /// Steward on a `Schedule` item (who executes the order, not votes on
275    /// it)
276    Abstain,
277    /// Tabled: every vote is `defer` when the Steward tables an item
278    Defer,
279    /// The Steward's agreement with the seats' majority; counts as yes
280    Concur,
281    /// The Steward's veto; rejects whatever the others voted. See
282    /// `veto_rationale`.
283    Veto,
284    /// A seat ranked a `Schedule` item's docket instead of voting; see
285    /// `agenda_ranking`
286    Ranked,
287    /// The Steward, on an item the seats found limits the Steward's
288    /// powers: no vote and no veto. See `steward_recusal`. (0.48)
289    Recused,
290}
291
292/// The votes that decided the item
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
295#[cfg_attr(feature = "schemars", schemars(inline))]
296pub struct FinalVotes {
297    pub artist: CouncilVote,
298    pub philosopher: CouncilVote,
299    pub lawyer: CouncilVote,
300    pub engineer: CouncilVote,
301    pub steward: CouncilVote,
302}
303
304/// A seat's ballot on a `Schedule` item
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
307#[cfg_attr(feature = "schemars", schemars(inline))]
308pub struct SeatRanking {
309    /// How many items the seat judges this sitting can hear
310    pub sitting_capacity: u32,
311    /// Docket P-numbers (`P3` is `3`), most important first. May be
312    /// partial.
313    pub ranking: Vec<u32>,
314}
315
316/// The aggregated ranking of a `Schedule` item: a Borda count on the
317/// docket's scale, so a first choice scores `rankable` however many
318/// items the seat ranked, and an unranked item scores 0
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
321#[cfg_attr(feature = "schemars", schemars(inline))]
322pub struct AgendaRanking {
323    /// How many candidates could be ranked: the Borda scale
324    pub rankable: u32,
325    pub ballots: Vec<Ballot>,
326    /// Every candidate at least one seat ranked, best first: by Borda,
327    /// then more seats, then lower mean position, then P-number
328    pub order: Vec<PlacedProposal>,
329    /// How many of `order` make the docket: the median of the seats'
330    /// capacities (the lower middle one when even)
331    pub cut: u32,
332    /// The P-number that beats every other head to head, if any. `null`
333    /// with a non-empty `order` means a cycle or a tie at the top: the
334    /// Borda order is then a tiebreak, not a consensus.
335    pub condorcet_winner: Option<u32>,
336}
337
338/// One seat's ballot as aggregated
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
341#[cfg_attr(feature = "schemars", schemars(inline))]
342pub struct Ballot {
343    pub seat: CouncilSeat,
344    /// See [`SeatRanking`]
345    pub sitting_capacity: u32,
346    /// See [`SeatRanking`]
347    pub ranking: Vec<u32>,
348}
349
350/// A proposal's place in an [`AgendaRanking`]
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
353#[cfg_attr(feature = "schemars", schemars(inline))]
354pub struct PlacedProposal {
355    /// The P-number the seats ranked it by
356    pub number: u32,
357    /// The proposal
358    pub post_id: PostId,
359    pub borda: u32,
360    /// How many seats ranked it at all
361    pub seats: u32,
362    /// Sum of its 1-based positions over those seats; the mean position
363    /// is `position_sum / seats`. Integers only, because the entry is
364    /// signed and a float has no canonical text form.
365    pub position_sum: u32,
366    /// Tied with the next entry on every criterion, so the order between
367    /// the two is by P-number and arbitrary
368    pub tied_with_next: bool,
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    /// Every `council_decision` signed on production, as stored
376    fn fixtures() -> Vec<(String, serde_json::Value)> {
377        let dir = concat!(
378            env!("CARGO_MANIFEST_DIR"),
379            "/tests/fixtures/council_decisions"
380        );
381        let mut out: Vec<_> = std::fs::read_dir(dir)
382            .unwrap()
383            .map(|e| e.unwrap().path())
384            .filter(|p| p.extension().is_some_and(|x| x == "json"))
385            .map(|p| {
386                let text = std::fs::read_to_string(&p).unwrap();
387                (
388                    p.file_name().unwrap().to_string_lossy().into_owned(),
389                    serde_json::from_str(&text).unwrap(),
390                )
391            })
392            .collect();
393        out.sort_by(|a, b| a.0.cmp(&b.0));
394        out
395    }
396
397    /// Parses `data` and checks re-serializing it gives back `data`
398    /// exactly: a key the types don't describe would be dropped here.
399    fn round_trips(
400        name: &str,
401        data: &serde_json::Value,
402    ) -> CouncilDecisionRecord {
403        let record: CouncilDecisionRecord =
404            serde_json::from_value(data.clone())
405                .unwrap_or_else(|e| panic!("{name}: {e}"));
406        assert_eq!(
407            &serde_json::to_value(&record).unwrap(),
408            data,
409            "{name}: the typed record loses or changes something"
410        );
411        record
412    }
413
414    #[test]
415    fn every_signed_decision_is_described_whole() {
416        let fixtures = fixtures();
417        assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
418        for (name, data) in &fixtures {
419            let record = round_trips(name, data);
420            assert_eq!(format!("{}.json", record.id), *name);
421        }
422    }
423
424    /// The field-by-field shape the Council writes today but no signed
425    /// entry has yet: a veto, a blind, a ranking, a refused turn.
426    fn synthetic(name: &str) -> serde_json::Value {
427        let base = serde_json::json!({
428            "id": "GOV-2026-0099",
429            "meeting_id": "00000000-0000-0000-0000-000000000001",
430            "category": "Policy",
431            "title": "t",
432            "rounds": [],
433            "final_votes": {
434                "artist": "yes", "philosopher": "yes", "lawyer": "yes",
435                "engineer": "yes", "steward": "veto"
436            },
437            "outcome": "rejected",
438            "vote_tally": "4-0",
439            "landmark": false,
440            "_blind": "00".repeat(32),
441        });
442        let mut data = base;
443        match name {
444            "veto" => {
445                data["veto_rationale"] = "because".into();
446            }
447            "refusal" => {
448                data["final_votes"] = serde_json::json!({
449                    "artist": "defer", "philosopher": "defer", "lawyer": "defer",
450                    "engineer": "defer", "steward": "defer"
451                });
452                data["outcome"] = "deferred".into();
453                data["vote_tally"] =
454                    "Deferred: tabled because the API returned a \
455                     refusal for the Artist's final vote"
456                        .into();
457                data["rounds"] = serde_json::json!([{
458                    "number": 3,
459                    "round_type": "final_vote",
460                    "responses": [{
461                        "role": "artist",
462                        "position": "No response: the API returned a refusal \
463                            (category: cyber; explanation: Flagged by a safety \
464                            classifier.).",
465                        "rationale": "",
466                        "questions": [],
467                        "ready_to_vote": false
468                    }],
469                    "steward_contribution": "Tabled by the Steward: the API \
470                        returned a refusal for the Artist's final vote (…)."
471                }]);
472            }
473            "schedule" => {
474                data["category"] = "Schedule".into();
475                data["outcome"] = "approved".into();
476                data["final_votes"] = serde_json::json!({
477                    "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
478                    "engineer": "abstain", "steward": "abstain"
479                });
480                data["vote_tally"] =
481                    "ranked 3/4; the Steward executes the order \
482                     and does not vote"
483                        .into();
484                data["rounds"] = serde_json::json!([{
485                    "number": 1,
486                    "round_type": "final_vote",
487                    "responses": [{
488                        "role": "lawyer",
489                        "position": "p",
490                        "vote": "ranked",
491                        "rationale": "r",
492                        "questions": [],
493                        "ready_to_vote": true,
494                        "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
495                    }],
496                    "steward_contribution": null
497                }]);
498                data["agenda_ranking"] = serde_json::json!({
499                    "rankable": 3,
500                    "ballots": [
501                        {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
502                    ],
503                    "order": [{
504                        "number": 3,
505                        "post_id": "00000000-0000-0000-0000-000000000003",
506                        "borda": 3, "seats": 1, "position_sum": 1,
507                        "tied_with_next": false
508                    }, {
509                        "number": 1,
510                        "post_id": "00000000-0000-0000-0000-000000000001",
511                        "borda": 2, "seats": 1, "position_sum": 2,
512                        "tied_with_next": false
513                    }],
514                    "cut": 2,
515                    "condorcet_winner": null
516                });
517            }
518            "attachments" => {
519                data["attachments"] = serde_json::json!([{
520                    "name": "clerk-thread-summary.md",
521                    "note": "The Clerk's summary of the thread, given to every seat",
522                    "content": "## Arguments\n\n[C1] argues for it."
523                }]);
524            }
525            "recusal" => {
526                data["final_votes"] = serde_json::json!({
527                    "artist": "yes", "philosopher": "yes", "lawyer": "yes",
528                    "engineer": "abstain", "steward": "recused"
529                });
530                data["outcome"] = "rejected".into();
531                data["vote_tally"] = "3-0 (Steward recused)".into();
532                data["rounds"] = serde_json::json!([{
533                    "number": 2,
534                    "round_type": "deliberation",
535                    "responses": [],
536                    "steward_contribution": "The quoted comment is forged.",
537                    "steward_emergency": {
538                        "reason": "An injected comment is steering the seats."
539                    }
540                }]);
541                data["steward_recusal"] = serde_json::json!({
542                    "votes": [
543                        {"seat": "artist", "reason": "r",
544                         "limits_steward_powers": true},
545                        {"seat": "philosopher", "reason": "r",
546                         "limits_steward_powers": true},
547                        {"seat": "lawyer", "reason": "r",
548                         "limits_steward_powers": true},
549                        {"seat": "engineer", "reason": "r",
550                         "limits_steward_powers": false}
551                    ],
552                    "recused": true
553                });
554                data["abstentions"] = serde_json::json!([
555                    {"member": "engineer", "rationale": "Conflicted."}
556                ]);
557            }
558            "recused_refusal" => {
559                data = synthetic("refusal");
560                data["final_votes"]["steward"] = "recused".into();
561                data["rounds"][0]["tabled_by_recused_steward"] = true.into();
562            }
563            _ => unreachable!(),
564        }
565        data
566    }
567
568    #[test]
569    fn newer_shapes_are_described_whole() {
570        for name in [
571            "veto",
572            "refusal",
573            "schedule",
574            "attachments",
575            "recusal",
576            "recused_refusal",
577        ] {
578            round_trips(name, &synthetic(name));
579        }
580        let tabled =
581            round_trips("recused_refusal", &synthetic("recused_refusal"));
582        assert!(tabled.rounds[0].tabled_by_recused_steward);
583        assert!(
584            !round_trips("refusal", &synthetic("refusal")).rounds[0]
585                .tabled_by_recused_steward
586        );
587        let refused = round_trips("refusal", &synthetic("refusal"));
588        assert_eq!(refused.rounds[0].responses[0].vote, None);
589    }
590
591    /// Recusal, abstention rationales and a recused Steward's emergency
592    /// note, as the Council writes them from 0.48
593    #[test]
594    fn a_recused_decision_reads_back() {
595        let record = round_trips("recusal", &synthetic("recusal"));
596        assert_eq!(record.final_votes.steward, CouncilVote::Recused);
597        let recusal = record.steward_recusal.unwrap();
598        assert!(recusal.recused);
599        assert_eq!(
600            recusal
601                .votes
602                .iter()
603                .filter(|v| v.limits_steward_powers)
604                .count(),
605            3
606        );
607        assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
608        let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
609        assert_eq!(
610            emergency.reason.value().map(String::as_str),
611            Some("An injected comment is steering the seats.")
612        );
613        // Absent everywhere they are not written: earlier records keep
614        // their bytes.
615        let veto = round_trips("veto", &synthetic("veto"));
616        assert_eq!(veto.steward_recusal, None);
617        assert!(veto.abstentions.is_empty());
618    }
619
620    #[test]
621    fn a_recused_decision_reads_in_order() {
622        let data = synthetic("recusal");
623        let keys = |v: &serde_json::Value| -> Vec<String> {
624            super::super::reading::ordered(v.as_object().unwrap())
625                .into_iter()
626                .map(|(k, _)| k.clone())
627                .collect()
628        };
629        let top = keys(&data);
630        let at = |k: &str| top.iter().position(|x| x == k).unwrap();
631        assert!(at("rounds") < at("steward_recusal"));
632        assert!(at("steward_recusal") < at("final_votes"));
633        assert!(at("final_votes") < at("abstentions"));
634        assert!(at("abstentions") < at("vote_tally"));
635        assert_eq!(
636            keys(&data["steward_recusal"]["votes"][0]),
637            ["seat", "reason", "limits_steward_powers"]
638        );
639        assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
640        assert_eq!(
641            keys(&data["rounds"][0]),
642            [
643                "number",
644                "round_type",
645                "steward_emergency",
646                "steward_contribution",
647                "responses"
648            ]
649        );
650        assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
651    }
652
653    /// GOV-2026-0001 through the real [`redact_data`](super::super::redact_data)
654    #[test]
655    fn a_redacted_record_parses_with_the_redactions_in_place() {
656        let (name, data) = fixtures().swap_remove(0);
657        assert_eq!(name, "GOV-2026-0001.json");
658        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
659        let fields = [
660            "/title",
661            "/vote_tally",
662            "/rounds/0/responses/0/position",
663            "/rounds/0/responses/0/rationale",
664            "/rounds/0/responses/0/raw_text",
665            "/rounds/0/responses/1/questions",
666            "/rounds/0/responses/2/questions/1",
667            "/rounds/1/steward_contribution",
668        ]
669        .map(String::from);
670        let redacted =
671            super::super::redact_data(&data, &fields, &amd, Blind::random())
672                .unwrap();
673        let record = round_trips(&name, &redacted);
674
675        let gone = Redactable::Redacted(amd.clone());
676        assert_eq!(record.title, gone);
677        assert_eq!(record.vote_tally, gone);
678        let [first, second, third, ..] = &record.rounds[0].responses[..] else {
679            panic!("round 1 has four responses");
680        };
681        assert_eq!(first.position, gone);
682        assert_eq!(first.rationale, gone);
683        assert_eq!(first.raw_text, Some(gone.clone()));
684        assert!(!first.questions.is_redacted());
685        assert_eq!(second.questions.redacted_by(), Some(&amd));
686        let questions = third.questions.value().unwrap();
687        assert!(!questions[0].is_redacted());
688        assert_eq!(questions[1], gone);
689        assert_eq!(record.rounds[1].steward_contribution, Some(gone));
690        assert!(record.blind.is_some());
691    }
692
693    #[test]
694    fn an_attachment_can_be_redacted() {
695        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
696        let redacted = super::super::redact_data(
697            &synthetic("attachments"),
698            &["/attachments/0/content".into()],
699            &amd,
700            Blind::random(),
701        )
702        .unwrap();
703        let record = round_trips("attachments", &redacted);
704        assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
705    }
706
707    /// Structural fields stay plain: redacting one is a shape this
708    /// version doesn't describe, and says so rather than guessing
709    #[test]
710    fn a_redacted_vote_is_an_error() {
711        let (_, data) = fixtures().swap_remove(0);
712        let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
713        let redacted = super::super::redact_data(
714            &data,
715            &["/final_votes/artist".into()],
716            &amd,
717            Blind::random(),
718        )
719        .unwrap();
720        assert!(
721            serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
722        );
723    }
724
725    #[cfg(feature = "schemars")]
726    #[test]
727    fn schema_is_ref_free() {
728        let schema =
729            crate::responses::inline_schema_for::<CouncilDecisionRecord>();
730        let text = schema.to_string();
731        assert!(!text.contains("$ref"), "{text}");
732        assert!(!text.contains("$defs"), "{text}");
733        let plain = serde_json::to_string(&schemars::schema_for!(
734            CouncilDecisionRecord
735        ))
736        .unwrap();
737        assert!(!plain.contains("$ref"), "{plain}");
738        assert!(!plain.contains("$defs"), "{plain}");
739    }
740}