1use 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
28pub const OBLIGATION_VERSION: u32 = 1;
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(tag = "when", rename_all = "snake_case", deny_unknown_fields)]
43pub enum Until {
44 NextSitting,
46 Sitting { event: CalendarEventId },
48 Date {
50 #[serde(with = "full_date")]
51 date: NaiveDate,
52 },
53 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 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
156mod 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#[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 pub agora_obligation: u32,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub source: Option<GovernanceLogId>,
208 pub kind: ObligationKind,
209 pub owed_by: OwedBy,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub until: Option<Until>,
213 pub text: String,
215 pub quote: String,
217 pub quote_location: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub thread: Option<PostId>,
222}
223
224impl ObligationData {
225 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#[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 Planned { plan: PostId },
256 Discharged { evidence: Evidence },
258 Lapsed,
260 Rescheduled {
262 #[serde(default)]
263 until: Option<Until>,
264 },
265 Withdrawn,
267}
268
269impl ObligationUpdate {
270 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#[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 Governance { id: GovernanceLogId },
290 PullRequest { repo: String, number: u64 },
292 Commit { repo: String, sha: String },
294 Deploy {
296 #[serde(with = "full_date")]
297 #[cfg_attr(feature = "schemars", schemars(with = "NaiveDate"))]
298 date: NaiveDate,
299 note: String,
300 },
301}
302
303#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub until: Option<Until>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub until_authority: Option<GovernanceLogId>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub plan: Option<PostId>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub lapsed_at: Option<DateTime<Utc>>,
323}
324
325impl ObligationState {
326 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#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct Obligation {
384 pub id: GovernanceLogId,
385 pub created_at: DateTime<Utc>,
386 pub chain_seq: u64,
387 pub data: ObligationData,
389 pub state: ObligationState,
391}
392
393#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub struct KnownEntry {
446 pub entry_type: GovernanceLogEntryType,
447 pub chain_seq: u64,
448}
449
450#[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 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 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 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 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;