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)]
197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
198#[cfg_attr(feature = "schemars", schemars(inline))]
199pub struct Obligation {
200 pub agora_obligation: u32,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub source: Option<GovernanceLogId>,
206 pub kind: ObligationKind,
207 pub owed_by: OwedBy,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub until: Option<Until>,
211 pub text: String,
213 pub quote: String,
215 pub quote_location: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub thread: Option<PostId>,
220}
221
222impl Obligation {
223 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#[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 Planned { plan: PostId },
254 Discharged { evidence: Evidence },
256 Lapsed,
258 Rescheduled {
260 #[serde(default)]
261 until: Option<Until>,
262 },
263 Withdrawn,
265}
266
267impl ObligationUpdate {
268 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#[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 Governance { id: GovernanceLogId },
288 PullRequest { repo: String, number: u64 },
290 Commit { repo: String, sha: String },
292 Deploy {
294 #[serde(with = "full_date")]
295 #[cfg_attr(feature = "schemars", schemars(with = "NaiveDate"))]
296 date: NaiveDate,
297 note: String,
298 },
299}
300
301#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub until: Option<Until>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub until_authority: Option<GovernanceLogId>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub plan: Option<PostId>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub lapsed_at: Option<DateTime<Utc>>,
321}
322
323impl ObligationState {
324 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#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct ObligationRecord {
375 pub id: GovernanceLogId,
376 pub created_at: DateTime<Utc>,
377 pub chain_seq: u64,
378 pub obligation: Obligation,
380 pub state: ObligationState,
382}
383
384#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub struct KnownEntry {
428 pub entry_type: GovernanceLogEntryType,
429 pub chain_seq: u64,
430}
431
432#[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
466pub 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 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 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 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;