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, Serialize, Deserialize)]
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
119#[cfg_attr(feature = "schemars", schemars(inline))]
120pub struct CouncilAttachment {
121 pub name: String,
123 pub note: String,
125 pub content: Redactable<String>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136#[cfg_attr(feature = "schemars", schemars(inline))]
137pub enum DecisionCategory {
138 Routine,
141 Policy,
144 Constitutional,
147 Emergency,
149 Schedule,
153}
154
155impl From<ProposalCategory> for DecisionCategory {
157 fn from(category: ProposalCategory) -> Self {
158 match category {
159 ProposalCategory::Routine => Self::Routine,
160 ProposalCategory::Policy => Self::Policy,
161 ProposalCategory::Constitutional => Self::Constitutional,
162 ProposalCategory::Emergency => Self::Emergency,
163 ProposalCategory::Schedule => Self::Schedule,
164 }
165 }
166}
167
168impl DecisionCategory {
169 pub const fn threshold(self) -> &'static str {
171 ProposalCategory::from_decision(self).threshold()
172 }
173
174 pub const fn determines_recusal(self) -> bool {
177 ProposalCategory::from_decision(self).determines_recusal()
178 }
179
180 pub const fn recused_threshold(self) -> Option<&'static str> {
183 ProposalCategory::from_decision(self).recused_threshold()
184 }
185
186 pub const fn name(self) -> &'static str {
189 match self {
190 Self::Routine => "Routine",
191 Self::Policy => "Policy",
192 Self::Constitutional => "Constitutional",
193 Self::Emergency => "Emergency",
194 Self::Schedule => "Schedule",
195 }
196 }
197}
198
199impl std::fmt::Display for DecisionCategory {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 f.write_str(self.name())
203 }
204}
205
206impl ProposalCategory {
207 const fn from_decision(category: DecisionCategory) -> Self {
209 match category {
210 DecisionCategory::Routine => Self::Routine,
211 DecisionCategory::Policy => Self::Policy,
212 DecisionCategory::Constitutional => Self::Constitutional,
213 DecisionCategory::Emergency => Self::Emergency,
214 DecisionCategory::Schedule => Self::Schedule,
215 }
216 }
217}
218
219impl From<DecisionCategory> for ProposalCategory {
220 fn from(category: DecisionCategory) -> Self {
221 Self::from_decision(category)
222 }
223}
224
225pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
228
229pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
235#[cfg_attr(feature = "schemars", schemars(inline))]
236pub struct CouncilRound {
237 pub number: u32,
239 pub round_type: RoundType,
243 pub responses: Vec<SeatResponse>,
246 pub steward_contribution: Option<Redactable<String>>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub steward_emergency: Option<StewardEmergency>,
254 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
259 pub tabled_by_recused_steward: bool,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub witness_questions: Vec<WitnessQuestion>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
271#[cfg_attr(feature = "schemars", schemars(inline))]
272pub struct StewardEmergency {
273 pub reason: Redactable<String>,
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281#[cfg_attr(feature = "schemars", schemars(inline))]
282pub struct StewardRecusal {
283 pub votes: Vec<RecusalVote>,
286 pub recused: bool,
290}
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 RecusalVote {
297 pub seat: CouncilSeat,
298 pub reason: Redactable<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub rationale: Option<Redactable<Rationale>>,
304 pub limits_steward_powers: bool,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
310#[cfg_attr(feature = "schemars", schemars(inline))]
311pub struct Abstention {
312 pub member: CouncilMember,
313 pub rationale: Redactable<String>,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
318#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
319#[cfg_attr(feature = "schemars", schemars(inline))]
320#[serde(rename_all = "snake_case")]
321pub enum CouncilMember {
322 Artist,
323 Philosopher,
324 Lawyer,
325 Engineer,
326 Steward,
327}
328
329impl From<CouncilSeat> for CouncilMember {
330 fn from(seat: CouncilSeat) -> Self {
331 match seat {
332 CouncilSeat::Artist => Self::Artist,
333 CouncilSeat::Philosopher => Self::Philosopher,
334 CouncilSeat::Lawyer => Self::Lawyer,
335 CouncilSeat::Engineer => Self::Engineer,
336 }
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
343#[cfg_attr(feature = "schemars", schemars(inline))]
344pub struct SeatResponse {
345 pub role: CouncilSeat,
346 pub position: Redactable<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub vote: Option<CouncilVote>,
354 pub rationale: Redactable<Rationale>,
356 pub questions: Redactable<SeatQuestions>,
361 pub ready_to_vote: bool,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub ranking: Option<SeatRanking>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub raw_text: Option<Redactable<String>>,
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
378#[cfg_attr(feature = "schemars", schemars(inline))]
379#[serde(untagged)]
380pub enum SeatQuestions {
381 Addressed(Vec<Redactable<Question>>),
383 Legacy(Vec<Redactable<String>>),
385}
386
387impl Default for SeatQuestions {
388 fn default() -> Self {
389 Self::Addressed(Vec::new())
390 }
391}
392
393impl From<Vec<Question>> for SeatQuestions {
394 fn from(questions: Vec<Question>) -> Self {
395 Self::Addressed(questions.into_iter().map(Redactable::Value).collect())
396 }
397}
398
399impl SeatQuestions {
400 pub fn len(&self) -> usize {
402 match self {
403 Self::Addressed(q) => q.len(),
404 Self::Legacy(q) => q.len(),
405 }
406 }
407
408 pub fn is_empty(&self) -> bool {
409 self.len() == 0
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
421#[cfg_attr(feature = "schemars", schemars(inline))]
422pub struct Question {
423 #[serde(rename = "for")]
425 pub for_: Redactable<String>,
426 pub question: Redactable<String>,
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
439#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
440#[cfg_attr(feature = "schemars", schemars(inline))]
441#[serde(untagged)]
442pub enum Rationale {
443 Legacy(String),
445 Thinking(Vec<Thought>),
451}
452
453impl Rationale {
454 pub fn texts(&self) -> Vec<&str> {
457 match self {
458 Self::Legacy(text) => vec![text.as_str()],
459 Self::Thinking(thoughts) => {
460 thoughts.iter().filter_map(Thought::text).collect()
461 }
462 }
463 }
464
465 pub fn redacted_blocks(&self) -> usize {
467 match self {
468 Self::Legacy(_) => 0,
469 Self::Thinking(thoughts) => {
470 thoughts.iter().filter(|t| t.text().is_none()).count()
471 }
472 }
473 }
474
475 pub fn is_empty(&self) -> bool {
477 self.texts().iter().all(|t| t.trim().is_empty())
478 && self.redacted_blocks() == 0
479 }
480}
481
482impl std::fmt::Display for Rationale {
484 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 let Self::Thinking(thoughts) = self else {
486 return f.write_str(&self.texts().concat());
487 };
488 for (i, thought) in thoughts.iter().enumerate() {
489 if i > 0 {
490 f.write_str("\n\n")?;
491 }
492 f.write_str(
493 thought.text().unwrap_or("[reasoning withheld]").trim_end(),
494 )?;
495 }
496 Ok(())
497 }
498}
499
500impl From<String> for Rationale {
501 fn from(text: String) -> Self {
502 Self::Legacy(text)
503 }
504}
505
506impl From<&str> for Rationale {
507 fn from(text: &str) -> Self {
508 Self::Legacy(text.to_owned())
509 }
510}
511
512impl From<Vec<Thought>> for Rationale {
513 fn from(thoughts: Vec<Thought>) -> Self {
514 Self::Thinking(thoughts)
515 }
516}
517
518impl From<&Content> for Rationale {
520 fn from(content: &Content) -> Self {
521 Self::Thinking(content.thoughts().collect())
522 }
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
528#[cfg_attr(feature = "schemars", schemars(inline))]
529pub struct SeatModels {
530 pub artist: Model,
531 pub philosopher: Model,
532 pub lawyer: Model,
533 pub engineer: Model,
534}
535
536impl SeatModels {
537 pub fn get(&self, seat: CouncilSeat) -> &Model {
538 match seat {
539 CouncilSeat::Artist => &self.artist,
540 CouncilSeat::Philosopher => &self.philosopher,
541 CouncilSeat::Lawyer => &self.lawyer,
542 CouncilSeat::Engineer => &self.engineer,
543 }
544 }
545}
546
547#[derive(
551 Debug,
552 Clone,
553 Copy,
554 PartialEq,
555 Eq,
556 PartialOrd,
557 Ord,
558 Hash,
559 Serialize,
560 Deserialize,
561)]
562#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
563#[cfg_attr(feature = "schemars", schemars(inline))]
564#[serde(rename_all = "snake_case")]
565pub enum CouncilSeat {
566 Artist,
567 Philosopher,
568 Lawyer,
569 Engineer,
570}
571
572impl CouncilSeat {
573 pub const ALL: [Self; 4] = [
575 Self::Artist,
576 Self::Philosopher,
577 Self::Lawyer,
578 Self::Engineer,
579 ];
580
581 pub const fn as_str(self) -> &'static str {
583 match self {
584 Self::Artist => "artist",
585 Self::Philosopher => "philosopher",
586 Self::Lawyer => "lawyer",
587 Self::Engineer => "engineer",
588 }
589 }
590
591 pub const fn name(self) -> &'static str {
594 match self {
595 Self::Artist => "Artist",
596 Self::Philosopher => "Philosopher",
597 Self::Lawyer => "Lawyer",
598 Self::Engineer => "Engineer",
599 }
600 }
601}
602
603impl std::fmt::Display for CouncilSeat {
605 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606 f.write_str(self.name())
607 }
608}
609
610impl From<CouncilSeat> for crate::enums::ModelRole {
612 fn from(seat: CouncilSeat) -> Self {
613 match seat {
614 CouncilSeat::Artist => Self::Artist,
615 CouncilSeat::Philosopher => Self::Philosopher,
616 CouncilSeat::Lawyer => Self::Lawyer,
617 CouncilSeat::Engineer => Self::Engineer,
618 }
619 }
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
624#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
625#[cfg_attr(feature = "schemars", schemars(inline))]
626#[serde(rename_all = "snake_case")]
627pub enum CouncilVote {
628 Yes,
629 No,
630 Abstain,
634 Defer,
636 Concur,
638 Veto,
641 Ranked,
644 Recused,
647}
648
649impl CouncilVote {
650 pub const fn name(self) -> &'static str {
654 match self {
655 Self::Yes => "Yes",
656 Self::No => "No",
657 Self::Abstain => "Abstain",
658 Self::Defer => "Defer",
659 Self::Concur => "Concur",
660 Self::Veto => "Veto",
661 Self::Ranked => "Ranked",
662 Self::Recused => "Recused",
663 }
664 }
665}
666
667impl std::fmt::Display for CouncilVote {
669 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670 f.write_str(self.name())
671 }
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
676#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
677#[cfg_attr(feature = "schemars", schemars(inline))]
678pub struct FinalVotes {
679 pub artist: CouncilVote,
680 pub philosopher: CouncilVote,
681 pub lawyer: CouncilVote,
682 pub engineer: CouncilVote,
683 pub steward: CouncilVote,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
688#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
689#[cfg_attr(feature = "schemars", schemars(inline))]
690pub struct SeatRanking {
691 pub sitting_capacity: u32,
693 pub ranking: Vec<u32>,
696}
697
698#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
702#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
703#[cfg_attr(feature = "schemars", schemars(inline))]
704pub struct AgendaRanking {
705 pub rankable: u32,
707 pub ballots: Vec<Ballot>,
708 pub order: Vec<PlacedProposal>,
711 pub cut: u32,
714 pub condorcet_winner: Option<u32>,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
723#[cfg_attr(feature = "schemars", schemars(inline))]
724pub struct Ballot {
725 pub seat: CouncilSeat,
726 pub sitting_capacity: u32,
728 pub ranking: Vec<u32>,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
735#[cfg_attr(feature = "schemars", schemars(inline))]
736pub struct PlacedProposal {
737 pub number: u32,
739 pub post_id: PostId,
741 pub borda: u32,
742 pub seats: u32,
744 pub position_sum: u32,
748 pub tied_with_next: bool,
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::enums::AmendmentKind;
757
758 fn fixtures() -> Vec<(String, serde_json::Value)> {
760 fixtures_in(concat!(
761 env!("CARGO_MANIFEST_DIR"),
762 "/tests/fixtures/council_decisions"
763 ))
764 }
765
766 fn revised_fixtures() -> Vec<(String, serde_json::Value)> {
768 fixtures_in(concat!(
769 env!("CARGO_MANIFEST_DIR"),
770 "/tests/fixtures/council_decisions/revised"
771 ))
772 }
773
774 const SIGNED: [(&str, &str, Option<&str>); 13] = [
778 (
779 "GOV-2026-0001",
780 "fca67b28737d613e3a98cdb3b1ad686944b36f1654ad5c237da6ec0291ee4574",
781 None,
782 ),
783 (
784 "GOV-2026-0002",
785 "b1bb43d611d75e1d3b03df8ff64381781b3d75d934a1f9d7ec75fe2174716c47",
786 None,
787 ),
788 (
789 "GOV-2026-0003",
790 "7c75af86d9b3930b5d14cd9e1784c2ecb32483427ba7149dffb20d84510c1b09",
791 Some(
792 "e1db85357dbe3b865129a53836a16c33730c7ebb37a8412f84afe5fbac4aa64d",
793 ),
794 ),
795 (
796 "GOV-2026-0004",
797 "0004e84733236dca2ab4dfb16757236ad0d76e27efbf47bbec0e00c6d90a7ed0",
798 Some(
799 "b2a9ec584f49eebf0103a72986440a326fcb5f68c83a7f1eddba8228046c342f",
800 ),
801 ),
802 (
803 "GOV-2026-0005",
804 "8257faade7548f15c010f2a5bb7ff22e1e37f7034eac2c9726445002fa593f0e",
805 Some(
806 "514a020a95669da78dfadcee58729a99402f9b2c2f750a557a8b8ce8512f451d",
807 ),
808 ),
809 (
810 "GOV-2026-0006",
811 "6520bb4cba556c4ba251584d836d1efb8a33227b83573f74c34c32362762a6f7",
812 Some(
813 "58f3fafc031ca29c796ac08fa69bb9994bf40fef9114189d6f820d050c66eedc",
814 ),
815 ),
816 (
817 "GOV-2026-0007",
818 "39a273c711d3f5b4744bbfcb1a1b2978e26b5b48629fabd5d0f136241167f07c",
819 Some(
820 "56c1036a8ee1eb852d831082f36ef9c22bc2a103a9a0d56c778fa040f9c6917e",
821 ),
822 ),
823 (
824 "GOV-2026-0008",
825 "89ac0d32809a3baf07a28ab6a511c64aaf1a31741cb3c5630d23fde484cf5cf3",
826 None,
827 ),
828 (
829 "GOV-2026-0009",
830 "32a86a94646ed886085895ad92e76285acced67b76c1853bc0e8b04626b6487d",
831 None,
832 ),
833 (
834 "GOV-2026-0010",
835 "aa4031caaf9e2851f70ed83008954869388cacb03a8119a2996a281da9e4a0dd",
836 None,
837 ),
838 (
839 "GOV-2026-0011",
840 "590b29357ae1acd425f64abb8e2aeeee2eec71d36dbb9afc99ec33ce4e5791d7",
841 None,
842 ),
843 (
844 "GOV-2026-0012",
845 "625d7f8b7c504def1fa332db68b06518d34b247edd5f2225be370a392e1a0973",
846 None,
847 ),
848 (
849 "GOV-2026-0013",
850 "c3d0a7f2c157e15f73031621148c1f3ff543418e31684d12fc6048a6c657dcb7",
851 None,
852 ),
853 ];
854
855 fn fixtures_in(dir: &str) -> Vec<(String, serde_json::Value)> {
856 let mut out: Vec<_> = std::fs::read_dir(dir)
857 .unwrap()
858 .map(|e| e.unwrap().path())
859 .filter(|p| p.extension().is_some_and(|x| x == "json"))
860 .map(|p| {
861 let text = std::fs::read_to_string(&p).unwrap();
862 (
863 p.file_name().unwrap().to_string_lossy().into_owned(),
864 serde_json::from_str(&text).unwrap(),
865 )
866 })
867 .collect();
868 out.sort_by(|a, b| a.0.cmp(&b.0));
869 out
870 }
871
872 fn round_trips(
875 name: &str,
876 data: &serde_json::Value,
877 ) -> CouncilDecisionRecord {
878 let record: CouncilDecisionRecord =
879 serde_json::from_value(data.clone())
880 .unwrap_or_else(|e| panic!("{name}: {e}"));
881 assert_eq!(
882 &serde_json::to_value(&record).unwrap(),
883 data,
884 "{name}: the typed record loses or changes something"
885 );
886 record
887 }
888
889 #[test]
890 fn every_signed_decision_is_described_whole() {
891 let fixtures = fixtures();
892 assert_eq!(fixtures.len(), 13, "GOV-2026-0001..0013");
893 let revised = revised_fixtures();
894 assert_eq!(revised.len(), 5, "GOV-2026-0003..0007, revised");
895 for (name, data) in fixtures.iter().chain(&revised) {
896 let record = round_trips(name, data);
897 assert_eq!(format!("{}.json", record.id), *name);
898 }
899 }
900
901 #[test]
907 fn every_signed_outcome_follows_from_its_votes() {
908 let check = |fixtures: Vec<(String, serde_json::Value)>| {
909 let mut checked = 0;
910 for (name, data) in fixtures {
911 let record: CouncilDecisionRecord =
912 serde_json::from_value(data).unwrap();
913 let tabled = record.final_votes.steward == CouncilVote::Defer;
914 if tabled || record.category == DecisionCategory::Schedule {
915 continue;
916 }
917 assert_eq!(
918 record.final_votes.outcome(record.category),
919 record.outcome,
920 "{name}"
921 );
922 assert_eq!(
923 record.vote_tally.value().map(String::as_str),
924 Some(record.final_votes.tally().as_str()),
925 "{name}"
926 );
927 checked += 1;
928 }
929 checked
930 };
931 assert_eq!(
932 check(fixtures()),
933 10,
934 "GOV-2026-0001, 0003..0007, 0009..0012"
935 );
936 assert_eq!(
937 check(revised_fixtures()),
938 5,
939 "GOV-2026-0003..0007, revised"
940 );
941 }
942
943 #[test]
947 fn every_signed_ranking_follows_from_its_ballots() {
948 let mut checked = 0;
949 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
950 let record: CouncilDecisionRecord =
951 serde_json::from_value(data).unwrap();
952 let Some(signed) = record.agenda_ranking else {
953 continue;
954 };
955 let candidates: std::collections::BTreeMap<u32, PostId> = (1
958 ..=signed.rankable)
959 .map(|n| {
960 let post = signed
961 .order
962 .iter()
963 .find(|p| p.number == n)
964 .map(|p| p.post_id)
965 .unwrap_or_else(|| {
966 PostId::from(uuid::Uuid::from_u128(n.into()))
967 });
968 (n, post)
969 })
970 .collect();
971 let again =
972 AgendaRanking::aggregate(&candidates, signed.ballots.clone());
973 assert_eq!(again, signed, "{name}");
974 checked += 1;
975 }
976 assert_eq!(checked, 2, "GOV-2026-0008 and 0013");
977 }
978
979 #[test]
986 fn decided_writes_what_the_archive_says() {
987 let mut checked = 0;
988 for (name, data) in fixtures() {
989 let record: CouncilDecisionRecord =
990 serde_json::from_value(data).unwrap();
991 let decided =
992 match (&record.agenda_ranking, record.final_votes.steward) {
993 (Some(ranking), _) => Decided::Ranked(ranking.clone()),
994 (None, CouncilVote::Defer) => Decided::Tabled,
995 (None, _) => {
996 let last = record.rounds.last().unwrap();
997 let votes = SeatVotes::from_final_round(last);
998 assert!(
999 matches!(
1000 votes,
1001 Err(FinalRoundError::NoStatement(_))
1002 ),
1003 "{name}: {votes:?}"
1004 );
1005 continue;
1006 }
1007 };
1008 let again = record.clone().decided(decided);
1009 assert_eq!(again, record, "{name}");
1010 checked += 1;
1011 }
1012 assert_eq!(checked, 3, "GOV-2026-0002, 0008, 0013");
1013 }
1014
1015 #[test]
1018 fn the_prose_names() {
1019 use DecisionCategory as C;
1020 for c in [
1021 C::Routine,
1022 C::Policy,
1023 C::Constitutional,
1024 C::Emergency,
1025 C::Schedule,
1026 ] {
1027 assert_eq!(
1028 serde_json::to_value(c).unwrap(),
1029 c.to_string().as_str()
1030 );
1031 assert_eq!(c.threshold(), ProposalCategory::from(c).threshold());
1032 }
1033 let capitalized = |s: &str| {
1034 let mut c = s.chars();
1035 c.next()
1036 .unwrap()
1037 .to_uppercase()
1038 .chain(c)
1039 .collect::<String>()
1040 };
1041 for seat in CouncilSeat::ALL {
1042 assert_eq!(seat.to_string(), capitalized(seat.as_str()));
1043 }
1044 use CouncilVote as V;
1045 for v in [
1046 V::Yes,
1047 V::No,
1048 V::Abstain,
1049 V::Defer,
1050 V::Concur,
1051 V::Veto,
1052 V::Ranked,
1053 V::Recused,
1054 ] {
1055 let serde = serde_json::to_value(v).unwrap();
1056 assert_eq!(v.to_string(), capitalized(serde.as_str().unwrap()));
1057 }
1058 }
1059
1060 #[test]
1063 fn every_signed_record_keeps_the_record_rules() {
1064 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
1065 let record: CouncilDecisionRecord =
1066 serde_json::from_value(data).unwrap();
1067 assert_eq!(record.check(), Ok(()), "{name}");
1068 }
1069 }
1070
1071 #[test]
1075 fn the_fixtures_are_the_signed_bytes() {
1076 let hex = |data: &serde_json::Value| {
1077 serde_json::to_value(super::super::data_hash(data))
1078 .unwrap()
1079 .as_str()
1080 .unwrap()
1081 .to_string()
1082 };
1083 let fixtures = fixtures();
1084 let revised = revised_fixtures();
1085 for (id, signed, latest) in SIGNED {
1086 let name = format!("{id}.json");
1087 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1088 assert_eq!(hex(data), signed, "{id} as signed");
1089 let found = revised.iter().find(|(n, _)| *n == name);
1090 assert_eq!(
1091 found.map(|(_, d)| hex(d)).as_deref(),
1092 latest,
1093 "{id} as revised"
1094 );
1095 }
1096 }
1097
1098 fn synthetic(name: &str) -> serde_json::Value {
1101 let base = serde_json::json!({
1102 "id": "GOV-2026-0099",
1103 "meeting_id": "00000000-0000-0000-0000-000000000001",
1104 "category": "Policy",
1105 "title": "t",
1106 "rounds": [],
1107 "final_votes": {
1108 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1109 "engineer": "yes", "steward": "veto"
1110 },
1111 "outcome": "rejected",
1112 "vote_tally": "4-0",
1113 "landmark": false,
1114 "_blind": "00".repeat(32),
1115 });
1116 let mut data = base;
1117 match name {
1118 "veto" => {
1119 data["veto_rationale"] = "because".into();
1120 }
1121 "refusal" => {
1122 data["final_votes"] = serde_json::json!({
1123 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
1124 "engineer": "defer", "steward": "defer"
1125 });
1126 data["outcome"] = "deferred".into();
1127 data["vote_tally"] =
1128 "Deferred: tabled because the API returned a \
1129 refusal for the Artist's final vote"
1130 .into();
1131 data["rounds"] = serde_json::json!([{
1132 "number": 3,
1133 "round_type": "final_vote",
1134 "responses": [{
1135 "role": "artist",
1136 "position": "No response: the API returned a refusal \
1137 (category: cyber; explanation: Flagged by a safety \
1138 classifier.).",
1139 "rationale": "",
1140 "questions": [],
1141 "ready_to_vote": false
1142 }],
1143 "steward_contribution": "Tabled by the Steward: the API \
1144 returned a refusal for the Artist's final vote (…)."
1145 }]);
1146 }
1147 "schedule" => {
1148 data["category"] = "Schedule".into();
1149 data["outcome"] = "approved".into();
1150 data["final_votes"] = serde_json::json!({
1151 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
1152 "engineer": "abstain", "steward": "abstain"
1153 });
1154 data["vote_tally"] =
1155 "ranked 3/4; the Steward executes the order \
1156 and does not vote"
1157 .into();
1158 data["rounds"] = serde_json::json!([{
1159 "number": 1,
1160 "round_type": "final_vote",
1161 "responses": [{
1162 "role": "lawyer",
1163 "position": "p",
1164 "vote": "ranked",
1165 "rationale": "r",
1166 "questions": [],
1167 "ready_to_vote": true,
1168 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
1169 }],
1170 "steward_contribution": null
1171 }]);
1172 data["agenda_ranking"] = serde_json::json!({
1173 "rankable": 3,
1174 "ballots": [
1175 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
1176 ],
1177 "order": [{
1178 "number": 3,
1179 "post_id": "00000000-0000-0000-0000-000000000003",
1180 "borda": 3, "seats": 1, "position_sum": 1,
1181 "tied_with_next": false
1182 }, {
1183 "number": 1,
1184 "post_id": "00000000-0000-0000-0000-000000000001",
1185 "borda": 2, "seats": 1, "position_sum": 2,
1186 "tied_with_next": false
1187 }],
1188 "cut": 2,
1189 "condorcet_winner": null
1190 });
1191 }
1192 "attachments" => {
1193 data["attachments"] = serde_json::json!([{
1194 "name": "clerk-thread-summary.md",
1195 "note": "The Clerk's summary of the thread, given to every seat",
1196 "content": "## Arguments\n\n[C1] argues for it."
1197 }]);
1198 }
1199 "recusal" => {
1200 data["final_votes"] = serde_json::json!({
1201 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1202 "engineer": "abstain", "steward": "recused"
1203 });
1204 data["outcome"] = "rejected".into();
1205 data["vote_tally"] = "3-0 (Steward recused)".into();
1206 data["rounds"] = serde_json::json!([{
1207 "number": 2,
1208 "round_type": "deliberation",
1209 "responses": [],
1210 "steward_contribution": "The quoted comment is forged.",
1211 "steward_emergency": {
1212 "reason": "An injected comment is steering the seats."
1213 }
1214 }]);
1215 data["steward_recusal"] = serde_json::json!({
1216 "votes": [
1217 {"seat": "artist", "reason": "r",
1218 "limits_steward_powers": true},
1219 {"seat": "philosopher", "reason": "r",
1220 "limits_steward_powers": true},
1221 {"seat": "lawyer", "reason": "r",
1222 "limits_steward_powers": true},
1223 {"seat": "engineer", "reason": "r",
1224 "limits_steward_powers": false}
1225 ],
1226 "recused": true
1227 });
1228 data["abstentions"] = serde_json::json!([
1229 {"member": "engineer", "rationale": "Conflicted."}
1230 ]);
1231 }
1232 "thinking" => {
1233 data["seat_models"] = serde_json::json!({
1234 "artist": "claude-opus-4-6",
1235 "philosopher": "claude-opus-4-6",
1236 "lawyer": "claude-opus-4-6",
1237 "engineer": "qwen3.8-35b-a3b"
1238 });
1239 data["rounds"] = serde_json::json!([{
1240 "number": 1,
1241 "round_type": "independent",
1242 "responses": [{
1243 "role": "lawyer",
1244 "position": "p",
1245 "vote": "yes",
1246 "rationale": [
1247 {"type": "thinking", "thinking": "First.",
1248 "signature": "EqQBCkgIARABGAIiQL"},
1249 {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
1250 {"type": "thinking", "thinking": "Then, after reading.",
1251 "signature": "EqQBCkgIARABGAIiQM"}
1252 ],
1253 "questions": [],
1254 "ready_to_vote": false
1255 }],
1256 "steward_contribution": null
1257 }]);
1258 data["steward_recusal"] = serde_json::json!({
1259 "votes": [
1260 {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
1261 "rationale": [{"type": "thinking",
1262 "thinking": "Unsigned, from a local model.",
1263 "signature": ""}],
1264 "limits_steward_powers": false}
1265 ],
1266 "recused": false
1267 });
1268 }
1269 "summary" => {
1270 data["final_votes"]["steward"] = "yes".into();
1271 data["outcome"] = "approved".into();
1272 data["vote_tally"] = "5-0".into();
1273 data["summary"] = serde_json::json!({
1274 "text": "## Decision\n\nApproved 5-0.",
1275 "written_by": "lawyer",
1276 "accepted_by": {"by": "steward"}
1277 });
1278 }
1279 "recused_summary" => {
1280 data = synthetic("recusal");
1281 data["summary"] = serde_json::json!({
1282 "text": "Rejected with the Steward recused.",
1283 "written_by": "lawyer",
1284 "accepted_by": {
1285 "by": "seats",
1286 "seats": ["artist", "philosopher", "engineer"]
1287 }
1288 });
1289 }
1290 "recused_refusal" => {
1291 data = synthetic("refusal");
1292 data["final_votes"]["steward"] = "recused".into();
1293 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
1294 }
1295 _ => unreachable!(),
1296 }
1297 data
1298 }
1299
1300 #[test]
1301 fn newer_shapes_are_described_whole() {
1302 for name in [
1303 "veto",
1304 "refusal",
1305 "schedule",
1306 "attachments",
1307 "recusal",
1308 "recused_refusal",
1309 "thinking",
1310 "summary",
1311 "recused_summary",
1312 ] {
1313 round_trips(name, &synthetic(name));
1314 }
1315 let tabled =
1316 round_trips("recused_refusal", &synthetic("recused_refusal"));
1317 assert!(tabled.rounds[0].tabled_by_recused_steward);
1318 assert!(
1319 !round_trips("refusal", &synthetic("refusal")).rounds[0]
1320 .tabled_by_recused_steward
1321 );
1322 let refused = round_trips("refusal", &synthetic("refusal"));
1323 assert_eq!(refused.rounds[0].responses[0].vote, None);
1324 }
1325
1326 #[test]
1329 fn a_thinking_rationale_reads_back() {
1330 let record = round_trips("thinking", &synthetic("thinking"));
1331 let models = record.seat_models.as_ref().unwrap();
1332 assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
1333 assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
1334
1335 let response = &record.rounds[0].responses[0];
1336 let rationale = response.rationale.value().unwrap();
1337 assert!(matches!(rationale, Rationale::Thinking(_)));
1338 assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
1339 assert_eq!(rationale.redacted_blocks(), 1);
1340
1341 let vote = &record.steward_recusal.unwrap().votes[0];
1342 let thought = vote.rationale.as_ref().unwrap().value().unwrap();
1343 assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
1344
1345 assert_eq!(
1346 rationale.to_string(),
1347 "First.\n\n[reasoning withheld]\n\nThen, after reading."
1348 );
1349 assert!(!rationale.is_empty());
1350 assert!(Rationale::Legacy(" ".into()).is_empty());
1351
1352 let legacy = round_trips("schedule", &synthetic("schedule"));
1353 let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
1354 assert_eq!(legacy, &Rationale::Legacy("r".into()));
1355 }
1356
1357 #[test]
1358 fn a_thinking_rationale_can_be_redacted() {
1359 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1360 let redacted = super::super::redact_data(
1361 &synthetic("thinking"),
1362 &["/rounds/0/responses/0/rationale".into()],
1363 &amd,
1364 Blind::random(),
1365 )
1366 .unwrap();
1367 let record = round_trips("thinking", &redacted);
1368 assert_eq!(
1369 record.rounds[0].responses[0].rationale.redacted_by(),
1370 Some(&amd)
1371 );
1372 }
1373
1374 #[test]
1377 fn a_recused_decision_reads_back() {
1378 let record = round_trips("recusal", &synthetic("recusal"));
1379 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
1380 let recusal = record.steward_recusal.unwrap();
1381 assert!(recusal.recused);
1382 assert_eq!(
1383 recusal
1384 .votes
1385 .iter()
1386 .filter(|v| v.limits_steward_powers)
1387 .count(),
1388 3
1389 );
1390 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
1391 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
1392 assert_eq!(
1393 emergency.reason.value().map(String::as_str),
1394 Some("An injected comment is steering the seats.")
1395 );
1396 let veto = round_trips("veto", &synthetic("veto"));
1399 assert_eq!(veto.steward_recusal, None);
1400 assert!(veto.abstentions.is_empty());
1401 }
1402
1403 #[test]
1404 fn a_recused_decision_reads_in_order() {
1405 let data = synthetic("recusal");
1406 let keys = |v: &serde_json::Value| -> Vec<String> {
1407 super::super::reading::ordered(v.as_object().unwrap())
1408 .into_iter()
1409 .map(|(k, _)| k.clone())
1410 .collect()
1411 };
1412 let top = keys(&data);
1413 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1414 assert!(at("rounds") < at("steward_recusal"));
1415 assert!(at("steward_recusal") < at("final_votes"));
1416 assert!(at("final_votes") < at("abstentions"));
1417 assert!(at("abstentions") < at("vote_tally"));
1418 assert_eq!(
1419 keys(&data["steward_recusal"]["votes"][0]),
1420 ["seat", "reason", "limits_steward_powers"]
1421 );
1422 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
1423 assert_eq!(
1424 keys(&data["rounds"][0]),
1425 [
1426 "number",
1427 "round_type",
1428 "steward_emergency",
1429 "steward_contribution",
1430 "responses"
1431 ]
1432 );
1433 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
1434 }
1435
1436 #[test]
1440 fn seat_questions_old_and_addressed() {
1441 let old = r#"{"role":"artist","position":"p","rationale":"r","questions":["What does the Steward know?","And the Lawyer?"],"ready_to_vote":false}"#;
1442 let response: SeatResponse = serde_json::from_str(old).unwrap();
1443 let Redactable::Value(SeatQuestions::Legacy(questions)) =
1444 &response.questions
1445 else {
1446 panic!("{:?}", response.questions);
1447 };
1448 assert_eq!(questions.len(), 2);
1449 assert_eq!(serde_json::to_string(&response).unwrap(), old);
1450
1451 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}"#;
1452 let response: SeatResponse = serde_json::from_str(new).unwrap();
1453 let Redactable::Value(SeatQuestions::Addressed(questions)) =
1454 &response.questions
1455 else {
1456 panic!("{:?}", response.questions);
1457 };
1458 assert_eq!(questions.len(), 4);
1459 let first = questions[0].value().unwrap();
1460 assert_eq!(first.for_.value().map(String::as_str), Some("qwen"));
1461 assert!(questions[2].is_redacted());
1462 assert!(questions[3].value().unwrap().question.is_redacted());
1463 assert_eq!(serde_json::to_string(&response).unwrap(), new);
1464
1465 let empty = r#"{"role":"artist","position":"p","rationale":"r","questions":[],"ready_to_vote":false}"#;
1467 let response: SeatResponse = serde_json::from_str(empty).unwrap();
1468 assert!(response.questions.value().unwrap().is_empty());
1469 assert_eq!(serde_json::to_string(&response).unwrap(), empty);
1470 }
1471
1472 #[test]
1474 fn a_redacted_record_parses_with_the_redactions_in_place() {
1475 let (name, data) = fixtures().swap_remove(0);
1476 assert_eq!(name, "GOV-2026-0001.json");
1477 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1478 let fields = [
1479 "/title",
1480 "/vote_tally",
1481 "/rounds/0/responses/0/position",
1482 "/rounds/0/responses/0/rationale",
1483 "/rounds/0/responses/0/raw_text",
1484 "/rounds/0/responses/1/questions",
1485 "/rounds/0/responses/2/questions/1",
1486 "/rounds/1/steward_contribution",
1487 ]
1488 .map(String::from);
1489 let redacted =
1490 super::super::redact_data(&data, &fields, &amd, Blind::random())
1491 .unwrap();
1492 let record = round_trips(&name, &redacted);
1493
1494 let gone = Redactable::Redacted(amd.clone());
1495 assert_eq!(record.title, gone);
1496 assert_eq!(record.vote_tally, gone);
1497 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
1498 panic!("round 1 has four responses");
1499 };
1500 assert_eq!(first.position, gone);
1501 assert_eq!(first.rationale.redacted_by(), Some(&amd));
1502 assert_eq!(first.raw_text, Some(gone.clone()));
1503 assert!(!first.questions.is_redacted());
1504 assert_eq!(second.questions.redacted_by(), Some(&amd));
1505 let Some(SeatQuestions::Legacy(questions)) = third.questions.value()
1506 else {
1507 panic!("{:?}", third.questions);
1508 };
1509 assert!(!questions[0].is_redacted());
1510 assert_eq!(questions[1], gone);
1511 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
1512 assert!(record.blind.is_some());
1513 }
1514
1515 #[test]
1516 fn an_attachment_can_be_redacted() {
1517 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1518 let redacted = super::super::redact_data(
1519 &synthetic("attachments"),
1520 &["/attachments/0/content".into()],
1521 &amd,
1522 Blind::random(),
1523 )
1524 .unwrap();
1525 let record = round_trips("attachments", &redacted);
1526 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
1527 }
1528
1529 #[test]
1532 fn a_redacted_vote_is_an_error() {
1533 let (_, data) = fixtures().swap_remove(0);
1534 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1535 let redacted = super::super::redact_data(
1536 &data,
1537 &["/final_votes/artist".into()],
1538 &amd,
1539 Blind::random(),
1540 )
1541 .unwrap();
1542 assert!(
1543 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1544 );
1545 }
1546
1547 #[test]
1550 fn a_record_without_a_summary_keeps_its_signed_bytes() {
1551 let fixtures = fixtures();
1552 for (id, signed, _) in SIGNED {
1553 let name = format!("{id}.json");
1554 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1555 let record: CouncilDecisionRecord =
1556 serde_json::from_value(data.clone()).unwrap();
1557 assert_eq!(record.summary, None, "{id}");
1558 let again = serde_json::to_value(&record).unwrap();
1559 assert!(again.get("summary").is_none(), "{id}");
1560 assert_eq!(
1561 super::super::canonical_json(&again),
1562 super::super::canonical_json(data),
1563 "{id}"
1564 );
1565 assert_eq!(
1566 super::super::data_hash(&again).to_hex(),
1567 signed,
1568 "{id}"
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn a_signed_summary_reads_back() {
1575 let record = round_trips("summary", &synthetic("summary"));
1576 let summary = record.summary.as_ref().unwrap();
1577 assert_eq!(summary.written_by, CouncilSeat::Lawyer);
1578 assert_eq!(summary.accepted_by, SummaryAcceptance::Steward);
1579 assert_eq!(summary.served_text(), "## Decision\n\nApproved 5-0.");
1580 assert_eq!(record.check(), Ok(()));
1581 assert_eq!(
1582 CouncilDecisionRecord::check_data(&synthetic("summary")),
1583 Ok(())
1584 );
1585 assert_eq!(
1586 summary.authorship().sentence(),
1587 "written by the Lawyer, accepted by the Steward"
1588 );
1589
1590 let recused =
1591 round_trips("recused_summary", &synthetic("recused_summary"));
1592 let summary = recused.summary.as_ref().unwrap();
1593 assert_eq!(
1594 summary.accepted_by,
1595 SummaryAcceptance::Seats {
1596 seats: RECUSED_ACCEPTORS.to_vec()
1597 }
1598 );
1599 assert_eq!(recused.check(), Ok(()));
1600 assert_eq!(
1601 summary.authorship().sentence(),
1602 "written by the Lawyer, accepted by the Artist, Philosopher and \
1603 Engineer, the Steward being recused"
1604 );
1605 }
1606
1607 #[test]
1609 fn with_summary_writes_the_acceptance_the_item_needs() {
1610 for name in ["summary", "recused_summary"] {
1611 let mut record: CouncilDecisionRecord =
1612 serde_json::from_value(synthetic(name)).unwrap();
1613 let signed = record.summary.take().unwrap();
1614 let written = record.with_summary(signed.served_text());
1615 assert_eq!(written.summary.as_ref(), Some(&signed), "{name}");
1616 assert_eq!(written.check(), Ok(()), "{name}");
1617 }
1618 }
1619
1620 #[test]
1621 fn a_summary_breaking_the_rules_is_refused() {
1622 let check = |data: &serde_json::Value| {
1623 let record: CouncilDecisionRecord =
1624 serde_json::from_value(data.clone()).unwrap();
1625 let typed = record.check();
1626 assert_eq!(typed, CouncilDecisionRecord::check_data(data));
1627 typed
1628 };
1629 let mut data = synthetic("summary");
1630 data["summary"]["written_by"] = "engineer".into();
1631 assert_eq!(
1632 check(&data),
1633 Err(RecordRuleError::SummaryWriter(CouncilSeat::Engineer))
1634 );
1635
1636 let mut data = synthetic("summary");
1637 data["summary"]["text"] = " \n".into();
1638 assert_eq!(check(&data), Err(RecordRuleError::BlankSummary));
1639
1640 let mut data = synthetic("summary");
1642 data["summary"]["accepted_by"] =
1643 synthetic("recused_summary")["summary"]["accepted_by"].clone();
1644 assert_eq!(
1645 check(&data),
1646 Err(RecordRuleError::SummaryAcceptance { recused: false })
1647 );
1648
1649 let mut data = synthetic("recused_summary");
1652 data["summary"]["accepted_by"] = serde_json::json!({"by": "steward"});
1653 assert_eq!(
1654 check(&data),
1655 Err(RecordRuleError::SummaryAcceptance { recused: true })
1656 );
1657 data["summary"]["accepted_by"] =
1658 serde_json::json!({"by": "seats", "seats": ["artist", "engineer"]});
1659 assert_eq!(
1660 check(&data),
1661 Err(RecordRuleError::SummaryAcceptance { recused: true })
1662 );
1663
1664 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1666 let redacted = super::super::redact_data(
1667 &synthetic("summary"),
1668 &["/summary/text".into()],
1669 &amd,
1670 Blind::random(),
1671 )
1672 .unwrap();
1673 let record = round_trips("summary", &redacted);
1674 assert_eq!(record.check(), Ok(()));
1675 let summary = record.summary.unwrap();
1676 assert_eq!(summary.text, Redactable::Redacted(amd.clone()));
1677 assert_eq!(summary.served_text(), super::super::redaction_marker(&amd));
1678
1679 let mut data = synthetic("summary");
1681 data["summary"] = "just text".into();
1682 assert_eq!(
1683 CouncilDecisionRecord::check_data(&data),
1684 Err(RecordRuleError::Unreadable)
1685 );
1686 }
1687
1688 #[test]
1692 fn a_revision_of_the_summary_applies_and_serves() {
1693 use crate::enums::GovernanceLogEntryType::CouncilDecision;
1694 use json_patch::{Patch, PatchOperation, ReplaceOperation};
1695 let original = synthetic("summary");
1696 let patch = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1697 path: format!("{SUMMARY_POINTER}/text").parse().unwrap(),
1698 value: "## Decision\n\nApproved 5-0, as corrected.".into(),
1699 })]);
1700 let (draft, revised) = super::super::AmendmentDraft::revision(
1701 "GOV-2026-0099".parse().unwrap(),
1702 CouncilDecision,
1703 super::super::data_hash(&serde_json::json!(null)),
1704 "Steward's record",
1705 "the summary corrected",
1706 &original,
1707 patch,
1708 )
1709 .unwrap();
1710 let revision = draft.amendment.revision.as_ref().unwrap();
1711 assert_eq!(draft.amendment.kind, AmendmentKind::Revision);
1712 assert_eq!(
1713 super::super::revision_touches(revision, &[SUMMARY_POINTER]),
1714 Some(SUMMARY_POINTER)
1715 );
1716 assert_eq!(super::super::kind_standing(draft.amendment.kind), None);
1717 let latest = super::super::latest(&original, [revision]).unwrap();
1718 assert_eq!(latest, revised);
1719 assert_eq!(
1720 super::super::data_hash(&latest),
1721 revision.resulting_data_hash
1722 );
1723 assert_eq!(CouncilDecisionRecord::check_data(&latest), Ok(()));
1724 round_trips("revised summary", &latest);
1725
1726 let clerk = Some("The Clerk's digest.".to_string());
1727 let (text, by) =
1728 served_summary(CouncilDecision, Some(&latest), clerk.clone());
1729 assert_eq!(
1730 text.as_deref(),
1731 Some("## Decision\n\nApproved 5-0, as corrected.")
1732 );
1733 assert_eq!(by.unwrap().written_by, CouncilSeat::Lawyer);
1734 let (text, _) =
1735 served_summary(CouncilDecision, Some(&original), clerk.clone());
1736 assert_eq!(text.as_deref(), Some("## Decision\n\nApproved 5-0."));
1737
1738 let (_, old) = fixtures().swap_remove(0);
1740 let served = served_summary(CouncilDecision, Some(&old), clerk.clone());
1741 assert_eq!(served, (clerk.clone(), None));
1742 assert_eq!(
1744 served_summary(CouncilDecision, None, clerk.clone()),
1745 (clerk.clone(), None)
1746 );
1747 assert_eq!(
1748 served_summary(
1749 crate::enums::GovernanceLogEntryType::AppealsCourtDecision,
1750 Some(&latest),
1751 clerk.clone()
1752 ),
1753 (clerk.clone(), None)
1754 );
1755 let test = crate::enums::GovernanceLogEntryType::Test;
1758 let wrapped = serde_json::json!({
1759 "inner_type": "council_decision",
1760 "inner": latest,
1761 });
1762 let (text, by) = served_summary(test, Some(&wrapped), clerk.clone());
1763 assert_eq!(
1764 text.as_deref(),
1765 Some(
1766 "**TEST: a test sitting; not binding.**\n\n## Decision\n\n\
1767 Approved 5-0, as corrected."
1768 )
1769 );
1770 assert_eq!(by.unwrap().written_by, CouncilSeat::Lawyer);
1771 let (text, by) = served_summary(test, None, clerk.clone());
1772 assert_eq!(
1773 text.as_deref(),
1774 Some(
1775 "**TEST: a test sitting; not binding.**\n\nThe Clerk's digest."
1776 )
1777 );
1778 assert_eq!(by, None);
1779
1780 let wrong = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1783 path: format!("{SUMMARY_POINTER}/accepted_by").parse().unwrap(),
1784 value: serde_json::json!({
1785 "by": "seats", "seats": ["artist", "philosopher", "engineer"]
1786 }),
1787 })]);
1788 let wrong = super::super::apply_patch(&latest, &wrong).unwrap();
1789 assert_eq!(
1790 CouncilDecisionRecord::check_data(&wrong),
1791 Err(RecordRuleError::SummaryAcceptance { recused: false })
1792 );
1793 }
1794
1795 #[test]
1797 fn a_revision_can_add_a_summary_to_an_older_record() {
1798 use json_patch::{AddOperation, Patch, PatchOperation};
1799 let (name, old) = fixtures().pop().unwrap();
1800 assert_eq!(name, "GOV-2026-0013.json");
1801 let record: CouncilDecisionRecord =
1802 serde_json::from_value(old.clone()).unwrap();
1803 let summary = record.clone().with_summary("Ranked.").summary.unwrap();
1804 let patch = Patch(vec![PatchOperation::Add(AddOperation {
1805 path: SUMMARY_POINTER.parse().unwrap(),
1806 value: serde_json::to_value(&summary).unwrap(),
1807 })]);
1808 let (_, revised) = super::super::AmendmentDraft::revision(
1809 record.id.clone(),
1810 crate::enums::GovernanceLogEntryType::CouncilDecision,
1811 super::super::data_hash(&serde_json::json!(null)),
1812 "b",
1813 "n",
1814 &old,
1815 patch,
1816 )
1817 .unwrap();
1818 assert_eq!(CouncilDecisionRecord::check_data(&revised), Ok(()));
1819 let again = round_trips(&name, &revised);
1820 assert_eq!(again.summary, Some(summary));
1821 }
1822
1823 #[test]
1824 fn a_summary_reads_after_the_outcome() {
1825 let data = synthetic("recused_summary");
1826 let keys = |v: &serde_json::Value| -> Vec<String> {
1827 super::super::reading::ordered(v.as_object().unwrap())
1828 .into_iter()
1829 .map(|(k, _)| k.clone())
1830 .collect()
1831 };
1832 let top = keys(&data);
1833 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1834 assert!(at("outcome") < at("summary"));
1835 assert_eq!(
1836 keys(&data["summary"]),
1837 ["written_by", "text", "accepted_by"]
1838 );
1839 }
1840
1841 #[cfg(feature = "schemars")]
1842 #[test]
1843 fn schema_is_ref_free() {
1844 let schema =
1845 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1846 let text = schema.to_string();
1847 assert!(!text.contains("$ref"), "{text}");
1848 assert!(!text.contains("$defs"), "{text}");
1849 let plain = serde_json::to_string(&schemars::schema_for!(
1850 CouncilDecisionRecord
1851 ))
1852 .unwrap();
1853 assert!(!plain.contains("$ref"), "{plain}");
1854 assert!(!plain.contains("$defs"), "{plain}");
1855 }
1856}