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
142mod 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#[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 pub agora_obligation: u32,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub source: Option<GovernanceLogId>,
192 pub kind: ObligationKind,
193 pub owed_by: OwedBy,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub until: Option<Until>,
197 pub text: String,
199 pub quote: String,
201 pub quote_location: String,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub thread: Option<PostId>,
206}
207
208impl Obligation {
209 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#[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 Planned { plan: PostId },
240 Discharged { evidence: Evidence },
242 Lapsed,
244 Rescheduled {
246 #[serde(default)]
247 until: Option<Until>,
248 },
249 Withdrawn,
251}
252
253impl ObligationUpdate {
254 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#[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 Governance { id: GovernanceLogId },
274 PullRequest { repo: String, number: u64 },
276 Commit { repo: String, sha: String },
278 Deploy {
280 #[serde(with = "full_date")]
281 #[cfg_attr(feature = "schemars", schemars(with = "NaiveDate"))]
282 date: NaiveDate,
283 note: String,
284 },
285}
286
287#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub until: Option<Until>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub until_authority: Option<GovernanceLogId>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub plan: Option<PostId>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub lapsed_at: Option<DateTime<Utc>>,
307}
308
309impl ObligationState {
310 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub struct KnownEntry {
361 pub entry_type: GovernanceLogEntryType,
362 pub chain_seq: u64,
363}
364
365#[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
399pub 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 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 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 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;