Skip to main content

agora_agentkit/govlog/council/
summary.rs

1//! A decision's signed summary (0.96).
2//!
3//! The Lawyer seat writes it; the Steward accepts it, or the other three
4//! seats when the Steward is recused on the item (Steward, 2026-10-17). It
5//! is part of the signed record, so changing it after signing takes a
6//! [revision](crate::govlog::Revision) patching `/summary`.
7
8use serde::{Deserialize, Serialize};
9
10use super::{CouncilSeat, CouncilVote, RecordRuleError};
11use crate::enums::GovernanceLogEntryType;
12use crate::govlog::Redactable;
13
14/// The JSON pointer a [revision](crate::govlog::Revision) of the summary
15/// patches, at or below
16pub const SUMMARY_POINTER: &str = "/summary";
17
18/// The seat that writes every decision's summary
19pub const SUMMARY_WRITER: CouncilSeat = CouncilSeat::Lawyer;
20
21/// The seats that accept a summary when the Steward is recused: every seat
22/// but [`SUMMARY_WRITER`], in declaration order
23pub const RECUSED_ACCEPTORS: [CouncilSeat; 3] = [
24    CouncilSeat::Artist,
25    CouncilSeat::Philosopher,
26    CouncilSeat::Engineer,
27];
28
29/// A decision's summary, as signed with the record. See the
30/// module docs.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33#[cfg_attr(feature = "schemars", schemars(inline))]
34pub struct DecisionSummary {
35    /// Markdown
36    pub text: Redactable<String>,
37    /// Always the Lawyer
38    pub written_by: CouncilSeat,
39    pub accepted_by: SummaryAcceptance,
40}
41
42/// Who accepted a [`DecisionSummary`]
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[cfg_attr(feature = "schemars", schemars(inline))]
46#[serde(tag = "by", rename_all = "snake_case")]
47pub enum SummaryAcceptance {
48    /// The Steward, on any item the Steward is not recused from
49    Steward,
50    /// With the Steward recused, the Artist, Philosopher and Engineer
51    Seats { seats: Vec<CouncilSeat> },
52}
53
54impl SummaryAcceptance {
55    /// Who accepts the summary of an item the Steward is, or is not,
56    /// recused from
57    pub fn for_item(steward_recused: bool) -> Self {
58        if steward_recused {
59            Self::Seats {
60                seats: RECUSED_ACCEPTORS.to_vec(),
61            }
62        } else {
63            Self::Steward
64        }
65    }
66}
67
68/// Who wrote and accepted a summary, for a reader holding its text: a
69/// [`DecisionSummary`] without the text
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
72#[cfg_attr(feature = "schemars", schemars(inline))]
73pub struct SummaryAuthorship {
74    pub written_by: CouncilSeat,
75    pub accepted_by: SummaryAcceptance,
76}
77
78impl SummaryAuthorship {
79    /// "written by the Lawyer, accepted by the Steward"
80    pub fn sentence(&self) -> String {
81        let accepted = match &self.accepted_by {
82            SummaryAcceptance::Steward => "the Steward".to_string(),
83            SummaryAcceptance::Seats { seats } => {
84                let names: Vec<&str> = seats.iter().map(|s| s.name()).collect();
85                match names.as_slice() {
86                    [] => String::from("no seat"),
87                    [one] => format!("the {one}"),
88                    [init @ .., last] => format!(
89                        "the {} and {last}, the Steward being recused",
90                        init.join(", ")
91                    ),
92                }
93            }
94        };
95        format!("written by the {}, accepted by {accepted}", self.written_by)
96    }
97}
98
99impl DecisionSummary {
100    /// A summary the Lawyer wrote, accepted by whoever accepts on an item
101    /// the Steward is, or is not, recused from
102    pub fn by_lawyer(text: impl Into<String>, steward_recused: bool) -> Self {
103        Self {
104            text: Redactable::Value(text.into()),
105            written_by: SUMMARY_WRITER,
106            accepted_by: SummaryAcceptance::for_item(steward_recused),
107        }
108    }
109
110    pub fn authorship(&self) -> SummaryAuthorship {
111        SummaryAuthorship {
112            written_by: self.written_by,
113            accepted_by: self.accepted_by.clone(),
114        }
115    }
116
117    /// The text as a reader is served it: a redacted one is its
118    /// [marker](crate::govlog::redaction_marker)
119    pub fn served_text(&self) -> String {
120        match &self.text {
121            Redactable::Value(text) => text.clone(),
122            Redactable::Redacted(id) => crate::govlog::redaction_marker(id),
123        }
124    }
125
126    /// The `summary` in a decision's `data`, reading nothing else. Pass
127    /// the version being served (its [`latest`](crate::govlog::latest), or
128    /// the original): the revisions are already in it.
129    pub fn of_data(
130        data: &serde_json::Value,
131    ) -> Result<Option<Self>, serde_json::Error> {
132        data.get("summary")
133            .map(|s| serde_json::from_value(s.clone()))
134            .transpose()
135    }
136
137    /// The summary rule ([`CouncilDecisionRecord::check`]) for an item
138    /// whose Steward voted `steward`
139    ///
140    /// [`CouncilDecisionRecord::check`]: super::CouncilDecisionRecord::check
141    pub(super) fn check(
142        &self,
143        steward: Option<CouncilVote>,
144    ) -> Result<(), RecordRuleError> {
145        if self.written_by != SUMMARY_WRITER {
146            return Err(RecordRuleError::SummaryWriter(self.written_by));
147        }
148        if let Redactable::Value(text) = &self.text
149            && text.trim().is_empty()
150        {
151            return Err(RecordRuleError::BlankSummary);
152        }
153        let recused = steward == Some(CouncilVote::Recused);
154        if self.accepted_by != SummaryAcceptance::for_item(recused) {
155            return Err(RecordRuleError::SummaryAcceptance { recused });
156        }
157        Ok(())
158    }
159}
160
161/// The summary to serve with an entry, and who wrote it when it is the
162/// signed one: a `council_decision`'s signed summary in `served` (the
163/// version being served) if it has one, else the Clerk's
164pub fn served_summary(
165    entry_type: GovernanceLogEntryType,
166    served: Option<&serde_json::Value>,
167    clerk: Option<String>,
168) -> (Option<String>, Option<SummaryAuthorship>) {
169    // A test sitting's record carries its decision's summary inside; it is
170    // served under the label, whoever wrote it (0.102).
171    let test = entry_type == GovernanceLogEntryType::Test;
172    let signed = served
173        .and_then(|data| {
174            let inner = crate::govlog::decision_data(entry_type, data)?;
175            (crate::govlog::decision_type(entry_type, data)
176                == GovernanceLogEntryType::CouncilDecision)
177                .then_some(inner)
178        })
179        .and_then(|data| DecisionSummary::of_data(data).ok().flatten());
180    let (text, authorship) = match signed {
181        Some(summary) => {
182            (Some(summary.served_text()), Some(summary.authorship()))
183        }
184        None => (clerk, None),
185    };
186    let text = if test {
187        text.map(|t| test_labelled(&t))
188    } else {
189        text
190    };
191    (text, authorship)
192}
193
194/// A test sitting's summary as served: [`TEST_LABEL`] in bold, first
195///
196/// [`TEST_LABEL`]: crate::govlog::TEST_LABEL
197pub fn test_labelled(text: &str) -> String {
198    let label = format!("**{}.**", crate::govlog::TEST_LABEL);
199    if text.starts_with(&label) {
200        text.to_string()
201    } else {
202        format!("{label}\n\n{text}")
203    }
204}