1use 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#[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
129impl 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
154pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
157
158pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
160
161#[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 pub number: u32,
168 pub round_type: RoundType,
172 pub responses: Vec<SeatResponse>,
175 pub steward_contribution: Option<Redactable<String>>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub steward_emergency: Option<StewardEmergency>,
183 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
188 pub tabled_by_recused_steward: bool,
189}
190
191#[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 pub reason: Redactable<String>,
199}
200
201#[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 pub votes: Vec<RecusalVote>,
210 pub recused: bool,
214}
215
216#[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 pub reason: Redactable<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub rationale: Option<Redactable<Rationale>>,
228 pub limits_steward_powers: bool,
229}
230
231#[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#[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#[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 pub position: Redactable<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub vote: Option<CouncilVote>,
278 pub rationale: Redactable<Rationale>,
280 pub questions: Redactable<Vec<Redactable<String>>>,
283 pub ready_to_vote: bool,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub ranking: Option<SeatRanking>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub raw_text: Option<Redactable<String>>,
292}
293
294#[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 Legacy(String),
310 Thinking(Vec<Thought>),
316}
317
318impl Rationale {
319 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 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 pub fn is_empty(&self) -> bool {
342 self.texts().iter().all(|t| t.trim().is_empty())
343 && self.redacted_blocks() == 0
344 }
345}
346
347impl 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
383impl From<&Content> for Rationale {
385 fn from(content: &Content) -> Self {
386 Self::Thinking(content.thoughts().collect())
387 }
388}
389
390#[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#[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 pub const ALL: [Self; 4] = [
440 Self::Artist,
441 Self::Philosopher,
442 Self::Lawyer,
443 Self::Engineer,
444 ];
445
446 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 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#[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 Abstain,
479 Defer,
481 Concur,
483 Veto,
486 Ranked,
489 Recused,
492}
493
494#[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#[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 pub sitting_capacity: u32,
513 pub ranking: Vec<u32>,
516}
517
518#[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 pub rankable: u32,
527 pub ballots: Vec<Ballot>,
528 pub order: Vec<PlacedProposal>,
531 pub cut: u32,
534 pub condorcet_winner: Option<u32>,
538}
539
540#[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 pub sitting_capacity: u32,
548 pub ranking: Vec<u32>,
550}
551
552#[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 pub number: u32,
559 pub post_id: PostId,
561 pub borda: u32,
562 pub seats: u32,
564 pub position_sum: u32,
568 pub tied_with_next: bool,
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 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 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 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 #[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 #[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 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 #[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 #[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}