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    /// A record with no participants or attachments yet
57    pub fn new(
58        kind: impl Into<String>,
59        title: impl Into<String>,
60        body: impl Into<String>,
61    ) -> Self {
62        Self {
63            agora_steward_record: STEWARD_RECORD_VERSION,
64            kind: kind.into(),
65            title: title.into(),
66            body: body.into(),
67            concerns: Vec::new(),
68            participants: Vec::new(),
69            attachments: Vec::new(),
70        }
71    }
72}
73
74/// Someone who took part in a recorded act
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
77#[cfg_attr(feature = "schemars", schemars(inline))]
78pub struct RecordParticipant {
79    pub name: String,
80    /// `"Steward"`, `"scribe"`, `"delegate"`
81    pub role: String,
82    /// What this participant can vouch for, in their own terms — and so,
83    /// by omission, what they cannot
84    pub attests: String,
85}
86
87/// A piece of supporting material carried inside a record
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
90#[cfg_attr(feature = "schemars", schemars(inline))]
91pub struct RecordAttachment {
92    pub name: String,
93    /// What it is and how to check it
94    pub note: String,
95    pub content: String,
96}
97
98#[cfg(test)]
99mod tests {
100    use super::super::{Blind, blind_data, non_integer_number, redact_data};
101    use super::*;
102
103    fn ceremony() -> StewardRecord {
104        let mut record = StewardRecord::new(
105            "key_ceremony",
106            "The first key ceremony",
107            "The root certified a fresh online key.",
108        );
109        record.concerns = vec!["KEY-2026-0001".parse().unwrap()];
110        record.participants = vec![RecordParticipant {
111            name: "A. Steward".into(),
112            role: "Steward".into(),
113            attests: "held the key".into(),
114        }];
115        record.attachments = vec![RecordAttachment {
116            name: "attestation.pem".into(),
117            note: "chains to the vendor's root".into(),
118            content: "-----BEGIN CERTIFICATE-----\n…".into(),
119        }];
120        record
121    }
122
123    #[test]
124    fn a_record_round_trips_and_holds_no_fractions() {
125        let record = ceremony();
126        let value = serde_json::to_value(&record).unwrap();
127        assert_eq!(value["agora_steward_record"], 1);
128        assert_eq!(non_integer_number(&value), None);
129        let back: StewardRecord = serde_json::from_value(value).unwrap();
130        assert_eq!(back, record);
131    }
132
133    #[test]
134    fn an_empty_list_is_not_written() {
135        let value =
136            serde_json::to_value(StewardRecord::new("restore", "t", "b"))
137                .unwrap();
138        let keys: Vec<&str> = value
139            .as_object()
140            .unwrap()
141            .keys()
142            .map(String::as_str)
143            .collect();
144        assert_eq!(keys, ["agora_steward_record", "body", "kind", "title"]);
145    }
146
147    /// The point of the type: it is stored blinded, a name can be taken
148    /// out of it, and what is left is still a record.
149    #[test]
150    fn a_stored_and_redacted_record_still_reads() {
151        let stored = blind_data(
152            &serde_json::to_value(ceremony()).unwrap(),
153            Blind::from([7; 32]),
154        )
155        .unwrap();
156        let read: StewardRecord =
157            serde_json::from_value(stored.clone()).unwrap();
158        assert_eq!(read, ceremony());
159
160        let amendment: GovernanceLogId = "AMD-2026-0009".parse().unwrap();
161        let redacted = redact_data(
162            &stored,
163            &["/participants/0/name".to_string()],
164            &amendment,
165            Blind::from([8; 32]),
166        )
167        .unwrap();
168        let read: StewardRecord = serde_json::from_value(redacted).unwrap();
169        assert_eq!(read.participants[0].name, "[redacted by AMD-2026-0009]");
170        assert_eq!(read.participants[0].role, "Steward");
171    }
172}