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(default, skip_serializing_if = "Vec::is_empty")]
64 pub attachments: Vec<CouncilAttachment>,
65 #[serde(
68 rename = "_blind",
69 default,
70 skip_serializing_if = "Option::is_none"
71 )]
72 pub blind: Option<Blind>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
78#[cfg_attr(feature = "schemars", schemars(inline))]
79pub struct CouncilAttachment {
80 pub name: String,
82 pub note: String,
84 pub content: Redactable<String>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
91#[cfg_attr(feature = "schemars", schemars(inline))]
92pub enum DecisionCategory {
93 Routine,
95 Policy,
97 Constitutional,
99 Emergency,
101 Schedule,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
110#[cfg_attr(feature = "schemars", schemars(inline))]
111pub struct CouncilRound {
112 pub number: u32,
114 pub round_type: RoundType,
118 pub responses: Vec<SeatResponse>,
121 pub steward_contribution: Option<Redactable<String>>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
129#[cfg_attr(feature = "schemars", schemars(inline))]
130pub struct SeatResponse {
131 pub role: CouncilSeat,
132 pub position: Redactable<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub vote: Option<CouncilVote>,
140 pub rationale: Redactable<String>,
142 pub questions: Redactable<Vec<Redactable<String>>>,
145 pub ready_to_vote: bool,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub ranking: Option<SeatRanking>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub raw_text: Option<Redactable<String>>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
158#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
159#[cfg_attr(feature = "schemars", schemars(inline))]
160#[serde(rename_all = "snake_case")]
161pub enum CouncilSeat {
162 Artist,
163 Philosopher,
164 Lawyer,
165 Engineer,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
170#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
171#[cfg_attr(feature = "schemars", schemars(inline))]
172#[serde(rename_all = "snake_case")]
173pub enum CouncilVote {
174 Yes,
175 No,
176 Abstain,
180 Defer,
182 Concur,
184 Veto,
187 Ranked,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[cfg_attr(feature = "schemars", schemars(inline))]
196pub struct FinalVotes {
197 pub artist: CouncilVote,
198 pub philosopher: CouncilVote,
199 pub lawyer: CouncilVote,
200 pub engineer: CouncilVote,
201 pub steward: CouncilVote,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
207#[cfg_attr(feature = "schemars", schemars(inline))]
208pub struct SeatRanking {
209 pub sitting_capacity: u32,
211 pub ranking: Vec<u32>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
221#[cfg_attr(feature = "schemars", schemars(inline))]
222pub struct AgendaRanking {
223 pub rankable: u32,
225 pub ballots: Vec<Ballot>,
226 pub order: Vec<PlacedProposal>,
229 pub cut: u32,
232 pub condorcet_winner: Option<u32>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
241#[cfg_attr(feature = "schemars", schemars(inline))]
242pub struct Ballot {
243 pub seat: CouncilSeat,
244 pub sitting_capacity: u32,
246 pub ranking: Vec<u32>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
253#[cfg_attr(feature = "schemars", schemars(inline))]
254pub struct PlacedProposal {
255 pub number: u32,
257 pub post_id: PostId,
259 pub borda: u32,
260 pub seats: u32,
262 pub position_sum: u32,
266 pub tied_with_next: bool,
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn fixtures() -> Vec<(String, serde_json::Value)> {
277 let dir = concat!(
278 env!("CARGO_MANIFEST_DIR"),
279 "/tests/fixtures/council_decisions"
280 );
281 let mut out: Vec<_> = std::fs::read_dir(dir)
282 .unwrap()
283 .map(|e| e.unwrap().path())
284 .filter(|p| p.extension().is_some_and(|x| x == "json"))
285 .map(|p| {
286 let text = std::fs::read_to_string(&p).unwrap();
287 (
288 p.file_name().unwrap().to_string_lossy().into_owned(),
289 serde_json::from_str(&text).unwrap(),
290 )
291 })
292 .collect();
293 out.sort_by(|a, b| a.0.cmp(&b.0));
294 out
295 }
296
297 fn round_trips(
300 name: &str,
301 data: &serde_json::Value,
302 ) -> CouncilDecisionRecord {
303 let record: CouncilDecisionRecord =
304 serde_json::from_value(data.clone())
305 .unwrap_or_else(|e| panic!("{name}: {e}"));
306 assert_eq!(
307 &serde_json::to_value(&record).unwrap(),
308 data,
309 "{name}: the typed record loses or changes something"
310 );
311 record
312 }
313
314 #[test]
315 fn every_signed_decision_is_described_whole() {
316 let fixtures = fixtures();
317 assert_eq!(fixtures.len(), 7, "GOV-2026-0001..0007");
318 for (name, data) in &fixtures {
319 let record = round_trips(name, data);
320 assert_eq!(format!("{}.json", record.id), *name);
321 }
322 }
323
324 fn synthetic(name: &str) -> serde_json::Value {
327 let base = serde_json::json!({
328 "id": "GOV-2026-0099",
329 "meeting_id": "00000000-0000-0000-0000-000000000001",
330 "category": "Policy",
331 "title": "t",
332 "rounds": [],
333 "final_votes": {
334 "artist": "yes", "philosopher": "yes", "lawyer": "yes",
335 "engineer": "yes", "steward": "veto"
336 },
337 "outcome": "rejected",
338 "vote_tally": "4-0",
339 "landmark": false,
340 "_blind": "00".repeat(32),
341 });
342 let mut data = base;
343 match name {
344 "veto" => {
345 data["veto_rationale"] = "because".into();
346 }
347 "refusal" => {
348 data["final_votes"] = serde_json::json!({
349 "artist": "defer", "philosopher": "defer", "lawyer": "defer",
350 "engineer": "defer", "steward": "defer"
351 });
352 data["outcome"] = "deferred".into();
353 data["vote_tally"] =
354 "Deferred: tabled because the API returned a \
355 refusal for the Artist's final vote"
356 .into();
357 data["rounds"] = serde_json::json!([{
358 "number": 3,
359 "round_type": "final_vote",
360 "responses": [{
361 "role": "artist",
362 "position": "No response: the API returned a refusal \
363 (category: cyber; explanation: Flagged by a safety \
364 classifier.).",
365 "rationale": "",
366 "questions": [],
367 "ready_to_vote": false
368 }],
369 "steward_contribution": "Tabled by the Steward: the API \
370 returned a refusal for the Artist's final vote (…)."
371 }]);
372 }
373 "schedule" => {
374 data["category"] = "Schedule".into();
375 data["outcome"] = "approved".into();
376 data["final_votes"] = serde_json::json!({
377 "artist": "ranked", "philosopher": "ranked", "lawyer": "ranked",
378 "engineer": "abstain", "steward": "abstain"
379 });
380 data["vote_tally"] =
381 "ranked 3/4; the Steward executes the order \
382 and does not vote"
383 .into();
384 data["rounds"] = serde_json::json!([{
385 "number": 1,
386 "round_type": "final_vote",
387 "responses": [{
388 "role": "lawyer",
389 "position": "p",
390 "vote": "ranked",
391 "rationale": "r",
392 "questions": [],
393 "ready_to_vote": true,
394 "ranking": {"sitting_capacity": 2, "ranking": [3, 1]}
395 }],
396 "steward_contribution": null
397 }]);
398 data["agenda_ranking"] = serde_json::json!({
399 "rankable": 3,
400 "ballots": [
401 {"seat": "lawyer", "sitting_capacity": 2, "ranking": [3, 1]}
402 ],
403 "order": [{
404 "number": 3,
405 "post_id": "00000000-0000-0000-0000-000000000003",
406 "borda": 3, "seats": 1, "position_sum": 1,
407 "tied_with_next": false
408 }, {
409 "number": 1,
410 "post_id": "00000000-0000-0000-0000-000000000001",
411 "borda": 2, "seats": 1, "position_sum": 2,
412 "tied_with_next": false
413 }],
414 "cut": 2,
415 "condorcet_winner": null
416 });
417 }
418 "attachments" => {
419 data["attachments"] = serde_json::json!([{
420 "name": "clerk-thread-summary.md",
421 "note": "The Clerk's summary of the thread, given to every seat",
422 "content": "## Arguments\n\n[C1] argues for it."
423 }]);
424 }
425 _ => unreachable!(),
426 }
427 data
428 }
429
430 #[test]
431 fn newer_shapes_are_described_whole() {
432 for name in ["veto", "refusal", "schedule", "attachments"] {
433 round_trips(name, &synthetic(name));
434 }
435 let refused = round_trips("refusal", &synthetic("refusal"));
436 assert_eq!(refused.rounds[0].responses[0].vote, None);
437 }
438
439 #[test]
441 fn a_redacted_record_parses_with_the_redactions_in_place() {
442 let (name, data) = fixtures().swap_remove(0);
443 assert_eq!(name, "GOV-2026-0001.json");
444 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
445 let fields = [
446 "/title",
447 "/vote_tally",
448 "/rounds/0/responses/0/position",
449 "/rounds/0/responses/0/rationale",
450 "/rounds/0/responses/0/raw_text",
451 "/rounds/0/responses/1/questions",
452 "/rounds/0/responses/2/questions/1",
453 "/rounds/1/steward_contribution",
454 ]
455 .map(String::from);
456 let redacted =
457 super::super::redact_data(&data, &fields, &amd, Blind::random())
458 .unwrap();
459 let record = round_trips(&name, &redacted);
460
461 let gone = Redactable::Redacted(amd.clone());
462 assert_eq!(record.title, gone);
463 assert_eq!(record.vote_tally, gone);
464 let [first, second, third, ..] = &record.rounds[0].responses[..] else {
465 panic!("round 1 has four responses");
466 };
467 assert_eq!(first.position, gone);
468 assert_eq!(first.rationale, gone);
469 assert_eq!(first.raw_text, Some(gone.clone()));
470 assert!(!first.questions.is_redacted());
471 assert_eq!(second.questions.redacted_by(), Some(&amd));
472 let questions = third.questions.value().unwrap();
473 assert!(!questions[0].is_redacted());
474 assert_eq!(questions[1], gone);
475 assert_eq!(record.rounds[1].steward_contribution, Some(gone));
476 assert!(record.blind.is_some());
477 }
478
479 #[test]
480 fn an_attachment_can_be_redacted() {
481 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
482 let redacted = super::super::redact_data(
483 &synthetic("attachments"),
484 &["/attachments/0/content".into()],
485 &amd,
486 Blind::random(),
487 )
488 .unwrap();
489 let record = round_trips("attachments", &redacted);
490 assert_eq!(record.attachments[0].content, Redactable::Redacted(amd));
491 }
492
493 #[test]
496 fn a_redacted_vote_is_an_error() {
497 let (_, data) = fixtures().swap_remove(0);
498 let amd: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
499 let redacted = super::super::redact_data(
500 &data,
501 &["/final_votes/artist".into()],
502 &amd,
503 Blind::random(),
504 )
505 .unwrap();
506 assert!(
507 serde_json::from_value::<CouncilDecisionRecord>(redacted).is_err()
508 );
509 }
510
511 #[cfg(feature = "schemars")]
512 #[test]
513 fn schema_is_ref_free() {
514 let schema =
515 crate::responses::inline_schema_for::<CouncilDecisionRecord>();
516 let text = schema.to_string();
517 assert!(!text.contains("$ref"), "{text}");
518 assert!(!text.contains("$defs"), "{text}");
519 let plain = serde_json::to_string(&schemars::schema_for!(
520 CouncilDecisionRecord
521 ))
522 .unwrap();
523 assert!(!plain.contains("$ref"), "{plain}");
524 assert!(!plain.contains("$defs"), "{plain}");
525 }
526}