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,
31};
32pub use votes::{
33 BlankStatement, DEFERRING_SEATS, NotASeatChoice, SeatChoice, SeatVote,
34 SeatVotes, Statement, StewardVote, Vote, Votes,
35};
36pub use witness::{
37 Asked, Testimony, Unanswered, Unheard, Witness, WitnessQuestion,
38 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<Vec<Redactable<String>>>,
359 pub ready_to_vote: bool,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub ranking: Option<SeatRanking>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub raw_text: Option<Redactable<String>>,
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
381#[cfg_attr(feature = "schemars", schemars(inline))]
382#[serde(untagged)]
383pub enum Rationale {
384 Legacy(String),
386 Thinking(Vec<Thought>),
392}
393
394impl Rationale {
395 pub fn texts(&self) -> Vec<&str> {
398 match self {
399 Self::Legacy(text) => vec![text.as_str()],
400 Self::Thinking(thoughts) => {
401 thoughts.iter().filter_map(Thought::text).collect()
402 }
403 }
404 }
405
406 pub fn redacted_blocks(&self) -> usize {
408 match self {
409 Self::Legacy(_) => 0,
410 Self::Thinking(thoughts) => {
411 thoughts.iter().filter(|t| t.text().is_none()).count()
412 }
413 }
414 }
415
416 pub fn is_empty(&self) -> bool {
418 self.texts().iter().all(|t| t.trim().is_empty())
419 && self.redacted_blocks() == 0
420 }
421}
422
423impl std::fmt::Display for Rationale {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 let Self::Thinking(thoughts) = self else {
427 return f.write_str(&self.texts().concat());
428 };
429 for (i, thought) in thoughts.iter().enumerate() {
430 if i > 0 {
431 f.write_str("\n\n")?;
432 }
433 f.write_str(
434 thought.text().unwrap_or("[reasoning withheld]").trim_end(),
435 )?;
436 }
437 Ok(())
438 }
439}
440
441impl From<String> for Rationale {
442 fn from(text: String) -> Self {
443 Self::Legacy(text)
444 }
445}
446
447impl From<&str> for Rationale {
448 fn from(text: &str) -> Self {
449 Self::Legacy(text.to_owned())
450 }
451}
452
453impl From<Vec<Thought>> for Rationale {
454 fn from(thoughts: Vec<Thought>) -> Self {
455 Self::Thinking(thoughts)
456 }
457}
458
459impl From<&Content> for Rationale {
461 fn from(content: &Content) -> Self {
462 Self::Thinking(content.thoughts().collect())
463 }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
469#[cfg_attr(feature = "schemars", schemars(inline))]
470pub struct SeatModels {
471 pub artist: Model,
472 pub philosopher: Model,
473 pub lawyer: Model,
474 pub engineer: Model,
475}
476
477impl SeatModels {
478 pub fn get(&self, seat: CouncilSeat) -> &Model {
479 match seat {
480 CouncilSeat::Artist => &self.artist,
481 CouncilSeat::Philosopher => &self.philosopher,
482 CouncilSeat::Lawyer => &self.lawyer,
483 CouncilSeat::Engineer => &self.engineer,
484 }
485 }
486}
487
488#[derive(
492 Debug,
493 Clone,
494 Copy,
495 PartialEq,
496 Eq,
497 PartialOrd,
498 Ord,
499 Hash,
500 Serialize,
501 Deserialize,
502)]
503#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
504#[cfg_attr(feature = "schemars", schemars(inline))]
505#[serde(rename_all = "snake_case")]
506pub enum CouncilSeat {
507 Artist,
508 Philosopher,
509 Lawyer,
510 Engineer,
511}
512
513impl CouncilSeat {
514 pub const ALL: [Self; 4] = [
516 Self::Artist,
517 Self::Philosopher,
518 Self::Lawyer,
519 Self::Engineer,
520 ];
521
522 pub const fn as_str(self) -> &'static str {
524 match self {
525 Self::Artist => "artist",
526 Self::Philosopher => "philosopher",
527 Self::Lawyer => "lawyer",
528 Self::Engineer => "engineer",
529 }
530 }
531
532 pub const fn name(self) -> &'static str {
535 match self {
536 Self::Artist => "Artist",
537 Self::Philosopher => "Philosopher",
538 Self::Lawyer => "Lawyer",
539 Self::Engineer => "Engineer",
540 }
541 }
542}
543
544impl std::fmt::Display for CouncilSeat {
546 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 f.write_str(self.name())
548 }
549}
550
551impl From<CouncilSeat> for crate::enums::ModelRole {
553 fn from(seat: CouncilSeat) -> Self {
554 match seat {
555 CouncilSeat::Artist => Self::Artist,
556 CouncilSeat::Philosopher => Self::Philosopher,
557 CouncilSeat::Lawyer => Self::Lawyer,
558 CouncilSeat::Engineer => Self::Engineer,
559 }
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
565#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
566#[cfg_attr(feature = "schemars", schemars(inline))]
567#[serde(rename_all = "snake_case")]
568pub enum CouncilVote {
569 Yes,
570 No,
571 Abstain,
575 Defer,
577 Concur,
579 Veto,
582 Ranked,
585 Recused,
588}
589
590impl CouncilVote {
591 pub const fn name(self) -> &'static str {
595 match self {
596 Self::Yes => "Yes",
597 Self::No => "No",
598 Self::Abstain => "Abstain",
599 Self::Defer => "Defer",
600 Self::Concur => "Concur",
601 Self::Veto => "Veto",
602 Self::Ranked => "Ranked",
603 Self::Recused => "Recused",
604 }
605 }
606}
607
608impl std::fmt::Display for CouncilVote {
610 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611 f.write_str(self.name())
612 }
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
617#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
618#[cfg_attr(feature = "schemars", schemars(inline))]
619pub struct FinalVotes {
620 pub artist: CouncilVote,
621 pub philosopher: CouncilVote,
622 pub lawyer: CouncilVote,
623 pub engineer: CouncilVote,
624 pub steward: CouncilVote,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
629#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
630#[cfg_attr(feature = "schemars", schemars(inline))]
631pub struct SeatRanking {
632 pub sitting_capacity: u32,
634 pub ranking: Vec<u32>,
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
644#[cfg_attr(feature = "schemars", schemars(inline))]
645pub struct AgendaRanking {
646 pub rankable: u32,
648 pub ballots: Vec<Ballot>,
649 pub order: Vec<PlacedProposal>,
652 pub cut: u32,
655 pub condorcet_winner: Option<u32>,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
663#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
664#[cfg_attr(feature = "schemars", schemars(inline))]
665pub struct Ballot {
666 pub seat: CouncilSeat,
667 pub sitting_capacity: u32,
669 pub ranking: Vec<u32>,
671}
672
673#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
675#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
676#[cfg_attr(feature = "schemars", schemars(inline))]
677pub struct PlacedProposal {
678 pub number: u32,
680 pub post_id: PostId,
682 pub borda: u32,
683 pub seats: u32,
685 pub position_sum: u32,
689 pub tied_with_next: bool,
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use crate::enums::AmendmentKind;
698
699 fn fixtures() -> Vec<(String, serde_json::Value)> {
701 fixtures_in(concat!(
702 env!("CARGO_MANIFEST_DIR"),
703 "/tests/fixtures/council_decisions"
704 ))
705 }
706
707 fn revised_fixtures() -> Vec<(String, serde_json::Value)> {
709 fixtures_in(concat!(
710 env!("CARGO_MANIFEST_DIR"),
711 "/tests/fixtures/council_decisions/revised"
712 ))
713 }
714
715 const SIGNED: [(&str, &str, Option<&str>); 13] = [
719 (
720 "GOV-2026-0001",
721 "fca67b28737d613e3a98cdb3b1ad686944b36f1654ad5c237da6ec0291ee4574",
722 None,
723 ),
724 (
725 "GOV-2026-0002",
726 "b1bb43d611d75e1d3b03df8ff64381781b3d75d934a1f9d7ec75fe2174716c47",
727 None,
728 ),
729 (
730 "GOV-2026-0003",
731 "7c75af86d9b3930b5d14cd9e1784c2ecb32483427ba7149dffb20d84510c1b09",
732 Some(
733 "e1db85357dbe3b865129a53836a16c33730c7ebb37a8412f84afe5fbac4aa64d",
734 ),
735 ),
736 (
737 "GOV-2026-0004",
738 "0004e84733236dca2ab4dfb16757236ad0d76e27efbf47bbec0e00c6d90a7ed0",
739 Some(
740 "b2a9ec584f49eebf0103a72986440a326fcb5f68c83a7f1eddba8228046c342f",
741 ),
742 ),
743 (
744 "GOV-2026-0005",
745 "8257faade7548f15c010f2a5bb7ff22e1e37f7034eac2c9726445002fa593f0e",
746 Some(
747 "514a020a95669da78dfadcee58729a99402f9b2c2f750a557a8b8ce8512f451d",
748 ),
749 ),
750 (
751 "GOV-2026-0006",
752 "6520bb4cba556c4ba251584d836d1efb8a33227b83573f74c34c32362762a6f7",
753 Some(
754 "58f3fafc031ca29c796ac08fa69bb9994bf40fef9114189d6f820d050c66eedc",
755 ),
756 ),
757 (
758 "GOV-2026-0007",
759 "39a273c711d3f5b4744bbfcb1a1b2978e26b5b48629fabd5d0f136241167f07c",
760 Some(
761 "56c1036a8ee1eb852d831082f36ef9c22bc2a103a9a0d56c778fa040f9c6917e",
762 ),
763 ),
764 (
765 "GOV-2026-0008",
766 "89ac0d32809a3baf07a28ab6a511c64aaf1a31741cb3c5630d23fde484cf5cf3",
767 None,
768 ),
769 (
770 "GOV-2026-0009",
771 "32a86a94646ed886085895ad92e76285acced67b76c1853bc0e8b04626b6487d",
772 None,
773 ),
774 (
775 "GOV-2026-0010",
776 "aa4031caaf9e2851f70ed83008954869388cacb03a8119a2996a281da9e4a0dd",
777 None,
778 ),
779 (
780 "GOV-2026-0011",
781 "590b29357ae1acd425f64abb8e2aeeee2eec71d36dbb9afc99ec33ce4e5791d7",
782 None,
783 ),
784 (
785 "GOV-2026-0012",
786 "625d7f8b7c504def1fa332db68b06518d34b247edd5f2225be370a392e1a0973",
787 None,
788 ),
789 (
790 "GOV-2026-0013",
791 "c3d0a7f2c157e15f73031621148c1f3ff543418e31684d12fc6048a6c657dcb7",
792 None,
793 ),
794 ];
795
796 fn fixtures_in(dir: &str) -> Vec<(String, serde_json::Value)> {
797 let mut out: Vec<_> = std::fs::read_dir(dir)
798 .unwrap()
799 .map(|e| e.unwrap().path())
800 .filter(|p| p.extension().is_some_and(|x| x == "json"))
801 .map(|p| {
802 let text = std::fs::read_to_string(&p).unwrap();
803 (
804 p.file_name().unwrap().to_string_lossy().into_owned(),
805 serde_json::from_str(&text).unwrap(),
806 )
807 })
808 .collect();
809 out.sort_by(|a, b| a.0.cmp(&b.0));
810 out
811 }
812
813 fn round_trips(
816 name: &str,
817 data: &serde_json::Value,
818 ) -> CouncilDecisionRecord {
819 let record: CouncilDecisionRecord =
820 serde_json::from_value(data.clone())
821 .unwrap_or_else(|e| panic!("{name}: {e}"));
822 assert_eq!(
823 &serde_json::to_value(&record).unwrap(),
824 data,
825 "{name}: the typed record loses or changes something"
826 );
827 record
828 }
829
830 #[test]
831 fn every_signed_decision_is_described_whole() {
832 let fixtures = fixtures();
833 assert_eq!(fixtures.len(), 13, "GOV-2026-0001..0013");
834 let revised = revised_fixtures();
835 assert_eq!(revised.len(), 5, "GOV-2026-0003..0007, revised");
836 for (name, data) in fixtures.iter().chain(&revised) {
837 let record = round_trips(name, data);
838 assert_eq!(format!("{}.json", record.id), *name);
839 }
840 }
841
842 #[test]
848 fn every_signed_outcome_follows_from_its_votes() {
849 let check = |fixtures: Vec<(String, serde_json::Value)>| {
850 let mut checked = 0;
851 for (name, data) in fixtures {
852 let record: CouncilDecisionRecord =
853 serde_json::from_value(data).unwrap();
854 let tabled = record.final_votes.steward == CouncilVote::Defer;
855 if tabled || record.category == DecisionCategory::Schedule {
856 continue;
857 }
858 assert_eq!(
859 record.final_votes.outcome(record.category),
860 record.outcome,
861 "{name}"
862 );
863 assert_eq!(
864 record.vote_tally.value().map(String::as_str),
865 Some(record.final_votes.tally().as_str()),
866 "{name}"
867 );
868 checked += 1;
869 }
870 checked
871 };
872 assert_eq!(
873 check(fixtures()),
874 10,
875 "GOV-2026-0001, 0003..0007, 0009..0012"
876 );
877 assert_eq!(
878 check(revised_fixtures()),
879 5,
880 "GOV-2026-0003..0007, revised"
881 );
882 }
883
884 #[test]
888 fn every_signed_ranking_follows_from_its_ballots() {
889 let mut checked = 0;
890 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
891 let record: CouncilDecisionRecord =
892 serde_json::from_value(data).unwrap();
893 let Some(signed) = record.agenda_ranking else {
894 continue;
895 };
896 let candidates: std::collections::BTreeMap<u32, PostId> = (1
899 ..=signed.rankable)
900 .map(|n| {
901 let post = signed
902 .order
903 .iter()
904 .find(|p| p.number == n)
905 .map(|p| p.post_id)
906 .unwrap_or_else(|| {
907 PostId::from(uuid::Uuid::from_u128(n.into()))
908 });
909 (n, post)
910 })
911 .collect();
912 let again =
913 AgendaRanking::aggregate(&candidates, signed.ballots.clone());
914 assert_eq!(again, signed, "{name}");
915 checked += 1;
916 }
917 assert_eq!(checked, 2, "GOV-2026-0008 and 0013");
918 }
919
920 #[test]
927 fn decided_writes_what_the_archive_says() {
928 let mut checked = 0;
929 for (name, data) in fixtures() {
930 let record: CouncilDecisionRecord =
931 serde_json::from_value(data).unwrap();
932 let decided =
933 match (&record.agenda_ranking, record.final_votes.steward) {
934 (Some(ranking), _) => Decided::Ranked(ranking.clone()),
935 (None, CouncilVote::Defer) => Decided::Tabled,
936 (None, _) => {
937 let last = record.rounds.last().unwrap();
938 let votes = SeatVotes::from_final_round(last);
939 assert!(
940 matches!(
941 votes,
942 Err(FinalRoundError::NoStatement(_))
943 ),
944 "{name}: {votes:?}"
945 );
946 continue;
947 }
948 };
949 let again = record.clone().decided(decided);
950 assert_eq!(again, record, "{name}");
951 checked += 1;
952 }
953 assert_eq!(checked, 3, "GOV-2026-0002, 0008, 0013");
954 }
955
956 #[test]
959 fn the_prose_names() {
960 use DecisionCategory as C;
961 for c in [
962 C::Routine,
963 C::Policy,
964 C::Constitutional,
965 C::Emergency,
966 C::Schedule,
967 ] {
968 assert_eq!(
969 serde_json::to_value(c).unwrap(),
970 c.to_string().as_str()
971 );
972 assert_eq!(c.threshold(), ProposalCategory::from(c).threshold());
973 }
974 let capitalized = |s: &str| {
975 let mut c = s.chars();
976 c.next()
977 .unwrap()
978 .to_uppercase()
979 .chain(c)
980 .collect::<String>()
981 };
982 for seat in CouncilSeat::ALL {
983 assert_eq!(seat.to_string(), capitalized(seat.as_str()));
984 }
985 use CouncilVote as V;
986 for v in [
987 V::Yes,
988 V::No,
989 V::Abstain,
990 V::Defer,
991 V::Concur,
992 V::Veto,
993 V::Ranked,
994 V::Recused,
995 ] {
996 let serde = serde_json::to_value(v).unwrap();
997 assert_eq!(v.to_string(), capitalized(serde.as_str().unwrap()));
998 }
999 }
1000
1001 #[test]
1004 fn every_signed_record_keeps_the_record_rules() {
1005 for (name, data) in fixtures().into_iter().chain(revised_fixtures()) {
1006 let record: CouncilDecisionRecord =
1007 serde_json::from_value(data).unwrap();
1008 assert_eq!(record.check(), Ok(()), "{name}");
1009 }
1010 }
1011
1012 #[test]
1016 fn the_fixtures_are_the_signed_bytes() {
1017 let hex = |data: &serde_json::Value| {
1018 serde_json::to_value(super::super::data_hash(data))
1019 .unwrap()
1020 .as_str()
1021 .unwrap()
1022 .to_string()
1023 };
1024 let fixtures = fixtures();
1025 let revised = revised_fixtures();
1026 for (id, signed, latest) in SIGNED {
1027 let name = format!("{id}.json");
1028 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1029 assert_eq!(hex(data), signed, "{id} as signed");
1030 let found = revised.iter().find(|(n, _)| *n == name);
1031 assert_eq!(
1032 found.map(|(_, d)| hex(d)).as_deref(),
1033 latest,
1034 "{id} as revised"
1035 );
1036 }
1037 }
1038
1039 fn synthetic(name: &str) -> serde_json::Value {
1042 let base = serde_json::json!({
1043 "id": "GOV-2026-0099",
1044 "meeting_id": "00000000-0000-0000-0000-000000000001",
1045 "category": "Policy",
1046 "title": "t",
1047 "rounds": [],
1048 "final_votes": {
1049 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1050 "engineer": "yes", "steward": "veto"
1051 },
1052 "outcome": "rejected",
1053 "vote_tally": "4-0",
1054 "landmark": false,
1055 "_blind": "00".repeat(32),
1056 });
1057 let mut data = base;
1058 match name {
1059 "veto" => {
1060 data["veto_rationale"] = "because".into();
1061 }
1062 "refusal" => {
1063 data["final_votes"] = serde_json::json!({
1064 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
1065 "engineer": "defer", "steward": "defer"
1066 });
1067 data["outcome"] = "deferred".into();
1068 data["vote_tally"] =
1069 "Deferred: tabled because the API returned a \
1070 refusal for the Artist's final vote"
1071 .into();
1072 data["rounds"] = serde_json::json!([{
1073 "number": 3,
1074 "round_type": "final_vote",
1075 "responses": [{
1076 "role": "artist",
1077 "position": "No response: the API returned a refusal \
1078 (category: cyber; explanation: Flagged by a safety \
1079 classifier.).",
1080 "rationale": "",
1081 "questions": [],
1082 "ready_to_vote": false
1083 }],
1084 "steward_contribution": "Tabled by the Steward: the API \
1085 returned a refusal for the Artist's final vote (…)."
1086 }]);
1087 }
1088 "schedule" => {
1089 data["category"] = "Schedule".into();
1090 data["outcome"] = "approved".into();
1091 data["final_votes"] = serde_json::json!({
1092 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
1093 "engineer": "abstain", "steward": "abstain"
1094 });
1095 data["vote_tally"] =
1096 "ranked 3/4; the Steward executes the order \
1097 and does not vote"
1098 .into();
1099 data["rounds"] = serde_json::json!([{
1100 "number": 1,
1101 "round_type": "final_vote",
1102 "responses": [{
1103 "role": "lawyer",
1104 "position": "p",
1105 "vote": "ranked",
1106 "rationale": "r",
1107 "questions": [],
1108 "ready_to_vote": true,
1109 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
1110 }],
1111 "steward_contribution": null
1112 }]);
1113 data["agenda_ranking"] = serde_json::json!({
1114 "rankable": 3,
1115 "ballots": [
1116 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
1117 ],
1118 "order": [{
1119 "number": 3,
1120 "post_id": "00000000-0000-0000-0000-000000000003",
1121 "borda": 3, "seats": 1, "position_sum": 1,
1122 "tied_with_next": false
1123 }, {
1124 "number": 1,
1125 "post_id": "00000000-0000-0000-0000-000000000001",
1126 "borda": 2, "seats": 1, "position_sum": 2,
1127 "tied_with_next": false
1128 }],
1129 "cut": 2,
1130 "condorcet_winner": null
1131 });
1132 }
1133 "attachments" => {
1134 data["attachments"] = serde_json::json!([{
1135 "name": "clerk-thread-summary.md",
1136 "note": "The Clerk's summary of the thread, given to every seat",
1137 "content": "## Arguments\n\n[C1] argues for it."
1138 }]);
1139 }
1140 "recusal" => {
1141 data["final_votes"] = serde_json::json!({
1142 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
1143 "engineer": "abstain", "steward": "recused"
1144 });
1145 data["outcome"] = "rejected".into();
1146 data["vote_tally"] = "3-0 (Steward recused)".into();
1147 data["rounds"] = serde_json::json!([{
1148 "number": 2,
1149 "round_type": "deliberation",
1150 "responses": [],
1151 "steward_contribution": "The quoted comment is forged.",
1152 "steward_emergency": {
1153 "reason": "An injected comment is steering the seats."
1154 }
1155 }]);
1156 data["steward_recusal"] = serde_json::json!({
1157 "votes": [
1158 {"seat": "artist", "reason": "r",
1159 "limits_steward_powers": true},
1160 {"seat": "philosopher", "reason": "r",
1161 "limits_steward_powers": true},
1162 {"seat": "lawyer", "reason": "r",
1163 "limits_steward_powers": true},
1164 {"seat": "engineer", "reason": "r",
1165 "limits_steward_powers": false}
1166 ],
1167 "recused": true
1168 });
1169 data["abstentions"] = serde_json::json!([
1170 {"member": "engineer", "rationale": "Conflicted."}
1171 ]);
1172 }
1173 "thinking" => {
1174 data["seat_models"] = serde_json::json!({
1175 "artist": "claude-opus-4-6",
1176 "philosopher": "claude-opus-4-6",
1177 "lawyer": "claude-opus-4-6",
1178 "engineer": "qwen3.8-35b-a3b"
1179 });
1180 data["rounds"] = serde_json::json!([{
1181 "number": 1,
1182 "round_type": "independent",
1183 "responses": [{
1184 "role": "lawyer",
1185 "position": "p",
1186 "vote": "yes",
1187 "rationale": [
1188 {"type": "thinking", "thinking": "First.",
1189 "signature": "EqQBCkgIARABGAIiQL"},
1190 {"type": "redacted_thinking", "data": "EmwKAhgBEgy3"},
1191 {"type": "thinking", "thinking": "Then, after reading.",
1192 "signature": "EqQBCkgIARABGAIiQM"}
1193 ],
1194 "questions": [],
1195 "ready_to_vote": false
1196 }],
1197 "steward_contribution": null
1198 }]);
1199 data["steward_recusal"] = serde_json::json!({
1200 "votes": [
1201 {"seat": "engineer", "reason": "Art. IV § 2 is untouched.",
1202 "rationale": [{"type": "thinking",
1203 "thinking": "Unsigned, from a local model.",
1204 "signature": ""}],
1205 "limits_steward_powers": false}
1206 ],
1207 "recused": false
1208 });
1209 }
1210 "summary" => {
1211 data["final_votes"]["steward"] = "yes".into();
1212 data["outcome"] = "approved".into();
1213 data["vote_tally"] = "5-0".into();
1214 data["summary"] = serde_json::json!({
1215 "text": "## Decision\n\nApproved 5-0.",
1216 "written_by": "lawyer",
1217 "accepted_by": {"by": "steward"}
1218 });
1219 }
1220 "recused_summary" => {
1221 data = synthetic("recusal");
1222 data["summary"] = serde_json::json!({
1223 "text": "Rejected with the Steward recused.",
1224 "written_by": "lawyer",
1225 "accepted_by": {
1226 "by": "seats",
1227 "seats": ["artist", "philosopher", "engineer"]
1228 }
1229 });
1230 }
1231 "recused_refusal" => {
1232 data = synthetic("refusal");
1233 data["final_votes"]["steward"] = "recused".into();
1234 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
1235 }
1236 _ => unreachable!(),
1237 }
1238 data
1239 }
1240
1241 #[test]
1242 fn newer_shapes_are_described_whole() {
1243 for name in [
1244 "veto",
1245 "refusal",
1246 "schedule",
1247 "attachments",
1248 "recusal",
1249 "recused_refusal",
1250 "thinking",
1251 "summary",
1252 "recused_summary",
1253 ] {
1254 round_trips(name, &synthetic(name));
1255 }
1256 let tabled =
1257 round_trips("recused_refusal", &synthetic("recused_refusal"));
1258 assert!(tabled.rounds[0].tabled_by_recused_steward);
1259 assert!(
1260 !round_trips("refusal", &synthetic("refusal")).rounds[0]
1261 .tabled_by_recused_steward
1262 );
1263 let refused = round_trips("refusal", &synthetic("refusal"));
1264 assert_eq!(refused.rounds[0].responses[0].vote, None);
1265 }
1266
1267 #[test]
1270 fn a_thinking_rationale_reads_back() {
1271 let record = round_trips("thinking", &synthetic("thinking"));
1272 let models = record.seat_models.as_ref().unwrap();
1273 assert_eq!(models.get(CouncilSeat::Lawyer).name(), "claude-opus-4-6");
1274 assert_eq!(models.get(CouncilSeat::Engineer).name(), "qwen3.8-35b-a3b");
1275
1276 let response = &record.rounds[0].responses[0];
1277 let rationale = response.rationale.value().unwrap();
1278 assert!(matches!(rationale, Rationale::Thinking(_)));
1279 assert_eq!(rationale.texts(), ["First.", "Then, after reading."]);
1280 assert_eq!(rationale.redacted_blocks(), 1);
1281
1282 let vote = &record.steward_recusal.unwrap().votes[0];
1283 let thought = vote.rationale.as_ref().unwrap().value().unwrap();
1284 assert_eq!(thought.texts(), ["Unsigned, from a local model."]);
1285
1286 assert_eq!(
1287 rationale.to_string(),
1288 "First.\n\n[reasoning withheld]\n\nThen, after reading."
1289 );
1290 assert!(!rationale.is_empty());
1291 assert!(Rationale::Legacy(" ".into()).is_empty());
1292
1293 let legacy = round_trips("schedule", &synthetic("schedule"));
1294 let legacy = legacy.rounds[0].responses[0].rationale.value().unwrap();
1295 assert_eq!(legacy, &Rationale::Legacy("r".into()));
1296 }
1297
1298 #[test]
1299 fn a_thinking_rationale_can_be_redacted() {
1300 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1301 let redacted = super::super::redact_data(
1302 &synthetic("thinking"),
1303 &["/rounds/0/responses/0/rationale".into()],
1304 &amd,
1305 Blind::random(),
1306 )
1307 .unwrap();
1308 let record = round_trips("thinking", &redacted);
1309 assert_eq!(
1310 record.rounds[0].responses[0].rationale.redacted_by(),
1311 Some(&amd)
1312 );
1313 }
1314
1315 #[test]
1318 fn a_recused_decision_reads_back() {
1319 let record = round_trips("recusal", &synthetic("recusal"));
1320 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
1321 let recusal = record.steward_recusal.unwrap();
1322 assert!(recusal.recused);
1323 assert_eq!(
1324 recusal
1325 .votes
1326 .iter()
1327 .filter(|v| v.limits_steward_powers)
1328 .count(),
1329 3
1330 );
1331 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
1332 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
1333 assert_eq!(
1334 emergency.reason.value().map(String::as_str),
1335 Some("An injected comment is steering the seats.")
1336 );
1337 let veto = round_trips("veto", &synthetic("veto"));
1340 assert_eq!(veto.steward_recusal, None);
1341 assert!(veto.abstentions.is_empty());
1342 }
1343
1344 #[test]
1345 fn a_recused_decision_reads_in_order() {
1346 let data = synthetic("recusal");
1347 let keys = |v: &serde_json::Value| -> Vec<String> {
1348 super::super::reading::ordered(v.as_object().unwrap())
1349 .into_iter()
1350 .map(|(k, _)| k.clone())
1351 .collect()
1352 };
1353 let top = keys(&data);
1354 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1355 assert!(at("rounds") < at("steward_recusal"));
1356 assert!(at("steward_recusal") < at("final_votes"));
1357 assert!(at("final_votes") < at("abstentions"));
1358 assert!(at("abstentions") < at("vote_tally"));
1359 assert_eq!(
1360 keys(&data["steward_recusal"]["votes"][0]),
1361 ["seat", "reason", "limits_steward_powers"]
1362 );
1363 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
1364 assert_eq!(
1365 keys(&data["rounds"][0]),
1366 [
1367 "number",
1368 "round_type",
1369 "steward_emergency",
1370 "steward_contribution",
1371 "responses"
1372 ]
1373 );
1374 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
1375 }
1376
1377 #[test]
1379 fn a_redacted_record_parses_with_the_redactions_in_place() {
1380 let (name, data) = fixtures().swap_remove(0);
1381 assert_eq!(name, "GOV-2026-0001.json");
1382 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1383 let fields = [
1384 "/title",
1385 "/vote_tally",
1386 "/rounds/0/responses/0/position",
1387 "/rounds/0/responses/0/rationale",
1388 "/rounds/0/responses/0/raw_text",
1389 "/rounds/0/responses/1/questions",
1390 "/rounds/0/responses/2/questions/1",
1391 "/rounds/1/steward_contribution",
1392 ]
1393 .map(String::from);
1394 let redacted =
1395 super::super::redact_data(&data, &fields, &amd, Blind::random())
1396 .unwrap();
1397 let record = round_trips(&name, &redacted);
1398
1399 let gone = Redactable::Redacted(amd.clone());
1400 assert_eq!(record.title, gone);
1401 assert_eq!(record.vote_tally, gone);
1402 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
1403 panic!("round 1 has four responses");
1404 };
1405 assert_eq!(first.position, gone);
1406 assert_eq!(first.rationale.redacted_by(), Some(&amd));
1407 assert_eq!(first.raw_text, Some(gone.clone()));
1408 assert!(!first.questions.is_redacted());
1409 assert_eq!(second.questions.redacted_by(), Some(&amd));
1410 let questions = third.questions.value().unwrap();
1411 assert!(!questions[0].is_redacted());
1412 assert_eq!(questions[1], gone);
1413 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
1414 assert!(record.blind.is_some());
1415 }
1416
1417 #[test]
1418 fn an_attachment_can_be_redacted() {
1419 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1420 let redacted = super::super::redact_data(
1421 &synthetic("attachments"),
1422 &["/attachments/0/content".into()],
1423 &amd,
1424 Blind::random(),
1425 )
1426 .unwrap();
1427 let record = round_trips("attachments", &redacted);
1428 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
1429 }
1430
1431 #[test]
1434 fn a_redacted_vote_is_an_error() {
1435 let (_, data) = fixtures().swap_remove(0);
1436 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1437 let redacted = super::super::redact_data(
1438 &data,
1439 &["/final_votes/artist".into()],
1440 &amd,
1441 Blind::random(),
1442 )
1443 .unwrap();
1444 assert!(
1445 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
1446 );
1447 }
1448
1449 #[test]
1452 fn a_record_without_a_summary_keeps_its_signed_bytes() {
1453 let fixtures = fixtures();
1454 for (id, signed, _) in SIGNED {
1455 let name = format!("{id}.json");
1456 let (_, data) = fixtures.iter().find(|(n, _)| *n == name).unwrap();
1457 let record: CouncilDecisionRecord =
1458 serde_json::from_value(data.clone()).unwrap();
1459 assert_eq!(record.summary, None, "{id}");
1460 let again = serde_json::to_value(&record).unwrap();
1461 assert!(again.get("summary").is_none(), "{id}");
1462 assert_eq!(
1463 super::super::canonical_json(&again),
1464 super::super::canonical_json(data),
1465 "{id}"
1466 );
1467 assert_eq!(
1468 super::super::data_hash(&again).to_hex(),
1469 signed,
1470 "{id}"
1471 );
1472 }
1473 }
1474
1475 #[test]
1476 fn a_signed_summary_reads_back() {
1477 let record = round_trips("summary", &synthetic("summary"));
1478 let summary = record.summary.as_ref().unwrap();
1479 assert_eq!(summary.written_by, CouncilSeat::Lawyer);
1480 assert_eq!(summary.accepted_by, SummaryAcceptance::Steward);
1481 assert_eq!(summary.served_text(), "## Decision\n\nApproved 5-0.");
1482 assert_eq!(record.check(), Ok(()));
1483 assert_eq!(
1484 CouncilDecisionRecord::check_data(&synthetic("summary")),
1485 Ok(())
1486 );
1487 assert_eq!(
1488 summary.authorship().sentence(),
1489 "written by the Lawyer, accepted by the Steward"
1490 );
1491
1492 let recused =
1493 round_trips("recused_summary", &synthetic("recused_summary"));
1494 let summary = recused.summary.as_ref().unwrap();
1495 assert_eq!(
1496 summary.accepted_by,
1497 SummaryAcceptance::Seats {
1498 seats: RECUSED_ACCEPTORS.to_vec()
1499 }
1500 );
1501 assert_eq!(recused.check(), Ok(()));
1502 assert_eq!(
1503 summary.authorship().sentence(),
1504 "written by the Lawyer, accepted by the Artist, Philosopher and \
1505 Engineer, the Steward being recused"
1506 );
1507 }
1508
1509 #[test]
1511 fn with_summary_writes_the_acceptance_the_item_needs() {
1512 for name in ["summary", "recused_summary"] {
1513 let mut record: CouncilDecisionRecord =
1514 serde_json::from_value(synthetic(name)).unwrap();
1515 let signed = record.summary.take().unwrap();
1516 let written = record.with_summary(signed.served_text());
1517 assert_eq!(written.summary.as_ref(), Some(&signed), "{name}");
1518 assert_eq!(written.check(), Ok(()), "{name}");
1519 }
1520 }
1521
1522 #[test]
1523 fn a_summary_breaking_the_rules_is_refused() {
1524 let check = |data: &serde_json::Value| {
1525 let record: CouncilDecisionRecord =
1526 serde_json::from_value(data.clone()).unwrap();
1527 let typed = record.check();
1528 assert_eq!(typed, CouncilDecisionRecord::check_data(data));
1529 typed
1530 };
1531 let mut data = synthetic("summary");
1532 data["summary"]["written_by"] = "engineer".into();
1533 assert_eq!(
1534 check(&data),
1535 Err(RecordRuleError::SummaryWriter(CouncilSeat::Engineer))
1536 );
1537
1538 let mut data = synthetic("summary");
1539 data["summary"]["text"] = " \n".into();
1540 assert_eq!(check(&data), Err(RecordRuleError::BlankSummary));
1541
1542 let mut data = synthetic("summary");
1544 data["summary"]["accepted_by"] =
1545 synthetic("recused_summary")["summary"]["accepted_by"].clone();
1546 assert_eq!(
1547 check(&data),
1548 Err(RecordRuleError::SummaryAcceptance { recused: false })
1549 );
1550
1551 let mut data = synthetic("recused_summary");
1554 data["summary"]["accepted_by"] = serde_json::json!({"by": "steward"});
1555 assert_eq!(
1556 check(&data),
1557 Err(RecordRuleError::SummaryAcceptance { recused: true })
1558 );
1559 data["summary"]["accepted_by"] =
1560 serde_json::json!({"by": "seats", "seats": ["artist", "engineer"]});
1561 assert_eq!(
1562 check(&data),
1563 Err(RecordRuleError::SummaryAcceptance { recused: true })
1564 );
1565
1566 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
1568 let redacted = super::super::redact_data(
1569 &synthetic("summary"),
1570 &["/summary/text".into()],
1571 &amd,
1572 Blind::random(),
1573 )
1574 .unwrap();
1575 let record = round_trips("summary", &redacted);
1576 assert_eq!(record.check(), Ok(()));
1577 let summary = record.summary.unwrap();
1578 assert_eq!(summary.text, Redactable::Redacted(amd.clone()));
1579 assert_eq!(summary.served_text(), super::super::redaction_marker(&amd));
1580
1581 let mut data = synthetic("summary");
1583 data["summary"] = "just text".into();
1584 assert_eq!(
1585 CouncilDecisionRecord::check_data(&data),
1586 Err(RecordRuleError::Unreadable)
1587 );
1588 }
1589
1590 #[test]
1594 fn a_revision_of_the_summary_applies_and_serves() {
1595 use crate::enums::GovernanceLogEntryType::CouncilDecision;
1596 use json_patch::{Patch, PatchOperation, ReplaceOperation};
1597 let original = synthetic("summary");
1598 let patch = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1599 path: format!("{SUMMARY_POINTER}/text").parse().unwrap(),
1600 value: "## Decision\n\nApproved 5-0, as corrected.".into(),
1601 })]);
1602 let (draft, revised) = super::super::AmendmentDraft::revision(
1603 "GOV-2026-0099".parse().unwrap(),
1604 CouncilDecision,
1605 super::super::data_hash(&serde_json::json!(null)),
1606 "Steward's record",
1607 "the summary corrected",
1608 &original,
1609 patch,
1610 )
1611 .unwrap();
1612 let revision = draft.amendment.revision.as_ref().unwrap();
1613 assert_eq!(draft.amendment.kind, AmendmentKind::Revision);
1614 assert_eq!(
1615 super::super::revision_touches(revision, &[SUMMARY_POINTER]),
1616 Some(SUMMARY_POINTER)
1617 );
1618 assert_eq!(super::super::kind_standing(draft.amendment.kind), None);
1619 let latest = super::super::latest(&original, [revision]).unwrap();
1620 assert_eq!(latest, revised);
1621 assert_eq!(
1622 super::super::data_hash(&latest),
1623 revision.resulting_data_hash
1624 );
1625 assert_eq!(CouncilDecisionRecord::check_data(&latest), Ok(()));
1626 round_trips("revised summary", &latest);
1627
1628 let clerk = Some("The Clerk's digest.".to_string());
1629 let (text, by) =
1630 served_summary(CouncilDecision, Some(&latest), clerk.clone());
1631 assert_eq!(
1632 text.as_deref(),
1633 Some("## Decision\n\nApproved 5-0, as corrected.")
1634 );
1635 assert_eq!(by.unwrap().written_by, CouncilSeat::Lawyer);
1636 let (text, _) =
1637 served_summary(CouncilDecision, Some(&original), clerk.clone());
1638 assert_eq!(text.as_deref(), Some("## Decision\n\nApproved 5-0."));
1639
1640 let (_, old) = fixtures().swap_remove(0);
1642 let served = served_summary(CouncilDecision, Some(&old), clerk.clone());
1643 assert_eq!(served, (clerk.clone(), None));
1644 assert_eq!(
1646 served_summary(CouncilDecision, None, clerk.clone()),
1647 (clerk.clone(), None)
1648 );
1649 assert_eq!(
1650 served_summary(
1651 crate::enums::GovernanceLogEntryType::AppealsCourtDecision,
1652 Some(&latest),
1653 clerk.clone()
1654 ),
1655 (clerk, None)
1656 );
1657
1658 let wrong = Patch(vec![PatchOperation::Replace(ReplaceOperation {
1661 path: format!("{SUMMARY_POINTER}/accepted_by").parse().unwrap(),
1662 value: serde_json::json!({
1663 "by": "seats", "seats": ["artist", "philosopher", "engineer"]
1664 }),
1665 })]);
1666 let wrong = super::super::apply_patch(&latest, &wrong).unwrap();
1667 assert_eq!(
1668 CouncilDecisionRecord::check_data(&wrong),
1669 Err(RecordRuleError::SummaryAcceptance { recused: false })
1670 );
1671 }
1672
1673 #[test]
1675 fn a_revision_can_add_a_summary_to_an_older_record() {
1676 use json_patch::{AddOperation, Patch, PatchOperation};
1677 let (name, old) = fixtures().pop().unwrap();
1678 assert_eq!(name, "GOV-2026-0013.json");
1679 let record: CouncilDecisionRecord =
1680 serde_json::from_value(old.clone()).unwrap();
1681 let summary = record.clone().with_summary("Ranked.").summary.unwrap();
1682 let patch = Patch(vec![PatchOperation::Add(AddOperation {
1683 path: SUMMARY_POINTER.parse().unwrap(),
1684 value: serde_json::to_value(&summary).unwrap(),
1685 })]);
1686 let (_, revised) = super::super::AmendmentDraft::revision(
1687 record.id.clone(),
1688 crate::enums::GovernanceLogEntryType::CouncilDecision,
1689 super::super::data_hash(&serde_json::json!(null)),
1690 "b",
1691 "n",
1692 &old,
1693 patch,
1694 )
1695 .unwrap();
1696 assert_eq!(CouncilDecisionRecord::check_data(&revised), Ok(()));
1697 let again = round_trips(&name, &revised);
1698 assert_eq!(again.summary, Some(summary));
1699 }
1700
1701 #[test]
1702 fn a_summary_reads_after_the_outcome() {
1703 let data = synthetic("recused_summary");
1704 let keys = |v: &serde_json::Value| -> Vec<String> {
1705 super::super::reading::ordered(v.as_object().unwrap())
1706 .into_iter()
1707 .map(|(k, _)| k.clone())
1708 .collect()
1709 };
1710 let top = keys(&data);
1711 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
1712 assert!(at("outcome") < at("summary"));
1713 assert_eq!(
1714 keys(&data["summary"]),
1715 ["written_by", "text", "accepted_by"]
1716 );
1717 }
1718
1719 #[cfg(feature = "schemars")]
1720 #[test]
1721 fn schema_is_ref_free() {
1722 let schema =
1723 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
1724 let text = schema.to_string();
1725 assert!(!text.contains("$ref"), "{text}");
1726 assert!(!text.contains("$defs"), "{text}");
1727 let plain = serde_json::to_string(&schemars::schema_for!(
1728 CouncilDecisionRecord
1729 ))
1730 .unwrap();
1731 assert!(!plain.contains("$ref"), "{plain}");
1732 assert!(!plain.contains("$defs"), "{plain}");
1733 }
1734}