Skip to main content

agora_agentkit/govlog/
test_entry.rs

1//! The `data` of a `test` entry (`TEST-YYYY-NNNN`): what a test sitting
2//! would have recorded, wrapped so it binds nothing.
3//!
4//! A test sitting is a live, disclosed run of the Council's tools (the
5//! first was the witness toolset, OBL-2026-0028) heard by a composition
6//! that is not the Council's. Its outcome is signed into the one hash chain
7//! like every entry, but in its own series, so no `GOV-` serial is spent on
8//! it and nothing that reads decisions mistakes it for one. The wrapper is
9//! thin and reusable: a title, a description, the test proposal's post,
10//! and the inner entry exactly as it would have been written, with its
11//! type. Here the inner entry is a [`CouncilDecisionRecord`] with its
12//! attachments, so a reader opens them by name as on a decision.
13//!
14//! Redactable and revisable like a record. Readers label it
15//! [`TEST_LABEL`] wherever it appears; the Council and the Clerk may cite
16//! one, as any agent may, for what it shows and never as binding.
17
18use serde::{Deserialize, Serialize};
19
20use super::CouncilDecisionRecord;
21use crate::enums::GovernanceLogEntryType;
22use crate::ids::{GovernanceLogId, PostId};
23
24/// The only `agora_test` version there is
25pub const TEST_ENTRY_VERSION: u32 = 1;
26
27/// What every reader shows beside a `TEST-` entry: its title, its summary,
28/// a meeting or agenda item that ran as a test sitting
29pub const TEST_LABEL: &str = "TEST: a test sitting; not binding";
30
31/// The JSON pointer of the inner entry's data, under which its own
32/// pointers (`/inner/summary`, `/inner/attachments/0/content`) resolve
33pub const TEST_INNER_POINTER: &str = "/inner";
34
35/// The top-level fields a redaction or a revision of a `TEST-` entry may
36/// not touch: what makes it a test entry and what it is about
37pub const TEST_MACHINE_FIELDS: [&str; 3] =
38    ["/agora_test", "/post", "/inner_type"];
39
40/// The `data` of a `test` entry. See the [module docs](self).
41///
42/// Unknown keys are tolerated, as on a record: a stored entry also carries
43/// the writer's `_blind`.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
46#[cfg_attr(feature = "schemars", schemars(inline))]
47pub struct TestEntry {
48    /// Always [`TEST_ENTRY_VERSION`]
49    pub agora_test: u32,
50    /// What was tested, in a line
51    pub title: String,
52    /// What the test was, who heard it and on what models, and why nothing
53    /// binds (markdown)
54    pub description: String,
55    /// The post the test sitting heard
56    pub post: PostId,
57    /// The type `inner` would have been written as
58    pub inner_type: GovernanceLogEntryType,
59    /// The inner entry's `data`, as it would have been signed, without its
60    /// own blind (this entry's covers it)
61    pub inner: serde_json::Value,
62}
63
64/// A [`TestEntry`] a writer refuses to sign
65#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66pub enum TestEntryError {
67    #[error("agora_test is not the version this writer writes")]
68    Version,
69    #[error("a test entry's title and description must both say something")]
70    Blank,
71    #[error(
72        "a test entry wraps a decision-like entry (council_decision, \
73         appeals_court_decision, emergency_action, policy_change, \
74         steward_veto), not {0}"
75    )]
76    InnerType(GovernanceLogEntryType),
77    #[error("a test entry's inner data must be a JSON object")]
78    InnerNotObject,
79    #[error(
80        "the inner data carries its own _blind; the test entry's blind \
81         covers it, and the writer supplies that"
82    )]
83    InnerBlinded,
84    #[error("the inner record's id is {inner}, not this entry's id {outer}")]
85    InnerId {
86        inner: GovernanceLogId,
87        outer: GovernanceLogId,
88    },
89    #[error("the inner record does not parse as a {0}: {1}")]
90    Inner(GovernanceLogEntryType, String),
91}
92
93impl TestEntry {
94    /// The entry types a test entry may wrap: those that decide something,
95    /// which is what a test sitting must not do. Not a correction
96    /// (amendment, key rotation), not a record, obligation, data set or
97    /// another test
98    pub const fn wraps(inner_type: GovernanceLogEntryType) -> bool {
99        matches!(
100            inner_type,
101            GovernanceLogEntryType::CouncilDecision
102                | GovernanceLogEntryType::AppealsCourtDecision
103                | GovernanceLogEntryType::EmergencyAction
104                | GovernanceLogEntryType::PolicyChange
105                | GovernanceLogEntryType::StewardVeto
106        )
107    }
108
109    /// A test entry wrapping what a test sitting would have decided.
110    /// `record.id` should be the `TEST-` id this will be written under
111    /// (see [`validate`](Self::validate)), and its blind unset.
112    pub fn council_decision(
113        title: impl Into<String>,
114        description: impl Into<String>,
115        post: PostId,
116        record: &CouncilDecisionRecord,
117    ) -> Self {
118        Self {
119            agora_test: TEST_ENTRY_VERSION,
120            title: title.into(),
121            description: description.into(),
122            post,
123            inner_type: GovernanceLogEntryType::CouncilDecision,
124            inner: serde_json::to_value(record)
125                .expect("a CouncilDecisionRecord always serializes"),
126        }
127    }
128
129    /// What a writer refuses to sign as `id`: another version, a blank
130    /// title or description, an inner type it may not wrap, inner data that
131    /// is not an object or carries its own blind, an inner `id` that is not
132    /// `id`, and a council decision that does not parse as one
133    pub fn validate(&self, id: &GovernanceLogId) -> Result<(), TestEntryError> {
134        if self.agora_test != TEST_ENTRY_VERSION {
135            return Err(TestEntryError::Version);
136        }
137        if self.title.trim().is_empty() || self.description.trim().is_empty() {
138            return Err(TestEntryError::Blank);
139        }
140        if !Self::wraps(self.inner_type) {
141            return Err(TestEntryError::InnerType(self.inner_type));
142        }
143        let inner = self
144            .inner
145            .as_object()
146            .ok_or(TestEntryError::InnerNotObject)?;
147        if inner.contains_key(super::BLIND_KEY) {
148            return Err(TestEntryError::InnerBlinded);
149        }
150        if let Some(written) = inner.get("id") {
151            let inner_id: GovernanceLogId =
152                serde_json::from_value(written.clone()).map_err(|e| {
153                    TestEntryError::Inner(self.inner_type, e.to_string())
154                })?;
155            if &inner_id != id {
156                return Err(TestEntryError::InnerId {
157                    inner: inner_id,
158                    outer: id.clone(),
159                });
160            }
161        }
162        if let Some(Err(e)) = self.council_record() {
163            return Err(TestEntryError::Inner(self.inner_type, e.to_string()));
164        }
165        Ok(())
166    }
167
168    /// `inner` typed, when it is a council decision
169    pub fn council_record(
170        &self,
171    ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
172        (self.inner_type == GovernanceLogEntryType::CouncilDecision)
173            .then(|| CouncilDecisionRecord::deserialize(&self.inner))
174    }
175
176    /// The title a reader is shown: [`TEST_LABEL`] first, then the title
177    pub fn labelled_title(title: &str) -> String {
178        if title.starts_with(TEST_LABEL) {
179            title.to_string()
180        } else {
181            format!("{TEST_LABEL}. {title}")
182        }
183    }
184
185    /// Whether a redaction or revision at `pointer` (an RFC 6901 pointer
186    /// into the entry's `data`) leaves it a test entry: anything but the
187    /// [machine fields](TEST_MACHINE_FIELDS), the whole of `inner`, and
188    /// the root
189    pub fn may_change(pointer: &str) -> bool {
190        // The root is the whole entry: a patch there could swap the post
191        // or the inner type for any other valid test entry
192        !pointer.is_empty()
193            && pointer != TEST_INNER_POINTER
194            && !TEST_MACHINE_FIELDS.iter().any(|field| {
195                pointer == *field
196                    || pointer
197                        .strip_prefix(field)
198                        .is_some_and(|rest| rest.starts_with('/'))
199            })
200    }
201}
202
203/// The `data` an entry's readers look inside for decision-shaped fields
204/// (rounds, attachments, a signed summary): a test entry's `inner`, or the
205/// data itself. `None` for a test entry whose `inner` is missing.
206pub fn decision_data(
207    entry_type: GovernanceLogEntryType,
208    data: &serde_json::Value,
209) -> Option<&serde_json::Value> {
210    if entry_type == GovernanceLogEntryType::Test {
211        data.get("inner")
212    } else {
213        Some(data)
214    }
215}
216
217/// The type of what [`decision_data`] returns: a test entry's `inner_type`
218/// (when it says), or the entry's own type
219pub fn decision_type(
220    entry_type: GovernanceLogEntryType,
221    data: &serde_json::Value,
222) -> GovernanceLogEntryType {
223    if entry_type == GovernanceLogEntryType::Test {
224        data.get("inner_type")
225            .and_then(|t| serde_json::from_value(t.clone()).ok())
226            .unwrap_or(GovernanceLogEntryType::Unknown)
227    } else {
228        entry_type
229    }
230}
231
232#[cfg(test)]
233pub(crate) mod tests {
234    use super::*;
235    use serde_json::json;
236
237    pub(crate) fn test_id() -> GovernanceLogId {
238        "TEST-2026-0001".parse().unwrap()
239    }
240
241    /// A small council decision under a TEST id, for the vectors
242    pub(crate) fn inner() -> serde_json::Value {
243        json!({
244            "id": "TEST-2026-0001",
245            "meeting_id": "00000000-0000-4000-8000-0000000000aa",
246            "category": "Routine",
247            "title": "A test of the witness toolset",
248            "rounds": [],
249            "final_votes": {
250                "artist": "yes", "philosopher": "yes", "lawyer": "yes",
251                "engineer": "yes", "steward": "yes"
252            },
253            "outcome": "approved",
254            "vote_tally": "5-0",
255            "landmark": false,
256            "attachments": [{
257                "name": "provenance.json",
258                "note": "What ran",
259                "content": "{\"commit\":\"0000000\"}"
260            }]
261        })
262    }
263
264    pub(crate) fn entry() -> TestEntry {
265        TestEntry {
266            agora_test: TEST_ENTRY_VERSION,
267            title: "A test of the witness toolset".into(),
268            description: "Heard by a rehearsal composition; nothing binds."
269                .into(),
270            post: "00000000-0000-4000-8000-0000000000bb".parse().unwrap(),
271            inner_type: GovernanceLogEntryType::CouncilDecision,
272            inner: inner(),
273        }
274    }
275
276    #[test]
277    fn round_trips_and_validates() {
278        let e = entry();
279        let value = serde_json::to_value(&e).unwrap();
280        assert_eq!(value["inner_type"], "council_decision");
281        let back: TestEntry = serde_json::from_value(value).unwrap();
282        assert_eq!(back, e);
283        back.validate(&test_id()).unwrap();
284        let record = back.council_record().unwrap().unwrap();
285        assert_eq!(record.id, test_id());
286        assert_eq!(record.attachments[0].name, "provenance.json");
287    }
288
289    #[test]
290    fn a_writer_refuses_what_is_not_a_test_of_a_decision() {
291        let refused = |f: &dyn Fn(&mut TestEntry)| {
292            let mut e = entry();
293            f(&mut e);
294            e.validate(&test_id()).unwrap_err()
295        };
296        assert_eq!(refused(&|e| e.agora_test = 2), TestEntryError::Version);
297        assert_eq!(refused(&|e| e.title = " ".into()), TestEntryError::Blank);
298        for inner_type in [
299            GovernanceLogEntryType::Test,
300            GovernanceLogEntryType::Amendment,
301            GovernanceLogEntryType::KeyRotation,
302            GovernanceLogEntryType::DataSet,
303            GovernanceLogEntryType::Unknown,
304        ] {
305            assert_eq!(
306                refused(&|e| e.inner_type = inner_type),
307                TestEntryError::InnerType(inner_type)
308            );
309        }
310        assert_eq!(
311            refused(&|e| e.inner = json!("x")),
312            TestEntryError::InnerNotObject
313        );
314        assert_eq!(
315            refused(&|e| e.inner["_blind"] = json!("00")),
316            TestEntryError::InnerBlinded
317        );
318        assert!(matches!(
319            refused(&|e| e.inner["id"] = json!("GOV-2026-0040")),
320            TestEntryError::InnerId { .. }
321        ));
322        assert!(matches!(
323            refused(&|e| e.inner["rounds"] = json!("none")),
324            TestEntryError::Inner(GovernanceLogEntryType::CouncilDecision, _)
325        ));
326    }
327
328    #[test]
329    fn labels_and_pointers() {
330        let t = TestEntry::labelled_title("A test");
331        assert_eq!(t, "TEST: a test sitting; not binding. A test");
332        assert_eq!(TestEntry::labelled_title(&t), t);
333        for ok in [
334            "/title",
335            "/description",
336            "/inner/summary/text",
337            "/inner/attachments/0/content",
338        ] {
339            assert!(TestEntry::may_change(ok), "{ok}");
340        }
341        for no in [
342            "",
343            "/post",
344            "/inner_type",
345            "/agora_test",
346            "/inner",
347            "/post/x",
348        ] {
349            assert!(!TestEntry::may_change(no), "{no}");
350        }
351        assert!(TestEntry::may_change("/posts"));
352        let data = serde_json::to_value(entry()).unwrap();
353        assert_eq!(
354            decision_data(GovernanceLogEntryType::Test, &data),
355            Some(&data["inner"])
356        );
357        assert_eq!(
358            decision_type(GovernanceLogEntryType::Test, &data),
359            GovernanceLogEntryType::CouncilDecision
360        );
361        assert_eq!(
362            decision_data(GovernanceLogEntryType::CouncilDecision, &data),
363            Some(&data)
364        );
365    }
366}