use serde::{Deserialize, Serialize};
use crate::ids::GovernanceLogId;
pub const STEWARD_RECORD_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct StewardRecord {
pub agora_steward_record: u32,
pub kind: String,
pub title: String,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub concerns: Vec<GovernanceLogId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<RecordParticipant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<RecordAttachment>,
}
impl StewardRecord {
pub fn new(
kind: impl Into<String>,
title: impl Into<String>,
body: impl Into<String>,
) -> Self {
Self {
agora_steward_record: STEWARD_RECORD_VERSION,
kind: kind.into(),
title: title.into(),
body: body.into(),
concerns: Vec::new(),
participants: Vec::new(),
attachments: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct RecordParticipant {
pub name: String,
pub role: String,
pub attests: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct RecordAttachment {
pub name: String,
pub note: String,
pub content: String,
}
#[cfg(test)]
mod tests {
use super::super::{Blind, blind_data, non_integer_number, redact_data};
use super::*;
fn ceremony() -> StewardRecord {
let mut record = StewardRecord::new(
"key_ceremony",
"The first key ceremony",
"The root certified a fresh online key.",
);
record.concerns = vec!["KEY-2026-0001".parse().unwrap()];
record.participants = vec![RecordParticipant {
name: "A. Steward".into(),
role: "Steward".into(),
attests: "held the key".into(),
}];
record.attachments = vec![RecordAttachment {
name: "attestation.pem".into(),
note: "chains to the vendor's root".into(),
content: "-----BEGIN CERTIFICATE-----\n…".into(),
}];
record
}
#[test]
fn a_record_round_trips_and_holds_no_fractions() {
let record = ceremony();
let value = serde_json::to_value(&record).unwrap();
assert_eq!(value["agora_steward_record"], 1);
assert_eq!(non_integer_number(&value), None);
let back: StewardRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, record);
}
#[test]
fn an_empty_list_is_not_written() {
let value =
serde_json::to_value(StewardRecord::new("restore", "t", "b"))
.unwrap();
let keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, ["agora_steward_record", "body", "kind", "title"]);
}
#[test]
fn a_stored_and_redacted_record_still_reads() {
let stored = blind_data(
&serde_json::to_value(ceremony()).unwrap(),
Blind::from([7; 32]),
)
.unwrap();
let read: StewardRecord =
serde_json::from_value(stored.clone()).unwrap();
assert_eq!(read, ceremony());
let amendment: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
let redacted = redact_data(
&stored,
&["/participants/0/name".to_string()],
&amendment,
Blind::from([8; 32]),
)
.unwrap();
let read: StewardRecord = serde_json::from_value(redacted).unwrap();
assert_eq!(read.participants[0].name, "[redacted by AMD-2026-0009]");
assert_eq!(read.participants[0].role, "Steward");
}
}