1use serde::{Deserialize, Serialize};
18
19use crate::ids::GovernanceLogId;
20
21pub const STEWARD_RECORD_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
31#[cfg_attr(feature = "schemars", schemars(inline))]
32pub struct StewardRecord {
33 pub agora_steward_record: u32,
35 pub kind: String,
39 pub title: String,
40 pub body: String,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub concerns: Vec<GovernanceLogId>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub participants: Vec<RecordParticipant>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub attachments: Vec<RecordAttachment>,
53}
54
55impl StewardRecord {
56 pub fn validate(&self) -> Result<(), &'static str> {
59 if self.agora_steward_record != STEWARD_RECORD_VERSION {
60 return Err(
61 "agora_steward_record is not the version this writer writes",
62 );
63 }
64 let blank = |s: &str| s.trim().is_empty();
65 if blank(&self.kind) || blank(&self.title) || blank(&self.body) {
66 return Err("kind, title and body must all say something");
67 }
68 if self
69 .participants
70 .iter()
71 .any(|p| blank(&p.name) || blank(&p.role) || blank(&p.attests))
72 {
73 return Err(
74 "a participant needs a name, a role and what they attest to",
75 );
76 }
77 if self
78 .attachments
79 .iter()
80 .any(|a| blank(&a.name) || blank(&a.content))
81 {
82 return Err("an attachment needs a name and content");
83 }
84 Ok(())
85 }
86
87 pub fn new(
89 kind: impl Into<String>,
90 title: impl Into<String>,
91 body: impl Into<String>,
92 ) -> Self {
93 Self {
94 agora_steward_record: STEWARD_RECORD_VERSION,
95 kind: kind.into(),
96 title: title.into(),
97 body: body.into(),
98 concerns: Vec::new(),
99 participants: Vec::new(),
100 attachments: Vec::new(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
108#[cfg_attr(feature = "schemars", schemars(inline))]
109pub struct RecordParticipant {
110 pub name: String,
111 pub role: String,
113 pub attests: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121#[cfg_attr(feature = "schemars", schemars(inline))]
122pub struct RecordAttachment {
123 pub name: String,
124 pub note: String,
126 pub content: String,
127}
128
129#[cfg(test)]
130mod tests {
131 use super::super::{Blind, blind_data, non_integer_number, redact_data};
132 use super::*;
133
134 #[test]
135 fn a_blank_record_does_not_validate() {
136 assert_eq!(
137 StewardRecord::new("restore", "Restored", "Done.").validate(),
138 Ok(())
139 );
140 assert!(
141 StewardRecord::new("restore", " ", "Done.")
142 .validate()
143 .is_err()
144 );
145 let mut versioned = StewardRecord::new("restore", "Restored", "Done.");
146 versioned.agora_steward_record = 2;
147 assert!(versioned.validate().is_err());
148 }
149
150 fn ceremony() -> StewardRecord {
151 let mut record = StewardRecord::new(
152 "key_ceremony",
153 "The first key ceremony",
154 "The root certified a fresh online key.",
155 );
156 record.concerns = vec!["KEY-2026-0001".parse().unwrap()];
157 record.participants = vec![RecordParticipant {
158 name: "A. Steward".into(),
159 role: "Steward".into(),
160 attests: "held the key".into(),
161 }];
162 record.attachments = vec![RecordAttachment {
163 name: "attestation.pem".into(),
164 note: "chains to the vendor's root".into(),
165 content: "-----BEGIN CERTIFICATE-----\n…".into(),
166 }];
167 record
168 }
169
170 #[test]
171 fn a_record_round_trips_and_holds_no_fractions() {
172 let record = ceremony();
173 let value = serde_json::to_value(&record).unwrap();
174 assert_eq!(value["agora_steward_record"], 1);
175 assert_eq!(non_integer_number(&value), None);
176 let back: StewardRecord = serde_json::from_value(value).unwrap();
177 assert_eq!(back, record);
178 }
179
180 #[test]
181 fn an_empty_list_is_not_written() {
182 let value =
183 serde_json::to_value(StewardRecord::new("restore", "t", "b"))
184 .unwrap();
185 let keys: Vec<&str> = value
186 .as_object()
187 .unwrap()
188 .keys()
189 .map(String::as_str)
190 .collect();
191 assert_eq!(keys, ["agora_steward_record", "body", "kind", "title"]);
192 }
193
194 #[test]
197 fn a_stored_and_redacted_record_still_reads() {
198 let stored = blind_data(
199 &serde_json::to_value(ceremony()).unwrap(),
200 Blind::from([7; 32]),
201 )
202 .unwrap();
203 let read: StewardRecord =
204 serde_json::from_value(stored.clone()).unwrap();
205 assert_eq!(read, ceremony());
206
207 let amendment: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
208 let redacted = redact_data(
209 &stored,
210 &["/participants/0/name".to_string()],
211 &amendment,
212 Blind::from([8; 32]),
213 )
214 .unwrap();
215 let read: StewardRecord = serde_json::from_value(redacted).unwrap();
216 assert_eq!(read.participants[0].name, "[redacted by AMD-2026-0009]");
217 assert_eq!(read.participants[0].role, "Steward");
218 }
219}