Skip to main content

agora_agentkit/govlog/obligation/
request.rs

1//! Obligation request files (`governance/obligations/**/*.json`): what the
2//! Steward reviews before `govlog obligation` signs an `OBL-` entry, and
3//! before `govlog obligation-update` signs one of the five status
4//! amendments (`docs/design-obligations.md`, "By request file").
5//!
6//! A request carries what the entry will say plus what only the review
7//! needs: where to check the quote ([`QuoteSource`]), a deadline that may
8//! name a sitting by its date ([`UntilRequest::SittingOn`]), and an
9//! unsigned `review_note`. Every enum is closed and every struct refuses
10//! unknown fields: a typo in a permanent record fails before it is signed.
11//!
12//! Pure (serde and chrono only), so it can move to agentkit beside
13//! [`ObligationData`]. The database half (resolving a sitting, reading the
14//! source, appending) is the agora server's `govlog obligation`.
15
16use crate::govlog::{Evidence, ObligationData, ObligationUpdate, Until};
17use chrono::NaiveDate;
18use serde::{Deserialize, Deserializer};
19
20use crate::enums::{ObligationKind, OwedBy};
21use crate::ids::{CalendarEventId, CommentId, GovernanceLogId, PostId};
22
23/// A request to append one obligation. See the [module docs](self).
24#[derive(Debug, Clone, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct ObligationRequest {
27    /// The entry that created it; absent or `null` for a top-level one
28    #[serde(default)]
29    pub source: Option<GovernanceLogId>,
30    pub kind: ObligationKind,
31    pub owed_by: OwedBy,
32    /// Absent or `null` for an undated, standing duty
33    #[serde(default)]
34    pub until: Option<UntilRequest>,
35    pub text: String,
36    pub quote: String,
37    pub quote_location: String,
38    /// Where the writer checks `quote` before signing. Never signed
39    pub quote_source: QuoteSource,
40    #[serde(default)]
41    pub thread: Option<PostId>,
42    /// For the reviewer: why the fields are what they are. Never signed
43    #[serde(default)]
44    pub review_note: Option<String>,
45}
46
47/// When an obligation is due, as a request file may say it: agentkit's
48/// [`Until`], plus two forms the writer resolves or refuses.
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
50#[serde(tag = "when", rename_all = "snake_case", deny_unknown_fields)]
51pub enum UntilRequest {
52    /// [`Until::NextSitting`]. It rolls forward with every sitting that
53    /// does not hear it until the lapse sweep exists; prefer `sitting_on`.
54    /// A struct variant, not a unit one, so unknown fields beside it are
55    /// refused (serde does not deny them beside a tagged unit variant)
56    NextSitting {},
57    /// [`Until::Sitting`], by calendar event id
58    Sitting { event: CalendarEventId },
59    /// The one scheduled sitting (or special sitting) on the calendar
60    /// expected on `date` (UTC), resolved to [`Until::Sitting`] at
61    /// signing. Refused if there is none, or more than one
62    SittingOn {
63        #[serde(deserialize_with = "full_date")]
64        date: NaiveDate,
65    },
66    /// [`Until::Date`]
67    Date {
68        #[serde(deserialize_with = "full_date")]
69        date: NaiveDate,
70    },
71    /// [`Until::AfterDecision`]: the decision must already be in the log
72    AfterDecision { decision: GovernanceLogId },
73    /// Held: the deadline is a decision that is not taken yet, described
74    /// in `on`. Always refused at signing; replace it with
75    /// `after_decision` once the decision's id exists
76    AwaitingDecision { on: String },
77}
78
79impl UntilRequest {
80    /// The [`Until`] this is without the calendar or the log, when it
81    /// needs neither. `None` for [`SittingOn`](Self::SittingOn) and
82    /// [`AwaitingDecision`](Self::AwaitingDecision).
83    pub fn direct(&self) -> Option<Until> {
84        match self {
85            Self::NextSitting {} => Some(Until::NextSitting),
86            Self::Sitting { event } => Some(Until::Sitting { event: *event }),
87            Self::Date { date } => Some(Until::Date { date: *date }),
88            Self::AfterDecision { decision } => Some(Until::AfterDecision {
89                decision: decision.clone(),
90            }),
91            Self::SittingOn { .. } | Self::AwaitingDecision { .. } => None,
92        }
93    }
94}
95
96/// Where a quote is checked before signing
97#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
99pub enum QuoteSource {
100    /// A governance entry: its latest version (revisions applied) and,
101    /// for an amendment, the texts stored beside it. Checked by the writer
102    Governance { id: GovernanceLogId },
103    /// A decision's generated summary (the precedent corpus). Checked by
104    /// the writer, which warns that a summary is not the signed record
105    Summary { id: GovernanceLogId },
106    /// A file in this repository, relative to its root, as checked out
107    /// where the writer runs. Checked by the writer
108    RepoFile { path: String },
109    /// A post. Checked by the operator (`--quote-checked-by`)
110    Post { id: PostId },
111    /// A comment. Checked by the operator
112    Comment { id: CommentId },
113    /// A GitHub issue, PR or comment, by URL. Checked by the operator
114    Github { url: String },
115}
116
117impl QuoteSource {
118    /// Whether the operator, not the writer, checks quotes from here
119    pub fn operator_checked(&self) -> bool {
120        matches!(
121            self,
122            Self::Post { .. } | Self::Comment { .. } | Self::Github { .. }
123        )
124    }
125
126    /// What it is, for the dry run
127    pub fn describe(&self) -> String {
128        match self {
129            Self::Governance { id } => format!("governance entry {id}"),
130            Self::Summary { id } => format!("the generated summary of {id}"),
131            Self::RepoFile { path } => format!("repository file {path}"),
132            Self::Post { id } => format!("post {id}"),
133            Self::Comment { id } => format!("comment {id}"),
134            Self::Github { url } => format!("GitHub {url}"),
135        }
136    }
137}
138
139/// A request file that is not one the writer will sign
140#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
141pub enum RequestError {
142    #[error("`{0}` must say something")]
143    Blank(&'static str),
144    #[error(transparent)]
145    Quote(#[from] crate::govlog::quote::QuoteError),
146    #[error(
147        "quote_source.path {0:?} must be a relative path inside the repository"
148    )]
149    RepoPath(String),
150    #[error("quote_source.url {0:?} is not an https://github.com/ URL")]
151    GithubUrl(String),
152    #[error("held: the deadline waits on {0}, which is not decided yet")]
153    Held(String),
154}
155
156impl ObligationRequest {
157    /// Everything checkable without the database: the texts say
158    /// something, the quote survives normalising, the quote source is
159    /// well formed, and the deadline is not held.
160    pub fn check_shape(&self) -> Result<(), RequestError> {
161        blank(&self.text, "text")?;
162        blank(&self.quote, "quote")?;
163        blank(&self.quote_location, "quote_location")?;
164        if crate::govlog::quote::normalize(&self.quote).is_empty() {
165            return Err(crate::govlog::quote::QuoteError::Empty.into());
166        }
167        if let Some(UntilRequest::AwaitingDecision { on }) = &self.until {
168            return Err(RequestError::Held(on.clone()));
169        }
170        match &self.quote_source {
171            QuoteSource::RepoFile { path } => {
172                let p = std::path::Path::new(path);
173                let inside = !path.is_empty()
174                    && p.is_relative()
175                    && p.components()
176                        .all(|c| matches!(c, std::path::Component::Normal(_)));
177                if !inside {
178                    return Err(RequestError::RepoPath(path.clone()));
179                }
180            }
181            QuoteSource::Github { url } => {
182                let ok = url::Url::parse(url).is_ok_and(|u| {
183                    u.scheme() == "https" && u.host_str() == Some("github.com")
184                });
185                if !ok {
186                    return Err(RequestError::GithubUrl(url.clone()));
187                }
188            }
189            QuoteSource::Governance { .. }
190            | QuoteSource::Summary { .. }
191            | QuoteSource::Post { .. }
192            | QuoteSource::Comment { .. } => {}
193        }
194        Ok(())
195    }
196
197    /// The `data` the entry will carry, given the resolved deadline. The
198    /// quote source and review note are not part of it.
199    pub fn obligation(&self, until: Option<Until>) -> ObligationData {
200        let mut o = ObligationData::new(
201            self.kind,
202            self.owed_by,
203            self.text.clone(),
204            self.quote.clone(),
205            self.quote_location.clone(),
206        );
207        o.source = self.source.clone();
208        o.until = until;
209        o.thread = self.thread;
210        o
211    }
212}
213
214/// A request to append one status amendment to an obligation
215#[derive(Debug, Clone, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct ObligationUpdateRequest {
218    /// The `OBL-` entry
219    pub target: GovernanceLogId,
220    pub update: UpdateRequest,
221    /// The governance entry that authorizes it. A Council decision where
222    /// the rules require one (agentkit `check_obligation_amendment`)
223    #[serde(default)]
224    pub authority: Option<GovernanceLogId>,
225    pub basis: String,
226    pub note: String,
227    #[serde(default)]
228    pub rationale: Option<String>,
229    /// For the reviewer. Never signed
230    #[serde(default)]
231    pub review_note: Option<String>,
232}
233
234/// agentkit's [`ObligationUpdate`] as a request file says it, with the
235/// deadline of a reschedule in [`UntilRequest`] form and required, so a
236/// misspelt `until` cannot silently make an obligation undated
237#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
238#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
239pub enum UpdateRequest {
240    Planned {
241        plan: PostId,
242    },
243    Discharged {
244        evidence: Evidence,
245    },
246    /// Struct variants, so unknown fields beside the tag are refused
247    Lapsed {},
248    Rescheduled {
249        /// `null` makes it undated; the key itself is required
250        #[serde(deserialize_with = "required_option")]
251        until: Option<UntilRequest>,
252    },
253    Withdrawn {},
254}
255
256impl UpdateRequest {
257    /// The update with its deadline resolved
258    pub fn resolve(&self, until: Option<Until>) -> ObligationUpdate {
259        match self {
260            Self::Planned { plan } => ObligationUpdate::Planned { plan: *plan },
261            Self::Discharged { evidence } => ObligationUpdate::Discharged {
262                evidence: evidence.clone(),
263            },
264            Self::Lapsed {} => ObligationUpdate::Lapsed,
265            Self::Rescheduled { .. } => ObligationUpdate::Rescheduled { until },
266            Self::Withdrawn {} => ObligationUpdate::Withdrawn,
267        }
268    }
269
270    /// The deadline a reschedule names, to resolve
271    pub fn until(&self) -> Option<&UntilRequest> {
272        match self {
273            Self::Rescheduled { until } => until.as_ref(),
274            _ => None,
275        }
276    }
277}
278
279impl ObligationUpdateRequest {
280    /// Everything checkable without the database
281    pub fn check_shape(&self) -> Result<(), RequestError> {
282        blank(&self.basis, "basis")?;
283        blank(&self.note, "note")?;
284        if let Some(r) = &self.rationale {
285            blank(r, "rationale")?;
286        }
287        if let Some(UntilRequest::AwaitingDecision { on }) = self.update.until()
288        {
289            return Err(RequestError::Held(on.clone()));
290        }
291        Ok(())
292    }
293}
294
295fn blank(s: &str, field: &'static str) -> Result<(), RequestError> {
296    match s.trim().is_empty() {
297        true => Err(RequestError::Blank(field)),
298        false => Ok(()),
299    }
300}
301
302/// An `Option` whose key must be present (`null` allowed): serde
303/// otherwise reads a missing `Option` field as `None`
304fn required_option<'de, D, T>(d: D) -> Result<Option<T>, D::Error>
305where
306    D: Deserializer<'de>,
307    T: Deserialize<'de>,
308{
309    Option::<T>::deserialize(d)
310}
311
312/// A date only as `YYYY-MM-DD`, as agentkit signs it: chrono alone also
313/// reads `+2027-4-1`
314fn full_date<'de, D: Deserializer<'de>>(d: D) -> Result<NaiveDate, D::Error> {
315    let s = String::deserialize(d)?;
316    let shaped = s.len() == 10
317        && s.char_indices().all(|(i, c)| match i {
318            4 | 7 => c == '-',
319            _ => c.is_ascii_digit(),
320        });
321    shaped
322        .then(|| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok())
323        .flatten()
324        .ok_or_else(|| {
325            serde::de::Error::custom(format!(
326                "{s:?} is not a date as YYYY-MM-DD"
327            ))
328        })
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn request(extra: serde_json::Value) -> serde_json::Value {
336        let mut v = serde_json::json!({
337            "source": "GOV-2026-0001",
338            "kind": "review",
339            "owed_by": "council",
340            "text": "Review it.",
341            "quote": "it should be reviewed",
342            "quote_location": "R2, artist, position",
343            "quote_source": {"kind": "governance", "id": "GOV-2026-0001"}
344        });
345        v.as_object_mut()
346            .unwrap()
347            .extend(extra.as_object().unwrap().clone());
348        v
349    }
350
351    fn parse(
352        v: serde_json::Value,
353    ) -> Result<ObligationRequest, serde_json::Error> {
354        serde_json::from_value(v)
355    }
356
357    #[test]
358    fn a_request_builds_the_obligation_without_its_review_fields() {
359        let r = parse(request(serde_json::json!({
360            "until": {"when": "date", "date": "2026-10-12"},
361            "thread": "91e791c0-9aee-4c53-9fb9-ce9259557ef8",
362            "review_note": "O1"
363        })))
364        .unwrap();
365        r.check_shape().unwrap();
366        let o = r.obligation(r.until.as_ref().and_then(UntilRequest::direct));
367        let data = serde_json::to_value(&o).unwrap();
368        assert_eq!(
369            data["until"],
370            serde_json::json!({"when": "date", "date": "2026-10-12"})
371        );
372        assert!(
373            data.get("review_note").is_none()
374                && data.get("quote_source").is_none()
375        );
376        assert_eq!(data["source"], "GOV-2026-0001");
377    }
378
379    #[test]
380    fn closed_enums_and_unknown_fields_are_refused() {
381        for bad in [
382            serde_json::json!({"kind": "chore"}),
383            serde_json::json!({"owed_by": "everyone"}),
384            serde_json::json!({"until": {"when": "someday"}}),
385            serde_json::json!({"until": {"when": "date", "date": "2026-10-12", "x": 1}}),
386            serde_json::json!({"until": {"when": "date", "date": "+2026-1-2"}}),
387            serde_json::json!({"quote_source": {"kind": "rumour"}}),
388            serde_json::json!({"quote_source": {"kind": "governance", "id": "GOV-2026-0001", "at": "/x"}}),
389            serde_json::json!({"typo": true}),
390        ] {
391            assert!(parse(request(bad.clone())).is_err(), "{bad} parsed");
392        }
393    }
394
395    #[test]
396    fn shape_checks_refuse_what_should_not_be_signed() {
397        for (extra, want) in [
398            (
399                serde_json::json!({"text": "  "}),
400                RequestError::Blank("text"),
401            ),
402            (
403                serde_json::json!({"quote": "** `` **"}),
404                RequestError::Quote(crate::govlog::quote::QuoteError::Empty),
405            ),
406            (
407                serde_json::json!({"quote_source": {"kind": "repo_file", "path": "../secrets/x"}}),
408                RequestError::RepoPath("../secrets/x".into()),
409            ),
410            (
411                serde_json::json!({"quote_source": {"kind": "repo_file", "path": "/etc/passwd"}}),
412                RequestError::RepoPath("/etc/passwd".into()),
413            ),
414            (
415                serde_json::json!({"quote_source": {"kind": "github", "url": "http://example.com/x"}}),
416                RequestError::GithubUrl("http://example.com/x".into()),
417            ),
418            (
419                serde_json::json!({"until": {"when": "awaiting_decision", "on": "the 10-17 vote"}}),
420                RequestError::Held("the 10-17 vote".into()),
421            ),
422        ] {
423            assert_eq!(parse(request(extra)).unwrap().check_shape(), Err(want));
424        }
425    }
426
427    #[test]
428    fn a_reschedule_must_name_its_deadline_even_to_drop_it() {
429        let parse = |v| serde_json::from_value::<UpdateRequest>(v);
430        assert!(parse(serde_json::json!({"kind": "rescheduled"})).is_err());
431        assert!(
432            parse(serde_json::json!({"kind": "rescheduled", "untill": null}))
433                .is_err()
434        );
435        assert_eq!(
436            parse(serde_json::json!({"kind": "rescheduled", "until": null}))
437                .unwrap(),
438            UpdateRequest::Rescheduled { until: None }
439        );
440        assert!(
441            parse(serde_json::json!({"kind": "lapsed", "note": "x"})).is_err()
442        );
443        assert_eq!(
444            parse(serde_json::json!({"kind": "withdrawn"}))
445                .unwrap()
446                .resolve(None),
447            ObligationUpdate::Withdrawn
448        );
449    }
450}