Skip to main content

kernel/event/record/
governance.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What a person's standing decisions record: who may answer, what is
7//! shut, how a question was answered, and which governing document was
8//! written.
9
10use serde::{Deserialize, Serialize};
11
12use crate::approval::{ApprovalId, Autonomy, ClusterKey, Ruling};
13use crate::error::{AxCode, AxError};
14use crate::event::scope::Scope;
15use crate::locator::B3Hash;
16use crate::registry::ResidentId;
17
18/// `approval_resolved`: how one inbox item was answered.
19///
20/// The verdict is the [`Ruling`] itself rather than a word formatted
21/// from it, so every reader parses one spelling: a word compared against
22/// a literal at each reader could read as a refusal on one side and as
23/// consent on the other. A [`Ruling`] a reader cannot parse is a refusal
24/// to read the line at all.
25///
26/// No field defaults: all three keys have been written on every
27/// `approval_resolved` line since the kind existed, and an answer
28/// missing its verdict is a line to refuse rather than to guess at.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31pub struct ApprovalResolved {
32    /// The item answered.
33    pub id: ApprovalId,
34    /// What the answerer decided.
35    pub verdict: Ruling,
36    /// The group the person was shown and answered as one. A resumed
37    /// run may act again within it without asking, which is why the
38    /// group travels with the answer rather than being re-derived.
39    pub cluster: ClusterKey,
40}
41
42/// The word the ledger writes for who answers a scope's Approval
43/// Inbox, and its reader.
44///
45/// `owner` is the person; `delegate:<resident>` is the resident they
46/// appointed, whose address travels in the value so a replay knows who
47/// was appointed. The two halves sit together because they are one
48/// spelling: a writer and a reader in different modules is how an
49/// autonomy written by one build came back as `Owner` in the next.
50pub mod autonomy_word {
51    use super::{Autonomy, AxCode, AxError, ResidentId};
52
53    /// The word before an appointed resident's address.
54    const DELEGATE: &str = "delegate";
55
56    /// The person who owns the city.
57    const OWNER: &str = "owner";
58
59    /// What separates the appointment from the resident it names.
60    const AT: char = ':';
61
62    /// The word a line carries.
63    #[must_use]
64    pub fn spell(autonomy: &Autonomy) -> String {
65        match autonomy {
66            Autonomy::Owner => OWNER.to_owned(),
67            Autonomy::Delegate(resident) => {
68                format!("{DELEGATE}{AT}{resident}", resident = resident.as_str())
69            }
70        }
71    }
72
73    /// Reads back what [`spell`] wrote.
74    ///
75    /// # Errors
76    /// `E_WIRE_MISMATCH` for any other word. Falling back to the person
77    /// would read as the strict side and is not: a city
78    /// whose history appointed a delegate this build cannot read would
79    /// have shown the person questions the delegate was answering, and
80    /// said nothing about the line it could not read.
81    pub fn read(word: &str) -> Result<Autonomy, AxError> {
82        let unreadable = |detail: &str| {
83            AxError::failure(AxCode::WireMismatch, "read who answers for a scope", detail)
84                .with_recovery(
85                    "open this city with the build that wrote its history: this one knows \
86                     `owner` and `delegate:<resident>`",
87                )
88        };
89        if word == OWNER {
90            return Ok(Autonomy::Owner);
91        }
92        match word.split_once(AT) {
93            Some((DELEGATE, resident)) => ResidentId::new(resident)
94                .map(Autonomy::Delegate)
95                .ok_or_else(|| unreadable(resident)),
96            Some(_) | None => Err(unreadable(word)),
97        }
98    }
99
100    pub(super) fn serialize<S: serde::Serializer>(
101        autonomy: &Autonomy,
102        serializer: S,
103    ) -> Result<S::Ok, S::Error> {
104        serializer.serialize_str(&spell(autonomy))
105    }
106
107    pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>(
108        deserializer: D,
109    ) -> Result<Autonomy, D::Error> {
110        let word = <String as serde::Deserialize>::deserialize(deserializer)?;
111        read(&word).map_err(serde::de::Error::custom)
112    }
113}
114
115/// `autonomy_changed`: who answers the Approval Inbox for a scope from
116/// now on.
117///
118/// Both values are the words the ledger has always held: the scope as
119/// [`Scope`] spells it, the appointment as [`autonomy_word`] does. This
120/// struct is the only way in and out of the line, so no writer formats
121/// it and no reader splits it by hand.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124pub struct AutonomyChanged {
125    /// What the appointment applies to. A line written before the key
126    /// existed carries no scope and appointed the answerer for the
127    /// whole city, which is what [`city_wide`] reads it as.
128    #[serde(default = "city_wide")]
129    #[cfg_attr(feature = "schema", schemars(with = "String"))]
130    pub scope: Scope,
131    #[serde(with = "autonomy_word")]
132    #[cfg_attr(feature = "schema", schemars(with = "String"))]
133    pub autonomy: Autonomy,
134}
135
136/// What an `autonomy_changed` line with no scope applies to.
137fn city_wide() -> Scope {
138    Scope::City
139}
140
141/// Whether a scope is shut or open.
142///
143/// The two words a `city_halted` record carries are spelled here and
144/// nowhere else, so every fold reads an unrecognised word the same way;
145/// constants compared by hand at each fold let one read it as a release
146/// while another ignored the line (`crates/sprawling/Spec.lean` §8-74).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub enum Admittance {
151    /// Nothing may be dispatched into this scope.
152    Halted,
153    /// The scope takes work again.
154    Released,
155}
156
157/// `city_halted`: one scope stopped taking work, or started again.
158///
159/// One kind for both directions, because halting and releasing are one
160/// fact changing value, and a second kind would let a reader see a
161/// release with no halt before it.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
164pub struct CityHalted {
165    #[cfg_attr(feature = "schema", schemars(with = "String"))]
166    pub scope: Scope,
167    pub state: Admittance,
168}
169
170/// `governed_document_written`: which of the three documents a person
171/// wrote, and how long it now is.
172///
173/// Never the text. The document is on disk and readable there, and a
174/// copy here would be a second authority for words a person edits.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177pub struct GovernedDocumentWritten {
178    /// The document's file name, as `city::Governed::file` gives it.
179    pub which: String,
180    /// The length of what was written, in bytes.
181    pub bytes: usize,
182    /// The identity version the city has once this write landed: the
183    /// receipt a page waits for before it calls a save saved
184    /// (`crates/kernel/spec/Event/Record.lean` §8-79). Absent for `CLERK.md`, which names nobody,
185    /// and in lines written before the key existed.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
188    pub naming: Option<B3Hash>,
189}
190
191/// `rules_changed`: a document a dispatch stands under holds other bytes
192/// than the last line booked for it.
193///
194/// Never the text, for the reason [`GovernedDocumentWritten`] gives; the
195/// two digests are what a replay compares instead. One line's `after`
196/// is the next line's `before` for the same document as long as nothing
197/// wrote it between them, so a gap in that chain says a hand reached the
198/// file outside every door.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201pub struct RulesChanged {
202    /// What the document governs: the city, or one existing building.
203    #[cfg_attr(feature = "schema", schemars(with = "String"))]
204    pub scope: Scope,
205    /// Which of the scope's documents moved; a building holds both, so
206    /// the scope alone cannot say.
207    pub which: GoverningDocument,
208    /// What the last line left the document as. Absent opens the
209    /// document's account rather than claiming a change that never was.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
212    pub before: Option<B3Hash>,
213    /// What the document is now.
214    #[cfg_attr(feature = "schema", schemars(with = "String"))]
215    pub after: B3Hash,
216    /// The length of what now stands, in bytes.
217    pub bytes: usize,
218}
219
220/// The two documents a scope is judged by, spelled on the wire as the
221/// file names they live under.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub enum GoverningDocument {
225    /// A building's `RULES.toml`.
226    #[serde(rename = "RULES.toml")]
227    Rules,
228    /// A `CONFIG.toml` layer, the city's or a building's.
229    #[serde(rename = "CONFIG.toml")]
230    Config,
231}
232
233/// `spine_document_written`: which of a building's own spine documents
234/// a person wrote, and how long it now is.
235///
236/// Carries the building because these four live at a building's root
237/// while the three governed ones live in the city's own reserved
238/// subtree. Never the text, for the reason [`GovernedDocumentWritten`]
239/// gives.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct SpineDocumentWritten {
243    /// The building the document belongs to.
244    pub building: String,
245    /// The document's file name.
246    pub which: String,
247    /// The length of what was written, in bytes.
248    pub bytes: usize,
249}
250
251#[cfg(test)]
252#[allow(
253    clippy::unwrap_used,
254    clippy::expect_used,
255    clippy::panic,
256    clippy::indexing_slicing,
257    reason = "test code"
258)]
259mod tests {
260    use super::*;
261    use crate::address::Address;
262    use crate::approval::ApprovalClass;
263    use crate::event::Payload;
264
265    /// The bytes these structs write are the bytes the hand-written
266    /// writers wrote, key for key and word for word.
267    #[test]
268    fn an_answered_item_writes_the_keys_the_histories_already_hold() {
269        let answered = ApprovalResolved {
270            id: ApprovalId::new("ap-7").unwrap(),
271            verdict: Ruling::Allow,
272            cluster: ClusterKey {
273                class: ApprovalClass::Question,
274                detail: "ask the person".to_owned(),
275            },
276        };
277        let payload = Payload::of(&answered).unwrap();
278        assert_eq!(
279            serde_json::to_string(payload.as_map()).unwrap(),
280            r#"{"cluster":{"class":"question","detail":"ask the person"},"id":"ap-7","verdict":"allow"}"#
281        );
282        assert_eq!(payload.read::<ApprovalResolved>().unwrap(), answered);
283        let denied = ApprovalResolved {
284            verdict: Ruling::Deny,
285            ..answered
286        };
287        assert!(
288            serde_json::to_string(Payload::of(&denied).unwrap().as_map())
289                .unwrap()
290                .contains(r#""verdict":"deny""#)
291        );
292    }
293
294    /// The two words that used to fall back to the strict side, and the
295    /// scope that used to be read with `split_once(':')`.
296    #[test]
297    fn an_appointment_this_build_cannot_read_is_refused_rather_than_defaulted() {
298        let line = serde_json::json!({"scope": "city", "autonomy": "anybody"});
299        let payload: Payload = serde_json::from_value(line).unwrap();
300        let err = payload.read::<AutonomyChanged>().unwrap_err();
301        assert_eq!(err.code(), &AxCode::WireMismatch);
302        let line = serde_json::json!({"scope": "lab", "state": "halted"});
303        let payload: Payload = serde_json::from_value(line).unwrap();
304        assert!(payload.read::<CityHalted>().is_err());
305    }
306
307    #[test]
308    fn a_verdict_this_build_does_not_know_is_refused_rather_than_defaulted() {
309        let line = serde_json::json!({
310            "id": "ap-7",
311            "verdict": "maybe",
312            "cluster": {"class": "question", "detail": "d"},
313        });
314        let payload: Payload = serde_json::from_value(line).unwrap();
315        assert!(payload.read::<ApprovalResolved>().is_err());
316    }
317
318    #[test]
319    fn the_three_standing_records_keep_their_keys() {
320        let halted = Payload::of(&CityHalted {
321            scope: Scope::Building(Address::parse("lab").unwrap()),
322            state: Admittance::Halted,
323        })
324        .unwrap();
325        assert_eq!(
326            serde_json::to_string(halted.as_map()).unwrap(),
327            r#"{"scope":"building:lab","state":"halted"}"#
328        );
329        let autonomy = Payload::of(&AutonomyChanged {
330            scope: Scope::City,
331            autonomy: Autonomy::Delegate(ResidentId::new("hall/clerk.md").unwrap()),
332        })
333        .unwrap();
334        assert_eq!(
335            serde_json::to_string(autonomy.as_map()).unwrap(),
336            r#"{"autonomy":"delegate:hall/clerk.md","scope":"city"}"#
337        );
338        assert_eq!(
339            autonomy.read::<AutonomyChanged>().unwrap().autonomy,
340            Autonomy::Delegate(ResidentId::new("hall/clerk.md").unwrap())
341        );
342        let written = Payload::of(&GovernedDocumentWritten {
343            which: "MAYOR.md".to_owned(),
344            bytes: 12,
345            naming: None,
346        })
347        .unwrap();
348        assert_eq!(
349            serde_json::to_string(written.as_map()).unwrap(),
350            r#"{"bytes":12,"which":"MAYOR.md"}"#
351        );
352    }
353
354    /// The file names a replay reads, and the absent `before` that opens
355    /// a document's account.
356    #[test]
357    fn a_rules_change_carries_the_file_name_and_two_digests_never_the_text() {
358        let opening = RulesChanged {
359            scope: Scope::Building(Address::parse("lab").unwrap()),
360            which: GoverningDocument::Rules,
361            before: None,
362            after: B3Hash::digest(b"new"),
363            bytes: 3,
364        };
365        let payload = Payload::of(&opening).unwrap();
366        let text = serde_json::to_string(payload.as_map()).unwrap();
367        assert_eq!(
368            text,
369            format!(
370                r#"{{"after":"{}","bytes":3,"scope":"building:lab","which":"RULES.toml"}}"#,
371                B3Hash::digest(b"new")
372            )
373        );
374        assert_eq!(payload.read::<RulesChanged>().unwrap(), opening);
375        let moved = RulesChanged {
376            which: GoverningDocument::Config,
377            before: Some(B3Hash::digest(b"old")),
378            ..opening
379        };
380        assert_eq!(
381            Payload::of(&moved).unwrap().read::<RulesChanged>().unwrap(),
382            moved
383        );
384    }
385}