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
142impl Until {
143    /// The date this is, when it is a date: what the projection stores as
144    /// the due date. Sitting deadlines resolve against the calendar when
145    /// read ([`crate::calendar::Calendar::resolve`]), never here.
146    pub fn fixed_date(&self) -> Option<NaiveDate> {
147        match self {
148            Self::Date { date } => Some(*date),
149            Self::NextSitting
150            | Self::Sitting { .. }
151            | Self::AfterDecision { .. } => None,
152        }
153    }
154}
155
156/// A date in signed data, read only as `YYYY-MM-DD`.
157///
158/// chrono alone also reads `+2027-4-1` and `2027 - 04 - 01`. Every
159/// verifier must agree on what an amendment says, and a second one should
160/// not have to reproduce chrono's leniency to do it.
161mod full_date {
162    use chrono::NaiveDate;
163    use serde::{Deserialize, Deserializer, Serializer};
164
165    pub fn serialize<S: Serializer>(
166        date: &NaiveDate,
167        s: S,
168    ) -> Result<S::Ok, S::Error> {
169        s.collect_str(&date.format("%Y-%m-%d"))
170    }
171
172    pub fn deserialize<'de, D: Deserializer<'de>>(
173        d: D,
174    ) -> Result<NaiveDate, D::Error> {
175        let s = String::deserialize(d)?;
176        let shaped = s.len() == 10
177            && s.char_indices().all(|(i, c)| match i {
178                4 | 7 => c == '-',
179                _ => c.is_ascii_digit(),
180            });
181        shaped
182            .then(|| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok())
183            .flatten()
184            .ok_or_else(|| {
185                serde::de::Error::custom(format!(
186                    "{s:?} is not a date as YYYY-MM-DD"
187                ))
188            })
189    }
190}
191
192/// The `data` of an `obligation` entry: what was signed, and never
193/// changes but by a [`Revision`]. See the [module docs](self), and
194/// [`Obligation`] for the entry as the log has it now.
195///
196/// Unknown keys are tolerated on purpose: a stored obligation also carries
197/// the writer's `_blind`.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
200#[cfg_attr(feature = "schemars", schemars(inline))]
201pub struct ObligationData {
202    /// Always [`OBLIGATION_VERSION`]
203    pub agora_obligation: u32,
204    /// The entry that created it. `None` makes it top-level: a standing
205    /// duty, or a promise made in a post rather than a record
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub source: Option<GovernanceLogId>,
208    pub kind: ObligationKind,
209    pub owed_by: OwedBy,
210    /// When it is due; `None` is a standing duty, which never lapses
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub until: Option<Until>,
213    /// What is owed, in a sentence
214    pub text: String,
215    /// The words in the source that create it, verbatim
216    pub quote: String,
217    /// Where they are: round and seat, section, or a post or comment id
218    pub quote_location: String,
219    /// Where it was promised, when that is a post
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub thread: Option<PostId>,
222}
223
224impl ObligationData {
225    /// A top-level, undated obligation with no thread; set the rest on it
226    pub fn new(
227        kind: ObligationKind,
228        owed_by: OwedBy,
229        text: impl Into<String>,
230        quote: impl Into<String>,
231        quote_location: impl Into<String>,
232    ) -> Self {
233        Self {
234            agora_obligation: OBLIGATION_VERSION,
235            source: None,
236            kind,
237            owed_by,
238            until: None,
239            text: text.into(),
240            quote: quote.into(),
241            quote_location: quote_location.into(),
242            thread: None,
243        }
244    }
245}
246
247/// What an amendment of an [obligation kind](AmendmentKind::OBLIGATION)
248/// says, tagged by that kind: [`Amendment::update`].
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
251#[cfg_attr(feature = "schemars", schemars(inline))]
252#[serde(tag = "kind", rename_all = "snake_case")]
253pub enum ObligationUpdate {
254    /// The thread where the work is planned. A later plan replaces it
255    Planned { plan: PostId },
256    /// Met
257    Discharged { evidence: Evidence },
258    /// Missed and still owed. The amendment's note says what was missed
259    Lapsed,
260    /// Due at a new time, or undated
261    Rescheduled {
262        #[serde(default)]
263        until: Option<Until>,
264    },
265    /// Released
266    Withdrawn,
267}
268
269impl ObligationUpdate {
270    /// The [`AmendmentKind`] this belongs on
271    pub fn kind(&self) -> AmendmentKind {
272        match self {
273            Self::Planned { .. } => AmendmentKind::Planned,
274            Self::Discharged { .. } => AmendmentKind::Discharged,
275            Self::Lapsed => AmendmentKind::Lapsed,
276            Self::Rescheduled { .. } => AmendmentKind::Rescheduled,
277            Self::Withdrawn => AmendmentKind::Withdrawn,
278        }
279    }
280}
281
282/// What shows an obligation was met
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
285#[cfg_attr(feature = "schemars", schemars(inline))]
286#[serde(tag = "kind", rename_all = "snake_case")]
287pub enum Evidence {
288    /// A governance entry: the decision that heard it, a record
289    Governance { id: GovernanceLogId },
290    /// A pull request, as `owner/name` and its number
291    PullRequest { repo: String, number: u64 },
292    /// A commit, as `owner/name` and its hex sha (7 to 40 digits)
293    Commit { repo: String, sha: String },
294    /// A deploy, on a date, and what went out
295    Deploy {
296        #[serde(with = "full_date")]
297        #[cfg_attr(feature = "schemars", schemars(with = "NaiveDate"))]
298        date: NaiveDate,
299        note: String,
300    },
301}
302
303/// Where an obligation stands, from its amendments: see
304/// [`ObligationState::from_amendments`]
305#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
306#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
307#[cfg_attr(feature = "schemars", schemars(inline))]
308pub struct ObligationState {
309    pub status: ObligationStatus,
310    /// When it is due now: the last reschedule's, or the obligation's own
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub until: Option<Until>,
313    /// The `authority` of the reschedule that set `until`, if one did
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub until_authority: Option<GovernanceLogId>,
316    /// The latest plan
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub plan: Option<PostId>,
319    /// When it last lapsed. Kept after a discharge: a late discharge reads
320    /// as "discharged, lapsed on …"
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub lapsed_at: Option<DateTime<Utc>>,
323}
324
325impl ObligationState {
326    /// The state of `obligation` (its latest version) after `amendments`,
327    /// those naming it with their `created_at`, in chain order. Kinds that
328    /// are not obligation kinds change nothing here.
329    ///
330    /// A discharge or withdrawal closes it, and the last one wins. A lapse
331    /// marks an open obligation lapsed, and a reschedule reopens a lapsed
332    /// one: it has a new due date and has not missed it yet. Neither moves
333    /// a closed one.
334    pub fn from_amendments<'a>(
335        obligation: &ObligationData,
336        amendments: impl IntoIterator<Item = (DateTime<Utc>, &'a Amendment)>,
337    ) -> Self {
338        let mut state = Self {
339            until: obligation.until.clone(),
340            ..Self::default()
341        };
342        for (at, amendment) in amendments {
343            let Some(update) = &amendment.update else {
344                continue;
345            };
346            match update {
347                ObligationUpdate::Planned { plan } => state.plan = Some(*plan),
348                ObligationUpdate::Discharged { .. } => {
349                    state.status = ObligationStatus::Discharged;
350                }
351                ObligationUpdate::Withdrawn => {
352                    state.status = ObligationStatus::Withdrawn;
353                }
354                ObligationUpdate::Lapsed => {
355                    state.lapsed_at = Some(at);
356                    if state.status == ObligationStatus::Open {
357                        state.status = ObligationStatus::Lapsed;
358                    }
359                }
360                ObligationUpdate::Rescheduled { until } => {
361                    state.until = until.clone();
362                    state.until_authority = amendment.authority.clone();
363                    if state.status == ObligationStatus::Lapsed {
364                        state.status = ObligationStatus::Open;
365                    }
366                }
367            }
368        }
369        state
370    }
371}
372
373/// An obligation (`OBL-…`): the signed entry as the log has it now, its
374/// latest version and what its amendments add up to.
375///
376/// The one type for an obligation. What was signed ([`ObligationData`])
377/// and what it adds up to ([`ObligationState`]) are kept apart inside it on
378/// purpose: the first is covered by a signature and never changes but by a
379/// revision; the second is derived and does, and must never be signed by
380/// mistake. Everything else (the to-do list's rows, the calendar's
381/// deadlines, the serving types) is derived from this.
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct Obligation {
384    pub id: GovernanceLogId,
385    pub created_at: DateTime<Utc>,
386    pub chain_seq: u64,
387    /// Its latest version, every revision applied
388    pub data: ObligationData,
389    /// What its amendments add up to
390    pub state: ObligationState,
391}
392
393/// Why a stored obligation entry could not be read as an [`Obligation`]
394#[derive(Debug, thiserror::Error)]
395pub enum ObligationError {
396    #[error("its revisions do not apply: {0}")]
397    Revisions(#[from] super::ReviseError),
398    #[error("it is not an obligation: {0}")]
399    Malformed(#[from] serde_json::Error),
400}
401
402impl Obligation {
403    /// The entry `id` with its stored `data`, after `amendments`: those
404    /// naming it, with their `created_at`, in chain order. Revisions are
405    /// applied to `data` for the latest version, and the state is folded
406    /// from all of them ([`ObligationState::from_amendments`]).
407    pub fn from_log(
408        id: GovernanceLogId,
409        created_at: DateTime<Utc>,
410        chain_seq: u64,
411        data: &serde_json::Value,
412        amendments: &[(DateTime<Utc>, Amendment)],
413    ) -> Result<Self, ObligationError> {
414        let latest = super::latest(
415            data,
416            amendments.iter().filter_map(|(_, a)| a.revision.as_ref()),
417        )?;
418        let data: ObligationData = serde_json::from_value(latest)?;
419        let state = ObligationState::from_amendments(
420            &data,
421            amendments.iter().map(|(at, a)| (*at, a)),
422        );
423        Ok(Self {
424            id,
425            created_at,
426            chain_seq,
427            data,
428            state,
429        })
430    }
431
432    /// The `after_decision` decision its current deadline waits on: what
433    /// a server looks up before [`crate::calendar::ResolvedObligation::new`]
434    pub fn awaited_decision(&self) -> Option<&GovernanceLogId> {
435        match &self.state.until {
436            Some(Until::AfterDecision { decision }) => Some(decision),
437            _ => None,
438        }
439    }
440}
441
442/// What the server knows about a governance entry that the obligation
443/// rules need: see [`Obligation::check_amendment`]
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub struct KnownEntry {
446    pub entry_type: GovernanceLogEntryType,
447    pub chain_seq: u64,
448}
449
450/// An obligation amendment that may not be appended
451#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
452pub enum ObligationRuleError {
453    #[error(transparent)]
454    Shape(#[from] AmendmentError),
455    #[error("{0} is not an entry of the log")]
456    UnknownEntry(GovernanceLogId),
457    #[error(
458        "the obligation is {0}; only a record closes it, and nothing reopens it"
459    )]
460    Closed(ObligationStatus),
461    #[error("an undated obligation never lapses")]
462    Undated,
463    #[error("the obligation has already lapsed; reschedule it first")]
464    AlreadyLapsed,
465    #[error(
466        "`{0}` here needs a Council decision as its `authority`: the \
467         Council owes it, or set its deadline"
468    )]
469    NeedsCouncil(AmendmentKind),
470    #[error(
471        "the authority {authority} is not later than {earlier}: the \
472         obligation, or the Council decision that set its deadline"
473    )]
474    AuthorityNotLater {
475        authority: GovernanceLogId,
476        earlier: GovernanceLogId,
477    },
478    #[error("evidence: {0:?} is not a commit sha (7 to 40 hex digits)")]
479    BadSha(String),
480    #[error("evidence: {0:?} is not a repository as `owner/name`")]
481    BadRepo(String),
482}
483
484impl Obligation {
485    /// Whether `amendment` may be appended to this obligation as it now
486    /// stands. `entry` looks up an entry of the log by id.
487    ///
488    /// The rules (agora `docs/design-obligations.md`):
489    ///
490    /// - `planned`: anyone may record one;
491    /// - `discharged`: the Steward's for Steward- and staff-owed obligations;
492    ///   a Council-owed one needs a Council decision as `authority`;
493    /// - `lapsed`: only a dated, open obligation lapses;
494    /// - `rescheduled`, `withdrawn`: by whoever set the deadline. If a Council
495    ///   decision did (the source, or the authority of the last reschedule),
496    ///   `authority` must be a Council decision. A Council-owed obligation is
497    ///   withdrawn only by the Council;
498    /// - a `revision` never touches [`OBLIGATION_FIXED`]. One that narrows
499    ///   `text` is a partial discharge: if the Council owes the obligation or
500    ///   set its deadline, it needs a Council decision too. Its other fields
501    ///   are clerical.
502    ///
503    /// A Council decision that authorizes anything here is later than the
504    /// obligation and than the decision that set its deadline. Nothing but a
505    /// revision or another non-obligation kind amends a closed obligation, and
506    /// those kinds are otherwise not this method's business.
507    pub fn check_amendment(
508        &self,
509        amendment: &Amendment,
510        entry: impl Fn(&GovernanceLogId) -> Option<KnownEntry>,
511    ) -> Result<(), ObligationRuleError> {
512        let (obligation, id, chain_seq, state) =
513            (&self.data, &self.id, self.chain_seq, &self.state);
514        amendment.validate()?;
515        if let Some(revision) = &amendment.revision
516            && let Some(path) = revision_touches(revision, &OBLIGATION_FIXED)
517        {
518            return Err(
519                AmendmentError::FixedObligationField(path.into()).into()
520            );
521        }
522        let narrows_text = amendment
523            .revision
524            .as_ref()
525            .is_some_and(|r| revision_touches(r, &["/text"]).is_some());
526        if amendment.update.is_none() && !narrows_text {
527            return Ok(());
528        }
529        if amendment.update.is_some() && state.status.is_closed() {
530            return Err(ObligationRuleError::Closed(state.status));
531        }
532        let known = |id: &GovernanceLogId| {
533            entry(id)
534                .ok_or_else(|| ObligationRuleError::UnknownEntry(id.clone()))
535        };
536        let council = |id: &GovernanceLogId| -> Result<
537            Option<KnownEntry>,
538            ObligationRuleError,
539        > {
540            let e = known(id)?;
541            Ok((e.entry_type == GovernanceLogEntryType::CouncilDecision)
542                .then_some(e))
543        };
544        if let Some(authority) = &amendment.authority {
545            known(authority)?;
546        }
547        // The Council decision that set the deadline, if one did
548        let mut setter = None;
549        for id in [obligation.source.as_ref(), state.until_authority.as_ref()]
550            .into_iter()
551            .flatten()
552        {
553            if let Some(e) = council(id)? {
554                setter = Some((id, e.chain_seq));
555            }
556        }
557        // `authority`: a Council decision, later than the obligation and than
558        // the decision that set its deadline
559        let needs_council = || {
560            let authority = amendment
561                .authority
562                .as_ref()
563                .ok_or(ObligationRuleError::NeedsCouncil(amendment.kind))?;
564            let decision = council(authority)?
565                .ok_or(ObligationRuleError::NeedsCouncil(amendment.kind))?;
566            for (earlier, seq) in
567                [Some((id, chain_seq)), setter].into_iter().flatten()
568            {
569                if decision.chain_seq <= seq {
570                    return Err(ObligationRuleError::AuthorityNotLater {
571                        authority: authority.clone(),
572                        earlier: earlier.clone(),
573                    });
574                }
575            }
576            Ok(())
577        };
578        let councils =
579            obligation.owed_by == OwedBy::Council || setter.is_some();
580        let Some(update) = &amendment.update else {
581            // A revision narrowing `text`: a partial discharge
582            return if councils { needs_council() } else { Ok(()) };
583        };
584        match update {
585            ObligationUpdate::Planned { .. } => Ok(()),
586            ObligationUpdate::Lapsed => match (&state.until, state.status) {
587                (None, _) => Err(ObligationRuleError::Undated),
588                (_, ObligationStatus::Lapsed) => {
589                    Err(ObligationRuleError::AlreadyLapsed)
590                }
591                _ => Ok(()),
592            },
593            ObligationUpdate::Discharged { evidence } => {
594                check_evidence(evidence, &known)?;
595                match obligation.owed_by {
596                    OwedBy::Council => needs_council(),
597                    OwedBy::Steward | OwedBy::Staff => Ok(()),
598                }
599            }
600            ObligationUpdate::Rescheduled { .. } => match setter {
601                Some(_) => needs_council(),
602                None => Ok(()),
603            },
604            ObligationUpdate::Withdrawn => match councils {
605                true => needs_council(),
606                false => Ok(()),
607            },
608        }
609    }
610}
611
612fn check_evidence(
613    evidence: &Evidence,
614    known: &impl Fn(&GovernanceLogId) -> Result<KnownEntry, ObligationRuleError>,
615) -> Result<(), ObligationRuleError> {
616    let repo = |repo: &str| {
617        let parts: Vec<&str> = repo.split('/').collect();
618        match parts.as_slice() {
619            [owner, name] if !owner.is_empty() && !name.is_empty() => Ok(()),
620            _ => Err(ObligationRuleError::BadRepo(repo.to_string())),
621        }
622    };
623    match evidence {
624        Evidence::Governance { id } => known(id).map(|_| ()),
625        Evidence::PullRequest { repo: r, .. } => repo(r),
626        Evidence::Commit { repo: r, sha } => {
627            repo(r)?;
628            let hex = (7..=40).contains(&sha.len())
629                && sha.chars().all(|c| c.is_ascii_hexdigit());
630            hex.then_some(())
631                .ok_or_else(|| ObligationRuleError::BadSha(sha.clone()))
632        }
633        Evidence::Deploy { .. } => Ok(()),
634    }
635}
636
637pub mod request;
638
639#[cfg(test)]
640mod tests;