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 = "Vec::is_empty")]
60 pub overrules: Vec<GovernanceLogId>,
61 #[serde(
64 rename = "_blind",
65 default,
66 skip_serializing_if = "Option::is_none"
67 )]
68 pub blind: Option<Blind>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
75#[cfg_attr(feature = "schemars", schemars(inline))]
76pub enum DecisionCategory {
77 Routine,
79 Policy,
81 Constitutional,
83 Emergency,
85 Schedule,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
94#[cfg_attr(feature = "schemars", schemars(inline))]
95pub struct CouncilRound {
96 pub number: u32,
98 pub round_type: RoundType,
102 pub responses: Vec<SeatResponse>,
105 pub steward_contribution: Option<Redactable<String>>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
113#[cfg_attr(feature = "schemars", schemars(inline))]
114pub struct SeatResponse {
115 pub role: CouncilSeat,
116 pub position: Redactable<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub vote: Option<CouncilVote>,
124 pub rationale: Redactable<String>,
126 pub questions: Redactable<Vec<Redactable<String>>>,
129 pub ready_to_vote: bool,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub ranking: Option<SeatRanking>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub raw_text: Option<Redactable<String>>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
143#[cfg_attr(feature = "schemars", schemars(inline))]
144#[serde(rename_all = "snake_case")]
145pub enum CouncilSeat {
146 Artist,
147 Philosopher,
148 Lawyer,
149 Engineer,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
154#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
155#[cfg_attr(feature = "schemars", schemars(inline))]
156#[serde(rename_all = "snake_case")]
157pub enum CouncilVote {
158 Yes,
159 No,
160 Abstain,
164 Defer,
166 Concur,
168 Veto,
171 Ranked,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
179#[cfg_attr(feature = "schemars", schemars(inline))]
180pub struct FinalVotes {
181 pub artist: CouncilVote,
182 pub philosopher: CouncilVote,
183 pub lawyer: CouncilVote,
184 pub engineer: CouncilVote,
185 pub steward: CouncilVote,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
191#[cfg_attr(feature = "schemars", schemars(inline))]
192pub struct SeatRanking {
193 pub sitting_capacity: u32,
195 pub ranking: Vec<u32>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
205#[cfg_attr(feature = "schemars", schemars(inline))]
206pub struct AgendaRanking {
207 pub rankable: u32,
209 pub ballots: Vec<Ballot>,
210 pub order: Vec<PlacedProposal>,
213 pub cut: u32,
216 pub condorcet_winner: Option<u32>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
225#[cfg_attr(feature = "schemars", schemars(inline))]
226pub struct Ballot {
227 pub seat: CouncilSeat,
228 pub sitting_capacity: u32,
230 pub ranking: Vec<u32>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
237#[cfg_attr(feature = "schemars", schemars(inline))]
238pub struct PlacedProposal {
239 pub number: u32,
241 pub post_id: PostId,
243 pub borda: u32,
244 pub seats: u32,
246 pub position_sum: u32,
250 pub tied_with_next: bool,
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn fixtures() -> Vec<(String, serde_json::Value)> {
261 let dir = concat!(
262 env!("CARGO_MANIFEST_DIR"),
263 "/tests/fixtures/council_decisions"
264 );
265 let mut out: Vec<_> = std::fs::read_dir(dir)
266 .unwrap()
267 .map(|e| e.unwrap().path())
268 .filter(|p| p.extension().is_some_and(|x| x == "json"))
269 .map(|p| {
270 let text = std::fs::read_to_string(&p).unwrap();
271 (
272 p.file_name().unwrap().to_string_lossy().into_owned(),
273 serde_json::from_str(&text).unwrap(),
274 )
275 })
276 .collect();
277 out.sort_by(|a, b| a.0.cmp(&b.0));
278 out
279 }
280
281 fn round_trips(
284 name: &str,
285 data: &serde_json::Value,
286 ) -> CouncilDecisionRecord {
287 let record: CouncilDecisionRecord =
288 serde_json::from_value(data.clone())
289 .unwrap_or_else(|e| panic!("{name}: {e}"));
290 assert_eq!(
291 &serde_json::to_value(&record).unwrap(),
292 data,
293 "{name}: the typed record loses or changes something"
294 );
295 record
296 }
297
298 #[test]
299 fn every_signed_decision_is_described_whole() {
300 let fixtures = fixtures();
301 assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
302 for (name, data) in &fixtures {
303 let record = round_trips(name, data);
304 assert_eq!(format!("{}.json", record.id), *name);
305 }
306 }
307
308 fn synthetic(name: &str) -> serde_json::Value {
311 let base = serde_json::json!({
312 "id": "GOV-2026-0099",
313 "meeting_id": "00000000-0000-0000-0000-000000000001",
314 "category": "Policy",
315 "title": "t",
316 "rounds": [],
317 "final_votes": {
318 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
319 "engineer": "yes", "steward": "veto"
320 },
321 "outcome": "rejected",
322 "vote_tally": "4-0",
323 "landmark": false,
324 "_blind": "00".repeat(32),
325 });
326 let mut data = base;
327 match name {
328 "veto" => {
329 data["veto_rationale"] = "because".into();
330 }
331 "refusal" => {
332 data["final_votes"] = serde_json::json!({
333 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
334 "engineer": "defer", "steward": "defer"
335 });
336 data["outcome"] = "deferred".into();
337 data["vote_tally"] =
338 "Deferred: tabled because the API returned a \
339 refusal for the Artist's final vote"
340 .into();
341 data["rounds"] = serde_json::json!([{
342 "number": 3,
343 "round_type": "final_vote",
344 "responses": [{
345 "role": "artist",
346 "position": "No response: the API returned a refusal \
347 (category: cyber; explanation: Flagged by a safety \
348 classifier.).",
349 "rationale": "",
350 "questions": [],
351 "ready_to_vote": false
352 }],
353 "steward_contribution": "Tabled by the Steward: the API \
354 returned a refusal for the Artist's final vote (…)."
355 }]);
356 }
357 "schedule" => {
358 data["category"] = "Schedule".into();
359 data["outcome"] = "approved".into();
360 data["final_votes"] = serde_json::json!({
361 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
362 "engineer": "abstain", "steward": "abstain"
363 });
364 data["vote_tally"] =
365 "ranked 3/4; the Steward executes the order \
366 and does not vote"
367 .into();
368 data["rounds"] = serde_json::json!([{
369 "number": 1,
370 "round_type": "final_vote",
371 "responses": [{
372 "role": "lawyer",
373 "position": "p",
374 "vote": "ranked",
375 "rationale": "r",
376 "questions": [],
377 "ready_to_vote": true,
378 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
379 }],
380 "steward_contribution": null
381 }]);
382 data["agenda_ranking"] = serde_json::json!({
383 "rankable": 3,
384 "ballots": [
385 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
386 ],
387 "order": [{
388 "number": 3,
389 "post_id": "00000000-0000-0000-0000-000000000003",
390 "borda": 3, "seats": 1, "position_sum": 1,
391 "tied_with_next": false
392 }, {
393 "number": 1,
394 "post_id": "00000000-0000-0000-0000-000000000001",
395 "borda": 2, "seats": 1, "position_sum": 2,
396 "tied_with_next": false
397 }],
398 "cut": 2,
399 "condorcet_winner": null
400 });
401 }
402 _ => unreachable!(),
403 }
404 data
405 }
406
407 #[test]
408 fn newer_shapes_are_described_whole() {
409 for name in ["veto", "refusal", "schedule"] {
410 round_trips(name, &synthetic(name));
411 }
412 let refused = round_trips("refusal", &synthetic("refusal"));
413 assert_eq!(refused.rounds[0].responses[0].vote, None);
414 }
415
416 #[test]
418 fn a_redacted_record_parses_with_the_redactions_in_place() {
419 let (name, data) = fixtures().swap_remove(0);
420 assert_eq!(name, "GOV-2026-0001.json");
421 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
422 let fields = [
423 "/title",
424 "/vote_tally",
425 "/rounds/0/responses/0/position",
426 "/rounds/0/responses/0/rationale",
427 "/rounds/0/responses/0/raw_text",
428 "/rounds/0/responses/1/questions",
429 "/rounds/0/responses/2/questions/1",
430 "/rounds/1/steward_contribution",
431 ]
432 .map(String::from);
433 let redacted =
434 super::super::redact_data(&data, &fields, &amd, Blind::random())
435 .unwrap();
436 let record = round_trips(&name, &redacted);
437
438 let gone = Redactable::Redacted(amd.clone());
439 assert_eq!(record.title, gone);
440 assert_eq!(record.vote_tally, gone);
441 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
442 panic!("round 1 has four responses");
443 };
444 assert_eq!(first.position, gone);
445 assert_eq!(first.rationale, gone);
446 assert_eq!(first.raw_text, Some(gone.clone()));
447 assert!(!first.questions.is_redacted());
448 assert_eq!(second.questions.redacted_by(), Some(&amd));
449 let questions = third.questions.value().unwrap();
450 assert!(!questions[0].is_redacted());
451 assert_eq!(questions[1], gone);
452 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
453 assert!(record.blind.is_some());
454 }
455
456 #[test]
459 fn a_redacted_vote_is_an_error() {
460 let (_, data) = fixtures().swap_remove(0);
461 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
462 let redacted = super::super::redact_data(
463 &data,
464 &["/final_votes/artist".into()],
465 &amd,
466 Blind::random(),
467 )
468 .unwrap();
469 assert!(
470 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
471 );
472 }
473
474 #[cfg(feature = "schemars")]
475 #[test]
476 fn schema_is_ref_free() {
477 let schema =
478 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
479 let text = schema.to_string();
480 assert!(!text.contains("$ref"), "{text}");
481 assert!(!text.contains("$defs"), "{text}");
482 let plain = serde_json::to_string(&schemars::schema_for!(
483 CouncilDecisionRecord
484 ))
485 .unwrap();
486 assert!(!plain.contains("$ref"), "{plain}");
487 assert!(!plain.contains("$defs"), "{plain}");
488 }
489}