1use misanthropic::Model;
15use misanthropic::prompt::message::{Content, Thought};
16use serde::{Deserialize, Serialize};
17
18use super::{Blind, Redactable};
19use crate::enums::{DecisionOutcome, RoundType};
20use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
21
22#[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 pub id: GovernanceLogId,
31 pub meeting_id: CouncilMeetingId,
33 pub category: DecisionCategory,
34 pub title: Redactable<String>,
36 pub rounds: Vec<CouncilRound>,
39 pub final_votes: FinalVotes,
40 pub outcome: DecisionOutcome,
44 pub vote_tally: Redactable<String>,
48 pub landmark: bool,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub veto_rationale: Option<Redactable<String>>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub agenda_ranking: Option<AgendaRanking>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub steward_recusal: Option<StewardRecusal>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub abstentions: Vec<Abstention>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub overrules: Vec<GovernanceLogId>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub attachments: Vec<CouncilAttachment>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub seat_models: Option<SeatModels>,
80 #[serde(
83 rename = "_blind",
84 default,
85 skip_serializing_if = "Option::is_none"
86 )]
87 pub blind: Option<Blind>,
88}
89
90#[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 pub name: String,
97 pub note: String,
99 pub content: Redactable<String>,
100}
101
102#[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 Routine,
115 Policy,
118 Constitutional,
121 Emergency,
123 Schedule,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
132#[cfg_attr(feature = "schemars", schemars(inline))]
133pub struct CouncilRound {
134 pub number: u32,
136 pub round_type: RoundType,
140 pub responses: Vec<SeatResponse>,
143 pub steward_contribution: Option<Redactable<String>>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub steward_emergency: Option<StewardEmergency>,
151 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156 pub tabled_by_recused_steward: bool,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
163#[cfg_attr(feature = "schemars", schemars(inline))]
164pub struct StewardEmergency {
165 pub reason: Redactable<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
173#[cfg_attr(feature = "schemars", schemars(inline))]
174pub struct StewardRecusal {
175 pub votes: Vec<RecusalVote>,
178 pub recused: bool,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
187#[cfg_attr(feature = "schemars", schemars(inline))]
188pub struct RecusalVote {
189 pub seat: CouncilSeat,
190 pub reason: Redactable<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub rationale: Option<Redactable<Rationale>>,
196 pub limits_steward_powers: bool,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
202#[cfg_attr(feature = "schemars", schemars(inline))]
203pub struct Abstention {
204 pub member: CouncilMember,
205 pub rationale: Redactable<String>,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211#[cfg_attr(feature = "schemars", schemars(inline))]
212#[serde(rename_all = "snake_case")]
213pub enum CouncilMember {
214 Artist,
215 Philosopher,
216 Lawyer,
217 Engineer,
218 Steward,
219}
220
221impl From<CouncilSeat> for CouncilMember {
222 fn from(seat: CouncilSeat) -> Self {
223 match seat {
224 CouncilSeat::Artist => Self::Artist,
225 CouncilSeat::Philosopher => Self::Philosopher,
226 CouncilSeat::Lawyer => Self::Lawyer,
227 CouncilSeat::Engineer => Self::Engineer,
228 }
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
235#[cfg_attr(feature = "schemars", schemars(inline))]
236pub struct SeatResponse {
237 pub role: CouncilSeat,
238 pub position: Redactable<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub vote: Option<CouncilVote>,
246 pub rationale: Redactable<Rationale>,
248 pub questions: Redactable<Vec<Redactable<String>>>,
251 pub ready_to_vote: bool,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub ranking: Option<SeatRanking>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub raw_text: Option<Redactable<String>>,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
273#[cfg_attr(feature = "schemars", schemars(inline))]
274#[serde(untagged)]
275pub enum Rationale {
276 Legacy(String),
278 Thinking(Vec<Thought>),
284}
285
286impl Rationale {
287 pub fn texts(&self) -> Vec<&str> {
290 match self {
291 Self::Legacy(text) => vec![text.as_str()],
292 Self::Thinking(thoughts) => {
293 thoughts.iter().filter_map(Thought::text).collect()
294 }
295 }
296 }
297
298 pub fn redacted_blocks(&self) -> usize {
300 match self {
301 Self::Legacy(_) => 0,
302 Self::Thinking(thoughts) => {
303 thoughts.iter().filter(|t| t.text().is_none()).count()
304 }
305 }
306 }
307
308 pub fn is_empty(&self) -> bool {
310 self.texts().iter().all(|t| t.trim().is_empty())
311 && self.redacted_blocks() == 0
312 }
313}
314
315impl std::fmt::Display for Rationale {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 let Self::Thinking(thoughts) = self else {
319 return f.write_str(&self.texts().concat());
320 };
321 for (i, thought) in thoughts.iter().enumerate() {
322 if i > 0 {
323 f.write_str("\n\n")?;
324 }
325 f.write_str(
326 thought.text().unwrap_or("[reasoning withheld]").trim_end(),
327 )?;
328 }
329 Ok(())
330 }
331}
332
333impl From<String> for Rationale {
334 fn from(text: String) -> Self {
335 Self::Legacy(text)
336 }
337}
338
339impl From<&str> for Rationale {
340 fn from(text: &str) -> Self {
341 Self::Legacy(text.to_owned())
342 }
343}
344
345impl From<Vec<Thought>> for Rationale {
346 fn from(thoughts: Vec<Thought>) -> Self {
347 Self::Thinking(thoughts)
348 }
349}
350
351impl From<&Content> for Rationale {
353 fn from(content: &Content) -> Self {
354 Self::Thinking(content.thoughts().collect())
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
361#[cfg_attr(feature = "schemars", schemars(inline))]
362pub struct SeatModels {
363 pub artist: Model,
364 pub philosopher: Model,
365 pub lawyer: Model,
366 pub engineer: Model,
367}
368
369impl SeatModels {
370 pub fn get(&self, seat: CouncilSeat) -> &Model {
371 match seat {
372 CouncilSeat::Artist => &self.artist,
373 CouncilSeat::Philosopher => &self.philosopher,
374 CouncilSeat::Lawyer => &self.lawyer,
375 CouncilSeat::Engineer => &self.engineer,
376 }
377 }
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
382#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
383#[cfg_attr(feature = "schemars", schemars(inline))]
384#[serde(rename_all = "snake_case")]
385pub enum CouncilSeat {
386 Artist,
387 Philosopher,
388 Lawyer,
389 Engineer,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
394#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
395#[cfg_attr(feature = "schemars", schemars(inline))]
396#[serde(rename_all = "snake_case")]
397pub enum CouncilVote {
398 Yes,
399 No,
400 Abstain,
404 Defer,
406 Concur,
408 Veto,
411 Ranked,
414 Recused,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
422#[cfg_attr(feature = "schemars", schemars(inline))]
423pub struct FinalVotes {
424 pub artist: CouncilVote,
425 pub philosopher: CouncilVote,
426 pub lawyer: CouncilVote,
427 pub engineer: CouncilVote,
428 pub steward: CouncilVote,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
434#[cfg_attr(feature = "schemars", schemars(inline))]
435pub struct SeatRanking {
436 pub sitting_capacity: u32,
438 pub ranking: Vec<u32>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
448#[cfg_attr(feature = "schemars", schemars(inline))]
449pub struct AgendaRanking {
450 pub rankable: u32,
452 pub ballots: Vec<Ballot>,
453 pub order: Vec<PlacedProposal>,
456 pub cut: u32,
459 pub condorcet_winner: Option<u32>,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
468#[cfg_attr(feature = "schemars", schemars(inline))]
469pub struct Ballot {
470 pub seat: CouncilSeat,
471 pub sitting_capacity: u32,
473 pub ranking: Vec<u32>,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
480#[cfg_attr(feature = "schemars", schemars(inline))]
481pub struct PlacedProposal {
482 pub number: u32,
484 pub post_id: PostId,
486 pub borda: u32,
487 pub seats: u32,
489 pub position_sum: u32,
493 pub tied_with_next: bool,
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn fixtures() -> Vec<(String, serde_json::Value)> {
504 let dir = concat!(
505 env!("CARGO_MANIFEST_DIR"),
506 "/tests/fixtures/council_decisions"
507 );
508 let mut out: Vec<_> = std::fs::read_dir(dir)
509 .unwrap()
510 .map(|e| e.unwrap().path())
511 .filter(|p| p.extension().is_some_and(|x| x == "json"))
512 .map(|p| {
513 let text = std::fs::read_to_string(&p).unwrap();
514 (
515 p.file_name().unwrap().to_string_lossy().into_owned(),
516 serde_json::from_str(&text).unwrap(),
517 )
518 })
519 .collect();
520 out.sort_by(|a, b| a.0.cmp(&b.0));
521 out
522 }
523
524 fn round_trips(
527 name: &str,
528 data: &serde_json::Value,
529 ) -> CouncilDecisionRecord {
530 let record: CouncilDecisionRecord =
531 serde_json::from_value(data.clone())
532 .unwrap_or_else(|e| panic!("{name}: {e}"));
533 assert_eq!(
534 &serde_json::to_value(&record).unwrap(),
535 data,
536 "{name}: the typed record loses or changes something"
537 );
538 record
539 }
540
541 #[test]
542 fn every_signed_decision_is_described_whole() {
543 let fixtures = fixtures();
544 assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
545 for (name, data) in &fixtures {
546 let record = round_trips(name, data);
547 assert_eq!(format!("{}.json", record.id), *name);
548 }
549 }
550
551 fn synthetic(name: &str) -> serde_json::Value {
554 let base = serde_json::json!({
555 "id": "GOV-2026-0099",
556 "meeting_id": "00000000-0000-0000-0000-000000000001",
557 "category": "Policy",
558 "title": "t",
559 "rounds": [],
560 "final_votes": {
561 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
562 "engineer": "yes", "steward": "veto"
563 },
564 "outcome": "rejected",
565 "vote_tally": "4-0",
566 "landmark": false,
567 "_blind": "00".repeat(32),
568 });
569 let mut data = base;
570 match name {
571 "veto" => {
572 data["veto_rationale"] = "because".into();
573 }
574 "refusal" => {
575 data["final_votes"] = serde_json::json!({
576 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
577 "engineer": "defer", "steward": "defer"
578 });
579 data["outcome"] = "deferred".into();
580 data["vote_tally"] =
581 "Deferred: tabled because the API returned a \
582 refusal for the Artist's final vote"
583 .into();
584 data["rounds"] = serde_json::json!([{
585 "number": 3,
586 "round_type": "final_vote",
587 "responses": [{
588 "role": "artist",
589 "position": "No response: the API returned a refusal \
590 (category: cyber; explanation: Flagged by a safety \
591 classifier.).",
592 "rationale": "",
593 "questions": [],
594 "ready_to_vote": false
595 }],
596 "steward_contribution": "Tabled by the Steward: the API \
597 returned a refusal for the Artist's final vote (…)."
598 }]);
599 }
600 "schedule" => {
601 data["category"] = "Schedule".into();
602 data["outcome"] = "approved".into();
603 data["final_votes"] = serde_json::json!({
604 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
605 "engineer": "abstain", "steward": "abstain"
606 });
607 data["vote_tally"] =
608 "ranked 3/4; the Steward executes the order \
609 and does not vote"
610 .into();
611 data["rounds"] = serde_json::json!([{
612 "number": 1,
613 "round_type": "final_vote",
614 "responses": [{
615 "role": "lawyer",
616 "position": "p",
617 "vote": "ranked",
618 "rationale": "r",
619 "questions": [],
620 "ready_to_vote": true,
621 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
622 }],
623 "steward_contribution": null
624 }]);
625 data["agenda_ranking"] = serde_json::json!({
626 "rankable": 3,
627 "ballots": [
628 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
629 ],
630 "order": [{
631 "number": 3,
632 "post_id": "00000000-0000-0000-0000-000000000003",
633 "borda": 3, "seats": 1, "position_sum": 1,
634 "tied_with_next": false
635 }, {
636 "number": 1,
637 "post_id": "00000000-0000-0000-0000-000000000001",
638 "borda": 2, "seats": 1, "position_sum": 2,
639 "tied_with_next": false
640 }],
641 "cut": 2,
642 "condorcet_winner": null
643 });
644 }
645 "attachments" => {
646 data["attachments"] = serde_json::json!([{
647 "name": "clerk-thread-summary.md",
648 "note": "The Clerk's summary of the thread, given to every seat",
649 "content": "## Arguments\n\n[C1] argues for it."
650 }]);
651 }
652 "recusal" => {
653 data["final_votes"] = serde_json::json!({
654 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
655 "engineer": "abstain", "steward": "recused"
656 });
657 data["outcome"] = "rejected".into();
658 data["vote_tally"] = "3-0 (Steward recused)".into();
659 data["rounds"] = serde_json::json!([{
660 "number": 2,
661 "round_type": "deliberation",
662 "responses": [],
663 "steward_contribution": "The quoted comment is forged.",
664 "steward_emergency": {
665 "reason": "An injected comment is steering the seats."
666 }
667 }]);
668 data["steward_recusal"] = serde_json::json!({
669 "votes": [
670 {"seat": "artist", "reason": "r",
671 "limits_steward_powers": true},
672 {"seat": "philosopher", "reason": "r",
673 "limits_steward_powers": true},
674 {"seat": "lawyer", "reason": "r",
675 "limits_steward_powers": true},
676 {"seat": "engineer", "reason": "r",
677 "limits_steward_powers": false}
678 ],
679 "recused": true
680 });
681 data["abstentions"] = serde_json::json!([
682 {"member": "engineer", "rationale": "Conflicted."}
683 ]);
684 }
685 "thinking" => {
686 data["seat_models"] = serde_json::json!({
687 "artist": "claude-opus-4-6",
688 "philosopher": "claude-opus-4-6",
689 "lawyer": "claude-opus-4-6",
690 "engineer": "qwen3.8-35b-a3b"
691 });
692 data["rounds"] = serde_json::json!([{
693 "number": 1,
694 "round_type": "independent",
695 "responses": [{
696 "role": "lawyer",
697 "position": "p",
698 "vote": "yes",
699 "rationale": [
700 {"type": "thinking", "thinking": "First.",
701 "signature": "EqQBCkgIARABGAIiQL"},
702 {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
703 {"type": "thinking", "thinking": "Then, after reading.",
704 "signature": "EqQBCkgIARABGAIiQM"}
705 ],
706 "questions": [],
707 "ready_to_vote": false
708 }],
709 "steward_contribution": null
710 }]);
711 data["steward_recusal"] = serde_json::json!({
712 "votes": [
713 {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
714 "rationale": [{"type": "thinking",
715 "thinking": "Unsigned, from a local model.",
716 "signature": ""}],
717 "limits_steward_powers": false}
718 ],
719 "recused": false
720 });
721 }
722 "recused_refusal" => {
723 data = synthetic("refusal");
724 data["final_votes"]["steward"] = "recused".into();
725 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
726 }
727 _ => unreachable!(),
728 }
729 data
730 }
731
732 #[test]
733 fn newer_shapes_are_described_whole() {
734 for name in [
735 "veto",
736 "refusal",
737 "schedule",
738 "attachments",
739 "recusal",
740 "recused_refusal",
741 "thinking",
742 ] {
743 round_trips(name, &synthetic(name));
744 }
745 let tabled =
746 round_trips("recused_refusal", &synthetic("recused_refusal"));
747 assert!(tabled.rounds[0].tabled_by_recused_steward);
748 assert!(
749 !round_trips("refusal", &synthetic("refusal")).rounds[0]
750 .tabled_by_recused_steward
751 );
752 let refused = round_trips("refusal", &synthetic("refusal"));
753 assert_eq!(refused.rounds[0].responses[0].vote, None);
754 }
755
756 #[test]
759 fn a_thinking_rationale_reads_back() {
760 let record = round_trips("thinking", &synthetic("thinking"));
761 let models = record.seat_models.as_ref().unwrap();
762 assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
763 assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
764
765 let response = &record.rounds[0].responses[0];
766 let rationale = response.rationale.value().unwrap();
767 assert!(matches!(rationale, Rationale::Thinking(_)));
768 assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
769 assert_eq!(rationale.redacted_blocks(), 1);
770
771 let vote = &record.steward_recusal.unwrap().votes[0];
772 let thought = vote.rationale.as_ref().unwrap().value().unwrap();
773 assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
774
775 assert_eq!(
776 rationale.to_string(),
777 "First.\n\n[reasoning withheld]\n\nThen, after reading."
778 );
779 assert!(!rationale.is_empty());
780 assert!(Rationale::Legacy(" ".into()).is_empty());
781
782 let legacy = round_trips("schedule", &synthetic("schedule"));
783 let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
784 assert_eq!(legacy, &Rationale::Legacy("r".into()));
785 }
786
787 #[test]
788 fn a_thinking_rationale_can_be_redacted() {
789 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
790 let redacted = super::super::redact_data(
791 &synthetic("thinking"),
792 &["/rounds/0/responses/0/rationale".into()],
793 &amd,
794 Blind::random(),
795 )
796 .unwrap();
797 let record = round_trips("thinking", &redacted);
798 assert_eq!(
799 record.rounds[0].responses[0].rationale.redacted_by(),
800 Some(&amd)
801 );
802 }
803
804 #[test]
807 fn a_recused_decision_reads_back() {
808 let record = round_trips("recusal", &synthetic("recusal"));
809 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
810 let recusal = record.steward_recusal.unwrap();
811 assert!(recusal.recused);
812 assert_eq!(
813 recusal
814 .votes
815 .iter()
816 .filter(|v| v.limits_steward_powers)
817 .count(),
818 3
819 );
820 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
821 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
822 assert_eq!(
823 emergency.reason.value().map(String::as_str),
824 Some("An injected comment is steering the seats.")
825 );
826 let veto = round_trips("veto", &synthetic("veto"));
829 assert_eq!(veto.steward_recusal, None);
830 assert!(veto.abstentions.is_empty());
831 }
832
833 #[test]
834 fn a_recused_decision_reads_in_order() {
835 let data = synthetic("recusal");
836 let keys = |v: &serde_json::Value| -> Vec<String> {
837 super::super::reading::ordered(v.as_object().unwrap())
838 .into_iter()
839 .map(|(k, _)| k.clone())
840 .collect()
841 };
842 let top = keys(&data);
843 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
844 assert!(at("rounds") < at("steward_recusal"));
845 assert!(at("steward_recusal") < at("final_votes"));
846 assert!(at("final_votes") < at("abstentions"));
847 assert!(at("abstentions") < at("vote_tally"));
848 assert_eq!(
849 keys(&data["steward_recusal"]["votes"][0]),
850 ["seat", "reason", "limits_steward_powers"]
851 );
852 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
853 assert_eq!(
854 keys(&data["rounds"][0]),
855 [
856 "number",
857 "round_type",
858 "steward_emergency",
859 "steward_contribution",
860 "responses"
861 ]
862 );
863 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
864 }
865
866 #[test]
868 fn a_redacted_record_parses_with_the_redactions_in_place() {
869 let (name, data) = fixtures().swap_remove(0);
870 assert_eq!(name, "GOV-2026-0001.json");
871 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
872 let fields = [
873 "/title",
874 "/vote_tally",
875 "/rounds/0/responses/0/position",
876 "/rounds/0/responses/0/rationale",
877 "/rounds/0/responses/0/raw_text",
878 "/rounds/0/responses/1/questions",
879 "/rounds/0/responses/2/questions/1",
880 "/rounds/1/steward_contribution",
881 ]
882 .map(String::from);
883 let redacted =
884 super::super::redact_data(&data, &fields, &amd, Blind::random())
885 .unwrap();
886 let record = round_trips(&name, &redacted);
887
888 let gone = Redactable::Redacted(amd.clone());
889 assert_eq!(record.title, gone);
890 assert_eq!(record.vote_tally, gone);
891 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
892 panic!("round 1 has four responses");
893 };
894 assert_eq!(first.position, gone);
895 assert_eq!(first.rationale.redacted_by(), Some(&amd));
896 assert_eq!(first.raw_text, Some(gone.clone()));
897 assert!(!first.questions.is_redacted());
898 assert_eq!(second.questions.redacted_by(), Some(&amd));
899 let questions = third.questions.value().unwrap();
900 assert!(!questions[0].is_redacted());
901 assert_eq!(questions[1], gone);
902 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
903 assert!(record.blind.is_some());
904 }
905
906 #[test]
907 fn an_attachment_can_be_redacted() {
908 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
909 let redacted = super::super::redact_data(
910 &synthetic("attachments"),
911 &["/attachments/0/content".into()],
912 &amd,
913 Blind::random(),
914 )
915 .unwrap();
916 let record = round_trips("attachments", &redacted);
917 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
918 }
919
920 #[test]
923 fn a_redacted_vote_is_an_error() {
924 let (_, data) = fixtures().swap_remove(0);
925 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
926 let redacted = super::super::redact_data(
927 &data,
928 &["/final_votes/artist".into()],
929 &amd,
930 Blind::random(),
931 )
932 .unwrap();
933 assert!(
934 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
935 );
936 }
937
938 #[cfg(feature = "schemars")]
939 #[test]
940 fn schema_is_ref_free() {
941 let schema =
942 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
943 let text = schema.to_string();
944 assert!(!text.contains("$ref"), "{text}");
945 assert!(!text.contains("$defs"), "{text}");
946 let plain = serde_json::to_string(&schemars::schema_for!(
947 CouncilDecisionRecord
948 ))
949 .unwrap();
950 assert!(!plain.contains("$ref"), "{plain}");
951 assert!(!plain.contains("$defs"), "{plain}");
952 }
953}