Skip to main content

agora_agentkit/govlog/
record.rs

1//! The `data` of a `steward_record` entry (`REC-YYYY-NNNN`).
2//!
3//! A Steward's record says what was *done* — a key ceremony, a restore from
4//! backup, the narrative behind a compromise declaration — and decides
5//! nothing. No verifier reads it: to the chain it is content like a Council
6//! decision's, covered by `data_hash` and nothing more.
7//!
8//! It exists because such a record names people, and the entries that carry
9//! the act itself cannot: a `key_rotation` can never be redacted, so it holds
10//! keys and hashes only. The narrative goes here, in a
11//! [redactable](super::is_redactable) entry, where Art. II § 7 can reach it.
12//!
13//! **Every field that might hold personal data is a plain string**, because
14//! a redaction replaces a value with a [string marker](super::redaction_marker):
15//! a record with a participant's name removed still reads as a record.
16
17use serde::{Deserialize, Serialize};
18
19use crate::ids::GovernanceLogId;
20
21/// The only `agora_steward_record` version there is
22pub const STEWARD_RECORD_VERSION: u32 = 1;
23
24/// The `data` of a `steward_record` entry. See the [module docs](self).
25///
26/// Unknown keys are tolerated on purpose: a stored record also carries the
27/// writer's `_blind`, and a record is prose for readers, not a rule for
28/// verifiers.
29#[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    /// Always [`STEWARD_RECORD_VERSION`]
34    pub agora_steward_record: u32,
35    /// What kind of act this records, as a short machine label:
36    /// `"key_ceremony"`, `"restore"`, `"incident"`. A label and not an
37    /// enum, so that a new kind of record is not a new wire format.
38    pub kind: String,
39    pub title: String,
40    /// What happened, in prose (markdown)
41    pub body: String,
42    /// The entries this record is about — a ceremony's `KEY-` entry, say
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub concerns: Vec<GovernanceLogId>,
45    /// Who took part, and what each of them can vouch for
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub participants: Vec<RecordParticipant>,
48    /// Supporting material, inline and as text: a certificate in PEM, a
49    /// command's output. Inline because a link rots and a hash of something
50    /// nobody can fetch proves nothing to a reader.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub attachments: Vec<RecordAttachment>,
53}
54
55impl StewardRecord {
56    /// What a writer refuses to sign: another version, or a blank kind,
57    /// title, body, participant field or attachment (0.74)
58    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    /// A record with no participants or attachments yet
88    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/// Someone who took part in a recorded act
106#[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    /// `"Steward"`, `"scribe"`, `"delegate"`
112    pub role: String,
113    /// What this participant can vouch for, in their own terms — and so,
114    /// by omission, what they cannot
115    pub attests: String,
116}
117
118/// A piece of supporting material carried inside a record
119#[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    /// What it is and how to check it
125    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    /// The point of the type: it is stored blinded, a name can be taken
195    /// out of it, and what is left is still a record.
196    #[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}