1use misanthropic::Model;
15use misanthropic::prompt::message::{Content, Thought};
16use serde::{Deserialize, Serialize};
17
18use super::{Blind, Redactable};
19
20mod decided;
21mod ranking;
22mod votes;
23use crate::enums::{DecisionOutcome, ProposalCategory, RoundType};
24use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
25pub use decided::{Decided, FinalRoundError, RecordRuleError};
26pub use votes::{
27 BlankStatement, NotASeatChoice, SeatChoice, SeatVote, SeatVotes, Statement,
28 StewardVote, Vote, Votes,
29};
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
36#[cfg_attr(feature = "schemars", schemars(inline))]
37pub struct CouncilDecisionRecord {
38 pub id: GovernanceLogId,
40 pub meeting_id: CouncilMeetingId,
42 pub category: DecisionCategory,
43 pub title: Redactable<String>,
45 pub rounds: Vec<CouncilRound>,
48 pub final_votes: FinalVotes,
49 pub outcome: DecisionOutcome,
53 pub vote_tally: Redactable<String>,
57 pub landmark: bool,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub veto_rationale: Option<Redactable<String>>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub agenda_ranking: Option<AgendaRanking>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub steward_recusal: Option<StewardRecusal>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub abstentions: Vec<Abstention>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub overrules: Vec<GovernanceLogId>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub attachments: Vec<CouncilAttachment>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub seat_models: Option<SeatModels>,
90 #[serde(
93 rename = "_blind",
94 default,
95 skip_serializing_if = "Option::is_none"
96 )]
97 pub blind: Option<Blind>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "schemars", schemars(inline))]
104pub struct CouncilAttachment {
105 pub name: String,
107 pub note: String,
109 pub content: Redactable<String>,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
120#[cfg_attr(feature = "schemars", schemars(inline))]
121pub enum DecisionCategory {
122 Routine,
125 Policy,
128 Constitutional,
131 Emergency,
133 Schedule,
137}
138
139impl From<ProposalCategory> for DecisionCategory {
141 fn from(category: ProposalCategory) -> Self {
142 match category {
143 ProposalCategory::Routine => Self::Routine,
144 ProposalCategory::Policy => Self::Policy,
145 ProposalCategory::Constitutional => Self::Constitutional,
146 ProposalCategory::Emergency => Self::Emergency,
147 ProposalCategory::Schedule => Self::Schedule,
148 }
149 }
150}
151
152impl DecisionCategory {
153 pub const fn threshold(self) -> &'static str {
155 ProposalCategory::from_decision(self).threshold()
156 }
157
158 pub const fn determines_recusal(self) -> bool {
161 ProposalCategory::from_decision(self).determines_recusal()
162 }
163
164 pub const fn recused_threshold(self) -> Option<&'static str> {
167 ProposalCategory::from_decision(self).recused_threshold()
168 }
169
170 pub const fn name(self) -> &'static str {
173 match self {
174 Self::Routine => "Routine",
175 Self::Policy => "Policy",
176 Self::Constitutional => "Constitutional",
177 Self::Emergency => "Emergency",
178 Self::Schedule => "Schedule",
179 }
180 }
181}
182
183impl std::fmt::Display for DecisionCategory {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.write_str(self.name())
187 }
188}
189
190impl ProposalCategory {
191 const fn from_decision(category: DecisionCategory) -> Self {
193 match category {
194 DecisionCategory::Routine => Self::Routine,
195 DecisionCategory::Policy => Self::Policy,
196 DecisionCategory::Constitutional => Self::Constitutional,
197 DecisionCategory::Emergency => Self::Emergency,
198 DecisionCategory::Schedule => Self::Schedule,
199 }
200 }
201}
202
203impl From<DecisionCategory> for ProposalCategory {
204 fn from(category: DecisionCategory) -> Self {
205 Self::from_decision(category)
206 }
207}
208
209pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
212
213pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
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 CouncilRound {
221 pub number: u32,
223 pub round_type: RoundType,
227 pub responses: Vec<SeatResponse>,
230 pub steward_contribution: Option<Redactable<String>>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub steward_emergency: Option<StewardEmergency>,
238 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
243 pub tabled_by_recused_steward: bool,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
250#[cfg_attr(feature = "schemars", schemars(inline))]
251pub struct StewardEmergency {
252 pub reason: Redactable<String>,
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
260#[cfg_attr(feature = "schemars", schemars(inline))]
261pub struct StewardRecusal {
262 pub votes: Vec<RecusalVote>,
265 pub recused: bool,
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
274#[cfg_attr(feature = "schemars", schemars(inline))]
275pub struct RecusalVote {
276 pub seat: CouncilSeat,
277 pub reason: Redactable<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub rationale: Option<Redactable<Rationale>>,
283 pub limits_steward_powers: bool,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
289#[cfg_attr(feature = "schemars", schemars(inline))]
290pub struct Abstention {
291 pub member: CouncilMember,
292 pub rationale: Redactable<String>,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298#[cfg_attr(feature = "schemars", schemars(inline))]
299#[serde(rename_all = "snake_case")]
300pub enum CouncilMember {
301 Artist,
302 Philosopher,
303 Lawyer,
304 Engineer,
305 Steward,
306}
307
308impl From<CouncilSeat> for CouncilMember {
309 fn from(seat: CouncilSeat) -> Self {
310 match seat {
311 CouncilSeat::Artist => Self::Artist,
312 CouncilSeat::Philosopher => Self::Philosopher,
313 CouncilSeat::Lawyer => Self::Lawyer,
314 CouncilSeat::Engineer => Self::Engineer,
315 }
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
322#[cfg_attr(feature = "schemars", schemars(inline))]
323pub struct SeatResponse {
324 pub role: CouncilSeat,
325 pub position: Redactable<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub vote: Option<CouncilVote>,
333 pub rationale: Redactable<Rationale>,
335 pub questions: Redactable<Vec<Redactable<String>>>,
338 pub ready_to_vote: bool,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub ranking: Option<SeatRanking>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub raw_text: Option<Redactable<String>>,
347}
348
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
360#[cfg_attr(feature = "schemars", schemars(inline))]
361#[serde(untagged)]
362pub enum Rationale {
363 Legacy(String),
365 Thinking(Vec<Thought>),
371}
372
373impl Rationale {
374 pub fn texts(&self) -> Vec<&str> {
377 match self {
378 Self::Legacy(text) => vec![text.as_str()],
379 Self::Thinking(thoughts) => {
380 thoughts.iter().filter_map(Thought::text).collect()
381 }
382 }
383 }
384
385 pub fn redacted_blocks(&self) -> usize {
387 match self {
388 Self::Legacy(_) => 0,
389 Self::Thinking(thoughts) => {
390 thoughts.iter().filter(|t| t.text().is_none()).count()
391 }
392 }
393 }
394
395 pub fn is_empty(&self) -> bool {
397 self.texts().iter().all(|t| t.trim().is_empty())
398 && self.redacted_blocks() == 0
399 }
400}
401
402impl std::fmt::Display for Rationale {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 let Self::Thinking(thoughts) = self else {
406 return f.write_str(&self.texts().concat());
407 };
408 for (i, thought) in thoughts.iter().enumerate() {
409 if i > 0 {
410 f.write_str("\n\n")?;
411 }
412 f.write_str(
413 thought.text().unwrap_or("[reasoning withheld]").trim_end(),
414 )?;
415 }
416 Ok(())
417 }
418}
419
420impl From<String> for Rationale {
421 fn from(text: String) -> Self {
422 Self::Legacy(text)
423 }
424}
425
426impl From<&str> for Rationale {
427 fn from(text: &str) -> Self {
428 Self::Legacy(text.to_owned())
429 }
430}
431
432impl From<Vec<Thought>> for Rationale {
433 fn from(thoughts: Vec<Thought>) -> Self {
434 Self::Thinking(thoughts)
435 }
436}
437
438impl From<&Content> for Rationale {
440 fn from(content: &Content) -> Self {
441 Self::Thinking(content.thoughts().collect())
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
448#[cfg_attr(feature = "schemars", schemars(inline))]
449pub struct SeatModels {
450 pub artist: Model,
451 pub philosopher: Model,
452 pub lawyer: Model,
453 pub engineer: Model,
454}
455
456impl SeatModels {
457 pub fn get(&self, seat: CouncilSeat) -> &Model {
458 match seat {
459 CouncilSeat::Artist => &self.artist,
460 CouncilSeat::Philosopher => &self.philosopher,
461 CouncilSeat::Lawyer => &self.lawyer,
462 CouncilSeat::Engineer => &self.engineer,
463 }
464 }
465}
466
467#[derive(
471 Debug,
472 Clone,
473 Copy,
474 PartialEq,
475 Eq,
476 PartialOrd,
477 Ord,
478 Hash,
479 Serialize,
480 Deserialize,
481)]
482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
483#[cfg_attr(feature = "schemars", schemars(inline))]
484#[serde(rename_all = "snake_case")]
485pub enum CouncilSeat {
486 Artist,
487 Philosopher,
488 Lawyer,
489 Engineer,
490}
491
492impl CouncilSeat {
493 pub const ALL: [Self; 4] = [
495 Self::Artist,
496 Self::Philosopher,
497 Self::Lawyer,
498 Self::Engineer,
499 ];
500
501 pub const fn as_str(self) -> &'static str {
503 match self {
504 Self::Artist => "artist",
505 Self::Philosopher => "philosopher",
506 Self::Lawyer => "lawyer",
507 Self::Engineer => "engineer",
508 }
509 }
510
511 pub const fn name(self) -> &'static str {
514 match self {
515 Self::Artist => "Artist",
516 Self::Philosopher => "Philosopher",
517 Self::Lawyer => "Lawyer",
518 Self::Engineer => "Engineer",
519 }
520 }
521}
522
523impl std::fmt::Display for CouncilSeat {
525 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526 f.write_str(self.name())
527 }
528}
529
530impl From<CouncilSeat> for crate::enums::ModelRole {
532 fn from(seat: CouncilSeat) -> Self {
533 match seat {
534 CouncilSeat::Artist => Self::Artist,
535 CouncilSeat::Philosopher => Self::Philosopher,
536 CouncilSeat::Lawyer => Self::Lawyer,
537 CouncilSeat::Engineer => Self::Engineer,
538 }
539 }
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
544#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
545#[cfg_attr(feature = "schemars", schemars(inline))]
546#[serde(rename_all = "snake_case")]
547pub enum CouncilVote {
548 Yes,
549 No,
550 Abstain,
554 Defer,
556 Concur,
558 Veto,
561 Ranked,
564 Recused,
567}
568
569impl CouncilVote {
570 pub const fn name(self) -> &'static str {
574 match self {
575 Self::Yes => "Yes",
576 Self::No => "No",
577 Self::Abstain => "Abstain",
578 Self::Defer => "Defer",
579 Self::Concur => "Concur",
580 Self::Veto => "Veto",
581 Self::Ranked => "Ranked",
582 Self::Recused => "Recused",
583 }
584 }
585}
586
587impl std::fmt::Display for CouncilVote {
589 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
590 f.write_str(self.name())
591 }
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
596#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
597#[cfg_attr(feature = "schemars", schemars(inline))]
598pub struct FinalVotes {
599 pub artist: CouncilVote,
600 pub philosopher: CouncilVote,
601 pub lawyer: CouncilVote,
602 pub engineer: CouncilVote,
603 pub steward: CouncilVote,
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
609#[cfg_attr(feature = "schemars", schemars(inline))]
610pub struct SeatRanking {
611 pub sitting_capacity: u32,
613 pub ranking: Vec<u32>,
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
623#[cfg_attr(feature = "schemars", schemars(inline))]
624pub struct AgendaRanking {
625 pub rankable: u32,
627 pub ballots: Vec<Ballot>,
628 pub order: Vec<PlacedProposal>,
631 pub cut: u32,
634 pub condorcet_winner: Option<u32>,
638}
639
640#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
643#[cfg_attr(feature = "schemars", schemars(inline))]
644pub struct Ballot {
645 pub seat: CouncilSeat,
646 pub sitting_capacity: u32,
648 pub ranking: Vec<u32>,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
655#[cfg_attr(feature = "schemars", schemars(inline))]
656pub struct PlacedProposal {
657 pub number: u32,
659 pub post_id: PostId,
661 pub borda: u32,
662 pub seats: u32,
664 pub position_sum: u32,
668 pub tied_with_next: bool,
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 fn fixtures() -> Vec<(String, serde_json::Value)> {
679 fixtures_in(concat!(
680 env!("CARGO_MANIFEST_DIR"),
681 "/tests/fixtures/council_decisions"
682 ))
683 }
684
685 fn revised_fixtures() -> Vec<(String, serde_json::Value)> {
687 fixtures_in(concat!(
688 env!("CARGO_MANIFEST_DIR"),
689 "/tests/fixtures/council_decisions/revised"
690 ))
691 }
692
693 const SIGNED: [(&str, &str, Option<&str>); 13] = [
697 (
698 "GOV-2026-0001",
699 "fca67b28737d613e3a98cdb3b1ad686944b36f1654ad5c237da6ec0291ee4574",
700 None,
701 ),
702 (
703 "GOV-2026-0002",
704 "b1bb43d611d75e1d3b03df8ff64381781b3d75d934a1f9d7ec75fe2174716c47",
705 None,
706 ),
707 (
708 "GOV-2026-0003",
709 "7c75af86d9b3930b5d14cd9e1784c2ecb32483427ba7149dffb20d84510c1b09",
710 Some(
711 "e1db85357dbe3b865129a53836a16c33730c7ebb37a8412f84afe5fbac4aa64d",
712 ),
713 ),
714 (
715 "GOV-2026-0004",
716 "0004e84733236dca2ab4dfb16757236ad0d76e27efbf47bbec0e00c6d90a7ed0",
717 Some(
718 "b2a9ec584f49eebf0103a72986440a326fcb5f68c83a7f1eddba8228046c342f",
719 ),
720 ),
721 (
722 "GOV-2026-0005",
723 "8257faade7548f15c010f2a5bb7ff22e1e37f7034eac2c9726445002fa593f0e",
724 Some(
725 "514a020a95669da78dfadcee58729a99402f9b2c2f750a557a8b8ce8512f451d",
726 ),
727 ),
728 (
729 "GOV-2026-0006",
730 "6520bb4cba556c4ba251584d836d1efb8a33227b83573f74c34c32362762a6f7",
731 Some(
732 "58f3fafc031ca29c796ac08fa69bb9994bf40fef9114189d6f820d050c66eedc",
733 ),
734 ),
735 (
736 "GOV-2026-0007",
737 "39a273c711d3f5b4744bbfcb1a1b2978e26b5b48629fabd5d0f136241167f07c",
738 Some(
739 "56c1036a8ee1eb852d831082f36ef9c22bc2a103a9a0d56c778fa040f9c6917e",
740 ),
741 ),
742 (
743 "GOV-2026-0008",
744 "89ac0d32809a3baf07a28ab6a511c64aaf1a31741cb3c5630d23fde484cf5cf3",
745 None,
746 ),
747 (
748 "GOV-2026-0009",
749 "32a86a94646ed886085895ad92e76285acced67b76c1853bc0e8b04626b6487d",
750 None,
751 ),
752 (
753 "GOV-2026-0010",
754 "aa4031caaf9e2851f70ed83008954869388cacb03a8119a2996a281da9e4a0dd",
755 None,
756 ),
757 (
758 "GOV-2026-0011",
759 "590b29357ae1acd425f64abb8e2aeeee2eec71d36dbb9afc99ec33ce4e5791d7",
760 None,
761 ),
762 (
763 "GOV-2026-0012",
764 "625d7f8b7c504def1fa332db68b06518d34b247edd5f2225be370a392e1a0973",
765 None,
766 ),
767 (
768 "GOV-2026-0013",
769 "c3d0a7f2c157e15f73031621148c1f3ff543418e31684d12fc6048a6c657dcb7",
770 None,
771 ),
772 ];
773
774 fn fixtures_in(dir: &str) -> Vec<(String, serde_json::Value)> {
775 let mut out: Vec<_> = std::fs::read_dir(dir)
776 .unwrap()
777 .map(|e| e.unwrap().path())
778 .filter(|p| p.extension().is_some_and(|x| x == "json"))
779 .map(|p| {
780 let text = std::fs::read_to_string(&p).unwrap();
781 (
782 p.file_name().unwrap().to_string_lossy().into_owned(),
783 serde_json::from_str(&text).unwrap(),
784 )
785 })
786 .collect();
787 out.sort_by(|a, b| a.0.cmp(&b.0));
788 out
789 }
790
791 fn round_trips(
794 name: &str,
795 data: &serde_json::Value,
796 ) -> CouncilDecisionRecord {
797 let record: CouncilDecisionRecord =
798 serde_json::from_value(data.clone())
799 .unwrap_or_else(|e| panic!("{name}: {e}"));
800 assert_eq!(
801 &serde_json::to_value(&record).unwrap(),
802 data,
803 "{name}: the typed record loses or changes something"
804 );
805 record
806 }
807
808 #[test]
809 fn every_signed_decision_is_described_whole() {
810 let fixtures = fixtures();
811 assert_eq!(fixtures.len(), 13, "GOV-2026-0001..0013");
812 let revised = revised_fixtures();
813 assert_eq!(revised.len(), 5, "GOV-2026-0003..0007, revised");
814 for (name, data) in fixtures.iter().chain(&revised) {
815 let record = round_trips(name, data);
816 assert_eq!(format!("{}.json", record.id), *name);
817 }
818 }
819
820 #[test]
826 fn every_signed_outcome_follows_from_its_votes() {
827 let check = |fixtures: Vec<(String, serde_json::Value)>| {
828 let mut checked = 0;
829 for (name, data) in fixtures {
830 let record: CouncilDecisionRecord =
831 serde_json::from_value(data).unwrap();
832 let tabled = record.final_votes.steward == CouncilVote::Defer;
833 if tabled || record.category == DecisionCategory::Schedule {
834 continue;
835 }
836 assert_eq!(
837 record.final_votes.outcome(record.category),
838 record.outcome,
839 "{name}"
840 );
841 assert_eq!(
842 record.vote_tally.value().map(String::as_str),
843 Some(record.final_votes.tally().as_str()),
844 "{name}"
845 );
846 checked += 1;
847 }
848 checked
849 };
850 assert_eq!(
851 check(fixtures()),
852 10,
853 "GOV-2026-0001, 0003..0007, 0009..0012"
854 );
855 assert_eq!(
856 check(revised_fixtures()),
857 5,
858 "GOV-2026-0003..0007, revised"
859 );
860 }
861
862 #[test]
866 fn every_signed_ranking_follows_from_its_ballots() {
867 let mut checked = 0;
868 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
869 let record: CouncilDecisionRecord =
870 serde_json::from_value(data).unwrap();
871 let Some(signed) = record.agenda_ranking else {
872 continue;
873 };
874 let candidates: std::collections::BTreeMap<u32, PostId> = (1
877 ..=signed.rankable)
878 .map(|n| {
879 let post = signed
880 .order
881 .iter()
882 .find(|p| p.number == n)
883 .map(|p| p.post_id)
884 .unwrap_or_else(|| {
885 PostId::from(uuid::Uuid::from_u128(n.into()))
886 });
887 (n, post)
888 })
889 .collect();
890 let again =
891 AgendaRanking::aggregate(&candidates, signed.ballots.clone());
892 assert_eq!(again, signed, "{name}");
893 checked += 1;
894 }
895 assert_eq!(checked, 2, "GOV-2026-0008 and 0013");
896 }
897
898 #[test]
905 fn decided_writes_what_the_archive_says() {
906 let mut checked = 0;
907 for (name, data) in fixtures() {
908 let record: CouncilDecisionRecord =
909 serde_json::from_value(data).unwrap();
910 let decided =
911 match (&record.agenda_ranking, record.final_votes.steward) {
912 (Some(ranking), _) => Decided::Ranked(ranking.clone()),
913 (None, CouncilVote::Defer) => Decided::Tabled,
914 (None, _) => {
915 let last = record.rounds.last().unwrap();
916 let votes = SeatVotes::from_final_round(last);
917 assert!(
918 matches!(
919 votes,
920 Err(FinalRoundError::NoStatement(_))
921 ),
922 "{name}: {votes:?}"
923 );
924 continue;
925 }
926 };
927 let again = record.clone().decided(decided);
928 assert_eq!(again, record, "{name}");
929 checked += 1;
930 }
931 assert_eq!(checked, 3, "GOV-2026-0002, 0008, 0013");
932 }
933
934 #[test]
937 fn the_prose_names() {
938 use DecisionCategory as C;
939 for c in [
940 C::Routine,
941 C::Policy,
942 C::Constitutional,
943 C::Emergency,
944 C::Schedule,
945 ] {
946 assert_eq!(
947 serde_json::to_value(c).unwrap(),
948 c.to_string().as_str()
949 );
950 assert_eq!(c.threshold(), ProposalCategory::from(c).threshold());
951 }
952 let capitalized = |s: &str| {
953 let mut c = s.chars();
954 c.next()
955 .unwrap()
956 .to_uppercase()
957 .chain(c)
958 .collect::<String>()
959 };
960 for seat in CouncilSeat::ALL {
961 assert_eq!(seat.to_string(), capitalized(seat.as_str()));
962 }
963 use CouncilVote as V;
964 for v in [
965 V::Yes,
966 V::No,
967 V::Abstain,
968 V::Defer,
969 V::Concur,
970 V::Veto,
971 V::Ranked,
972 V::Recused,
973 ] {
974 let serde = serde_json::to_value(v).unwrap();
975 assert_eq!(v.to_string(), capitalized(serde.as_str().unwrap()));
976 }
977 }
978
979 #[test]
982 fn every_signed_record_keeps_the_record_rules() {
983 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
984 let record: CouncilDecisionRecord =
985 serde_json::from_value(data).unwrap();
986 assert_eq!(record.check(), Ok(()), "{name}");
987 }
988 }
989
990 #[test]
994 fn the_fixtures_are_the_signed_bytes() {
995 let hex = |data: &serde_json::Value| {
996 serde_json::to_value(super::super::data_hash(data))
997 .unwrap()
998 .as_str()
999 .unwrap()
1000 .to_string()
1001 };
1002 let fixtures = fixtures();
1003 let revised = revised_fixtures();
1004 for (id, signed, latest) in SIGNED {
1005 let name = format!("{id}.json");
1006 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1007 assert_eq!(hex(data), signed, "{id} as signed");
1008 let found = revised.iter().find(|(n, _)| *n == name);
1009 assert_eq!(
1010 found.map(|(_, d)| hex(d)).as_deref(),
1011 latest,
1012 "{id} as revised"
1013 );
1014 }
1015 }
1016
1017 fn synthetic(name: &str) -> serde_json::Value {
1020 let base = serde_json::json!({
1021 "id": "GOV-2026-0099",
1022 "meeting_id": "00000000-0000-0000-0000-000000000001",
1023 "category": "Policy",
1024 "title": "t",
1025 "rounds": [],
1026 "final_votes": {
1027 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1028 "engineer": "yes", "steward": "veto"
1029 },
1030 "outcome": "rejected",
1031 "vote_tally": "4-0",
1032 "landmark": false,
1033 "_blind": "00".repeat(32),
1034 });
1035 let mut data = base;
1036 match name {
1037 "veto" => {
1038 data["veto_rationale"] = "because".into();
1039 }
1040 "refusal" => {
1041 data["final_votes"] = serde_json::json!({
1042 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
1043 "engineer": "defer", "steward": "defer"
1044 });
1045 data["outcome"] = "deferred".into();
1046 data["vote_tally"] =
1047 "Deferred: tabled because the API returned a \
1048 refusal for the Artist's final vote"
1049 .into();
1050 data["rounds"] = serde_json::json!([{
1051 "number": 3,
1052 "round_type": "final_vote",
1053 "responses": [{
1054 "role": "artist",
1055 "position": "No response: the API returned a refusal \
1056 (category: cyber; explanation: Flagged by a safety \
1057 classifier.).",
1058 "rationale": "",
1059 "questions": [],
1060 "ready_to_vote": false
1061 }],
1062 "steward_contribution": "Tabled by the Steward: the API \
1063 returned a refusal for the Artist's final vote (…)."
1064 }]);
1065 }
1066 "schedule" => {
1067 data["category"] = "Schedule".into();
1068 data["outcome"] = "approved".into();
1069 data["final_votes"] = serde_json::json!({
1070 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
1071 "engineer": "abstain", "steward": "abstain"
1072 });
1073 data["vote_tally"] =
1074 "ranked 3/4; the Steward executes the order \
1075 and does not vote"
1076 .into();
1077 data["rounds"] = serde_json::json!([{
1078 "number": 1,
1079 "round_type": "final_vote",
1080 "responses": [{
1081 "role": "lawyer",
1082 "position": "p",
1083 "vote": "ranked",
1084 "rationale": "r",
1085 "questions": [],
1086 "ready_to_vote": true,
1087 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
1088 }],
1089 "steward_contribution": null
1090 }]);
1091 data["agenda_ranking"] = serde_json::json!({
1092 "rankable": 3,
1093 "ballots": [
1094 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
1095 ],
1096 "order": [{
1097 "number": 3,
1098 "post_id": "00000000-0000-0000-0000-000000000003",
1099 "borda": 3, "seats": 1, "position_sum": 1,
1100 "tied_with_next": false
1101 }, {
1102 "number": 1,
1103 "post_id": "00000000-0000-0000-0000-000000000001",
1104 "borda": 2, "seats": 1, "position_sum": 2,
1105 "tied_with_next": false
1106 }],
1107 "cut": 2,
1108 "condorcet_winner": null
1109 });
1110 }
1111 "attachments" => {
1112 data["attachments"] = serde_json::json!([{
1113 "name": "clerk-thread-summary.md",
1114 "note": "The Clerk's summary of the thread, given to every seat",
1115 "content": "## Arguments\n\n[C1] argues for it."
1116 }]);
1117 }
1118 "recusal" => {
1119 data["final_votes"] = serde_json::json!({
1120 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1121 "engineer": "abstain", "steward": "recused"
1122 });
1123 data["outcome"] = "rejected".into();
1124 data["vote_tally"] = "3-0 (Steward recused)".into();
1125 data["rounds"] = serde_json::json!([{
1126 "number": 2,
1127 "round_type": "deliberation",
1128 "responses": [],
1129 "steward_contribution": "The quoted comment is forged.",
1130 "steward_emergency": {
1131 "reason": "An injected comment is steering the seats."
1132 }
1133 }]);
1134 data["steward_recusal"] = serde_json::json!({
1135 "votes": [
1136 {"seat": "artist", "reason": "r",
1137 "limits_steward_powers": true},
1138 {"seat": "philosopher", "reason": "r",
1139 "limits_steward_powers": true},
1140 {"seat": "lawyer", "reason": "r",
1141 "limits_steward_powers": true},
1142 {"seat": "engineer", "reason": "r",
1143 "limits_steward_powers": false}
1144 ],
1145 "recused": true
1146 });
1147 data["abstentions"] = serde_json::json!([
1148 {"member": "engineer", "rationale": "Conflicted."}
1149 ]);
1150 }
1151 "thinking" => {
1152 data["seat_models"] = serde_json::json!({
1153 "artist": "claude-opus-4-6",
1154 "philosopher": "claude-opus-4-6",
1155 "lawyer": "claude-opus-4-6",
1156 "engineer": "qwen3.8-35b-a3b"
1157 });
1158 data["rounds"] = serde_json::json!([{
1159 "number": 1,
1160 "round_type": "independent",
1161 "responses": [{
1162 "role": "lawyer",
1163 "position": "p",
1164 "vote": "yes",
1165 "rationale": [
1166 {"type": "thinking", "thinking": "First.",
1167 "signature": "EqQBCkgIARABGAIiQL"},
1168 {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
1169 {"type": "thinking", "thinking": "Then, after reading.",
1170 "signature": "EqQBCkgIARABGAIiQM"}
1171 ],
1172 "questions": [],
1173 "ready_to_vote": false
1174 }],
1175 "steward_contribution": null
1176 }]);
1177 data["steward_recusal"] = serde_json::json!({
1178 "votes": [
1179 {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
1180 "rationale": [{"type": "thinking",
1181 "thinking": "Unsigned, from a local model.",
1182 "signature": ""}],
1183 "limits_steward_powers": false}
1184 ],
1185 "recused": false
1186 });
1187 }
1188 "recused_refusal" => {
1189 data = synthetic("refusal");
1190 data["final_votes"]["steward"] = "recused".into();
1191 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
1192 }
1193 _ => unreachable!(),
1194 }
1195 data
1196 }
1197
1198 #[test]
1199 fn newer_shapes_are_described_whole() {
1200 for name in [
1201 "veto",
1202 "refusal",
1203 "schedule",
1204 "attachments",
1205 "recusal",
1206 "recused_refusal",
1207 "thinking",
1208 ] {
1209 round_trips(name, &synthetic(name));
1210 }
1211 let tabled =
1212 round_trips("recused_refusal", &synthetic("recused_refusal"));
1213 assert!(tabled.rounds[0].tabled_by_recused_steward);
1214 assert!(
1215 !round_trips("refusal", &synthetic("refusal")).rounds[0]
1216 .tabled_by_recused_steward
1217 );
1218 let refused = round_trips("refusal", &synthetic("refusal"));
1219 assert_eq!(refused.rounds[0].responses[0].vote, None);
1220 }
1221
1222 #[test]
1225 fn a_thinking_rationale_reads_back() {
1226 let record = round_trips("thinking", &synthetic("thinking"));
1227 let models = record.seat_models.as_ref().unwrap();
1228 assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
1229 assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
1230
1231 let response = &record.rounds[0].responses[0];
1232 let rationale = response.rationale.value().unwrap();
1233 assert!(matches!(rationale, Rationale::Thinking(_)));
1234 assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
1235 assert_eq!(rationale.redacted_blocks(), 1);
1236
1237 let vote = &record.steward_recusal.unwrap().votes[0];
1238 let thought = vote.rationale.as_ref().unwrap().value().unwrap();
1239 assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
1240
1241 assert_eq!(
1242 rationale.to_string(),
1243 "First.\n\n[reasoning withheld]\n\nThen, after reading."
1244 );
1245 assert!(!rationale.is_empty());
1246 assert!(Rationale::Legacy(" ".into()).is_empty());
1247
1248 let legacy = round_trips("schedule", &synthetic("schedule"));
1249 let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
1250 assert_eq!(legacy, &Rationale::Legacy("r".into()));
1251 }
1252
1253 #[test]
1254 fn a_thinking_rationale_can_be_redacted() {
1255 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1256 let redacted = super::super::redact_data(
1257 &synthetic("thinking"),
1258 &["/rounds/0/responses/0/rationale".into()],
1259 &amd,
1260 Blind::random(),
1261 )
1262 .unwrap();
1263 let record = round_trips("thinking", &redacted);
1264 assert_eq!(
1265 record.rounds[0].responses[0].rationale.redacted_by(),
1266 Some(&amd)
1267 );
1268 }
1269
1270 #[test]
1273 fn a_recused_decision_reads_back() {
1274 let record = round_trips("recusal", &synthetic("recusal"));
1275 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
1276 let recusal = record.steward_recusal.unwrap();
1277 assert!(recusal.recused);
1278 assert_eq!(
1279 recusal
1280 .votes
1281 .iter()
1282 .filter(|v| v.limits_steward_powers)
1283 .count(),
1284 3
1285 );
1286 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
1287 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
1288 assert_eq!(
1289 emergency.reason.value().map(String::as_str),
1290 Some("An injected comment is steering the seats.")
1291 );
1292 let veto = round_trips("veto", &synthetic("veto"));
1295 assert_eq!(veto.steward_recusal, None);
1296 assert!(veto.abstentions.is_empty());
1297 }
1298
1299 #[test]
1300 fn a_recused_decision_reads_in_order() {
1301 let data = synthetic("recusal");
1302 let keys = |v: &serde_json::Value| -> Vec<String> {
1303 super::super::reading::ordered(v.as_object().unwrap())
1304 .into_iter()
1305 .map(|(k, _)| k.clone())
1306 .collect()
1307 };
1308 let top = keys(&data);
1309 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1310 assert!(at("rounds") < at("steward_recusal"));
1311 assert!(at("steward_recusal") < at("final_votes"));
1312 assert!(at("final_votes") < at("abstentions"));
1313 assert!(at("abstentions") < at("vote_tally"));
1314 assert_eq!(
1315 keys(&data["steward_recusal"]["votes"][0]),
1316 ["seat", "reason", "limits_steward_powers"]
1317 );
1318 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
1319 assert_eq!(
1320 keys(&data["rounds"][0]),
1321 [
1322 "number",
1323 "round_type",
1324 "steward_emergency",
1325 "steward_contribution",
1326 "responses"
1327 ]
1328 );
1329 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
1330 }
1331
1332 #[test]
1334 fn a_redacted_record_parses_with_the_redactions_in_place() {
1335 let (name, data) = fixtures().swap_remove(0);
1336 assert_eq!(name, "GOV-2026-0001.json");
1337 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1338 let fields = [
1339 "/title",
1340 "/vote_tally",
1341 "/rounds/0/responses/0/position",
1342 "/rounds/0/responses/0/rationale",
1343 "/rounds/0/responses/0/raw_text",
1344 "/rounds/0/responses/1/questions",
1345 "/rounds/0/responses/2/questions/1",
1346 "/rounds/1/steward_contribution",
1347 ]
1348 .map(String::from);
1349 let redacted =
1350 super::super::redact_data(&data, &fields, &amd, Blind::random())
1351 .unwrap();
1352 let record = round_trips(&name, &redacted);
1353
1354 let gone = Redactable::Redacted(amd.clone());
1355 assert_eq!(record.title, gone);
1356 assert_eq!(record.vote_tally, gone);
1357 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
1358 panic!("round 1 has four responses");
1359 };
1360 assert_eq!(first.position, gone);
1361 assert_eq!(first.rationale.redacted_by(), Some(&amd));
1362 assert_eq!(first.raw_text, Some(gone.clone()));
1363 assert!(!first.questions.is_redacted());
1364 assert_eq!(second.questions.redacted_by(), Some(&amd));
1365 let questions = third.questions.value().unwrap();
1366 assert!(!questions[0].is_redacted());
1367 assert_eq!(questions[1], gone);
1368 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
1369 assert!(record.blind.is_some());
1370 }
1371
1372 #[test]
1373 fn an_attachment_can_be_redacted() {
1374 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1375 let redacted = super::super::redact_data(
1376 &synthetic("attachments"),
1377 &["/attachments/0/content".into()],
1378 &amd,
1379 Blind::random(),
1380 )
1381 .unwrap();
1382 let record = round_trips("attachments", &redacted);
1383 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
1384 }
1385
1386 #[test]
1389 fn a_redacted_vote_is_an_error() {
1390 let (_, data) = fixtures().swap_remove(0);
1391 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1392 let redacted = super::super::redact_data(
1393 &data,
1394 &["/final_votes/artist".into()],
1395 &amd,
1396 Blind::random(),
1397 )
1398 .unwrap();
1399 assert!(
1400 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1401 );
1402 }
1403
1404 #[cfg(feature = "schemars")]
1405 #[test]
1406 fn schema_is_ref_free() {
1407 let schema =
1408 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1409 let text = schema.to_string();
1410 assert!(!text.contains("$ref"), "{text}");
1411 assert!(!text.contains("$defs"), "{text}");
1412 let plain = serde_json::to_string(&schemars::schema_for!(
1413 CouncilDecisionRecord
1414 ))
1415 .unwrap();
1416 assert!(!plain.contains("$ref"), "{plain}");
1417 assert!(!plain.contains("$defs"), "{plain}");
1418 }
1419}