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