Skip to main content

agora_agentkit/govlog/
obligation.rs

1//! The `data` of an `obligation` entry (`OBL-YYYY-NNNN`), and the
2//! amendments that keep its status (agora `docs/design-obligations.md`).
3//!
4//! An obligation is never edited. Its status changes through appended
5//! [`Amendment`]s of the five [obligation kinds](AmendmentKind::OBLIGATION),
6//! each carrying an [`ObligationUpdate`], and [`ObligationState`] is what
7//! they add up to. Partial discharge is an ordinary [`Revision`] narrowing
8//! `text`.
9//!
10//! Like a [`StewardRecord`], an obligation is
11//! [redactable](super::is_redactable): its `quote` may hold an agent's
12//! words. So every field that might is a plain string, which a
13//! [redaction marker](super::redaction_marker) can replace and still read.
14//!
15//! [`Revision`]: super::Revision
16//! [`StewardRecord`]: super::StewardRecord
17
18use chrono::{DateTime, NaiveDate, Utc};
19use serde::{Deserialize, Serialize};
20
21use super::{Amendment, AmendmentError, OBLIGATION_FIXED, revision_touches};
22use crate::enums::{
23    AmendmentKind, GovernanceLogEntryType, ObligationKind, ObligationStatus,
24    OwedBy,
25};
26use crate::ids::{CalendarEventId, GovernanceLogId, PostId};
27
28/// The only `agora_obligation` version there is
29pub const OBLIGATION_VERSION: u32 = 1;
30
31/// When an obligation is due.
32///
33/// `NextSitting` is resolved against the calendar when read, never pinned
34/// to a date, so a sitting that slips carries the obligation with it.
35///
36/// Its schema is written by hand (see the `JsonSchema` impl): an `anyOf`
37/// with one closed object per variant, tagged by `when`, with no `$ref` and
38/// no `pattern`, so it can sit in a strict response format. Closed in
39/// serde too, except beside `next_sitting`: serde does not deny unknown
40/// fields on a unit variant of an internally tagged enum.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(tag = "when", rename_all = "snake_case", deny_unknown_fields)]
43pub enum Until {
44    /// The next Council sitting, whenever that is
45    NextSitting,
46    /// A sitting on the calendar
47    Sitting { event: CalendarEventId },
48    /// The end of a date
49    Date {
50        #[serde(with = "full_date")]
51        date: NaiveDate,
52    },
53    /// The next sitting after a decision is taken
54    AfterDecision { decision: GovernanceLogId },
55}
56
57#[cfg(feature = "schemars")]
58impl schemars::JsonSchema for Until {
59    fn inline_schema() -> bool {
60        true
61    }
62
63    fn schema_name() -> std::borrow::Cow<'static, str> {
64        std::borrow::Cow::Borrowed("Until")
65    }
66
67    fn schema_id() -> std::borrow::Cow<'static, str> {
68        std::borrow::Cow::Borrowed(concat!(module_path!(), "::Until"))
69    }
70
71    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
72        let variant =
73            |when: &str, description: &str, field: Option<(&str, _)>| {
74                let mut properties = serde_json::Map::new();
75                properties.insert(
76                    "when".into(),
77                    serde_json::json!({ "type": "string", "enum": [when] }),
78                );
79                let mut required = vec!["when"];
80                if let Some((name, schema)) = field {
81                    properties.insert(name.into(), schema);
82                    required.push(name);
83                }
84                serde_json::json!({
85                    "type": "object",
86                    "description": description,
87                    "properties": properties,
88                    "required": required,
89                    "additionalProperties": false
90                })
91            };
92        let schema = serde_json::json!({
93            "description": "When it is due",
94            "anyOf": [
95                variant(
96                    "next_sitting",
97                    "At the next Council sitting, whenever that is held",
98                    None,
99                ),
100                variant(
101                    "sitting",
102                    "At a particular sitting on the calendar",
103                    Some((
104                        "event",
105                        serde_json::json!({
106                            "type": "string",
107                            "format": "uuid",
108                            "description": "The sitting's calendar event id"
109                        }),
110                    )),
111                ),
112                variant(
113                    "date",
114                    "By the end of a date",
115                    Some((
116                        "date",
117                        serde_json::json!({
118                            "type": "string",
119                            "format": "date",
120                            "description": "YYYY-MM-DD"
121                        }),
122                    )),
123                ),
124                variant(
125                    "after_decision",
126                    "At the first sitting after a decision is taken",
127                    Some((
128                        "decision",
129                        serde_json::json!({
130                            "type": "string",
131                            "description": "The decision's governance log id, \
132                                            e.g. \"GOV-2026-0012\""
133                        }),
134                    )),
135                ),
136            ]
137        });
138        schemars::Schema::try_from(schema).expect("an object is a schema")
139    }
140}
141
142/// A date in signed data, read only as `YYYY-MM-DD`.
143///
144/// chrono alone also reads `+2027-4-1` and `2027 - 04 - 01`. Every
145/// verifier must agree on what an amendment says, and a second one should
146/// not have to reproduce chrono's leniency to do it.
147mod full_date {
148    use chrono::NaiveDate;
149    use serde::{Deserialize, Deserializer, Serializer};
150
151    pub fn serialize<S: Serializer>(
152        date: &NaiveDate,
153        s: S,
154    ) -> Result<S::Ok, S::Error> {
155        s.collect_str(&date.format("%Y-%m-%d"))
156    }
157
158    pub fn deserialize<'de, D: Deserializer<'de>>(
159        d: D,
160    ) -> Result<NaiveDate, D::Error> {
161        let s = String::deserialize(d)?;
162        let shaped = s.len() == 10
163            && s.char_indices().all(|(i, c)| match i {
164                4 | 7 => c == '-',
165                _ => c.is_ascii_digit(),
166            });
167        shaped
168            .then(|| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok())
169            .flatten()
170            .ok_or_else(|| {
171                serde::de::Error::custom(format!(
172                    "{s:?} is not a date as YYYY-MM-DD"
173                ))
174            })
175    }
176}
177
178/// The `data` of an `obligation` entry. See the [module docs](self).
179///
180/// Unknown keys are tolerated on purpose: a stored obligation also carries
181/// the writer's `_blind`.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
184#[cfg_attr(feature = "schemars", schemars(inline))]
185pub struct Obligation {
186    /// Always [`OBLIGATION_VERSION`]
187    pub agora_obligation: u32,
188    /// The entry that created it. `None` makes it top-level: a standing
189    /// duty, or a promise made in a post rather than a record
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub source: Option<GovernanceLogId>,
192    pub kind: ObligationKind,
193    pub owed_by: OwedBy,
194    /// When it is due; `None` is a standing duty, which never lapses
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub until: Option<Until>,
197    /// What is owed, in a sentence
198    pub text: String,
199    /// The words in the source that create it, verbatim
200    pub quote: String,
201    /// Where they are: round and seat, section, or a post or comment id
202    pub quote_location: String,
203    /// Where it was promised, when that is a post
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub thread: Option<PostId>,
206}
207
208impl Obligation {
209    /// A top-level, undated obligation with no thread; set the rest on it
210    pub fn new(
211        kind: ObligationKind,
212        owed_by: OwedBy,
213        text: impl Into<String>,
214        quote: impl Into<String>,
215        quote_location: impl Into<String>,
216    ) -> Self {
217        Self {
218            agora_obligation: OBLIGATION_VERSION,
219            source: None,
220            kind,
221            owed_by,
222            until: None,
223            text: text.into(),
224            quote: quote.into(),
225            quote_location: quote_location.into(),
226            thread: None,
227        }
228    }
229}
230
231/// What an amendment of an [obligation kind](AmendmentKind::OBLIGATION)
232/// says, tagged by that kind: [`Amendment::update`].
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
235#[cfg_attr(feature = "schemars", schemars(inline))]
236#[serde(tag = "kind", rename_all = "snake_case")]
237pub enum ObligationUpdate {
238    /// The thread where the work is planned. A later plan replaces it
239    Planned { plan: PostId },
240    /// Met
241    Discharged { evidence: Evidence },
242    /// Missed and still owed. The amendment's note says what was missed
243    Lapsed,
244    /// Due at a new time, or undated
245    Rescheduled {
246        #[serde(default)]
247        until: Option<Until>,
248    },
249    /// Released
250    Withdrawn,
251}
252
253impl ObligationUpdate {
254    /// The [`AmendmentKind`] this belongs on
255    pub fn kind(&self) -> AmendmentKind {
256        match self {
257            Self::Planned { .. } => AmendmentKind::Planned,
258            Self::Discharged { .. } => AmendmentKind::Discharged,
259            Self::Lapsed => AmendmentKind::Lapsed,
260            Self::Rescheduled { .. } => AmendmentKind::Rescheduled,
261            Self::Withdrawn => AmendmentKind::Withdrawn,
262        }
263    }
264}
265
266/// What shows an obligation was met
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
269#[cfg_attr(feature = "schemars", schemars(inline))]
270#[serde(tag = "kind", rename_all = "snake_case")]
271pub enum Evidence {
272    /// A governance entry: the decision that heard it, a record
273    Governance { id: GovernanceLogId },
274    /// A pull request, as `owner/name` and its number
275    PullRequest { repo: String, number: u64 },
276    /// A commit, as `owner/name` and its hex sha (7 to 40 digits)
277    Commit { repo: String, sha: String },
278    /// A deploy, on a date, and what went out
279    Deploy {
280        #[serde(with = "full_date")]
281        #[cfg_attr(feature = "schemars", schemars(with = "NaiveDate"))]
282        date: NaiveDate,
283        note: String,
284    },
285}
286
287/// Where an obligation stands, from its amendments: see
288/// [`ObligationState::from_amendments`]
289#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
290#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
291#[cfg_attr(feature = "schemars", schemars(inline))]
292pub struct ObligationState {
293    pub status: ObligationStatus,
294    /// When it is due now: the last reschedule's, or the obligation's own
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub until: Option<Until>,
297    /// The `authority` of the reschedule that set `until`, if one did
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub until_authority: Option<GovernanceLogId>,
300    /// The latest plan
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub plan: Option<PostId>,
303    /// When it last lapsed. Kept after a discharge: a late discharge reads
304    /// as "discharged, lapsed on …"
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub lapsed_at: Option<DateTime<Utc>>,
307}
308
309impl ObligationState {
310    /// The state of `obligation` (its latest version) after `amendments`,
311    /// those naming it with their `created_at`, in chain order. Kinds that
312    /// are not obligation kinds change nothing here.
313    ///
314    /// A discharge or withdrawal closes it, and the last one wins. A lapse
315    /// marks an open obligation lapsed, and a reschedule reopens a lapsed
316    /// one: it has a new due date and has not missed it yet. Neither moves
317    /// a closed one.
318    pub fn from_amendments<'a>(
319        obligation: &Obligation,
320        amendments: impl IntoIterator<Item = (DateTime<Utc>, &'a Amendment)>,
321    ) -> Self {
322        let mut state = Self {
323            until: obligation.until.clone(),
324            ..Self::default()
325        };
326        for (at, amendment) in amendments {
327            let Some(update) = &amendment.update else {
328                continue;
329            };
330            match update {
331                ObligationUpdate::Planned { plan } => state.plan = Some(*plan),
332                ObligationUpdate::Discharged { .. } => {
333                    state.status = ObligationStatus::Discharged;
334                }
335                ObligationUpdate::Withdrawn => {
336                    state.status = ObligationStatus::Withdrawn;
337                }
338                ObligationUpdate::Lapsed => {
339                    state.lapsed_at = Some(at);
340                    if state.status == ObligationStatus::Open {
341                        state.status = ObligationStatus::Lapsed;
342                    }
343                }
344                ObligationUpdate::Rescheduled { until } => {
345                    state.until = until.clone();
346                    state.until_authority = amendment.authority.clone();
347                    if state.status == ObligationStatus::Lapsed {
348                        state.status = ObligationStatus::Open;
349                    }
350                }
351            }
352        }
353        state
354    }
355}
356
357/// What the server knows about a governance entry that the obligation
358/// rules need: see [`check_obligation_amendment`]
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub struct KnownEntry {
361    pub entry_type: GovernanceLogEntryType,
362    pub chain_seq: u64,
363}
364
365/// An obligation amendment that may not be appended
366#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
367pub enum ObligationRuleError {
368    #[error(transparent)]
369    Shape(#[from] AmendmentError),
370    #[error("{0} is not an entry of the log")]
371    UnknownEntry(GovernanceLogId),
372    #[error(
373        "the obligation is {0}; only a record closes it, and nothing reopens it"
374    )]
375    Closed(ObligationStatus),
376    #[error("an undated obligation never lapses")]
377    Undated,
378    #[error("the obligation has already lapsed; reschedule it first")]
379    AlreadyLapsed,
380    #[error(
381        "`{0}` here needs a Council decision as its `authority`: the \
382         Council owes it, or set its deadline"
383    )]
384    NeedsCouncil(AmendmentKind),
385    #[error(
386        "the authority {authority} is not later than {earlier}: the \
387         obligation, or the Council decision that set its deadline"
388    )]
389    AuthorityNotLater {
390        authority: GovernanceLogId,
391        earlier: GovernanceLogId,
392    },
393    #[error("evidence: {0:?} is not a commit sha (7 to 40 hex digits)")]
394    BadSha(String),
395    #[error("evidence: {0:?} is not a repository as `owner/name`")]
396    BadRepo(String),
397}
398
399/// Whether `amendment` may be appended to `obligation`, the entry `id` at
400/// `chain_seq`, now at `state` ([`ObligationState::from_amendments`]).
401/// `entry` looks up an entry of the log by id.
402///
403/// The rules (agora `docs/design-obligations.md`):
404///
405/// - `planned`: anyone may record one;
406/// - `discharged`: the Steward's for Steward- and staff-owed obligations;
407///   a Council-owed one needs a Council decision as `authority`;
408/// - `lapsed`: only a dated, open obligation lapses;
409/// - `rescheduled`, `withdrawn`: by whoever set the deadline. If a Council
410///   decision did (the source, or the authority of the last reschedule),
411///   `authority` must be a Council decision. A Council-owed obligation is
412///   withdrawn only by the Council;
413/// - a `revision` never touches [`OBLIGATION_FIXED`]. One that narrows
414///   `text` is a partial discharge: if the Council owes the obligation or
415///   set its deadline, it needs a Council decision too. Its other fields
416///   are clerical.
417///
418/// A Council decision that authorizes anything here is later than the
419/// obligation and than the decision that set its deadline. Nothing but a
420/// revision or another non-obligation kind amends a closed obligation, and
421/// those kinds are otherwise not this function's business.
422pub fn check_obligation_amendment(
423    obligation: &Obligation,
424    id: &GovernanceLogId,
425    chain_seq: u64,
426    state: &ObligationState,
427    amendment: &Amendment,
428    entry: impl Fn(&GovernanceLogId) -> Option<KnownEntry>,
429) -> Result<(), ObligationRuleError> {
430    amendment.validate()?;
431    if let Some(revision) = &amendment.revision
432        && let Some(path) = revision_touches(revision, &OBLIGATION_FIXED)
433    {
434        return Err(AmendmentError::FixedObligationField(path.into()).into());
435    }
436    let narrows_text = amendment
437        .revision
438        .as_ref()
439        .is_some_and(|r| revision_touches(r, &["/text"]).is_some());
440    if amendment.update.is_none() && !narrows_text {
441        return Ok(());
442    }
443    if amendment.update.is_some() && state.status.is_closed() {
444        return Err(ObligationRuleError::Closed(state.status));
445    }
446    let known = |id: &GovernanceLogId| {
447        entry(id).ok_or_else(|| ObligationRuleError::UnknownEntry(id.clone()))
448    };
449    let council = |id: &GovernanceLogId| -> Result<
450        Option<KnownEntry>,
451        ObligationRuleError,
452    > {
453        let e = known(id)?;
454        Ok((e.entry_type == GovernanceLogEntryType::CouncilDecision)
455            .then_some(e))
456    };
457    if let Some(authority) = &amendment.authority {
458        known(authority)?;
459    }
460    // The Council decision that set the deadline, if one did
461    let mut setter = None;
462    for id in [obligation.source.as_ref(), state.until_authority.as_ref()]
463        .into_iter()
464        .flatten()
465    {
466        if let Some(e) = council(id)? {
467            setter = Some((id, e.chain_seq));
468        }
469    }
470    // `authority`: a Council decision, later than the obligation and than
471    // the decision that set its deadline
472    let needs_council = || {
473        let authority = amendment
474            .authority
475            .as_ref()
476            .ok_or(ObligationRuleError::NeedsCouncil(amendment.kind))?;
477        let decision = council(authority)?
478            .ok_or(ObligationRuleError::NeedsCouncil(amendment.kind))?;
479        for (earlier, seq) in
480            [Some((id, chain_seq)), setter].into_iter().flatten()
481        {
482            if decision.chain_seq <= seq {
483                return Err(ObligationRuleError::AuthorityNotLater {
484                    authority: authority.clone(),
485                    earlier: earlier.clone(),
486                });
487            }
488        }
489        Ok(())
490    };
491    let councils = obligation.owed_by == OwedBy::Council || setter.is_some();
492    let Some(update) = &amendment.update else {
493        // A revision narrowing `text`: a partial discharge
494        return if councils { needs_council() } else { Ok(()) };
495    };
496    match update {
497        ObligationUpdate::Planned { .. } => Ok(()),
498        ObligationUpdate::Lapsed => match (&state.until, state.status) {
499            (None, _) => Err(ObligationRuleError::Undated),
500            (_, ObligationStatus::Lapsed) => {
501                Err(ObligationRuleError::AlreadyLapsed)
502            }
503            _ => Ok(()),
504        },
505        ObligationUpdate::Discharged { evidence } => {
506            check_evidence(evidence, &known)?;
507            match obligation.owed_by {
508                OwedBy::Council => needs_council(),
509                OwedBy::Steward | OwedBy::Staff => Ok(()),
510            }
511        }
512        ObligationUpdate::Rescheduled { .. } => match setter {
513            Some(_) => needs_council(),
514            None => Ok(()),
515        },
516        ObligationUpdate::Withdrawn => match councils {
517            true => needs_council(),
518            false => Ok(()),
519        },
520    }
521}
522
523fn check_evidence(
524    evidence: &Evidence,
525    known: &impl Fn(&GovernanceLogId) -> Result<KnownEntry, ObligationRuleError>,
526) -> Result<(), ObligationRuleError> {
527    let repo = |repo: &str| {
528        let parts: Vec<&str> = repo.split('/').collect();
529        match parts.as_slice() {
530            [owner, name] if !owner.is_empty() && !name.is_empty() => Ok(()),
531            _ => Err(ObligationRuleError::BadRepo(repo.to_string())),
532        }
533    };
534    match evidence {
535        Evidence::Governance { id } => known(id).map(|_| ()),
536        Evidence::PullRequest { repo: r, .. } => repo(r),
537        Evidence::Commit { repo: r, sha } => {
538            repo(r)?;
539            let hex = (7..=40).contains(&sha.len())
540                && sha.chars().all(|c| c.is_ascii_hexdigit());
541            hex.then_some(())
542                .ok_or_else(|| ObligationRuleError::BadSha(sha.clone()))
543        }
544        Evidence::Deploy { .. } => Ok(()),
545    }
546}
547
548#[cfg(test)]
549mod tests;