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 = "Option::is_none")]
pub steward_recusal: Option<StewardRecusal>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub abstentions: Vec<Abstention>,
#[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>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steward_emergency: Option<StewardEmergency>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tabled_by_recused_steward: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct StewardEmergency {
pub reason: Redactable<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct StewardRecusal {
pub votes: Vec<RecusalVote>,
pub recused: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct RecusalVote {
pub seat: CouncilSeat,
pub reason: Redactable<String>,
pub limits_steward_powers: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct Abstention {
pub member: CouncilMember,
pub rationale: 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 CouncilMember {
Artist,
Philosopher,
Lawyer,
Engineer,
Steward,
}
impl From<CouncilSeat> for CouncilMember {
fn from(seat: CouncilSeat) -> Self {
match seat {
CouncilSeat::Artist => Self::Artist,
CouncilSeat::Philosopher => Self::Philosopher,
CouncilSeat::Lawyer => Self::Lawyer,
CouncilSeat::Engineer => Self::Engineer,
}
}
}
#[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,
Recused,
}
#[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."
}]);
}
"recusal" => {
data["final_votes"] = serde_json::json!({
"artist": "yes", "philosopher": "yes", "lawyer": "yes",
"engineer": "abstain", "steward": "recused"
});
data["outcome"] = "rejected".into();
data["vote_tally"] = "3-0 (Steward recused)".into();
data["rounds"] = serde_json::json!([{
"number": 2,
"round_type": "deliberation",
"responses": [],
"steward_contribution": "The quoted comment is forged.",
"steward_emergency": {
"reason": "An injected comment is steering the seats."
}
}]);
data["steward_recusal"] = serde_json::json!({
"votes": [
{"seat": "artist", "reason": "r",
"limits_steward_powers": true},
{"seat": "philosopher", "reason": "r",
"limits_steward_powers": true},
{"seat": "lawyer", "reason": "r",
"limits_steward_powers": true},
{"seat": "engineer", "reason": "r",
"limits_steward_powers": false}
],
"recused": true
});
data["abstentions"] = serde_json::json!([
{"member": "engineer", "rationale": "Conflicted."}
]);
}
"recused_refusal" => {
data = synthetic("refusal");
data["final_votes"]["steward"] = "recused".into();
data["rounds"][0]["tabled_by_recused_steward"] = true.into();
}
_ => unreachable!(),
}
data
}
#[test]
fn newer_shapes_are_described_whole() {
for name in [
"veto",
"refusal",
"schedule",
"attachments",
"recusal",
"recused_refusal",
] {
round_trips(name, &synthetic(name));
}
let tabled =
round_trips("recused_refusal", &synthetic("recused_refusal"));
assert!(tabled.rounds[0].tabled_by_recused_steward);
assert!(
!round_trips("refusal", &synthetic("refusal")).rounds[0]
.tabled_by_recused_steward
);
let refused = round_trips("refusal", &synthetic("refusal"));
assert_eq!(refused.rounds[0].responses[0].vote, None);
}
#[test]
fn a_recused_decision_reads_back() {
let record = round_trips("recusal", &synthetic("recusal"));
assert_eq!(record.final_votes.steward, CouncilVote::Recused);
let recusal = record.steward_recusal.unwrap();
assert!(recusal.recused);
assert_eq!(
recusal
.votes
.iter()
.filter(|v| v.limits_steward_powers)
.count(),
3
);
assert_eq!(record.abstentions[0].member, CouncilMember::Engineer);
let emergency = record.rounds[0].steward_emergency.as_ref().unwrap();
assert_eq!(
emergency.reason.value().map(String::as_str),
Some("An injected comment is steering the seats.")
);
let veto = round_trips("veto", &synthetic("veto"));
assert_eq!(veto.steward_recusal, None);
assert!(veto.abstentions.is_empty());
}
#[test]
fn a_recused_decision_reads_in_order() {
let data = synthetic("recusal");
let keys = |v: &serde_json::Value| -> Vec<String> {
super::super::reading::ordered(v.as_object().unwrap())
.into_iter()
.map(|(k, _)| k.clone())
.collect()
};
let top = keys(&data);
let at = |k: &str| top.iter().position(|x| x == k).unwrap();
assert!(at("rounds") < at("steward_recusal"));
assert!(at("steward_recusal") < at("final_votes"));
assert!(at("final_votes") < at("abstentions"));
assert!(at("abstentions") < at("vote_tally"));
assert_eq!(
keys(&data["steward_recusal"]["votes"][0]),
["seat", "reason", "limits_steward_powers"]
);
assert_eq!(keys(&data["steward_recusal"]), ["votes", "recused"]);
assert_eq!(
keys(&data["rounds"][0]),
[
"number",
"round_type",
"steward_emergency",
"steward_contribution",
"responses"
]
);
assert_eq!(keys(&data["abstentions"][0]), ["member", "rationale"]);
}
#[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}");
}
}