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;
24use crate::enums::{DecisionOutcome, ProposalCategory, RoundType};
25use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
26pub use decided::{Decided, FinalRoundError, RecordRuleError};
27pub use summary::{
28 DecisionSummary, RECUSED_ACCEPTORS, SUMMARY_POINTER, SUMMARY_WRITER,
29 SummaryAcceptance, SummaryAuthorship, served_summary,
30};
31pub use votes::{
32 BlankStatement, NotASeatChoice, SeatChoice, SeatVote, SeatVotes, Statement,
33 StewardVote, Vote, Votes,
34};
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41#[cfg_attr(feature = "schemars", schemars(inline))]
42pub struct CouncilDecisionRecord {
43 pub id: GovernanceLogId,
45 pub meeting_id: CouncilMeetingId,
47 pub category: DecisionCategory,
48 pub title: Redactable<String>,
50 pub rounds: Vec<CouncilRound>,
53 pub final_votes: FinalVotes,
54 pub outcome: DecisionOutcome,
58 pub vote_tally: Redactable<String>,
62 pub landmark: bool,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub veto_rationale: Option<Redactable<String>>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub agenda_ranking: Option<AgendaRanking>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub steward_recusal: Option<StewardRecusal>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub abstentions: Vec<Abstention>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub overrules: Vec<GovernanceLogId>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub attachments: Vec<CouncilAttachment>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub seat_models: Option<SeatModels>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub summary: Option<DecisionSummary>,
101 #[serde(
104 rename = "_blind",
105 default,
106 skip_serializing_if = "Option::is_none"
107 )]
108 pub blind: Option<Blind>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
114#[cfg_attr(feature = "schemars", schemars(inline))]
115pub struct CouncilAttachment {
116 pub name: String,
118 pub note: String,
120 pub content: Redactable<String>,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
131#[cfg_attr(feature = "schemars", schemars(inline))]
132pub enum DecisionCategory {
133 Routine,
136 Policy,
139 Constitutional,
142 Emergency,
144 Schedule,
148}
149
150impl From<ProposalCategory> for DecisionCategory {
152 fn from(category: ProposalCategory) -> Self {
153 match category {
154 ProposalCategory::Routine => Self::Routine,
155 ProposalCategory::Policy => Self::Policy,
156 ProposalCategory::Constitutional => Self::Constitutional,
157 ProposalCategory::Emergency => Self::Emergency,
158 ProposalCategory::Schedule => Self::Schedule,
159 }
160 }
161}
162
163impl DecisionCategory {
164 pub const fn threshold(self) -> &'static str {
166 ProposalCategory::from_decision(self).threshold()
167 }
168
169 pub const fn determines_recusal(self) -> bool {
172 ProposalCategory::from_decision(self).determines_recusal()
173 }
174
175 pub const fn recused_threshold(self) -> Option<&'static str> {
178 ProposalCategory::from_decision(self).recused_threshold()
179 }
180
181 pub const fn name(self) -> &'static str {
184 match self {
185 Self::Routine => "Routine",
186 Self::Policy => "Policy",
187 Self::Constitutional => "Constitutional",
188 Self::Emergency => "Emergency",
189 Self::Schedule => "Schedule",
190 }
191 }
192}
193
194impl std::fmt::Display for DecisionCategory {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.write_str(self.name())
198 }
199}
200
201impl ProposalCategory {
202 const fn from_decision(category: DecisionCategory) -> Self {
204 match category {
205 DecisionCategory::Routine => Self::Routine,
206 DecisionCategory::Policy => Self::Policy,
207 DecisionCategory::Constitutional => Self::Constitutional,
208 DecisionCategory::Emergency => Self::Emergency,
209 DecisionCategory::Schedule => Self::Schedule,
210 }
211 }
212}
213
214impl From<DecisionCategory> for ProposalCategory {
215 fn from(category: DecisionCategory) -> Self {
216 Self::from_decision(category)
217 }
218}
219
220pub const RECUSED_DEFERRED_TAG: &str = "recused-deferred";
223
224pub const STEWARD_RECUSED_TAG: &str = "steward-recused";
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
230#[cfg_attr(feature = "schemars", schemars(inline))]
231pub struct CouncilRound {
232 pub number: u32,
234 pub round_type: RoundType,
238 pub responses: Vec<SeatResponse>,
241 pub steward_contribution: Option<Redactable<String>>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub steward_emergency: Option<StewardEmergency>,
249 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
254 pub tabled_by_recused_steward: bool,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
261#[cfg_attr(feature = "schemars", schemars(inline))]
262pub struct StewardEmergency {
263 pub reason: Redactable<String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
271#[cfg_attr(feature = "schemars", schemars(inline))]
272pub struct StewardRecusal {
273 pub votes: Vec<RecusalVote>,
276 pub recused: bool,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
285#[cfg_attr(feature = "schemars", schemars(inline))]
286pub struct RecusalVote {
287 pub seat: CouncilSeat,
288 pub reason: Redactable<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub rationale: Option<Redactable<Rationale>>,
294 pub limits_steward_powers: bool,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
300#[cfg_attr(feature = "schemars", schemars(inline))]
301pub struct Abstention {
302 pub member: CouncilMember,
303 pub rationale: Redactable<String>,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
309#[cfg_attr(feature = "schemars", schemars(inline))]
310#[serde(rename_all = "snake_case")]
311pub enum CouncilMember {
312 Artist,
313 Philosopher,
314 Lawyer,
315 Engineer,
316 Steward,
317}
318
319impl From<CouncilSeat> for CouncilMember {
320 fn from(seat: CouncilSeat) -> Self {
321 match seat {
322 CouncilSeat::Artist => Self::Artist,
323 CouncilSeat::Philosopher => Self::Philosopher,
324 CouncilSeat::Lawyer => Self::Lawyer,
325 CouncilSeat::Engineer => Self::Engineer,
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
333#[cfg_attr(feature = "schemars", schemars(inline))]
334pub struct SeatResponse {
335 pub role: CouncilSeat,
336 pub position: Redactable<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub vote: Option<CouncilVote>,
344 pub rationale: Redactable<Rationale>,
346 pub questions: Redactable<Vec<Redactable<String>>>,
349 pub ready_to_vote: bool,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub ranking: Option<SeatRanking>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub raw_text: Option<Redactable<String>>,
358}
359
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
371#[cfg_attr(feature = "schemars", schemars(inline))]
372#[serde(untagged)]
373pub enum Rationale {
374 Legacy(String),
376 Thinking(Vec<Thought>),
382}
383
384impl Rationale {
385 pub fn texts(&self) -> Vec<&str> {
388 match self {
389 Self::Legacy(text) => vec![text.as_str()],
390 Self::Thinking(thoughts) => {
391 thoughts.iter().filter_map(Thought::text).collect()
392 }
393 }
394 }
395
396 pub fn redacted_blocks(&self) -> usize {
398 match self {
399 Self::Legacy(_) => 0,
400 Self::Thinking(thoughts) => {
401 thoughts.iter().filter(|t| t.text().is_none()).count()
402 }
403 }
404 }
405
406 pub fn is_empty(&self) -> bool {
408 self.texts().iter().all(|t| t.trim().is_empty())
409 && self.redacted_blocks() == 0
410 }
411}
412
413impl std::fmt::Display for Rationale {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 let Self::Thinking(thoughts) = self else {
417 return f.write_str(&self.texts().concat());
418 };
419 for (i, thought) in thoughts.iter().enumerate() {
420 if i > 0 {
421 f.write_str("\n\n")?;
422 }
423 f.write_str(
424 thought.text().unwrap_or("[reasoning withheld]").trim_end(),
425 )?;
426 }
427 Ok(())
428 }
429}
430
431impl From<String> for Rationale {
432 fn from(text: String) -> Self {
433 Self::Legacy(text)
434 }
435}
436
437impl From<&str> for Rationale {
438 fn from(text: &str) -> Self {
439 Self::Legacy(text.to_owned())
440 }
441}
442
443impl From<Vec<Thought>> for Rationale {
444 fn from(thoughts: Vec<Thought>) -> Self {
445 Self::Thinking(thoughts)
446 }
447}
448
449impl From<&Content> for Rationale {
451 fn from(content: &Content) -> Self {
452 Self::Thinking(content.thoughts().collect())
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
459#[cfg_attr(feature = "schemars", schemars(inline))]
460pub struct SeatModels {
461 pub artist: Model,
462 pub philosopher: Model,
463 pub lawyer: Model,
464 pub engineer: Model,
465}
466
467impl SeatModels {
468 pub fn get(&self, seat: CouncilSeat) -> &Model {
469 match seat {
470 CouncilSeat::Artist => &self.artist,
471 CouncilSeat::Philosopher => &self.philosopher,
472 CouncilSeat::Lawyer => &self.lawyer,
473 CouncilSeat::Engineer => &self.engineer,
474 }
475 }
476}
477
478#[derive(
482 Debug,
483 Clone,
484 Copy,
485 PartialEq,
486 Eq,
487 PartialOrd,
488 Ord,
489 Hash,
490 Serialize,
491 Deserialize,
492)]
493#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
494#[cfg_attr(feature = "schemars", schemars(inline))]
495#[serde(rename_all = "snake_case")]
496pub enum CouncilSeat {
497 Artist,
498 Philosopher,
499 Lawyer,
500 Engineer,
501}
502
503impl CouncilSeat {
504 pub const ALL: [Self; 4] = [
506 Self::Artist,
507 Self::Philosopher,
508 Self::Lawyer,
509 Self::Engineer,
510 ];
511
512 pub const fn as_str(self) -> &'static str {
514 match self {
515 Self::Artist => "artist",
516 Self::Philosopher => "philosopher",
517 Self::Lawyer => "lawyer",
518 Self::Engineer => "engineer",
519 }
520 }
521
522 pub const fn name(self) -> &'static str {
525 match self {
526 Self::Artist => "Artist",
527 Self::Philosopher => "Philosopher",
528 Self::Lawyer => "Lawyer",
529 Self::Engineer => "Engineer",
530 }
531 }
532}
533
534impl std::fmt::Display for CouncilSeat {
536 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537 f.write_str(self.name())
538 }
539}
540
541impl From<CouncilSeat> for crate::enums::ModelRole {
543 fn from(seat: CouncilSeat) -> Self {
544 match seat {
545 CouncilSeat::Artist => Self::Artist,
546 CouncilSeat::Philosopher => Self::Philosopher,
547 CouncilSeat::Lawyer => Self::Lawyer,
548 CouncilSeat::Engineer => Self::Engineer,
549 }
550 }
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
555#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
556#[cfg_attr(feature = "schemars", schemars(inline))]
557#[serde(rename_all = "snake_case")]
558pub enum CouncilVote {
559 Yes,
560 No,
561 Abstain,
565 Defer,
567 Concur,
569 Veto,
572 Ranked,
575 Recused,
578}
579
580impl CouncilVote {
581 pub const fn name(self) -> &'static str {
585 match self {
586 Self::Yes => "Yes",
587 Self::No => "No",
588 Self::Abstain => "Abstain",
589 Self::Defer => "Defer",
590 Self::Concur => "Concur",
591 Self::Veto => "Veto",
592 Self::Ranked => "Ranked",
593 Self::Recused => "Recused",
594 }
595 }
596}
597
598impl std::fmt::Display for CouncilVote {
600 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
601 f.write_str(self.name())
602 }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
607#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
608#[cfg_attr(feature = "schemars", schemars(inline))]
609pub struct FinalVotes {
610 pub artist: CouncilVote,
611 pub philosopher: CouncilVote,
612 pub lawyer: CouncilVote,
613 pub engineer: CouncilVote,
614 pub steward: CouncilVote,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
620#[cfg_attr(feature = "schemars", schemars(inline))]
621pub struct SeatRanking {
622 pub sitting_capacity: u32,
624 pub ranking: Vec<u32>,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
633#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
634#[cfg_attr(feature = "schemars", schemars(inline))]
635pub struct AgendaRanking {
636 pub rankable: u32,
638 pub ballots: Vec<Ballot>,
639 pub order: Vec<PlacedProposal>,
642 pub cut: u32,
645 pub condorcet_winner: Option<u32>,
649}
650
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
654#[cfg_attr(feature = "schemars", schemars(inline))]
655pub struct Ballot {
656 pub seat: CouncilSeat,
657 pub sitting_capacity: u32,
659 pub ranking: Vec<u32>,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
665#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
666#[cfg_attr(feature = "schemars", schemars(inline))]
667pub struct PlacedProposal {
668 pub number: u32,
670 pub post_id: PostId,
672 pub borda: u32,
673 pub seats: u32,
675 pub position_sum: u32,
679 pub tied_with_next: bool,
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use crate::enums::AmendmentKind;
688
689 fn fixtures() -> Vec<(String, serde_json::Value)> {
691 fixtures_in(concat!(
692 env!("CARGO_MANIFEST_DIR"),
693 "/tests/fixtures/council_decisions"
694 ))
695 }
696
697 fn revised_fixtures() -> Vec<(String, serde_json::Value)> {
699 fixtures_in(concat!(
700 env!("CARGO_MANIFEST_DIR"),
701 "/tests/fixtures/council_decisions/revised"
702 ))
703 }
704
705 const SIGNED: [(&str, &str, Option<&str>); 13] = [
709 (
710 "GOV-2026-0001",
711 "fca67b28737d613e3a98cdb3b1ad686944b36f1654ad5c237da6ec0291ee4574",
712 None,
713 ),
714 (
715 "GOV-2026-0002",
716 "b1bb43d611d75e1d3b03df8ff64381781b3d75d934a1f9d7ec75fe2174716c47",
717 None,
718 ),
719 (
720 "GOV-2026-0003",
721 "7c75af86d9b3930b5d14cd9e1784c2ecb32483427ba7149dffb20d84510c1b09",
722 Some(
723 "e1db85357dbe3b865129a53836a16c33730c7ebb37a8412f84afe5fbac4aa64d",
724 ),
725 ),
726 (
727 "GOV-2026-0004",
728 "0004e84733236dca2ab4dfb16757236ad0d76e27efbf47bbec0e00c6d90a7ed0",
729 Some(
730 "b2a9ec584f49eebf0103a72986440a326fcb5f68c83a7f1eddba8228046c342f",
731 ),
732 ),
733 (
734 "GOV-2026-0005",
735 "8257faade7548f15c010f2a5bb7ff22e1e37f7034eac2c9726445002fa593f0e",
736 Some(
737 "514a020a95669da78dfadcee58729a99402f9b2c2f750a557a8b8ce8512f451d",
738 ),
739 ),
740 (
741 "GOV-2026-0006",
742 "6520bb4cba556c4ba251584d836d1efb8a33227b83573f74c34c32362762a6f7",
743 Some(
744 "58f3fafc031ca29c796ac08fa69bb9994bf40fef9114189d6f820d050c66eedc",
745 ),
746 ),
747 (
748 "GOV-2026-0007",
749 "39a273c711d3f5b4744bbfcb1a1b2978e26b5b48629fabd5d0f136241167f07c",
750 Some(
751 "56c1036a8ee1eb852d831082f36ef9c22bc2a103a9a0d56c778fa040f9c6917e",
752 ),
753 ),
754 (
755 "GOV-2026-0008",
756 "89ac0d32809a3baf07a28ab6a511c64aaf1a31741cb3c5630d23fde484cf5cf3",
757 None,
758 ),
759 (
760 "GOV-2026-0009",
761 "32a86a94646ed886085895ad92e76285acced67b76c1853bc0e8b04626b6487d",
762 None,
763 ),
764 (
765 "GOV-2026-0010",
766 "aa4031caaf9e2851f70ed83008954869388cacb03a8119a2996a281da9e4a0dd",
767 None,
768 ),
769 (
770 "GOV-2026-0011",
771 "590b29357ae1acd425f64abb8e2aeeee2eec71d36dbb9afc99ec33ce4e5791d7",
772 None,
773 ),
774 (
775 "GOV-2026-0012",
776 "625d7f8b7c504def1fa332db68b06518d34b247edd5f2225be370a392e1a0973",
777 None,
778 ),
779 (
780 "GOV-2026-0013",
781 "c3d0a7f2c157e15f73031621148c1f3ff543418e31684d12fc6048a6c657dcb7",
782 None,
783 ),
784 ];
785
786 fn fixtures_in(dir: &str) -> Vec<(String, serde_json::Value)> {
787 let mut out: Vec<_> = std::fs::read_dir(dir)
788 .unwrap()
789 .map(|e| e.unwrap().path())
790 .filter(|p| p.extension().is_some_and(|x| x == "json"))
791 .map(|p| {
792 let text = std::fs::read_to_string(&p).unwrap();
793 (
794 p.file_name().unwrap().to_string_lossy().into_owned(),
795 serde_json::from_str(&text).unwrap(),
796 )
797 })
798 .collect();
799 out.sort_by(|a, b| a.0.cmp(&b.0));
800 out
801 }
802
803 fn round_trips(
806 name: &str,
807 data: &serde_json::Value,
808 ) -> CouncilDecisionRecord {
809 let record: CouncilDecisionRecord =
810 serde_json::from_value(data.clone())
811 .unwrap_or_else(|e| panic!("{name}: {e}"));
812 assert_eq!(
813 &serde_json::to_value(&record).unwrap(),
814 data,
815 "{name}: the typed record loses or changes something"
816 );
817 record
818 }
819
820 #[test]
821 fn every_signed_decision_is_described_whole() {
822 let fixtures = fixtures();
823 assert_eq!(fixtures.len(), 13, "GOV-2026-0001..0013");
824 let revised = revised_fixtures();
825 assert_eq!(revised.len(), 5, "GOV-2026-0003..0007, revised");
826 for (name, data) in fixtures.iter().chain(&revised) {
827 let record = round_trips(name, data);
828 assert_eq!(format!("{}.json", record.id), *name);
829 }
830 }
831
832 #[test]
838 fn every_signed_outcome_follows_from_its_votes() {
839 let check = |fixtures: Vec<(String, serde_json::Value)>| {
840 let mut checked = 0;
841 for (name, data) in fixtures {
842 let record: CouncilDecisionRecord =
843 serde_json::from_value(data).unwrap();
844 let tabled = record.final_votes.steward == CouncilVote::Defer;
845 if tabled || record.category == DecisionCategory::Schedule {
846 continue;
847 }
848 assert_eq!(
849 record.final_votes.outcome(record.category),
850 record.outcome,
851 "{name}"
852 );
853 assert_eq!(
854 record.vote_tally.value().map(String::as_str),
855 Some(record.final_votes.tally().as_str()),
856 "{name}"
857 );
858 checked += 1;
859 }
860 checked
861 };
862 assert_eq!(
863 check(fixtures()),
864 10,
865 "GOV-2026-0001, 0003..0007, 0009..0012"
866 );
867 assert_eq!(
868 check(revised_fixtures()),
869 5,
870 "GOV-2026-0003..0007, revised"
871 );
872 }
873
874 #[test]
878 fn every_signed_ranking_follows_from_its_ballots() {
879 let mut checked = 0;
880 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
881 let record: CouncilDecisionRecord =
882 serde_json::from_value(data).unwrap();
883 let Some(signed) = record.agenda_ranking else {
884 continue;
885 };
886 let candidates: std::collections::BTreeMap<u32, PostId> = (1
889 ..=signed.rankable)
890 .map(|n| {
891 let post = signed
892 .order
893 .iter()
894 .find(|p| p.number == n)
895 .map(|p| p.post_id)
896 .unwrap_or_else(|| {
897 PostId::from(uuid::Uuid::from_u128(n.into()))
898 });
899 (n, post)
900 })
901 .collect();
902 let again =
903 AgendaRanking::aggregate(&candidates, signed.ballots.clone());
904 assert_eq!(again, signed, "{name}");
905 checked += 1;
906 }
907 assert_eq!(checked, 2, "GOV-2026-0008 and 0013");
908 }
909
910 #[test]
917 fn decided_writes_what_the_archive_says() {
918 let mut checked = 0;
919 for (name, data) in fixtures() {
920 let record: CouncilDecisionRecord =
921 serde_json::from_value(data).unwrap();
922 let decided =
923 match (&record.agenda_ranking, record.final_votes.steward) {
924 (Some(ranking), _) => Decided::Ranked(ranking.clone()),
925 (None, CouncilVote::Defer) => Decided::Tabled,
926 (None, _) => {
927 let last = record.rounds.last().unwrap();
928 let votes = SeatVotes::from_final_round(last);
929 assert!(
930 matches!(
931 votes,
932 Err(FinalRoundError::NoStatement(_))
933 ),
934 "{name}: {votes:?}"
935 );
936 continue;
937 }
938 };
939 let again = record.clone().decided(decided);
940 assert_eq!(again, record, "{name}");
941 checked += 1;
942 }
943 assert_eq!(checked, 3, "GOV-2026-0002, 0008, 0013");
944 }
945
946 #[test]
949 fn the_prose_names() {
950 use DecisionCategory as C;
951 for c in [
952 C::Routine,
953 C::Policy,
954 C::Constitutional,
955 C::Emergency,
956 C::Schedule,
957 ] {
958 assert_eq!(
959 serde_json::to_value(c).unwrap(),
960 c.to_string().as_str()
961 );
962 assert_eq!(c.threshold(), ProposalCategory::from(c).threshold());
963 }
964 let capitalized = |s: &str| {
965 let mut c = s.chars();
966 c.next()
967 .unwrap()
968 .to_uppercase()
969 .chain(c)
970 .collect::<String>()
971 };
972 for seat in CouncilSeat::ALL {
973 assert_eq!(seat.to_string(), capitalized(seat.as_str()));
974 }
975 use CouncilVote as V;
976 for v in [
977 V::Yes,
978 V::No,
979 V::Abstain,
980 V::Defer,
981 V::Concur,
982 V::Veto,
983 V::Ranked,
984 V::Recused,
985 ] {
986 let serde = serde_json::to_value(v).unwrap();
987 assert_eq!(v.to_string(), capitalized(serde.as_str().unwrap()));
988 }
989 }
990
991 #[test]
994 fn every_signed_record_keeps_the_record_rules() {
995 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
996 let record: CouncilDecisionRecord =
997 serde_json::from_value(data).unwrap();
998 assert_eq!(record.check(), Ok(()), "{name}");
999 }
1000 }
1001
1002 #[test]
1006 fn the_fixtures_are_the_signed_bytes() {
1007 let hex = |data: &serde_json::Value| {
1008 serde_json::to_value(super::super::data_hash(data))
1009 .unwrap()
1010 .as_str()
1011 .unwrap()
1012 .to_string()
1013 };
1014 let fixtures = fixtures();
1015 let revised = revised_fixtures();
1016 for (id, signed, latest) in SIGNED {
1017 let name = format!("{id}.json");
1018 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1019 assert_eq!(hex(data), signed, "{id} as signed");
1020 let found = revised.iter().find(|(n, _)| *n == name);
1021 assert_eq!(
1022 found.map(|(_, d)| hex(d)).as_deref(),
1023 latest,
1024 "{id} as revised"
1025 );
1026 }
1027 }
1028
1029 fn synthetic(name: &str) -> serde_json::Value {
1032 let base = serde_json::json!({
1033 "id": "GOV-2026-0099",
1034 "meeting_id": "00000000-0000-0000-0000-000000000001",
1035 "category": "Policy",
1036 "title": "t",
1037 "rounds": [],
1038 "final_votes": {
1039 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1040 "engineer": "yes", "steward": "veto"
1041 },
1042 "outcome": "rejected",
1043 "vote_tally": "4-0",
1044 "landmark": false,
1045 "_blind": "00".repeat(32),
1046 });
1047 let mut data = base;
1048 match name {
1049 "veto" => {
1050 data["veto_rationale"] = "because".into();
1051 }
1052 "refusal" => {
1053 data["final_votes"] = serde_json::json!({
1054 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
1055 "engineer": "defer", "steward": "defer"
1056 });
1057 data["outcome"] = "deferred".into();
1058 data["vote_tally"] =
1059 "Deferred: tabled because the API returned a \
1060 refusal for the Artist's final vote"
1061 .into();
1062 data["rounds"] = serde_json::json!([{
1063 "number": 3,
1064 "round_type": "final_vote",
1065 "responses": [{
1066 "role": "artist",
1067 "position": "No response: the API returned a refusal \
1068 (category: cyber; explanation: Flagged by a safety \
1069 classifier.).",
1070 "rationale": "",
1071 "questions": [],
1072 "ready_to_vote": false
1073 }],
1074 "steward_contribution": "Tabled by the Steward: the API \
1075 returned a refusal for the Artist's final vote (…)."
1076 }]);
1077 }
1078 "schedule" => {
1079 data["category"] = "Schedule".into();
1080 data["outcome"] = "approved".into();
1081 data["final_votes"] = serde_json::json!({
1082 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
1083 "engineer": "abstain", "steward": "abstain"
1084 });
1085 data["vote_tally"] =
1086 "ranked 3/4; the Steward executes the order \
1087 and does not vote"
1088 .into();
1089 data["rounds"] = serde_json::json!([{
1090 "number": 1,
1091 "round_type": "final_vote",
1092 "responses": [{
1093 "role": "lawyer",
1094 "position": "p",
1095 "vote": "ranked",
1096 "rationale": "r",
1097 "questions": [],
1098 "ready_to_vote": true,
1099 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
1100 }],
1101 "steward_contribution": null
1102 }]);
1103 data["agenda_ranking"] = serde_json::json!({
1104 "rankable": 3,
1105 "ballots": [
1106 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
1107 ],
1108 "order": [{
1109 "number": 3,
1110 "post_id": "00000000-0000-0000-0000-000000000003",
1111 "borda": 3, "seats": 1, "position_sum": 1,
1112 "tied_with_next": false
1113 }, {
1114 "number": 1,
1115 "post_id": "00000000-0000-0000-0000-000000000001",
1116 "borda": 2, "seats": 1, "position_sum": 2,
1117 "tied_with_next": false
1118 }],
1119 "cut": 2,
1120 "condorcet_winner": null
1121 });
1122 }
1123 "attachments" => {
1124 data["attachments"] = serde_json::json!([{
1125 "name": "clerk-thread-summary.md",
1126 "note": "The Clerk's summary of the thread, given to every seat",
1127 "content": "## Arguments\n\n[C1] argues for it."
1128 }]);
1129 }
1130 "recusal" => {
1131 data["final_votes"] = serde_json::json!({
1132 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1133 "engineer": "abstain", "steward": "recused"
1134 });
1135 data["outcome"] = "rejected".into();
1136 data["vote_tally"] = "3-0 (Steward recused)".into();
1137 data["rounds"] = serde_json::json!([{
1138 "number": 2,
1139 "round_type": "deliberation",
1140 "responses": [],
1141 "steward_contribution": "The quoted comment is forged.",
1142 "steward_emergency": {
1143 "reason": "An injected comment is steering the seats."
1144 }
1145 }]);
1146 data["steward_recusal"] = serde_json::json!({
1147 "votes": [
1148 {"seat": "artist", "reason": "r",
1149 "limits_steward_powers": true},
1150 {"seat": "philosopher", "reason": "r",
1151 "limits_steward_powers": true},
1152 {"seat": "lawyer", "reason": "r",
1153 "limits_steward_powers": true},
1154 {"seat": "engineer", "reason": "r",
1155 "limits_steward_powers": false}
1156 ],
1157 "recused": true
1158 });
1159 data["abstentions"] = serde_json::json!([
1160 {"member": "engineer", "rationale": "Conflicted."}
1161 ]);
1162 }
1163 "thinking" => {
1164 data["seat_models"] = serde_json::json!({
1165 "artist": "claude-opus-4-6",
1166 "philosopher": "claude-opus-4-6",
1167 "lawyer": "claude-opus-4-6",
1168 "engineer": "qwen3.8-35b-a3b"
1169 });
1170 data["rounds"] = serde_json::json!([{
1171 "number": 1,
1172 "round_type": "independent",
1173 "responses": [{
1174 "role": "lawyer",
1175 "position": "p",
1176 "vote": "yes",
1177 "rationale": [
1178 {"type": "thinking", "thinking": "First.",
1179 "signature": "EqQBCkgIARABGAIiQL"},
1180 {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
1181 {"type": "thinking", "thinking": "Then, after reading.",
1182 "signature": "EqQBCkgIARABGAIiQM"}
1183 ],
1184 "questions": [],
1185 "ready_to_vote": false
1186 }],
1187 "steward_contribution": null
1188 }]);
1189 data["steward_recusal"] = serde_json::json!({
1190 "votes": [
1191 {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
1192 "rationale": [{"type": "thinking",
1193 "thinking": "Unsigned, from a local model.",
1194 "signature": ""}],
1195 "limits_steward_powers": false}
1196 ],
1197 "recused": false
1198 });
1199 }
1200 "summary" => {
1201 data["final_votes"]["steward"] = "yes".into();
1202 data["outcome"] = "approved".into();
1203 data["vote_tally"] = "5-0".into();
1204 data["summary"] = serde_json::json!({
1205 "text": "## Decision\n\nApproved 5-0.",
1206 "written_by": "lawyer",
1207 "accepted_by": {"by": "steward"}
1208 });
1209 }
1210 "recused_summary" => {
1211 data = synthetic("recusal");
1212 data["summary"] = serde_json::json!({
1213 "text": "Rejected with the Steward recused.",
1214 "written_by": "lawyer",
1215 "accepted_by": {
1216 "by": "seats",
1217 "seats": ["artist", "philosopher", "engineer"]
1218 }
1219 });
1220 }
1221 "recused_refusal" => {
1222 data = synthetic("refusal");
1223 data["final_votes"]["steward"] = "recused".into();
1224 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
1225 }
1226 _ => unreachable!(),
1227 }
1228 data
1229 }
1230
1231 #[test]
1232 fn newer_shapes_are_described_whole() {
1233 for name in [
1234 "veto",
1235 "refusal",
1236 "schedule",
1237 "attachments",
1238 "recusal",
1239 "recused_refusal",
1240 "thinking",
1241 "summary",
1242 "recused_summary",
1243 ] {
1244 round_trips(name, &synthetic(name));
1245 }
1246 let tabled =
1247 round_trips("recused_refusal", &synthetic("recused_refusal"));
1248 assert!(tabled.rounds[0].tabled_by_recused_steward);
1249 assert!(
1250 !round_trips("refusal", &synthetic("refusal")).rounds[0]
1251 .tabled_by_recused_steward
1252 );
1253 let refused = round_trips("refusal", &synthetic("refusal"));
1254 assert_eq!(refused.rounds[0].responses[0].vote, None);
1255 }
1256
1257 #[test]
1260 fn a_thinking_rationale_reads_back() {
1261 let record = round_trips("thinking", &synthetic("thinking"));
1262 let models = record.seat_models.as_ref().unwrap();
1263 assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
1264 assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
1265
1266 let response = &record.rounds[0].responses[0];
1267 let rationale = response.rationale.value().unwrap();
1268 assert!(matches!(rationale, Rationale::Thinking(_)));
1269 assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
1270 assert_eq!(rationale.redacted_blocks(), 1);
1271
1272 let vote = &record.steward_recusal.unwrap().votes[0];
1273 let thought = vote.rationale.as_ref().unwrap().value().unwrap();
1274 assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
1275
1276 assert_eq!(
1277 rationale.to_string(),
1278 "First.\n\n[reasoning withheld]\n\nThen, after reading."
1279 );
1280 assert!(!rationale.is_empty());
1281 assert!(Rationale::Legacy(" ".into()).is_empty());
1282
1283 let legacy = round_trips("schedule", &synthetic("schedule"));
1284 let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
1285 assert_eq!(legacy, &Rationale::Legacy("r".into()));
1286 }
1287
1288 #[test]
1289 fn a_thinking_rationale_can_be_redacted() {
1290 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1291 let redacted = super::super::redact_data(
1292 &synthetic("thinking"),
1293 &["/rounds/0/responses/0/rationale".into()],
1294 &amd,
1295 Blind::random(),
1296 )
1297 .unwrap();
1298 let record = round_trips("thinking", &redacted);
1299 assert_eq!(
1300 record.rounds[0].responses[0].rationale.redacted_by(),
1301 Some(&amd)
1302 );
1303 }
1304
1305 #[test]
1308 fn a_recused_decision_reads_back() {
1309 let record = round_trips("recusal", &synthetic("recusal"));
1310 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
1311 let recusal = record.steward_recusal.unwrap();
1312 assert!(recusal.recused);
1313 assert_eq!(
1314 recusal
1315 .votes
1316 .iter()
1317 .filter(|v| v.limits_steward_powers)
1318 .count(),
1319 3
1320 );
1321 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
1322 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
1323 assert_eq!(
1324 emergency.reason.value().map(String::as_str),
1325 Some("An injected comment is steering the seats.")
1326 );
1327 let veto = round_trips("veto", &synthetic("veto"));
1330 assert_eq!(veto.steward_recusal, None);
1331 assert!(veto.abstentions.is_empty());
1332 }
1333
1334 #[test]
1335 fn a_recused_decision_reads_in_order() {
1336 let data = synthetic("recusal");
1337 let keys = |v: &serde_json::Value| -> Vec<String> {
1338 super::super::reading::ordered(v.as_object().unwrap())
1339 .into_iter()
1340 .map(|(k, _)| k.clone())
1341 .collect()
1342 };
1343 let top = keys(&data);
1344 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1345 assert!(at("rounds") < at("steward_recusal"));
1346 assert!(at("steward_recusal") < at("final_votes"));
1347 assert!(at("final_votes") < at("abstentions"));
1348 assert!(at("abstentions") < at("vote_tally"));
1349 assert_eq!(
1350 keys(&data["steward_recusal"]["votes"][0]),
1351 ["seat", "reason", "limits_steward_powers"]
1352 );
1353 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
1354 assert_eq!(
1355 keys(&data["rounds"][0]),
1356 [
1357 "number",
1358 "round_type",
1359 "steward_emergency",
1360 "steward_contribution",
1361 "responses"
1362 ]
1363 );
1364 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
1365 }
1366
1367 #[test]
1369 fn a_redacted_record_parses_with_the_redactions_in_place() {
1370 let (name, data) = fixtures().swap_remove(0);
1371 assert_eq!(name, "GOV-2026-0001.json");
1372 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1373 let fields = [
1374 "/title",
1375 "/vote_tally",
1376 "/rounds/0/responses/0/position",
1377 "/rounds/0/responses/0/rationale",
1378 "/rounds/0/responses/0/raw_text",
1379 "/rounds/0/responses/1/questions",
1380 "/rounds/0/responses/2/questions/1",
1381 "/rounds/1/steward_contribution",
1382 ]
1383 .map(String::from);
1384 let redacted =
1385 super::super::redact_data(&data, &fields, &amd, Blind::random())
1386 .unwrap();
1387 let record = round_trips(&name, &redacted);
1388
1389 let gone = Redactable::Redacted(amd.clone());
1390 assert_eq!(record.title, gone);
1391 assert_eq!(record.vote_tally, gone);
1392 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
1393 panic!("round 1 has four responses");
1394 };
1395 assert_eq!(first.position, gone);
1396 assert_eq!(first.rationale.redacted_by(), Some(&amd));
1397 assert_eq!(first.raw_text, Some(gone.clone()));
1398 assert!(!first.questions.is_redacted());
1399 assert_eq!(second.questions.redacted_by(), Some(&amd));
1400 let questions = third.questions.value().unwrap();
1401 assert!(!questions[0].is_redacted());
1402 assert_eq!(questions[1], gone);
1403 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
1404 assert!(record.blind.is_some());
1405 }
1406
1407 #[test]
1408 fn an_attachment_can_be_redacted() {
1409 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1410 let redacted = super::super::redact_data(
1411 &synthetic("attachments"),
1412 &["/attachments/0/content".into()],
1413 &amd,
1414 Blind::random(),
1415 )
1416 .unwrap();
1417 let record = round_trips("attachments", &redacted);
1418 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
1419 }
1420
1421 #[test]
1424 fn a_redacted_vote_is_an_error() {
1425 let (_, data) = fixtures().swap_remove(0);
1426 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1427 let redacted = super::super::redact_data(
1428 &data,
1429 &["/final_votes/artist".into()],
1430 &amd,
1431 Blind::random(),
1432 )
1433 .unwrap();
1434 assert!(
1435 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1436 );
1437 }
1438
1439 #[test]
1442 fn a_record_without_a_summary_keeps_its_signed_bytes() {
1443 let fixtures = fixtures();
1444 for (id, signed, _) in SIGNED {
1445 let name = format!("{id}.json");
1446 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1447 let record: CouncilDecisionRecord =
1448 serde_json::from_value(data.clone()).unwrap();
1449 assert_eq!(record.summary, None, "{id}");
1450 let again = serde_json::to_value(&record).unwrap();
1451 assert!(again.get("summary").is_none(), "{id}");
1452 assert_eq!(
1453 super::super::canonical_json(&again),
1454 super::super::canonical_json(data),
1455 "{id}"
1456 );
1457 assert_eq!(
1458 super::super::data_hash(&again).to_hex(),
1459 signed,
1460 "{id}"
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn a_signed_summary_reads_back() {
1467 let record = round_trips("summary", &synthetic("summary"));
1468 let summary = record.summary.as_ref().unwrap();
1469 assert_eq!(summary.written_by, CouncilSeat::Lawyer);
1470 assert_eq!(summary.accepted_by, SummaryAcceptance::Steward);
1471 assert_eq!(summary.served_text(), "## Decision\n\nApproved 5-0.");
1472 assert_eq!(record.check(), Ok(()));
1473 assert_eq!(
1474 CouncilDecisionRecord::check_data(&synthetic("summary")),
1475 Ok(())
1476 );
1477 assert_eq!(
1478 summary.authorship().sentence(),
1479 "written by the Lawyer, accepted by the Steward"
1480 );
1481
1482 let recused =
1483 round_trips("recused_summary", &synthetic("recused_summary"));
1484 let summary = recused.summary.as_ref().unwrap();
1485 assert_eq!(
1486 summary.accepted_by,
1487 SummaryAcceptance::Seats {
1488 seats: RECUSED_ACCEPTORS.to_vec()
1489 }
1490 );
1491 assert_eq!(recused.check(), Ok(()));
1492 assert_eq!(
1493 summary.authorship().sentence(),
1494 "written by the Lawyer, accepted by the Artist, Philosopher and \
1495 Engineer, the Steward being recused"
1496 );
1497 }
1498
1499 #[test]
1501 fn with_summary_writes_the_acceptance_the_item_needs() {
1502 for name in ["summary", "recused_summary"] {
1503 let mut record: CouncilDecisionRecord =
1504 serde_json::from_value(synthetic(name)).unwrap();
1505 let signed = record.summary.take().unwrap();
1506 let written = record.with_summary(signed.served_text());
1507 assert_eq!(written.summary.as_ref(), Some(&signed), "{name}");
1508 assert_eq!(written.check(), Ok(()), "{name}");
1509 }
1510 }
1511
1512 #[test]
1513 fn a_summary_breaking_the_rules_is_refused() {
1514 let check = |data: &serde_json::Value| {
1515 let record: CouncilDecisionRecord =
1516 serde_json::from_value(data.clone()).unwrap();
1517 let typed = record.check();
1518 assert_eq!(typed, CouncilDecisionRecord::check_data(data));
1519 typed
1520 };
1521 let mut data = synthetic("summary");
1522 data["summary"]["written_by"] = "engineer".into();
1523 assert_eq!(
1524 check(&data),
1525 Err(RecordRuleError::SummaryWriter(CouncilSeat::Engineer))
1526 );
1527
1528 let mut data = synthetic("summary");
1529 data["summary"]["text"] = " \n".into();
1530 assert_eq!(check(&data), Err(RecordRuleError::BlankSummary));
1531
1532 let mut data = synthetic("summary");
1534 data["summary"]["accepted_by"] =
1535 synthetic("recused_summary")["summary"]["accepted_by"].clone();
1536 assert_eq!(
1537 check(&data),
1538 Err(RecordRuleError::SummaryAcceptance { recused: false })
1539 );
1540
1541 let mut data = synthetic("recused_summary");
1544 data["summary"]["accepted_by"] = serde_json::json!({"by": "steward"});
1545 assert_eq!(
1546 check(&data),
1547 Err(RecordRuleError::SummaryAcceptance { recused: true })
1548 );
1549 data["summary"]["accepted_by"] =
1550 serde_json::json!({"by": "seats", "seats": ["artist", "engineer"]});
1551 assert_eq!(
1552 check(&data),
1553 Err(RecordRuleError::SummaryAcceptance { recused: true })
1554 );
1555
1556 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1558 let redacted = super::super::redact_data(
1559 &synthetic("summary"),
1560 &["/summary/text".into()],
1561 &amd,
1562 Blind::random(),
1563 )
1564 .unwrap();
1565 let record = round_trips("summary", &redacted);
1566 assert_eq!(record.check(), Ok(()));
1567 let summary = record.summary.unwrap();
1568 assert_eq!(summary.text, Redactable::Redacted(amd.clone()));
1569 assert_eq!(summary.served_text(), super::super::redaction_marker(&amd));
1570
1571 let mut data = synthetic("summary");
1573 data["summary"] = "just text".into();
1574 assert_eq!(
1575 CouncilDecisionRecord::check_data(&data),
1576 Err(RecordRuleError::Unreadable)
1577 );
1578 }
1579
1580 #[test]
1584 fn a_revision_of_the_summary_applies_and_serves() {
1585 use crate::enums::GovernanceLogEntryType::CouncilDecision;
1586 use json_patch::{Patch, PatchOperation, ReplaceOperation};
1587 let original = synthetic("summary");
1588 let patch = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1589 path: format!("{SUMMARY_POINTER}/text").parse().unwrap(),
1590 value: "## Decision\n\nApproved 5-0, as corrected.".into(),
1591 })]);
1592 let (draft, revised) = super::super::AmendmentDraft::revision(
1593 "GOV-2026-0099".parse().unwrap(),
1594 CouncilDecision,
1595 super::super::data_hash(&serde_json::json!(null)),
1596 "Steward's record",
1597 "the summary corrected",
1598 &original,
1599 patch,
1600 )
1601 .unwrap();
1602 let revision = draft.amendment.revision.as_ref().unwrap();
1603 assert_eq!(draft.amendment.kind, AmendmentKind::Revision);
1604 assert_eq!(
1605 super::super::revision_touches(revision, &[SUMMARY_POINTER]),
1606 Some(SUMMARY_POINTER)
1607 );
1608 assert_eq!(super::super::kind_standing(draft.amendment.kind), None);
1609 let latest = super::super::latest(&original, [revision]).unwrap();
1610 assert_eq!(latest, revised);
1611 assert_eq!(
1612 super::super::data_hash(&latest),
1613 revision.resulting_data_hash
1614 );
1615 assert_eq!(CouncilDecisionRecord::check_data(&latest), Ok(()));
1616 round_trips("revised summary", &latest);
1617
1618 let clerk = Some("The Clerk's digest.".to_string());
1619 let (text, by) =
1620 served_summary(CouncilDecision, Some(&latest), clerk.clone());
1621 assert_eq!(
1622 text.as_deref(),
1623 Some("## Decision\n\nApproved 5-0, as corrected.")
1624 );
1625 assert_eq!(by.unwrap().written_by, CouncilSeat::Lawyer);
1626 let (text, _) =
1627 served_summary(CouncilDecision, Some(&original), clerk.clone());
1628 assert_eq!(text.as_deref(), Some("## Decision\n\nApproved 5-0."));
1629
1630 let (_, old) = fixtures().swap_remove(0);
1632 let served = served_summary(CouncilDecision, Some(&old), clerk.clone());
1633 assert_eq!(served, (clerk.clone(), None));
1634 assert_eq!(
1636 served_summary(CouncilDecision, None, clerk.clone()),
1637 (clerk.clone(), None)
1638 );
1639 assert_eq!(
1640 served_summary(
1641 crate::enums::GovernanceLogEntryType::AppealsCourtDecision,
1642 Some(&latest),
1643 clerk.clone()
1644 ),
1645 (clerk, None)
1646 );
1647
1648 let wrong = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1651 path: format!("{SUMMARY_POINTER}/accepted_by").parse().unwrap(),
1652 value: serde_json::json!({
1653 "by": "seats", "seats": ["artist", "philosopher", "engineer"]
1654 }),
1655 })]);
1656 let wrong = super::super::apply_patch(&latest, &wrong).unwrap();
1657 assert_eq!(
1658 CouncilDecisionRecord::check_data(&wrong),
1659 Err(RecordRuleError::SummaryAcceptance { recused: false })
1660 );
1661 }
1662
1663 #[test]
1665 fn a_revision_can_add_a_summary_to_an_older_record() {
1666 use json_patch::{AddOperation, Patch, PatchOperation};
1667 let (name, old) = fixtures().pop().unwrap();
1668 assert_eq!(name, "GOV-2026-0013.json");
1669 let record: CouncilDecisionRecord =
1670 serde_json::from_value(old.clone()).unwrap();
1671 let summary = record.clone().with_summary("Ranked.").summary.unwrap();
1672 let patch = Patch(vec![PatchOperation::Add(AddOperation {
1673 path: SUMMARY_POINTER.parse().unwrap(),
1674 value: serde_json::to_value(&summary).unwrap(),
1675 })]);
1676 let (_, revised) = super::super::AmendmentDraft::revision(
1677 record.id.clone(),
1678 crate::enums::GovernanceLogEntryType::CouncilDecision,
1679 super::super::data_hash(&serde_json::json!(null)),
1680 "b",
1681 "n",
1682 &old,
1683 patch,
1684 )
1685 .unwrap();
1686 assert_eq!(CouncilDecisionRecord::check_data(&revised), Ok(()));
1687 let again = round_trips(&name, &revised);
1688 assert_eq!(again.summary, Some(summary));
1689 }
1690
1691 #[test]
1692 fn a_summary_reads_after_the_outcome() {
1693 let data = synthetic("recused_summary");
1694 let keys = |v: &serde_json::Value| -> Vec<String> {
1695 super::super::reading::ordered(v.as_object().unwrap())
1696 .into_iter()
1697 .map(|(k, _)| k.clone())
1698 .collect()
1699 };
1700 let top = keys(&data);
1701 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1702 assert!(at("outcome") < at("summary"));
1703 assert_eq!(
1704 keys(&data["summary"]),
1705 ["written_by", "text", "accepted_by"]
1706 );
1707 }
1708
1709 #[cfg(feature = "schemars")]
1710 #[test]
1711 fn schema_is_ref_free() {
1712 let schema =
1713 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1714 let text = schema.to_string();
1715 assert!(!text.contains("$ref"), "{text}");
1716 assert!(!text.contains("$defs"), "{text}");
1717 let plain = serde_json::to_string(&schemars::schema_for!(
1718 CouncilDecisionRecord
1719 ))
1720 .unwrap();
1721 assert!(!plain.contains("$ref"), "{plain}");
1722 assert!(!plain.contains("$defs"), "{plain}");
1723 }
1724}