1use serde::{Deserialize, Serialize};
15
16use super::{Blind, Redactable};
17use crate::enums::{DecisionOutcome, RoundType};
18use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
25#[cfg_attr(feature = "schemars", schemars(inline))]
26pub struct CouncilDecisionRecord {
27 pub id: GovernanceLogId,
29 pub meeting_id: CouncilMeetingId,
31 pub category: DecisionCategory,
32 pub title: Redactable<String>,
34 pub rounds: Vec<CouncilRound>,
37 pub final_votes: FinalVotes,
38 pub outcome: DecisionOutcome,
42 pub vote_tally: Redactable<String>,
46 pub landmark: bool,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub veto_rationale: Option<Redactable<String>>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub agenda_ranking: Option<AgendaRanking>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub steward_recusal: Option<StewardRecusal>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub abstentions: Vec<Abstention>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub overrules: Vec<GovernanceLogId>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub attachments: Vec<CouncilAttachment>,
75 #[serde(
78 rename = "_blind",
79 default,
80 skip_serializing_if = "Option::is_none"
81 )]
82 pub blind: Option<Blind>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "schemars", schemars(inline))]
89pub struct CouncilAttachment {
90 pub name: String,
92 pub note: String,
94 pub content: Redactable<String>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105#[cfg_attr(feature = "schemars", schemars(inline))]
106pub enum DecisionCategory {
107 Routine,
110 Policy,
113 Constitutional,
116 Emergency,
118 Schedule,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
127#[cfg_attr(feature = "schemars", schemars(inline))]
128pub struct CouncilRound {
129 pub number: u32,
131 pub round_type: RoundType,
135 pub responses: Vec<SeatResponse>,
138 pub steward_contribution: Option<Redactable<String>>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub steward_emergency: Option<StewardEmergency>,
146 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
151 pub tabled_by_recused_steward: bool,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
158#[cfg_attr(feature = "schemars", schemars(inline))]
159pub struct StewardEmergency {
160 pub reason: Redactable<String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
168#[cfg_attr(feature = "schemars", schemars(inline))]
169pub struct StewardRecusal {
170 pub votes: Vec<RecusalVote>,
173 pub recused: bool,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
182#[cfg_attr(feature = "schemars", schemars(inline))]
183pub struct RecusalVote {
184 pub seat: CouncilSeat,
185 pub reason: Redactable<String>,
187 pub limits_steward_powers: bool,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
193#[cfg_attr(feature = "schemars", schemars(inline))]
194pub struct Abstention {
195 pub member: CouncilMember,
196 pub rationale: Redactable<String>,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
202#[cfg_attr(feature = "schemars", schemars(inline))]
203#[serde(rename_all = "snake_case")]
204pub enum CouncilMember {
205 Artist,
206 Philosopher,
207 Lawyer,
208 Engineer,
209 Steward,
210}
211
212impl From<CouncilSeat> for CouncilMember {
213 fn from(seat: CouncilSeat) -> Self {
214 match seat {
215 CouncilSeat::Artist => Self::Artist,
216 CouncilSeat::Philosopher => Self::Philosopher,
217 CouncilSeat::Lawyer => Self::Lawyer,
218 CouncilSeat::Engineer => Self::Engineer,
219 }
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
226#[cfg_attr(feature = "schemars", schemars(inline))]
227pub struct SeatResponse {
228 pub role: CouncilSeat,
229 pub position: Redactable<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub vote: Option<CouncilVote>,
237 pub rationale: Redactable<String>,
239 pub questions: Redactable<Vec<Redactable<String>>>,
242 pub ready_to_vote: bool,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub ranking: Option<SeatRanking>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub raw_text: Option<Redactable<String>>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
255#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
256#[cfg_attr(feature = "schemars", schemars(inline))]
257#[serde(rename_all = "snake_case")]
258pub enum CouncilSeat {
259 Artist,
260 Philosopher,
261 Lawyer,
262 Engineer,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
268#[cfg_attr(feature = "schemars", schemars(inline))]
269#[serde(rename_all = "snake_case")]
270pub enum CouncilVote {
271 Yes,
272 No,
273 Abstain,
277 Defer,
279 Concur,
281 Veto,
284 Ranked,
287 Recused,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
295#[cfg_attr(feature = "schemars", schemars(inline))]
296pub struct FinalVotes {
297 pub artist: CouncilVote,
298 pub philosopher: CouncilVote,
299 pub lawyer: CouncilVote,
300 pub engineer: CouncilVote,
301 pub steward: CouncilVote,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
307#[cfg_attr(feature = "schemars", schemars(inline))]
308pub struct SeatRanking {
309 pub sitting_capacity: u32,
311 pub ranking: Vec<u32>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
321#[cfg_attr(feature = "schemars", schemars(inline))]
322pub struct AgendaRanking {
323 pub rankable: u32,
325 pub ballots: Vec<Ballot>,
326 pub order: Vec<PlacedProposal>,
329 pub cut: u32,
332 pub condorcet_winner: Option<u32>,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
341#[cfg_attr(feature = "schemars", schemars(inline))]
342pub struct Ballot {
343 pub seat: CouncilSeat,
344 pub sitting_capacity: u32,
346 pub ranking: Vec<u32>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
353#[cfg_attr(feature = "schemars", schemars(inline))]
354pub struct PlacedProposal {
355 pub number: u32,
357 pub post_id: PostId,
359 pub borda: u32,
360 pub seats: u32,
362 pub position_sum: u32,
366 pub tied_with_next: bool,
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn fixtures() -> Vec<(String, serde_json::Value)> {
377 let dir = concat!(
378 env!("CARGO_MANIFEST_DIR"),
379 "/tests/fixtures/council_decisions"
380 );
381 let mut out: Vec<_> = std::fs::read_dir(dir)
382 .unwrap()
383 .map(|e| e.unwrap().path())
384 .filter(|p| p.extension().is_some_and(|x| x == "json"))
385 .map(|p| {
386 let text = std::fs::read_to_string(&p).unwrap();
387 (
388 p.file_name().unwrap().to_string_lossy().into_owned(),
389 serde_json::from_str(&text).unwrap(),
390 )
391 })
392 .collect();
393 out.sort_by(|a, b| a.0.cmp(&b.0));
394 out
395 }
396
397 fn round_trips(
400 name: &str,
401 data: &serde_json::Value,
402 ) -> CouncilDecisionRecord {
403 let record: CouncilDecisionRecord =
404 serde_json::from_value(data.clone())
405 .unwrap_or_else(|e| panic!("{name}: {e}"));
406 assert_eq!(
407 &serde_json::to_value(&record).unwrap(),
408 data,
409 "{name}: the typed record loses or changes something"
410 );
411 record
412 }
413
414 #[test]
415 fn every_signed_decision_is_described_whole() {
416 let fixtures = fixtures();
417 assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
418 for (name, data) in &fixtures {
419 let record = round_trips(name, data);
420 assert_eq!(format!("{}.json", record.id), *name);
421 }
422 }
423
424 fn synthetic(name: &str) -> serde_json::Value {
427 let base = serde_json::json!({
428 "id": "GOV-2026-0099",
429 "meeting_id": "00000000-0000-0000-0000-000000000001",
430 "category": "Policy",
431 "title": "t",
432 "rounds": [],
433 "final_votes": {
434 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
435 "engineer": "yes", "steward": "veto"
436 },
437 "outcome": "rejected",
438 "vote_tally": "4-0",
439 "landmark": false,
440 "_blind": "00".repeat(32),
441 });
442 let mut data = base;
443 match name {
444 "veto" => {
445 data["veto_rationale"] = "because".into();
446 }
447 "refusal" => {
448 data["final_votes"] = serde_json::json!({
449 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
450 "engineer": "defer", "steward": "defer"
451 });
452 data["outcome"] = "deferred".into();
453 data["vote_tally"] =
454 "Deferred: tabled because the API returned a \
455 refusal for the Artist's final vote"
456 .into();
457 data["rounds"] = serde_json::json!([{
458 "number": 3,
459 "round_type": "final_vote",
460 "responses": [{
461 "role": "artist",
462 "position": "No response: the API returned a refusal \
463 (category: cyber; explanation: Flagged by a safety \
464 classifier.).",
465 "rationale": "",
466 "questions": [],
467 "ready_to_vote": false
468 }],
469 "steward_contribution": "Tabled by the Steward: the API \
470 returned a refusal for the Artist's final vote (…)."
471 }]);
472 }
473 "schedule" => {
474 data["category"] = "Schedule".into();
475 data["outcome"] = "approved".into();
476 data["final_votes"] = serde_json::json!({
477 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
478 "engineer": "abstain", "steward": "abstain"
479 });
480 data["vote_tally"] =
481 "ranked 3/4; the Steward executes the order \
482 and does not vote"
483 .into();
484 data["rounds"] = serde_json::json!([{
485 "number": 1,
486 "round_type": "final_vote",
487 "responses": [{
488 "role": "lawyer",
489 "position": "p",
490 "vote": "ranked",
491 "rationale": "r",
492 "questions": [],
493 "ready_to_vote": true,
494 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
495 }],
496 "steward_contribution": null
497 }]);
498 data["agenda_ranking"] = serde_json::json!({
499 "rankable": 3,
500 "ballots": [
501 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
502 ],
503 "order": [{
504 "number": 3,
505 "post_id": "00000000-0000-0000-0000-000000000003",
506 "borda": 3, "seats": 1, "position_sum": 1,
507 "tied_with_next": false
508 }, {
509 "number": 1,
510 "post_id": "00000000-0000-0000-0000-000000000001",
511 "borda": 2, "seats": 1, "position_sum": 2,
512 "tied_with_next": false
513 }],
514 "cut": 2,
515 "condorcet_winner": null
516 });
517 }
518 "attachments" => {
519 data["attachments"] = serde_json::json!([{
520 "name": "clerk-thread-summary.md",
521 "note": "The Clerk's summary of the thread, given to every seat",
522 "content": "## Arguments\n\n[C1] argues for it."
523 }]);
524 }
525 "recusal" => {
526 data["final_votes"] = serde_json::json!({
527 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
528 "engineer": "abstain", "steward": "recused"
529 });
530 data["outcome"] = "rejected".into();
531 data["vote_tally"] = "3-0 (Steward recused)".into();
532 data["rounds"] = serde_json::json!([{
533 "number": 2,
534 "round_type": "deliberation",
535 "responses": [],
536 "steward_contribution": "The quoted comment is forged.",
537 "steward_emergency": {
538 "reason": "An injected comment is steering the seats."
539 }
540 }]);
541 data["steward_recusal"] = serde_json::json!({
542 "votes": [
543 {"seat": "artist", "reason": "r",
544 "limits_steward_powers": true},
545 {"seat": "philosopher", "reason": "r",
546 "limits_steward_powers": true},
547 {"seat": "lawyer", "reason": "r",
548 "limits_steward_powers": true},
549 {"seat": "engineer", "reason": "r",
550 "limits_steward_powers": false}
551 ],
552 "recused": true
553 });
554 data["abstentions"] = serde_json::json!([
555 {"member": "engineer", "rationale": "Conflicted."}
556 ]);
557 }
558 "recused_refusal" => {
559 data = synthetic("refusal");
560 data["final_votes"]["steward"] = "recused".into();
561 data["rounds"][0]["tabled_by_recused_steward"] = true.into();
562 }
563 _ => unreachable!(),
564 }
565 data
566 }
567
568 #[test]
569 fn newer_shapes_are_described_whole() {
570 for name in [
571 "veto",
572 "refusal",
573 "schedule",
574 "attachments",
575 "recusal",
576 "recused_refusal",
577 ] {
578 round_trips(name, &synthetic(name));
579 }
580 let tabled =
581 round_trips("recused_refusal", &synthetic("recused_refusal"));
582 assert!(tabled.rounds[0].tabled_by_recused_steward);
583 assert!(
584 !round_trips("refusal", &synthetic("refusal")).rounds[0]
585 .tabled_by_recused_steward
586 );
587 let refused = round_trips("refusal", &synthetic("refusal"));
588 assert_eq!(refused.rounds[0].responses[0].vote, None);
589 }
590
591 #[test]
594 fn a_recused_decision_reads_back() {
595 let record = round_trips("recusal", &synthetic("recusal"));
596 assert_eq!(record.final_votes.steward, CouncilVote::Recused);
597 let recusal = record.steward_recusal.unwrap();
598 assert!(recusal.recused);
599 assert_eq!(
600 recusal
601 .votes
602 .iter()
603 .filter(|v| v.limits_steward_powers)
604 .count(),
605 3
606 );
607 assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
608 let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
609 assert_eq!(
610 emergency.reason.value().map(String::as_str),
611 Some("An injected comment is steering the seats.")
612 );
613 let veto = round_trips("veto", &synthetic("veto"));
616 assert_eq!(veto.steward_recusal, None);
617 assert!(veto.abstentions.is_empty());
618 }
619
620 #[test]
621 fn a_recused_decision_reads_in_order() {
622 let data = synthetic("recusal");
623 let keys = |v: &serde_json::Value| -> Vec<String> {
624 super::super::reading::ordered(v.as_object().unwrap())
625 .into_iter()
626 .map(|(k, _)| k.clone())
627 .collect()
628 };
629 let top = keys(&data);
630 let at = |k: &str| top.iter().position(|x| x == k).unwrap();
631 assert!(at("rounds") < at("steward_recusal"));
632 assert!(at("steward_recusal") < at("final_votes"));
633 assert!(at("final_votes") < at("abstentions"));
634 assert!(at("abstentions") < at("vote_tally"));
635 assert_eq!(
636 keys(&data["steward_recusal"]["votes"][0]),
637 ["seat", "reason", "limits_steward_powers"]
638 );
639 assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
640 assert_eq!(
641 keys(&data["rounds"][0]),
642 [
643 "number",
644 "round_type",
645 "steward_emergency",
646 "steward_contribution",
647 "responses"
648 ]
649 );
650 assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
651 }
652
653 #[test]
655 fn a_redacted_record_parses_with_the_redactions_in_place() {
656 let (name, data) = fixtures().swap_remove(0);
657 assert_eq!(name, "GOV-2026-0001.json");
658 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
659 let fields = [
660 "/title",
661 "/vote_tally",
662 "/rounds/0/responses/0/position",
663 "/rounds/0/responses/0/rationale",
664 "/rounds/0/responses/0/raw_text",
665 "/rounds/0/responses/1/questions",
666 "/rounds/0/responses/2/questions/1",
667 "/rounds/1/steward_contribution",
668 ]
669 .map(String::from);
670 let redacted =
671 super::super::redact_data(&data, &fields, &amd, Blind::random())
672 .unwrap();
673 let record = round_trips(&name, &redacted);
674
675 let gone = Redactable::Redacted(amd.clone());
676 assert_eq!(record.title, gone);
677 assert_eq!(record.vote_tally, gone);
678 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
679 panic!("round 1 has four responses");
680 };
681 assert_eq!(first.position, gone);
682 assert_eq!(first.rationale, gone);
683 assert_eq!(first.raw_text, Some(gone.clone()));
684 assert!(!first.questions.is_redacted());
685 assert_eq!(second.questions.redacted_by(), Some(&amd));
686 let questions = third.questions.value().unwrap();
687 assert!(!questions[0].is_redacted());
688 assert_eq!(questions[1], gone);
689 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
690 assert!(record.blind.is_some());
691 }
692
693 #[test]
694 fn an_attachment_can_be_redacted() {
695 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
696 let redacted = super::super::redact_data(
697 &synthetic("attachments"),
698 &["/attachments/0/content".into()],
699 &amd,
700 Blind::random(),
701 )
702 .unwrap();
703 let record = round_trips("attachments", &redacted);
704 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
705 }
706
707 #[test]
710 fn a_redacted_vote_is_an_error() {
711 let (_, data) = fixtures().swap_remove(0);
712 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
713 let redacted = super::super::redact_data(
714 &data,
715 &["/final_votes/artist".into()],
716 &amd,
717 Blind::random(),
718 )
719 .unwrap();
720 assert!(
721 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
722 );
723 }
724
725 #[cfg(feature = "schemars")]
726 #[test]
727 fn schema_is_ref_free() {
728 let schema =
729 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
730 let text = schema.to_string();
731 assert!(!text.contains("$ref"), "{text}");
732 assert!(!text.contains("$defs"), "{text}");
733 let plain = serde_json::to_string(&schemars::schema_for!(
734 CouncilDecisionRecord
735 ))
736 .unwrap();
737 assert!(!plain.contains("$ref"), "{plain}");
738 assert!(!plain.contains("$defs"), "{plain}");
739 }
740}