use serde::{Deserialize, Serialize};
use super::{Blind, Redactable};
use crate::enums::{DecisionOutcome, RoundType};
use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct CouncilDecisionRecord {
pub id: GovernanceLogId,
pub meeting_id: CouncilMeetingId,
pub category: DecisionCategory,
pub title: Redactable<String>,
pub rounds: Vec<CouncilRound>,
pub final_votes: FinalVotes,
pub outcome: DecisionOutcome,
pub vote_tally: Redactable<String>,
pub landmark: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub veto_rationale: Option<Redactable<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agenda_ranking: Option<AgendaRanking>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overrules: Vec<GovernanceLogId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<CouncilAttachment>,
#[serde(
rename = "_blind",
default,
skip_serializing_if = "Option::is_none"
)]
pub blind: Option<Blind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct CouncilAttachment {
pub name: String,
pub note: String,
pub content: Redactable<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub enum DecisionCategory {
Routine,
Policy,
Constitutional,
Emergency,
Schedule,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct CouncilRound {
pub number: u32,
pub round_type: RoundType,
pub responses: Vec<SeatResponse>,
pub steward_contribution: Option<Redactable<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct SeatResponse {
pub role: CouncilSeat,
pub position: Redactable<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vote: Option<CouncilVote>,
pub rationale: Redactable<String>,
pub questions: Redactable<Vec<Redactable<String>>>,
pub ready_to_vote: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ranking: Option<SeatRanking>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_text: Option<Redactable<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(rename_all = "snake_case")]
pub enum CouncilSeat {
Artist,
Philosopher,
Lawyer,
Engineer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(rename_all = "snake_case")]
pub enum CouncilVote {
Yes,
No,
Abstain,
Defer,
Concur,
Veto,
Ranked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct FinalVotes {
pub artist: CouncilVote,
pub philosopher: CouncilVote,
pub lawyer: CouncilVote,
pub engineer: CouncilVote,
pub steward: CouncilVote,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct SeatRanking {
pub sitting_capacity: u32,
pub ranking: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct AgendaRanking {
pub rankable: u32,
pub ballots: Vec<Ballot>,
pub order: Vec<PlacedProposal>,
pub cut: u32,
pub condorcet_winner: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct Ballot {
pub seat: CouncilSeat,
pub sitting_capacity: u32,
pub ranking: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct PlacedProposal {
pub number: u32,
pub post_id: PostId,
pub borda: u32,
pub seats: u32,
pub position_sum: u32,
pub tied_with_next: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn fixtures() -> Vec<(String, serde_json::Value)> {
let dir = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/council_decisions"
);
let mut out: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.map(|p| {
let text = std::fs::read_to_string(&p).unwrap();
(
p.file_name().unwrap().to_string_lossy().into_owned(),
serde_json::from_str(&text).unwrap(),
)
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn round_trips(
name: &str,
data: &serde_json::Value,
) -> CouncilDecisionRecord {
let record: CouncilDecisionRecord =
serde_json::from_value(data.clone())
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(
&serde_json::to_value(&record).unwrap(),
data,
"{name}: the typed record loses or changes something"
);
record
}
#[test]
fn every_signed_decision_is_described_whole() {
let fixtures = fixtures();
assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
for (name, data) in &fixtures {
let record = round_trips(name, data);
assert_eq!(format!("{}.json", record.id), *name);
}
}
fn synthetic(name: &str) -> serde_json::Value {
let base = serde_json::json!({
"id": "GOV-2026-0099",
"meeting_id": "00000000-0000-0000-0000-000000000001",
"category": "Policy",
"title": "t",
"rounds": [],
"final_votes": {
"artist": "yes", "philosopher": "yes", "lawyer": "yes",
"engineer": "yes", "steward": "veto"
},
"outcome": "rejected",
"vote_tally": "4-0",
"landmark": false,
"_blind": "00".repeat(32),
});
let mut data = base;
match name {
"veto" => {
data["veto_rationale"] = "because".into();
}
"refusal" => {
data["final_votes"] = serde_json::json!({
"artist": "defer", "philosopher": "defer", "lawyer": "defer",
"engineer": "defer", "steward": "defer"
});
data["outcome"] = "deferred".into();
data["vote_tally"] =
"Deferred: tabled because the API returned a \
refusal for the Artist's final vote"
.into();
data["rounds"] = serde_json::json!([{
"number": 3,
"round_type": "final_vote",
"responses": [{
"role": "artist",
"position": "No response: the API returned a refusal \
(category: cyber; explanation: Flagged by a safety \
classifier.).",
"rationale": "",
"questions": [],
"ready_to_vote": false
}],
"steward_contribution": "Tabled by the Steward: the API \
returned a refusal for the Artist's final vote (โฆ)."
}]);
}
"schedule" => {
data["category"] = "Schedule".into();
data["outcome"] = "approved".into();
data["final_votes"] = serde_json::json!({
"artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
"engineer": "abstain", "steward": "abstain"
});
data["vote_tally"] =
"ranked 3/4; the Steward executes the order \
and does not vote"
.into();
data["rounds"] = serde_json::json!([{
"number": 1,
"round_type": "final_vote",
"responses": [{
"role": "lawyer",
"position": "p",
"vote": "ranked",
"rationale": "r",
"questions": [],
"ready_to_vote": true,
"ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
}],
"steward_contribution": null
}]);
data["agenda_ranking"] = serde_json::json!({
"rankable": 3,
"ballots": [
{"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
],
"order": [{
"number": 3,
"post_id": "00000000-0000-0000-0000-000000000003",
"borda": 3, "seats": 1, "position_sum": 1,
"tied_with_next": false
}, {
"number": 1,
"post_id": "00000000-0000-0000-0000-000000000001",
"borda": 2, "seats": 1, "position_sum": 2,
"tied_with_next": false
}],
"cut": 2,
"condorcet_winner": null
});
}
"attachments" => {
data["attachments"] = serde_json::json!([{
"name": "clerk-thread-summary.md",
"note": "The Clerk's summary of the thread, given to every seat",
"content": "## Arguments\n\n[C1] argues for it."
}]);
}
_ => unreachable!(),
}
data
}
#[test]
fn newer_shapes_are_described_whole() {
for name in ["veto", "refusal", "schedule", "attachments"] {
round_trips(name, &synthetic(name));
}
let refused = round_trips("refusal", &synthetic("refusal"));
assert_eq!(refused.rounds[0].responses[0].vote, None);
}
#[test]
fn a_redacted_record_parses_with_the_redactions_in_place() {
let (name, data) = fixtures().swap_remove(0);
assert_eq!(name, "GOV-2026-0001.json");
let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
let fields = [
"/title",
"/vote_tally",
"/rounds/0/responses/0/position",
"/rounds/0/responses/0/rationale",
"/rounds/0/responses/0/raw_text",
"/rounds/0/responses/1/questions",
"/rounds/0/responses/2/questions/1",
"/rounds/1/steward_contribution",
]
.map(String::from);
let redacted =
super::super::redact_data(&data, &fields, &amd, Blind::random())
.unwrap();
let record = round_trips(&name, &redacted);
let gone = Redactable::Redacted(amd.clone());
assert_eq!(record.title, gone);
assert_eq!(record.vote_tally, gone);
let [first, second, third, ..] = &record.rounds[0].responses[..] else {
panic!("round 1 has four responses");
};
assert_eq!(first.position, gone);
assert_eq!(first.rationale, gone);
assert_eq!(first.raw_text, Some(gone.clone()));
assert!(!first.questions.is_redacted());
assert_eq!(second.questions.redacted_by(), Some(&amd));
let questions = third.questions.value().unwrap();
assert!(!questions[0].is_redacted());
assert_eq!(questions[1], gone);
assert_eq!(record.rounds[1].steward_contribution, Some(gone));
assert!(record.blind.is_some());
}
#[test]
fn an_attachment_can_be_redacted() {
let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
let redacted = super::super::redact_data(
&synthetic("attachments"),
&["/attachments/0/content".into()],
&amd,
Blind::random(),
)
.unwrap();
let record = round_trips("attachments", &redacted);
assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
}
#[test]
fn a_redacted_vote_is_an_error() {
let (_, data) = fixtures().swap_remove(0);
let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
let redacted = super::super::redact_data(
&data,
&["/final_votes/artist".into()],
&amd,
Blind::random(),
)
.unwrap();
assert!(
serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
);
}
#[cfg(feature = "schemars")]
#[test]
fn schema_is_ref_free() {
let schema =
crate::responses::inline_schema_for::<CouncilDecisionRecord>();
let text = schema.to_string();
assert!(!text.contains("$ref"), "{text}");
assert!(!text.contains("$defs"), "{text}");
let plain = serde_json::to_string(&schemars::schema_for!(
CouncilDecisionRecord
))
.unwrap();
assert!(!plain.contains("$ref"), "{plain}");
assert!(!plain.contains("$defs"), "{plain}");
}
}