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