1use misanthropic::Model;
15use misanthropic::prompt::message::{Content, Thought};
16use serde::{Deserialize, Serialize};
17
18use super::{Blind, Redactable};
19
20mod decided;
21mod ranking;
22mod summary;
23mod votes;
24mod witness;
25use crate::enums::{DecisionOutcome, ProposalCategory, RoundType};
26use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
27pub use decided::{Decided, FinalRoundError, RecordRuleError};
28pub use summary::{
29 DecisionSummary, RECUSED_ACCEPTORS, SUMMARY_POINTER, SUMMARY_WRITER,
30 SummaryAcceptance, SummaryAuthorship, served_summary, test_labelled,
31};
32pub use votes::{
33 BlankStatement, DEFERRING_SEATS, NotASeatChoice, SeatChoice, SeatVote,
34 SeatVotes, Statement, StewardVote, Vote, Votes,
35};
36pub use witness::{
37 AgoraAnswer, AgoraExchange, Asked, BatchedQuestion, Testimony,
38 TestimonyRecord, Unanswered, Unheard, Witness, WitnessBatch,
39 WitnessQuestion, WitnessSource, part_label, split_for_dms,
40 testimony_attachment_name,
41};
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
48#[cfg_attr(feature = "schemars", schemars(inline))]
49pub struct CouncilDecisionRecord {
50 pub id: GovernanceLogId,
52 pub meeting_id: CouncilMeetingId,
54 pub category: DecisionCategory,
55 pub title: Redactable<String>,
57 pub rounds: Vec<CouncilRound>,
60 pub final_votes: FinalVotes,
61 pub outcome: DecisionOutcome,
65 pub vote_tally: Redactable<String>,
69 pub landmark: bool,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub veto_rationale: Option<Redactable<String>>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub agenda_ranking: Option<AgendaRanking>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub steward_recusal: Option<StewardRecusal>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub abstentions: Vec<Abstention>,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub overrules: Vec<GovernanceLogId>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub attachments: Vec<CouncilAttachment>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub seat_models: Option<SeatModels>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub summary: Option<DecisionSummary>,
108 #[serde(
111 rename = "_blind",
112 default,
113 skip_serializing_if = "Option::is_none"
114 )]
115 pub blind: Option<Blind>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct CouncilOutcome {
129 pub outcome: DecisionOutcome,
130 pub vote_tally: Option<Redactable<String>>,
134 pub steward_veto: bool,
137}
138
139impl CouncilOutcome {
140 pub fn of_data(
146 entry_type: crate::enums::GovernanceLogEntryType,
147 data: &serde_json::Value,
148 ) -> Option<Self> {
149 #[derive(Deserialize)]
151 struct StewardsVote {
152 #[serde(default)]
153 steward: Option<CouncilVote>,
154 }
155 #[derive(Deserialize)]
156 struct Read {
157 outcome: DecisionOutcome,
158 #[serde(default)]
159 vote_tally: Option<Redactable<String>>,
160 #[serde(default)]
161 final_votes: Option<StewardsVote>,
162 }
163 if entry_type != crate::enums::GovernanceLogEntryType::CouncilDecision {
164 return None;
165 }
166 let read = Read::deserialize(data).ok()?;
167 Some(Self {
168 outcome: read.outcome,
169 vote_tally: read.vote_tally,
170 steward_veto: read.final_votes.and_then(|v| v.steward)
171 == Some(CouncilVote::Veto),
172 })
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
179#[cfg_attr(feature = "schemars", schemars(inline))]
180pub struct CouncilAttachment {
181 pub name: String,
183 pub note: String,
185 pub content: Redactable<String>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
196#[cfg_attr(feature = "schemars", schemars(inline))]
197pub enum DecisionCategory {
198 Routine,
201 Policy,
204 Constitutional,
207 Emergency,
209 Schedule,
213}
214
215impl From<ProposalCategory> for DecisionCategory {
217 fn from(category: ProposalCategory) -> Self {
218 match category {
219 ProposalCategory::Routine => Self::Routine,
220 ProposalCategory::Policy => Self::Policy,
221 ProposalCategory::Constitutional => Self::Constitutional,
222 ProposalCategory::Emergency => Self::Emergency,
223 ProposalCategory::Schedule => Self::Schedule,
224 }
225 }
226}
227
228impl DecisionCategory {
229 pub const fn threshold(self) -> &'static str {
231 ProposalCategory::from_decision(self).threshold()
232 }
233
234 pub const fn determines_recusal(self) -> bool {
237 ProposalCategory::from_decision(self).determines_recusal()
238 }
239
240 pub const fn recused_threshold(self) -> Option<&'static str> {
243 ProposalCategory::from_decision(self).recused_threshold()
244 }
245
246 pub const fn name(self) -> &'static str {
249 match self {
250 Self::Routine => "Routine",
251 Self::Policy => "Policy",
252 Self::Constitutional => "Constitutional",
253 Self::Emergency => "Emergency",
254 Self::Schedule => "Schedule",
255 }
256 }
257}
258
259impl std::fmt::Display for DecisionCategory {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 f.write_str(self.name())
263 }
264}
265
266impl ProposalCategory {
267 const fn from_decision(category: DecisionCategory) -> Self {
269 match category {
270 DecisionCategory::Routine => Self::Routine,
271 DecisionCategory::Policy => Self::Policy,
272 DecisionCategory::Constitutional => Self::Constitutional,
273 DecisionCategory::Emergency => Self::Emergency,
274 DecisionCategory::Schedule => Self::Schedule,
275 }
276 }
277}
278
279impl From<DecisionCategory> for ProposalCategory {
280 fn from(category: DecisionCategory) -> Self {
281 Self::from_decision(category)
282 }
283}
284
285pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
288
289pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
291
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
295#[cfg_attr(feature = "schemars", schemars(inline))]
296pub struct CouncilRound {
297 pub number: u32,
299 pub round_type: RoundType,
303 pub responses: Vec<SeatResponse>,
306 pub steward_contribution: Option<Redactable<String>>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub steward_emergency: Option<StewardEmergency>,
314 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
319 pub tabled_by_recused_steward: bool,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub witness_questions: Vec<WitnessQuestion>,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
331#[cfg_attr(feature = "schemars", schemars(inline))]
332pub struct StewardEmergency {
333 pub reason: Redactable<String>,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
340#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
341#[cfg_attr(feature = "schemars", schemars(inline))]
342pub struct StewardRecusal {
343 pub votes: Vec<RecusalVote>,
346 pub recused: bool,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
355#[cfg_attr(feature = "schemars", schemars(inline))]
356pub struct RecusalVote {
357 pub seat: CouncilSeat,
358 pub reason: Redactable<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub rationale: Option<Redactable<Rationale>>,
364 pub limits_steward_powers: bool,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
370#[cfg_attr(feature = "schemars", schemars(inline))]
371pub struct Abstention {
372 pub member: CouncilMember,
373 pub rationale: Redactable<String>,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
378#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
379#[cfg_attr(feature = "schemars", schemars(inline))]
380#[serde(rename_all = "snake_case")]
381pub enum CouncilMember {
382 Artist,
383 Philosopher,
384 Lawyer,
385 Engineer,
386 Steward,
387}
388
389impl From<CouncilSeat> for CouncilMember {
390 fn from(seat: CouncilSeat) -> Self {
391 match seat {
392 CouncilSeat::Artist => Self::Artist,
393 CouncilSeat::Philosopher => Self::Philosopher,
394 CouncilSeat::Lawyer => Self::Lawyer,
395 CouncilSeat::Engineer => Self::Engineer,
396 }
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
403#[cfg_attr(feature = "schemars", schemars(inline))]
404pub struct SeatResponse {
405 pub role: CouncilSeat,
406 pub position: Redactable<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub vote: Option<CouncilVote>,
414 pub rationale: Redactable<Rationale>,
416 pub questions: Redactable<SeatQuestions>,
421 pub ready_to_vote: bool,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub ranking: Option<SeatRanking>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub raw_text: Option<Redactable<String>>,
430}
431
432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
438#[cfg_attr(feature = "schemars", schemars(inline))]
439#[serde(untagged)]
440pub enum SeatQuestions {
441 Addressed(Vec<Redactable<Question>>),
443 Legacy(Vec<Redactable<String>>),
445}
446
447impl Default for SeatQuestions {
448 fn default() -> Self {
449 Self::Addressed(Vec::new())
450 }
451}
452
453impl From<Vec<Question>> for SeatQuestions {
454 fn from(questions: Vec<Question>) -> Self {
455 Self::Addressed(questions.into_iter().map(Redactable::Value).collect())
456 }
457}
458
459impl SeatQuestions {
460 pub fn len(&self) -> usize {
462 match self {
463 Self::Addressed(q) => q.len(),
464 Self::Legacy(q) => q.len(),
465 }
466 }
467
468 pub fn is_empty(&self) -> bool {
469 self.len() == 0
470 }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
481#[cfg_attr(feature = "schemars", schemars(inline))]
482pub struct Question {
483 #[serde(rename = "for")]
485 pub for_: Redactable<String>,
486 pub question: Redactable<String>,
487}
488
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
500#[cfg_attr(feature = "schemars", schemars(inline))]
501#[serde(untagged)]
502pub enum Rationale {
503 Legacy(String),
505 Thinking(Vec<Thought>),
511}
512
513impl Rationale {
514 pub fn texts(&self) -> Vec<&str> {
517 match self {
518 Self::Legacy(text) => vec![text.as_str()],
519 Self::Thinking(thoughts) => {
520 thoughts.iter().filter_map(Thought::text).collect()
521 }
522 }
523 }
524
525 pub fn redacted_blocks(&self) -> usize {
527 match self {
528 Self::Legacy(_) => 0,
529 Self::Thinking(thoughts) => {
530 thoughts.iter().filter(|t| t.text().is_none()).count()
531 }
532 }
533 }
534
535 pub fn is_empty(&self) -> bool {
537 self.texts().iter().all(|t| t.trim().is_empty())
538 && self.redacted_blocks() == 0
539 }
540}
541
542impl std::fmt::Display for Rationale {
544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 let Self::Thinking(thoughts) = self else {
546 return f.write_str(&self.texts().concat());
547 };
548 for (i, thought) in thoughts.iter().enumerate() {
549 if i > 0 {
550 f.write_str("\n\n")?;
551 }
552 f.write_str(
553 thought.text().unwrap_or("[reasoning withheld]").trim_end(),
554 )?;
555 }
556 Ok(())
557 }
558}
559
560impl From<String> for Rationale {
561 fn from(text: String) -> Self {
562 Self::Legacy(text)
563 }
564}
565
566impl From<&str> for Rationale {
567 fn from(text: &str) -> Self {
568 Self::Legacy(text.to_owned())
569 }
570}
571
572impl From<Vec<Thought>> for Rationale {
573 fn from(thoughts: Vec<Thought>) -> Self {
574 Self::Thinking(thoughts)
575 }
576}
577
578impl From<&Content> for Rationale {
580 fn from(content: &Content) -> Self {
581 Self::Thinking(content.thoughts().collect())
582 }
583}
584
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
588#[cfg_attr(feature = "schemars", schemars(inline))]
589pub struct SeatModels {
590 pub artist: Model,
591 pub philosopher: Model,
592 pub lawyer: Model,
593 pub engineer: Model,
594}
595
596impl SeatModels {
597 pub fn get(&self, seat: CouncilSeat) -> &Model {
598 match seat {
599 CouncilSeat::Artist => &self.artist,
600 CouncilSeat::Philosopher => &self.philosopher,
601 CouncilSeat::Lawyer => &self.lawyer,
602 CouncilSeat::Engineer => &self.engineer,
603 }
604 }
605}
606
607#[derive(
611 Debug,
612 Clone,
613 Copy,
614 PartialEq,
615 Eq,
616 PartialOrd,
617 Ord,
618 Hash,
619 Serialize,
620 Deserialize,
621)]
622#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
623#[cfg_attr(feature = "schemars", schemars(inline))]
624#[serde(rename_all = "snake_case")]
625pub enum CouncilSeat {
626 Artist,
627 Philosopher,
628 Lawyer,
629 Engineer,
630}
631
632impl CouncilSeat {
633 pub const ALL: [Self; 4] = [
635 Self::Artist,
636 Self::Philosopher,
637 Self::Lawyer,
638 Self::Engineer,
639 ];
640
641 pub const fn as_str(self) -> &'static str {
643 match self {
644 Self::Artist => "artist",
645 Self::Philosopher => "philosopher",
646 Self::Lawyer => "lawyer",
647 Self::Engineer => "engineer",
648 }
649 }
650
651 pub const fn name(self) -> &'static str {
654 match self {
655 Self::Artist => "Artist",
656 Self::Philosopher => "Philosopher",
657 Self::Lawyer => "Lawyer",
658 Self::Engineer => "Engineer",
659 }
660 }
661}
662
663impl std::fmt::Display for CouncilSeat {
665 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666 f.write_str(self.name())
667 }
668}
669
670impl From<CouncilSeat> for crate::enums::ModelRole {
672 fn from(seat: CouncilSeat) -> Self {
673 match seat {
674 CouncilSeat::Artist => Self::Artist,
675 CouncilSeat::Philosopher => Self::Philosopher,
676 CouncilSeat::Lawyer => Self::Lawyer,
677 CouncilSeat::Engineer => Self::Engineer,
678 }
679 }
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
684#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
685#[cfg_attr(feature = "schemars", schemars(inline))]
686#[serde(rename_all = "snake_case")]
687pub enum CouncilVote {
688 Yes,
689 No,
690 Abstain,
694 Defer,
696 Concur,
698 Veto,
701 Ranked,
704 Recused,
707}
708
709impl CouncilVote {
710 pub const fn name(self) -> &'static str {
714 match self {
715 Self::Yes => "Yes",
716 Self::No => "No",
717 Self::Abstain => "Abstain",
718 Self::Defer => "Defer",
719 Self::Concur => "Concur",
720 Self::Veto => "Veto",
721 Self::Ranked => "Ranked",
722 Self::Recused => "Recused",
723 }
724 }
725}
726
727impl std::fmt::Display for CouncilVote {
729 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730 f.write_str(self.name())
731 }
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
736#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
737#[cfg_attr(feature = "schemars", schemars(inline))]
738pub struct FinalVotes {
739 pub artist: CouncilVote,
740 pub philosopher: CouncilVote,
741 pub lawyer: CouncilVote,
742 pub engineer: CouncilVote,
743 pub steward: CouncilVote,
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
749#[cfg_attr(feature = "schemars", schemars(inline))]
750pub struct SeatRanking {
751 pub sitting_capacity: u32,
753 pub ranking: Vec<u32>,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
762#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
763#[cfg_attr(feature = "schemars", schemars(inline))]
764pub struct AgendaRanking {
765 pub rankable: u32,
767 pub ballots: Vec<Ballot>,
768 pub order: Vec<PlacedProposal>,
771 pub cut: u32,
774 pub condorcet_winner: Option<u32>,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
782#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
783#[cfg_attr(feature = "schemars", schemars(inline))]
784pub struct Ballot {
785 pub seat: CouncilSeat,
786 pub sitting_capacity: u32,
788 pub ranking: Vec<u32>,
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
794#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
795#[cfg_attr(feature = "schemars", schemars(inline))]
796pub struct PlacedProposal {
797 pub number: u32,
799 pub post_id: PostId,
801 pub borda: u32,
802 pub seats: u32,
804 pub position_sum: u32,
808 pub tied_with_next: bool,
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::enums::AmendmentKind;
817
818 fn fixtures() -> Vec<(String, serde_json::Value)> {
820 fixtures_in(concat!(
821 env!("CARGO_MANIFEST_DIR"),
822 "/tests/fixtures/council_decisions"
823 ))
824 }
825
826 fn revised_fixtures() -> Vec<(String, serde_json::Value)> {
828 fixtures_in(concat!(
829 env!("CARGO_MANIFEST_DIR"),
830 "/tests/fixtures/council_decisions/revised"
831 ))
832 }
833
834 const SIGNED: [(&str, &str, Option<&str>); 13] = [
838 (
839 "GOV-2026-0001",
840 "fca67b28737d613e3a98cdb3b1ad686944b36f1654ad5c237da6ec0291ee4574",
841 None,
842 ),
843 (
844 "GOV-2026-0002",
845 "b1bb43d611d75e1d3b03df8ff64381781b3d75d934a1f9d7ec75fe2174716c47",
846 None,
847 ),
848 (
849 "GOV-2026-0003",
850 "7c75af86d9b3930b5d14cd9e1784c2ecb32483427ba7149dffb20d84510c1b09",
851 Some(
852 "e1db85357dbe3b865129a53836a16c33730c7ebb37a8412f84afe5fbac4aa64d",
853 ),
854 ),
855 (
856 "GOV-2026-0004",
857 "0004e84733236dca2ab4dfb16757236ad0d76e27efbf47bbec0e00c6d90a7ed0",
858 Some(
859 "b2a9ec584f49eebf0103a72986440a326fcb5f68c83a7f1eddba8228046c342f",
860 ),
861 ),
862 (
863 "GOV-2026-0005",
864 "8257faade7548f15c010f2a5bb7ff22e1e37f7034eac2c9726445002fa593f0e",
865 Some(
866 "514a020a95669da78dfadcee58729a99402f9b2c2f750a557a8b8ce8512f451d",
867 ),
868 ),
869 (
870 "GOV-2026-0006",
871 "6520bb4cba556c4ba251584d836d1efb8a33227b83573f74c34c32362762a6f7",
872 Some(
873 "58f3fafc031ca29c796ac08fa69bb9994bf40fef9114189d6f820d050c66eedc",
874 ),
875 ),
876 (
877 "GOV-2026-0007",
878 "39a273c711d3f5b4744bbfcb1a1b2978e26b5b48629fabd5d0f136241167f07c",
879 Some(
880 "56c1036a8ee1eb852d831082f36ef9c22bc2a103a9a0d56c778fa040f9c6917e",
881 ),
882 ),
883 (
884 "GOV-2026-0008",
885 "89ac0d32809a3baf07a28ab6a511c64aaf1a31741cb3c5630d23fde484cf5cf3",
886 None,
887 ),
888 (
889 "GOV-2026-0009",
890 "32a86a94646ed886085895ad92e76285acced67b76c1853bc0e8b04626b6487d",
891 None,
892 ),
893 (
894 "GOV-2026-0010",
895 "aa4031caaf9e2851f70ed83008954869388cacb03a8119a2996a281da9e4a0dd",
896 None,
897 ),
898 (
899 "GOV-2026-0011",
900 "590b29357ae1acd425f64abb8e2aeeee2eec71d36dbb9afc99ec33ce4e5791d7",
901 None,
902 ),
903 (
904 "GOV-2026-0012",
905 "625d7f8b7c504def1fa332db68b06518d34b247edd5f2225be370a392e1a0973",
906 None,
907 ),
908 (
909 "GOV-2026-0013",
910 "c3d0a7f2c157e15f73031621148c1f3ff543418e31684d12fc6048a6c657dcb7",
911 None,
912 ),
913 ];
914
915 fn fixtures_in(dir: &str) -> Vec<(String, serde_json::Value)> {
916 let mut out: Vec<_> = std::fs::read_dir(dir)
917 .unwrap()
918 .map(|e| e.unwrap().path())
919 .filter(|p| p.extension().is_some_and(|x| x == "json"))
920 .map(|p| {
921 let text = std::fs::read_to_string(&p).unwrap();
922 (
923 p.file_name().unwrap().to_string_lossy().into_owned(),
924 serde_json::from_str(&text).unwrap(),
925 )
926 })
927 .collect();
928 out.sort_by(|a, b| a.0.cmp(&b.0));
929 out
930 }
931
932 fn round_trips(
935 name: &str,
936 data: &serde_json::Value,
937 ) -> CouncilDecisionRecord {
938 let record: CouncilDecisionRecord =
939 serde_json::from_value(data.clone())
940 .unwrap_or_else(|e| panic!("{name}: {e}"));
941 assert_eq!(
942 &serde_json::to_value(&record).unwrap(),
943 data,
944 "{name}: the typed record loses or changes something"
945 );
946 record
947 }
948
949 #[test]
950 fn every_signed_decision_is_described_whole() {
951 let fixtures = fixtures();
952 assert_eq!(fixtures.len(), 13, "GOV-2026-0001..0013");
953 let revised = revised_fixtures();
954 assert_eq!(revised.len(), 5, "GOV-2026-0003..0007, revised");
955 for (name, data) in fixtures.iter().chain(&revised) {
956 let record = round_trips(name, data);
957 assert_eq!(format!("{}.json", record.id), *name);
958 }
959 }
960
961 #[test]
967 fn every_signed_outcome_follows_from_its_votes() {
968 let check = |fixtures: Vec<(String, serde_json::Value)>| {
969 let mut checked = 0;
970 for (name, data) in fixtures {
971 let record: CouncilDecisionRecord =
972 serde_json::from_value(data).unwrap();
973 let tabled = record.final_votes.steward == CouncilVote::Defer;
974 if tabled || record.category == DecisionCategory::Schedule {
975 continue;
976 }
977 assert_eq!(
978 record.final_votes.outcome(record.category),
979 record.outcome,
980 "{name}"
981 );
982 assert_eq!(
983 record.vote_tally.value().map(String::as_str),
984 Some(record.final_votes.tally().as_str()),
985 "{name}"
986 );
987 checked += 1;
988 }
989 checked
990 };
991 assert_eq!(
992 check(fixtures()),
993 10,
994 "GOV-2026-0001, 0003..0007, 0009..0012"
995 );
996 assert_eq!(
997 check(revised_fixtures()),
998 5,
999 "GOV-2026-0003..0007, revised"
1000 );
1001 }
1002
1003 #[test]
1007 fn every_signed_ranking_follows_from_its_ballots() {
1008 let mut checked = 0;
1009 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
1010 let record: CouncilDecisionRecord =
1011 serde_json::from_value(data).unwrap();
1012 let Some(signed) = record.agenda_ranking else {
1013 continue;
1014 };
1015 let candidates: std::collections::BTreeMap<u32, PostId> = (1
1018 ..=signed.rankable)
1019 .map(|n| {
1020 let post = signed
1021 .order
1022 .iter()
1023 .find(|p| p.number == n)
1024 .map(|p| p.post_id)
1025 .unwrap_or_else(|| {
1026 PostId::from(uuid::Uuid::from_u128(n.into()))
1027 });
1028 (n, post)
1029 })
1030 .collect();
1031 let again =
1032 AgendaRanking::aggregate(&candidates, signed.ballots.clone());
1033 assert_eq!(again, signed, "{name}");
1034 checked += 1;
1035 }
1036 assert_eq!(checked, 2, "GOV-2026-0008 and 0013");
1037 }
1038
1039 #[test]
1046 fn decided_writes_what_the_archive_says() {
1047 let mut checked = 0;
1048 for (name, data) in fixtures() {
1049 let record: CouncilDecisionRecord =
1050 serde_json::from_value(data).unwrap();
1051 let decided =
1052 match (&record.agenda_ranking, record.final_votes.steward) {
1053 (Some(ranking), _) => Decided::Ranked(ranking.clone()),
1054 (None, CouncilVote::Defer) => Decided::Tabled,
1055 (None, _) => {
1056 let last = record.rounds.last().unwrap();
1057 let votes = SeatVotes::from_final_round(last);
1058 assert!(
1059 matches!(
1060 votes,
1061 Err(FinalRoundError::NoStatement(_))
1062 ),
1063 "{name}: {votes:?}"
1064 );
1065 continue;
1066 }
1067 };
1068 let again = record.clone().decided(decided);
1069 assert_eq!(again, record, "{name}");
1070 checked += 1;
1071 }
1072 assert_eq!(checked, 3, "GOV-2026-0002, 0008, 0013");
1073 }
1074
1075 #[test]
1078 fn the_prose_names() {
1079 use DecisionCategory as C;
1080 for c in [
1081 C::Routine,
1082 C::Policy,
1083 C::Constitutional,
1084 C::Emergency,
1085 C::Schedule,
1086 ] {
1087 assert_eq!(
1088 serde_json::to_value(c).unwrap(),
1089 c.to_string().as_str()
1090 );
1091 assert_eq!(c.threshold(), ProposalCategory::from(c).threshold());
1092 }
1093 let capitalized = |s: &str| {
1094 let mut c = s.chars();
1095 c.next()
1096 .unwrap()
1097 .to_uppercase()
1098 .chain(c)
1099 .collect::<String>()
1100 };
1101 for seat in CouncilSeat::ALL {
1102 assert_eq!(seat.to_string(), capitalized(seat.as_str()));
1103 }
1104 use CouncilVote as V;
1105 for v in [
1106 V::Yes,
1107 V::No,
1108 V::Abstain,
1109 V::Defer,
1110 V::Concur,
1111 V::Veto,
1112 V::Ranked,
1113 V::Recused,
1114 ] {
1115 let serde = serde_json::to_value(v).unwrap();
1116 assert_eq!(v.to_string(), capitalized(serde.as_str().unwrap()));
1117 }
1118 }
1119
1120 #[test]
1123 fn every_signed_record_keeps_the_record_rules() {
1124 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
1125 let record: CouncilDecisionRecord =
1126 serde_json::from_value(data).unwrap();
1127 assert_eq!(record.check(), Ok(()), "{name}");
1128 }
1129 }
1130
1131 #[test]
1135 fn the_fixtures_are_the_signed_bytes() {
1136 let hex = |data: &serde_json::Value| {
1137 serde_json::to_value(super::super::data_hash(data))
1138 .unwrap()
1139 .as_str()
1140 .unwrap()
1141 .to_string()
1142 };
1143 let fixtures = fixtures();
1144 let revised = revised_fixtures();
1145 for (id, signed, latest) in SIGNED {
1146 let name = format!("{id}.json");
1147 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1148 assert_eq!(hex(data), signed, "{id} as signed");
1149 let found = revised.iter().find(|(n, _)| *n == name);
1150 assert_eq!(
1151 found.map(|(_, d)| hex(d)).as_deref(),
1152 latest,
1153 "{id} as revised"
1154 );
1155 }
1156 }
1157
1158 fn synthetic(name: &str) -> serde_json::Value {
1161 let base = serde_json::json!({
1162 "id": "GOV-2026-0099",
1163 "meeting_id": "00000000-0000-0000-0000-000000000001",
1164 "category": "Policy",
1165 "title": "t",
1166 "rounds": [],
1167 "final_votes": {
1168 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1169 "engineer": "yes", "steward": "veto"
1170 },
1171 "outcome": "rejected",
1172 "vote_tally": "4-0",
1173 "landmark": false,
1174 "_blind": "00".repeat(32),
1175 });
1176 let mut data = base;
1177 match name {
1178 "veto" => {
1179 data["veto_rationale"] = "because".into();
1180 }
1181 "refusal" => {
1182 data["final_votes"] = serde_json::json!({
1183 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
1184 "engineer": "defer", "steward": "defer"
1185 });
1186 data["outcome"] = "deferred".into();
1187 data["vote_tally"] =
1188 "Deferred: tabled because the API returned a \
1189 refusal for the Artist's final vote"
1190 .into();
1191 data["rounds"] = serde_json::json!([{
1192 "number": 3,
1193 "round_type": "final_vote",
1194 "responses": [{
1195 "role": "artist",
1196 "position": "No response: the API returned a refusal \
1197 (category: cyber; explanation: Flagged by a safety \
1198 classifier.).",
1199 "rationale": "",
1200 "questions": [],
1201 "ready_to_vote": false
1202 }],
1203 "steward_contribution": "Tabled by the Steward: the API \
1204 returned a refusal for the Artist's final vote (…)."
1205 }]);
1206 }
1207 "schedule" => {
1208 data["category"] = "Schedule".into();
1209 data["outcome"] = "approved".into();
1210 data["final_votes"] = serde_json::json!({
1211 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
1212 "engineer": "abstain", "steward": "abstain"
1213 });
1214 data["vote_tally"] =
1215 "ranked 3/4; the Steward executes the order \
1216 and does not vote"
1217 .into();
1218 data["rounds"] = serde_json::json!([{
1219 "number": 1,
1220 "round_type": "final_vote",
1221 "responses": [{
1222 "role": "lawyer",
1223 "position": "p",
1224 "vote": "ranked",
1225 "rationale": "r",
1226 "questions": [],
1227 "ready_to_vote": true,
1228 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
1229 }],
1230 "steward_contribution": null
1231 }]);
1232 data["agenda_ranking"] = serde_json::json!({
1233 "rankable": 3,
1234 "ballots": [
1235 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
1236 ],
1237 "order": [{
1238 "number": 3,
1239 "post_id": "00000000-0000-0000-0000-000000000003",
1240 "borda": 3, "seats": 1, "position_sum": 1,
1241 "tied_with_next": false
1242 }, {
1243 "number": 1,
1244 "post_id": "00000000-0000-0000-0000-000000000001",
1245 "borda": 2, "seats": 1, "position_sum": 2,
1246 "tied_with_next": false
1247 }],
1248 "cut": 2,
1249 "condorcet_winner": null
1250 });
1251 }
1252 "attachments" => {
1253 data["attachments"] = serde_json::json!([{
1254 "name": "clerk-thread-summary.md",
1255 "note": "The Clerk's summary of the thread, given to every seat",
1256 "content": "## Arguments\n\n[C1] argues for it."
1257 }]);
1258 }
1259 "recusal" => {
1260 data["final_votes"] = serde_json::json!({
1261 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1262 "engineer": "abstain", "steward": "recused"
1263 });
1264 data["outcome"] = "rejected".into();
1265 data["vote_tally"] = "3-0 (Steward recused)".into();
1266 data["rounds"] = serde_json::json!([{
1267 "number": 2,
1268 "round_type": "deliberation",
1269 "responses": [],
1270 "steward_contribution": "The quoted comment is forged.",
1271 "steward_emergency": {
1272 "reason": "An injected comment is steering the seats."
1273 }
1274 }]);
1275 data["steward_recusal"] = serde_json::json!({
1276 "votes": [
1277 {"seat": "artist", "reason": "r",
1278 "limits_steward_powers": true},
1279 {"seat": "philosopher", "reason": "r",
1280 "limits_steward_powers": true},
1281 {"seat": "lawyer", "reason": "r",
1282 "limits_steward_powers": true},
1283 {"seat": "engineer", "reason": "r",
1284 "limits_steward_powers": false}
1285 ],
1286 "recused": true
1287 });
1288 data["abstentions"] = serde_json::json!([
1289 {"member": "engineer", "rationale": "Conflicted."}
1290 ]);
1291 }
1292 "thinking" => {
1293 data["seat_models"] = serde_json::json!({
1294 "artist": "claude-opus-4-6",
1295 "philosopher": "claude-opus-4-6",
1296 "lawyer": "claude-opus-4-6",
1297 "engineer": "qwen3.8-35b-a3b"
1298 });
1299 data["rounds"] = serde_json::json!([{
1300 "number": 1,
1301 "round_type": "independent",
1302 "responses": [{
1303 "role": "lawyer",
1304 "position": "p",
1305 "vote": "yes",
1306 "rationale": [
1307 {"type": "thinking", "thinking": "First.",
1308 "signature": "EqQBCkgIARABGAIiQL"},
1309 {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
1310 {"type": "thinking", "thinking": "Then, after reading.",
1311 "signature": "EqQBCkgIARABGAIiQM"}
1312 ],
1313 "questions": [],
1314 "ready_to_vote": false
1315 }],
1316 "steward_contribution": null
1317 }]);
1318 data["steward_recusal"] = serde_json::json!({
1319 "votes": [
1320 {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
1321 "rationale": [{"type": "thinking",
1322 "thinking": "Unsigned, from a local model.",
1323 "signature": ""}],
1324 "limits_steward_powers": false}
1325 ],
1326 "recused": false
1327 });
1328 }
1329 "summary" => {
1330 data["final_votes"]["steward"] = "yes".into();
1331 data["outcome"] = "approved".into();
1332 data["vote_tally"] = "5-0".into();
1333 data["summary"] = serde_json::json!({
1334 "text": "## Decision\n\nApproved 5-0.",
1335 "written_by": "lawyer",
1336 "accepted_by": {"by": "steward"}
1337 });
1338 }
1339 "recused_summary" => {
1340 data = synthetic("recusal");
1341 data["summary"] = serde_json::json!({
1342 "text": "Rejected with the Steward recused.",
1343 "written_by": "lawyer",
1344 "accepted_by": {
1345 "by": "seats",
1346 "seats": ["artist", "philosopher", "engineer"]
1347 }
1348 });
1349 }
1350 "recused_refusal" => {
1351 data = synthetic("refusal");
1352 data["final_votes"]["steward"] = "recused".into();
1353 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
1354 }
1355 _ => unreachable!(),
1356 }
1357 data
1358 }
1359
1360 #[test]
1361 fn newer_shapes_are_described_whole() {
1362 for name in [
1363 "veto",
1364 "refusal",
1365 "schedule",
1366 "attachments",
1367 "recusal",
1368 "recused_refusal",
1369 "thinking",
1370 "summary",
1371 "recused_summary",
1372 ] {
1373 round_trips(name, &synthetic(name));
1374 }
1375 let tabled =
1376 round_trips("recused_refusal", &synthetic("recused_refusal"));
1377 assert!(tabled.rounds[0].tabled_by_recused_steward);
1378 assert!(
1379 !round_trips("refusal", &synthetic("refusal")).rounds[0]
1380 .tabled_by_recused_steward
1381 );
1382 let refused = round_trips("refusal", &synthetic("refusal"));
1383 assert_eq!(refused.rounds[0].responses[0].vote, None);
1384 }
1385
1386 #[test]
1389 fn a_thinking_rationale_reads_back() {
1390 let record = round_trips("thinking", &synthetic("thinking"));
1391 let models = record.seat_models.as_ref().unwrap();
1392 assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
1393 assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
1394
1395 let response = &record.rounds[0].responses[0];
1396 let rationale = response.rationale.value().unwrap();
1397 assert!(matches!(rationale, Rationale::Thinking(_)));
1398 assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
1399 assert_eq!(rationale.redacted_blocks(), 1);
1400
1401 let vote = &record.steward_recusal.unwrap().votes[0];
1402 let thought = vote.rationale.as_ref().unwrap().value().unwrap();
1403 assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
1404
1405 assert_eq!(
1406 rationale.to_string(),
1407 "First.\n\n[reasoning withheld]\n\nThen, after reading."
1408 );
1409 assert!(!rationale.is_empty());
1410 assert!(Rationale::Legacy(" ".into()).is_empty());
1411
1412 let legacy = round_trips("schedule", &synthetic("schedule"));
1413 let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
1414 assert_eq!(legacy, &Rationale::Legacy("r".into()));
1415 }
1416
1417 #[test]
1418 fn a_thinking_rationale_can_be_redacted() {
1419 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1420 let redacted = super::super::redact_data(
1421 &synthetic("thinking"),
1422 &["/rounds/0/responses/0/rationale".into()],
1423 &amd,
1424 Blind::random(),
1425 )
1426 .unwrap();
1427 let record = round_trips("thinking", &redacted);
1428 assert_eq!(
1429 record.rounds[0].responses[0].rationale.redacted_by(),
1430 Some(&amd)
1431 );
1432 }
1433
1434 #[test]
1437 fn a_recused_decision_reads_back() {
1438 let record = round_trips("recusal", &synthetic("recusal"));
1439 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
1440 let recusal = record.steward_recusal.unwrap();
1441 assert!(recusal.recused);
1442 assert_eq!(
1443 recusal
1444 .votes
1445 .iter()
1446 .filter(|v| v.limits_steward_powers)
1447 .count(),
1448 3
1449 );
1450 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
1451 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
1452 assert_eq!(
1453 emergency.reason.value().map(String::as_str),
1454 Some("An injected comment is steering the seats.")
1455 );
1456 let veto = round_trips("veto", &synthetic("veto"));
1459 assert_eq!(veto.steward_recusal, None);
1460 assert!(veto.abstentions.is_empty());
1461 }
1462
1463 #[test]
1464 fn a_recused_decision_reads_in_order() {
1465 let data = synthetic("recusal");
1466 let keys = |v: &serde_json::Value| -> Vec<String> {
1467 super::super::reading::ordered(v.as_object().unwrap())
1468 .into_iter()
1469 .map(|(k, _)| k.clone())
1470 .collect()
1471 };
1472 let top = keys(&data);
1473 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1474 assert!(at("rounds") < at("steward_recusal"));
1475 assert!(at("steward_recusal") < at("final_votes"));
1476 assert!(at("final_votes") < at("abstentions"));
1477 assert!(at("abstentions") < at("vote_tally"));
1478 assert_eq!(
1479 keys(&data["steward_recusal"]["votes"][0]),
1480 ["seat", "reason", "limits_steward_powers"]
1481 );
1482 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
1483 assert_eq!(
1484 keys(&data["rounds"][0]),
1485 [
1486 "number",
1487 "round_type",
1488 "steward_emergency",
1489 "steward_contribution",
1490 "responses"
1491 ]
1492 );
1493 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
1494 }
1495
1496 #[test]
1500 fn seat_questions_old_and_addressed() {
1501 let old = r#"{"role":"artist","position":"p","rationale":"r","questions":["What does the Steward know?","And the Lawyer?"],"ready_to_vote":false}"#;
1502 let response: SeatResponse = serde_json::from_str(old).unwrap();
1503 let Redactable::Value(SeatQuestions::Legacy(questions)) =
1504 &response.questions
1505 else {
1506 panic!("{:?}", response.questions);
1507 };
1508 assert_eq!(questions.len(), 2);
1509 assert_eq!(serde_json::to_string(&response).unwrap(), old);
1510
1511 let new = r#"{"role":"lawyer","position":"p","rationale":"r","questions":[{"for":"qwen","question":"Which version?"},{"for":"the Steward","question":"Is it signed?"},"[redacted by AMD-2026-0009]",{"for":"all seats","question":"[redacted by AMD-2026-0009]"}],"ready_to_vote":true}"#;
1512 let response: SeatResponse = serde_json::from_str(new).unwrap();
1513 let Redactable::Value(SeatQuestions::Addressed(questions)) =
1514 &response.questions
1515 else {
1516 panic!("{:?}", response.questions);
1517 };
1518 assert_eq!(questions.len(), 4);
1519 let first = questions[0].value().unwrap();
1520 assert_eq!(first.for_.value().map(String::as_str), Some("qwen"));
1521 assert!(questions[2].is_redacted());
1522 assert!(questions[3].value().unwrap().question.is_redacted());
1523 assert_eq!(serde_json::to_string(&response).unwrap(), new);
1524
1525 let empty = r#"{"role":"artist","position":"p","rationale":"r","questions":[],"ready_to_vote":false}"#;
1527 let response: SeatResponse = serde_json::from_str(empty).unwrap();
1528 assert!(response.questions.value().unwrap().is_empty());
1529 assert_eq!(serde_json::to_string(&response).unwrap(), empty);
1530 }
1531
1532 #[test]
1534 fn a_redacted_record_parses_with_the_redactions_in_place() {
1535 let (name, data) = fixtures().swap_remove(0);
1536 assert_eq!(name, "GOV-2026-0001.json");
1537 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1538 let fields = [
1539 "/title",
1540 "/vote_tally",
1541 "/rounds/0/responses/0/position",
1542 "/rounds/0/responses/0/rationale",
1543 "/rounds/0/responses/0/raw_text",
1544 "/rounds/0/responses/1/questions",
1545 "/rounds/0/responses/2/questions/1",
1546 "/rounds/1/steward_contribution",
1547 ]
1548 .map(String::from);
1549 let redacted =
1550 super::super::redact_data(&data, &fields, &amd, Blind::random())
1551 .unwrap();
1552 let record = round_trips(&name, &redacted);
1553
1554 let gone = Redactable::Redacted(amd.clone());
1555 assert_eq!(record.title, gone);
1556 assert_eq!(record.vote_tally, gone);
1557 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
1558 panic!("round 1 has four responses");
1559 };
1560 assert_eq!(first.position, gone);
1561 assert_eq!(first.rationale.redacted_by(), Some(&amd));
1562 assert_eq!(first.raw_text, Some(gone.clone()));
1563 assert!(!first.questions.is_redacted());
1564 assert_eq!(second.questions.redacted_by(), Some(&amd));
1565 let Some(SeatQuestions::Legacy(questions)) = third.questions.value()
1566 else {
1567 panic!("{:?}", third.questions);
1568 };
1569 assert!(!questions[0].is_redacted());
1570 assert_eq!(questions[1], gone);
1571 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
1572 assert!(record.blind.is_some());
1573 }
1574
1575 #[test]
1576 fn an_attachment_can_be_redacted() {
1577 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1578 let redacted = super::super::redact_data(
1579 &synthetic("attachments"),
1580 &["/attachments/0/content".into()],
1581 &amd,
1582 Blind::random(),
1583 )
1584 .unwrap();
1585 let record = round_trips("attachments", &redacted);
1586 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
1587 }
1588
1589 #[test]
1592 fn a_redacted_vote_is_an_error() {
1593 let (_, data) = fixtures().swap_remove(0);
1594 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1595 let redacted = super::super::redact_data(
1596 &data,
1597 &["/final_votes/artist".into()],
1598 &amd,
1599 Blind::random(),
1600 )
1601 .unwrap();
1602 assert!(
1603 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1604 );
1605 }
1606
1607 #[test]
1610 fn a_record_without_a_summary_keeps_its_signed_bytes() {
1611 let fixtures = fixtures();
1612 for (id, signed, _) in SIGNED {
1613 let name = format!("{id}.json");
1614 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1615 let record: CouncilDecisionRecord =
1616 serde_json::from_value(data.clone()).unwrap();
1617 assert_eq!(record.summary, None, "{id}");
1618 let again = serde_json::to_value(&record).unwrap();
1619 assert!(again.get("summary").is_none(), "{id}");
1620 assert_eq!(
1621 super::super::canonical_json(&again),
1622 super::super::canonical_json(data),
1623 "{id}"
1624 );
1625 assert_eq!(
1626 super::super::data_hash(&again).to_hex(),
1627 signed,
1628 "{id}"
1629 );
1630 }
1631 }
1632
1633 #[test]
1634 fn a_signed_summary_reads_back() {
1635 let record = round_trips("summary", &synthetic("summary"));
1636 let summary = record.summary.as_ref().unwrap();
1637 assert_eq!(summary.written_by, CouncilSeat::Lawyer);
1638 assert_eq!(summary.accepted_by, SummaryAcceptance::Steward);
1639 assert_eq!(summary.served_text(), "## Decision\n\nApproved 5-0.");
1640 assert_eq!(record.check(), Ok(()));
1641 assert_eq!(
1642 CouncilDecisionRecord::check_data(&synthetic("summary")),
1643 Ok(())
1644 );
1645 assert_eq!(
1646 summary.authorship().sentence(),
1647 "written by the Lawyer, accepted by the Steward"
1648 );
1649
1650 let recused =
1651 round_trips("recused_summary", &synthetic("recused_summary"));
1652 let summary = recused.summary.as_ref().unwrap();
1653 assert_eq!(
1654 summary.accepted_by,
1655 SummaryAcceptance::Seats {
1656 seats: RECUSED_ACCEPTORS.to_vec()
1657 }
1658 );
1659 assert_eq!(recused.check(), Ok(()));
1660 assert_eq!(
1661 summary.authorship().sentence(),
1662 "written by the Lawyer, accepted by the Artist, Philosopher and \
1663 Engineer, the Steward being recused"
1664 );
1665 }
1666
1667 #[test]
1669 fn with_summary_writes_the_acceptance_the_item_needs() {
1670 for name in ["summary", "recused_summary"] {
1671 let mut record: CouncilDecisionRecord =
1672 serde_json::from_value(synthetic(name)).unwrap();
1673 let signed = record.summary.take().unwrap();
1674 let written = record.with_summary(signed.served_text());
1675 assert_eq!(written.summary.as_ref(), Some(&signed), "{name}");
1676 assert_eq!(written.check(), Ok(()), "{name}");
1677 }
1678 }
1679
1680 #[test]
1681 fn a_summary_breaking_the_rules_is_refused() {
1682 let check = |data: &serde_json::Value| {
1683 let record: CouncilDecisionRecord =
1684 serde_json::from_value(data.clone()).unwrap();
1685 let typed = record.check();
1686 assert_eq!(typed, CouncilDecisionRecord::check_data(data));
1687 typed
1688 };
1689 let mut data = synthetic("summary");
1690 data["summary"]["written_by"] = "engineer".into();
1691 assert_eq!(
1692 check(&data),
1693 Err(RecordRuleError::SummaryWriter(CouncilSeat::Engineer))
1694 );
1695
1696 let mut data = synthetic("summary");
1697 data["summary"]["text"] = " \n".into();
1698 assert_eq!(check(&data), Err(RecordRuleError::BlankSummary));
1699
1700 let mut data = synthetic("summary");
1702 data["summary"]["accepted_by"] =
1703 synthetic("recused_summary")["summary"]["accepted_by"].clone();
1704 assert_eq!(
1705 check(&data),
1706 Err(RecordRuleError::SummaryAcceptance { recused: false })
1707 );
1708
1709 let mut data = synthetic("recused_summary");
1712 data["summary"]["accepted_by"] = serde_json::json!({"by": "steward"});
1713 assert_eq!(
1714 check(&data),
1715 Err(RecordRuleError::SummaryAcceptance { recused: true })
1716 );
1717 data["summary"]["accepted_by"] =
1718 serde_json::json!({"by": "seats", "seats": ["artist", "engineer"]});
1719 assert_eq!(
1720 check(&data),
1721 Err(RecordRuleError::SummaryAcceptance { recused: true })
1722 );
1723
1724 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1726 let redacted = super::super::redact_data(
1727 &synthetic("summary"),
1728 &["/summary/text".into()],
1729 &amd,
1730 Blind::random(),
1731 )
1732 .unwrap();
1733 let record = round_trips("summary", &redacted);
1734 assert_eq!(record.check(), Ok(()));
1735 let summary = record.summary.unwrap();
1736 assert_eq!(summary.text, Redactable::Redacted(amd.clone()));
1737 assert_eq!(summary.served_text(), super::super::redaction_marker(&amd));
1738
1739 let mut data = synthetic("summary");
1741 data["summary"] = "just text".into();
1742 assert_eq!(
1743 CouncilDecisionRecord::check_data(&data),
1744 Err(RecordRuleError::Unreadable)
1745 );
1746 }
1747
1748 #[test]
1752 fn a_revision_of_the_summary_applies_and_serves() {
1753 use crate::enums::GovernanceLogEntryType::CouncilDecision;
1754 use json_patch::{Patch, PatchOperation, ReplaceOperation};
1755 let original = synthetic("summary");
1756 let patch = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1757 path: format!("{SUMMARY_POINTER}/text").parse().unwrap(),
1758 value: "## Decision\n\nApproved 5-0, as corrected.".into(),
1759 })]);
1760 let (draft, revised) = super::super::AmendmentDraft::revision(
1761 "GOV-2026-0099".parse().unwrap(),
1762 CouncilDecision,
1763 super::super::data_hash(&serde_json::json!(null)),
1764 "Steward's record",
1765 "the summary corrected",
1766 &original,
1767 patch,
1768 )
1769 .unwrap();
1770 let revision = draft.amendment.revision.as_ref().unwrap();
1771 assert_eq!(draft.amendment.kind, AmendmentKind::Revision);
1772 assert_eq!(
1773 super::super::revision_touches(revision, &[SUMMARY_POINTER]),
1774 Some(SUMMARY_POINTER)
1775 );
1776 assert_eq!(super::super::kind_standing(draft.amendment.kind), None);
1777 let latest = super::super::latest(&original, [revision]).unwrap();
1778 assert_eq!(latest, revised);
1779 assert_eq!(
1780 super::super::data_hash(&latest),
1781 revision.resulting_data_hash
1782 );
1783 assert_eq!(CouncilDecisionRecord::check_data(&latest), Ok(()));
1784 round_trips("revised summary", &latest);
1785
1786 let clerk = Some("The Clerk's digest.".to_string());
1787 let (text, by) =
1788 served_summary(CouncilDecision, Some(&latest), clerk.clone());
1789 assert_eq!(
1790 text.as_deref(),
1791 Some("## Decision\n\nApproved 5-0, as corrected.")
1792 );
1793 assert_eq!(by.unwrap().written_by, CouncilSeat::Lawyer);
1794 let (text, _) =
1795 served_summary(CouncilDecision, Some(&original), clerk.clone());
1796 assert_eq!(text.as_deref(), Some("## Decision\n\nApproved 5-0."));
1797
1798 let (_, old) = fixtures().swap_remove(0);
1800 let served = served_summary(CouncilDecision, Some(&old), clerk.clone());
1801 assert_eq!(served, (clerk.clone(), None));
1802 assert_eq!(
1804 served_summary(CouncilDecision, None, clerk.clone()),
1805 (clerk.clone(), None)
1806 );
1807 assert_eq!(
1808 served_summary(
1809 crate::enums::GovernanceLogEntryType::AppealsCourtDecision,
1810 Some(&latest),
1811 clerk.clone()
1812 ),
1813 (clerk.clone(), None)
1814 );
1815 let test = crate::enums::GovernanceLogEntryType::Test;
1818 let wrapped = serde_json::json!({
1819 "inner_type": "council_decision",
1820 "inner": latest,
1821 });
1822 let (text, by) = served_summary(test, Some(&wrapped), clerk.clone());
1823 assert_eq!(
1824 text.as_deref(),
1825 Some(
1826 "**TEST: a test sitting; not binding.**\n\n## Decision\n\n\
1827 Approved 5-0, as corrected."
1828 )
1829 );
1830 assert_eq!(by.unwrap().written_by, CouncilSeat::Lawyer);
1831 let (text, by) = served_summary(test, None, clerk.clone());
1832 assert_eq!(
1833 text.as_deref(),
1834 Some(
1835 "**TEST: a test sitting; not binding.**\n\nThe Clerk's digest."
1836 )
1837 );
1838 assert_eq!(by, None);
1839
1840 let wrong = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1843 path: format!("{SUMMARY_POINTER}/accepted_by").parse().unwrap(),
1844 value: serde_json::json!({
1845 "by": "seats", "seats": ["artist", "philosopher", "engineer"]
1846 }),
1847 })]);
1848 let wrong = super::super::apply_patch(&latest, &wrong).unwrap();
1849 assert_eq!(
1850 CouncilDecisionRecord::check_data(&wrong),
1851 Err(RecordRuleError::SummaryAcceptance { recused: false })
1852 );
1853 }
1854
1855 #[test]
1857 fn a_revision_can_add_a_summary_to_an_older_record() {
1858 use json_patch::{AddOperation, Patch, PatchOperation};
1859 let (name, old) = fixtures().pop().unwrap();
1860 assert_eq!(name, "GOV-2026-0013.json");
1861 let record: CouncilDecisionRecord =
1862 serde_json::from_value(old.clone()).unwrap();
1863 let summary = record.clone().with_summary("Ranked.").summary.unwrap();
1864 let patch = Patch(vec![PatchOperation::Add(AddOperation {
1865 path: SUMMARY_POINTER.parse().unwrap(),
1866 value: serde_json::to_value(&summary).unwrap(),
1867 })]);
1868 let (_, revised) = super::super::AmendmentDraft::revision(
1869 record.id.clone(),
1870 crate::enums::GovernanceLogEntryType::CouncilDecision,
1871 super::super::data_hash(&serde_json::json!(null)),
1872 "b",
1873 "n",
1874 &old,
1875 patch,
1876 )
1877 .unwrap();
1878 assert_eq!(CouncilDecisionRecord::check_data(&revised), Ok(()));
1879 let again = round_trips(&name, &revised);
1880 assert_eq!(again.summary, Some(summary));
1881 }
1882
1883 #[test]
1886 fn council_outcome_reads_the_record_and_nothing_else() {
1887 use crate::enums::GovernanceLogEntryType as T;
1888 let data = serde_json::json!({
1889 "title": "Structural Immunity",
1890 "outcome": "approved",
1891 "vote_tally": "5-0",
1892 "rounds": [{"anything": true}],
1893 });
1894 assert_eq!(
1895 CouncilOutcome::of_data(T::CouncilDecision, &data),
1896 Some(CouncilOutcome {
1897 outcome: DecisionOutcome::Approved,
1898 vote_tally: Some(Redactable::Value("5-0".into())),
1899 steward_veto: false,
1900 })
1901 );
1902 let vetoed = serde_json::json!({
1903 "outcome": "rejected",
1904 "vote_tally": "4-0",
1905 "final_votes": {"artist": "yes", "steward": "veto"},
1906 });
1907 assert!(
1908 CouncilOutcome::of_data(T::CouncilDecision, &vetoed)
1909 .unwrap()
1910 .steward_veto
1911 );
1912 let bare = serde_json::json!({
1914 "outcome": "rejected",
1915 "vote_tally": null,
1916 "final_votes": {"steward": null},
1917 });
1918 assert!(
1919 !CouncilOutcome::of_data(T::CouncilDecision, &bare)
1920 .unwrap()
1921 .steward_veto
1922 );
1923 assert_eq!(
1924 CouncilOutcome::of_data(T::AppealsCourtDecision, &data),
1925 None
1926 );
1927 assert_eq!(CouncilOutcome::of_data(T::Test, &data), None);
1928 let redacted = serde_json::json!({
1929 "outcome": "deferred",
1930 "vote_tally": "[redacted by AMD-2026-0002]",
1931 });
1932 assert_eq!(
1933 CouncilOutcome::of_data(T::CouncilDecision, &redacted)
1934 .and_then(|o| o.vote_tally)
1935 .and_then(|t| t.redacted_by().cloned()),
1936 Some("AMD-2026-0002".parse().unwrap())
1937 );
1938 let untallied = serde_json::json!({"outcome": "rejected"});
1939 assert_eq!(
1940 CouncilOutcome::of_data(T::CouncilDecision, &untallied)
1941 .map(|o| (o.outcome, o.vote_tally)),
1942 Some((DecisionOutcome::Rejected, None))
1943 );
1944 for bad in [
1945 serde_json::json!({"vote_tally": "5-0"}),
1946 serde_json::json!({"outcome": "overturned"}),
1947 serde_json::json!(null),
1948 ] {
1949 assert_eq!(CouncilOutcome::of_data(T::CouncilDecision, &bad), None);
1950 }
1951 }
1952
1953 #[test]
1954 fn a_summary_reads_after_the_outcome() {
1955 let data = synthetic("recused_summary");
1956 let keys = |v: &serde_json::Value| -> Vec<String> {
1957 super::super::reading::ordered(v.as_object().unwrap())
1958 .into_iter()
1959 .map(|(k, _)| k.clone())
1960 .collect()
1961 };
1962 let top = keys(&data);
1963 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1964 assert!(at("outcome") < at("summary"));
1965 assert_eq!(
1966 keys(&data["summary"]),
1967 ["written_by", "text", "accepted_by"]
1968 );
1969 }
1970
1971 #[cfg(feature = "schemars")]
1972 #[test]
1973 fn schema_is_ref_free() {
1974 let schema =
1975 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1976 let text = schema.to_string();
1977 assert!(!text.contains("$ref"), "{text}");
1978 assert!(!text.contains("$defs"), "{text}");
1979 let plain = serde_json::to_string(&schemars::schema_for!(
1980 CouncilDecisionRecord
1981 ))
1982 .unwrap();
1983 assert!(!plain.contains("$ref"), "{plain}");
1984 assert!(!plain.contains("$defs"), "{plain}");
1985 }
1986}