1use serde::{Deserialize, Serialize};
13
14use super::Blind;
15use crate::enums::{DecisionOutcome, RoundType};
16use crate::ids::{CouncilMeetingId, GovernanceLogId, PostId};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[cfg_attr(feature = "schemars", schemars(inline))]
24pub struct CouncilDecisionRecord {
25 pub id: GovernanceLogId,
27 pub meeting_id: CouncilMeetingId,
29 pub category: DecisionCategory,
30 pub title: String,
32 pub rounds: Vec<CouncilRound>,
35 pub final_votes: FinalVotes,
36 pub outcome: DecisionOutcome,
40 pub vote_tally: String,
44 pub landmark: bool,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub veto_rationale: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub agenda_ranking: Option<AgendaRanking>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub overrules: Vec<GovernanceLogId>,
59 #[serde(
62 rename = "_blind",
63 default,
64 skip_serializing_if = "Option::is_none"
65 )]
66 pub blind: Option<Blind>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
73#[cfg_attr(feature = "schemars", schemars(inline))]
74pub enum DecisionCategory {
75 Routine,
77 Policy,
79 Constitutional,
81 Emergency,
83 Schedule,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
92#[cfg_attr(feature = "schemars", schemars(inline))]
93pub struct CouncilRound {
94 pub number: u32,
96 pub round_type: RoundType,
100 pub responses: Vec<SeatResponse>,
103 pub steward_contribution: Option<String>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
111#[cfg_attr(feature = "schemars", schemars(inline))]
112pub struct SeatResponse {
113 pub role: CouncilSeat,
114 pub position: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub vote: Option<CouncilVote>,
122 pub rationale: String,
124 pub questions: Vec<String>,
126 pub ready_to_vote: bool,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub ranking: Option<SeatRanking>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub raw_text: Option<String>,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140#[cfg_attr(feature = "schemars", schemars(inline))]
141#[serde(rename_all = "snake_case")]
142pub enum CouncilSeat {
143 Artist,
144 Philosopher,
145 Lawyer,
146 Engineer,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152#[cfg_attr(feature = "schemars", schemars(inline))]
153#[serde(rename_all = "snake_case")]
154pub enum CouncilVote {
155 Yes,
156 No,
157 Abstain,
161 Defer,
163 Concur,
165 Veto,
168 Ranked,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
176#[cfg_attr(feature = "schemars", schemars(inline))]
177pub struct FinalVotes {
178 pub artist: CouncilVote,
179 pub philosopher: CouncilVote,
180 pub lawyer: CouncilVote,
181 pub engineer: CouncilVote,
182 pub steward: CouncilVote,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
188#[cfg_attr(feature = "schemars", schemars(inline))]
189pub struct SeatRanking {
190 pub sitting_capacity: u32,
192 pub ranking: Vec<u32>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
202#[cfg_attr(feature = "schemars", schemars(inline))]
203pub struct AgendaRanking {
204 pub rankable: u32,
206 pub ballots: Vec<Ballot>,
207 pub order: Vec<PlacedProposal>,
210 pub cut: u32,
213 pub condorcet_winner: Option<u32>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
222#[cfg_attr(feature = "schemars", schemars(inline))]
223pub struct Ballot {
224 pub seat: CouncilSeat,
225 pub sitting_capacity: u32,
227 pub ranking: Vec<u32>,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
234#[cfg_attr(feature = "schemars", schemars(inline))]
235pub struct PlacedProposal {
236 pub number: u32,
238 pub post_id: PostId,
240 pub borda: u32,
241 pub seats: u32,
243 pub position_sum: u32,
247 pub tied_with_next: bool,
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn fixtures() -> Vec<(String, serde_json::Value)> {
258 let dir = concat!(
259 env!("CARGO_MANIFEST_DIR"),
260 "/tests/fixtures/council_decisions"
261 );
262 let mut out: Vec<_> = std::fs::read_dir(dir)
263 .unwrap()
264 .map(|e| e.unwrap().path())
265 .filter(|p| p.extension().is_some_and(|x| x == "json"))
266 .map(|p| {
267 let text = std::fs::read_to_string(&p).unwrap();
268 (
269 p.file_name().unwrap().to_string_lossy().into_owned(),
270 serde_json::from_str(&text).unwrap(),
271 )
272 })
273 .collect();
274 out.sort_by(|a, b| a.0.cmp(&b.0));
275 out
276 }
277
278 fn round_trips(
281 name: &str,
282 data: &serde_json::Value,
283 ) -> CouncilDecisionRecord {
284 let record: CouncilDecisionRecord =
285 serde_json::from_value(data.clone())
286 .unwrap_or_else(|e| panic!("{name}: {e}"));
287 assert_eq!(
288 &serde_json::to_value(&record).unwrap(),
289 data,
290 "{name}: the typed record loses or changes something"
291 );
292 record
293 }
294
295 #[test]
296 fn every_signed_decision_is_described_whole() {
297 let fixtures = fixtures();
298 assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
299 for (name, data) in &fixtures {
300 let record = round_trips(name, data);
301 assert_eq!(format!("{}.json", record.id), *name);
302 }
303 }
304
305 fn synthetic(name: &str) -> serde_json::Value {
308 let base = serde_json::json!({
309 "id": "GOV-2026-0099",
310 "meeting_id": "00000000-0000-0000-0000-000000000001",
311 "category": "Policy",
312 "title": "t",
313 "rounds": [],
314 "final_votes": {
315 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
316 "engineer": "yes", "steward": "veto"
317 },
318 "outcome": "rejected",
319 "vote_tally": "4-0",
320 "landmark": false,
321 "_blind": "00".repeat(32),
322 });
323 let mut data = base;
324 match name {
325 "veto" => {
326 data["veto_rationale"] = "because".into();
327 }
328 "refusal" => {
329 data["final_votes"] = serde_json::json!({
330 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
331 "engineer": "defer", "steward": "defer"
332 });
333 data["outcome"] = "deferred".into();
334 data["vote_tally"] =
335 "Deferred: tabled because the API returned a \
336 refusal for the Artist's final vote"
337 .into();
338 data["rounds"] = serde_json::json!([{
339 "number": 3,
340 "round_type": "final_vote",
341 "responses": [{
342 "role": "artist",
343 "position": "No response: the API returned a refusal \
344 (category: cyber; explanation: Flagged by a safety \
345 classifier.).",
346 "rationale": "",
347 "questions": [],
348 "ready_to_vote": false
349 }],
350 "steward_contribution": "Tabled by the Steward: the API \
351 returned a refusal for the Artist's final vote (…)."
352 }]);
353 }
354 "schedule" => {
355 data["category"] = "Schedule".into();
356 data["outcome"] = "approved".into();
357 data["final_votes"] = serde_json::json!({
358 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
359 "engineer": "abstain", "steward": "abstain"
360 });
361 data["vote_tally"] =
362 "ranked 3/4; the Steward executes the order \
363 and does not vote"
364 .into();
365 data["rounds"] = serde_json::json!([{
366 "number": 1,
367 "round_type": "final_vote",
368 "responses": [{
369 "role": "lawyer",
370 "position": "p",
371 "vote": "ranked",
372 "rationale": "r",
373 "questions": [],
374 "ready_to_vote": true,
375 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
376 }],
377 "steward_contribution": null
378 }]);
379 data["agenda_ranking"] = serde_json::json!({
380 "rankable": 3,
381 "ballots": [
382 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
383 ],
384 "order": [{
385 "number": 3,
386 "post_id": "00000000-0000-0000-0000-000000000003",
387 "borda": 3, "seats": 1, "position_sum": 1,
388 "tied_with_next": false
389 }, {
390 "number": 1,
391 "post_id": "00000000-0000-0000-0000-000000000001",
392 "borda": 2, "seats": 1, "position_sum": 2,
393 "tied_with_next": false
394 }],
395 "cut": 2,
396 "condorcet_winner": null
397 });
398 }
399 _ => unreachable!(),
400 }
401 data
402 }
403
404 #[test]
405 fn newer_shapes_are_described_whole() {
406 for name in ["veto", "refusal", "schedule"] {
407 round_trips(name, &synthetic(name));
408 }
409 let refused = round_trips("refusal", &synthetic("refusal"));
410 assert_eq!(refused.rounds[0].responses[0].vote, None);
411 }
412
413 #[cfg(feature = "schemars")]
414 #[test]
415 fn schema_is_ref_free() {
416 let schema =
417 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
418 let text = schema.to_string();
419 assert!(!text.contains("$ref"), "{text}");
420 assert!(!text.contains("$defs"), "{text}");
421 let plain = serde_json::to_string(&schemars::schema_for!(
422 CouncilDecisionRecord
423 ))
424 .unwrap();
425 assert!(!plain.contains("$ref"), "{plain}");
426 assert!(!plain.contains("$defs"), "{plain}");
427 }
428}