1use crate::engine::Engine;
20use crate::entity::MetadataValue;
21use crate::workspace::MountCapability;
22
23pub const DEFAULT_DUE_WINDOW: &str = "90d";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DueWindow {
31 Days(u32),
32 Months(u32),
33 Years(u32),
34}
35
36pub fn parse_due_window(input: &str) -> Result<DueWindow, String> {
40 let err = || {
41 format!(
42 "invalid window {input:?}: expected <N>d (days), <N>m (months), or <N>y (years) — \
43 e.g. 90d, 6m, 2y"
44 )
45 };
46 let (num, unit) = input.split_at(input.len().saturating_sub(1));
47 let n: u32 = num.parse().map_err(|_| err())?;
48 match unit {
49 "d" => Ok(DueWindow::Days(n)),
50 "m" => Ok(DueWindow::Months(n)),
51 "y" => Ok(DueWindow::Years(n)),
52 _ => Err(err()),
53 }
54}
55
56fn parse_ymd(s: &str) -> Option<(i64, u32, u32)> {
61 let mut it = s.splitn(3, '-');
62 let y: i64 = it.next()?.parse().ok()?;
63 let m: u32 = it.next()?.parse().ok()?;
64 let d: u32 = it
65 .next()?
66 .get(..2)
67 .unwrap_or(it.next().unwrap_or(""))
68 .parse()
69 .ok()?;
70 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
71 return None;
72 }
73 Some((y, m, d))
74}
75
76fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
77 let y = if m <= 2 { y - 1 } else { y };
78 let era = if y >= 0 { y } else { y - 399 } / 400;
79 let yoe = y - era * 400;
80 let mp = ((m + 9) % 12) as i64;
81 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
82 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
83 era * 146097 + doe - 719468
84}
85
86fn civil_from_days(z: i64) -> (i64, u32, u32) {
87 let z = z + 719468;
88 let era = if z >= 0 { z } else { z - 146096 } / 146097;
89 let doe = z - era * 146097;
90 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
91 let y = yoe + era * 400;
92 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
93 let mp = (5 * doy + 2) / 153;
94 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
95 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
96 (if m <= 2 { y + 1 } else { y }, m, d)
97}
98
99pub fn civil_from_days_pub(days_since_epoch: i64) -> (i64, u32, u32) {
103 civil_from_days(days_since_epoch)
104}
105
106fn last_day_of_month(y: i64, m: u32) -> u32 {
107 match m {
108 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
109 4 | 6 | 9 | 11 => 30,
110 _ => {
111 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
112 29
113 } else {
114 28
115 }
116 }
117 }
118}
119
120fn window_end(today: (i64, u32, u32), window: &DueWindow) -> String {
123 let (y, m, d) = today;
124 let (ey, em, ed) = match window {
125 DueWindow::Days(n) => civil_from_days(days_from_civil(y, m, d) + *n as i64),
126 DueWindow::Months(n) => {
127 let total = (y * 12 + (m as i64 - 1)) + *n as i64;
128 let ny = total.div_euclid(12);
129 let nm = (total.rem_euclid(12) + 1) as u32;
130 (ny, nm, d.min(last_day_of_month(ny, nm)))
131 }
132 DueWindow::Years(n) => {
133 let ny = y + *n as i64;
134 (ny, m, d.min(last_day_of_month(ny, m)))
135 }
136 };
137 format!("{ey:04}-{em:02}-{ed:02}")
138}
139
140struct DueEntry {
142 mem: String,
143 third_party: bool,
144 id: String,
145 title: String,
146 date: String,
147 status: String,
148 lead: Option<(String, String)>,
149 overdue: bool,
150}
151
152impl Engine {
153 pub fn render_due_brief(
161 &self,
162 today: &str,
163 window: &DueWindow,
164 mem_filter: Option<&str>,
165 ) -> Result<String, String> {
166 let today_ymd = parse_ymd(today)
167 .ok_or_else(|| format!("invalid date {today:?}: expected YYYY-MM-DD"))?;
168 let today_iso = format!("{:04}-{:02}-{:02}", today_ymd.0, today_ymd.1, today_ymd.2);
169 let end = window_end(today_ymd, window);
170
171 let mut entries: Vec<DueEntry> = Vec::new();
175 let mut declaring_mems: Vec<String> = Vec::new();
176 for mounted in &self.mounts {
177 let mem = mounted.mount.mem.as_str();
178 if let Some(filter) = mem_filter
179 && mem != filter
180 {
181 continue;
182 }
183 let Some(schema) = self.schemas.get(mem) else {
184 continue;
185 };
186 let declares = schema.types.values().any(|t| t.due.is_some());
187 if !declares {
188 continue;
189 }
190 declaring_mems.push(mem.to_string());
191 let third_party = mounted.mount.capability == MountCapability::ReadOnly;
192 for entity in self.store.all_entities() {
193 if entity.mem != mem || entity.stub {
194 continue;
195 }
196 let Some(td) = schema.types.get(&entity.entity_type) else {
197 continue;
198 };
199 let Some(due) = &td.due else { continue };
200 let status = match entity.metadata.get(&due.status_field) {
201 Some(MetadataValue::String(s)) => s.clone(),
202 _ => continue,
203 };
204 if !due.open_values.contains(&status) {
205 continue;
206 }
207 let date = match entity.metadata.get(&due.date_field) {
208 Some(MetadataValue::String(s)) => s.clone(),
209 _ => continue,
210 };
211 let date_part = date.get(..10).unwrap_or(&date).to_string();
215 if parse_ymd(&date_part).is_none() {
216 continue;
217 }
218 if date_part.as_str() > end.as_str() {
219 continue;
220 }
221 let lead = due.lead_section.as_ref().and_then(|key| {
222 entity
223 .sections
224 .get(key)
225 .filter(|body| !body.trim().is_empty())
226 .map(|body| (key.clone(), body.trim().to_string()))
227 });
228 entries.push(DueEntry {
229 mem: mem.to_string(),
230 third_party,
231 id: entity.id.to_string(),
232 title: entity.title.clone(),
233 date: date_part.clone(),
234 status,
235 lead,
236 overdue: date_part.as_str() < today_iso.as_str(),
237 });
238 }
239 }
240
241 entries.sort_by(|a, b| {
244 b.overdue
245 .cmp(&a.overdue)
246 .then_with(|| a.date.cmp(&b.date))
247 .then_with(|| a.id.cmp(&b.id))
248 });
249
250 let window_label = match window {
251 DueWindow::Days(n) => format!("{n}d"),
252 DueWindow::Months(n) => format!("{n}m"),
253 DueWindow::Years(n) => format!("{n}y"),
254 };
255 let mut out = String::new();
256 out.push_str(&format!(
257 "# Due brief — {today_iso}, window {window_label} (through {end})\n\n"
258 ));
259 if declaring_mems.is_empty() {
260 out.push_str(
261 "No mounted mem's schema declares a due axis (`due:` on a type). \
262 Nothing to render.\n",
263 );
264 return Ok(out);
265 }
266 declaring_mems.sort();
267 declaring_mems.dedup();
268 out.push_str(&format!("Mems: {}\n\n", declaring_mems.join(", ")));
269 if entries.is_empty() {
270 out.push_str("Nothing open is due in this window.\n");
271 return Ok(out);
272 }
273 let overdue_count = entries.iter().filter(|e| e.overdue).count();
274 out.push_str(&format!(
275 "{} entr{} ({} overdue)\n\n",
276 entries.len(),
277 if entries.len() == 1 { "y" } else { "ies" },
278 overdue_count
279 ));
280 for e in &entries {
281 let marker = if e.overdue { " **OVERDUE**" } else { "" };
282 let origin = if e.third_party { " [third-party]" } else { "" };
283 out.push_str(&format!(
284 "- `{}` — {} — **{}**{} (status: {}, mem: {}{})\n",
285 e.id, e.title, e.date, marker, e.status, e.mem, origin
286 ));
287 if let Some((key, body)) = &e.lead {
288 if e.third_party {
289 out.push_str(&format!(" - {key} (third-party, quoted):\n"));
292 for line in body.lines() {
293 out.push_str(&format!(" > {line}\n"));
294 }
295 } else {
296 out.push_str(&format!(" - {key}:\n"));
297 for line in body.lines() {
298 out.push_str(&format!(" {line}\n"));
299 }
300 }
301 }
302 }
303 Ok(out)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use std::path::Path;
311 use tempfile::TempDir;
312
313 use crate::backend::MemBackend;
314 use crate::storage::FilesystemMemWriter;
315 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
316
317 fn frist_schema_dir(root: &Path) {
318 let d = root.join("schemas").join("frist-schema");
319 std::fs::create_dir_all(d.join("types")).unwrap();
320 std::fs::write(
321 d.join("schema.yaml"),
322 "name: frist\nversion: 0.1.0\ndescription: t\nwhen_to_use: due tests\ntypes:\n - obligation\n - note\nrelationships:\n mode: strict\n definitions:\n - name: PART_OF\n description: h\n default_weight: 3.0\n - name: _default\n description: d\n default_weight: 1.0\ncommunity:\n resolution: 1.0\n seed: 42\n",
323 )
324 .unwrap();
325 std::fs::write(
326 d.join("types").join("obligation.yaml"),
327 "name: obligation\ndescription: dated obligation\nwhen_to_use: due tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\n - key: vorlauf\n heading: Vorlauf\n search_weight: 1.0\n write_rules: []\nmetadata_fields:\n - key: faellig_am\n description: due date\n field_type: date\n required: true\n - key: status\n description: state\n field_type: string\n required: true\n default_value: offen\n enum_values: [offen, in_arbeit, erledigt]\ndue:\n date_field: faellig_am\n status_field: status\n open_values: [offen, in_arbeit]\n lead_section: vorlauf\ntitle_weight: 100.0\ntext_fields: [body]\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields: [title, body, status, faellig_am]\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
328 )
329 .unwrap();
330 std::fs::write(
331 d.join("types").join("note.yaml"),
332 "name: note\ndescription: undeclared type\nwhen_to_use: due tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields: [body]\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields: [title, body]\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
333 )
334 .unwrap();
335 }
336
337 fn obligation_md(title: &str, date: &str, status: &str, vorlauf: Option<&str>) -> String {
338 let lead = vorlauf
339 .map(|v| format!("\n## Vorlauf\n\n{v}\n"))
340 .unwrap_or_default();
341 format!(
342 "---\ntype: obligation\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nfaellig_am: {date}\nstatus: {status}\n---\n# {title}\n\n## Body\n\nB.\n{lead}"
343 )
344 }
345
346 fn mount_with(mem: &str, path: std::path::PathBuf, capability: MountCapability) -> Mount {
347 Mount {
348 mem: mem.to_string(),
349 schema: Some("frist@0.1.0".parse().unwrap()),
350 storage: MountStorage::Folder { path },
351 capability,
352 lifecycle: MountLifecycle::Eager,
353 cross_linkable: true,
354 migration_target: None,
355 }
356 }
357
358 #[test]
363 fn due_brief_membership_order_and_labels() {
364 let tmp = TempDir::new().unwrap();
365 frist_schema_dir(tmp.path());
366 let own = tmp.path().join("own");
367 let foreign = tmp.path().join("foreign");
368 std::fs::create_dir_all(own.join(".memstead")).unwrap();
369 std::fs::create_dir_all(foreign.join(".memstead")).unwrap();
370 for dir in [&own, &foreign] {
373 std::fs::write(
374 dir.join(".memstead/config.json"),
375 "{\n \"version\": \"1.0.0\",\n \"description\": \"due fixture\",\n \"schema\": \"frist@0.1.0\"\n}",
376 )
377 .unwrap();
378 }
379 std::fs::write(
380 own.join("wartung.md"),
381 obligation_md(
382 "Wartung",
383 "2026-09-01",
384 "offen",
385 Some("Handwerker beauftragen"),
386 ),
387 )
388 .unwrap();
389 std::fs::write(
390 own.join("frist-alt.md"),
391 obligation_md("Frist Alt", "2026-07-01", "in_arbeit", None),
392 )
393 .unwrap();
394 std::fs::write(
395 own.join("weit-weg.md"),
396 obligation_md("Weit Weg", "2027-06-01", "offen", None),
397 )
398 .unwrap();
399 std::fs::write(
400 own.join("erledigt.md"),
401 obligation_md("Erledigt", "2026-08-20", "erledigt", None),
402 )
403 .unwrap();
404 std::fs::write(
405 own.join("plain-note.md"),
406 "---\ntype: note\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Plain Note\n\n## Body\n\nB.\n",
407 )
408 .unwrap();
409 std::fs::write(
411 own.join("b-gleich.md"),
412 obligation_md("B Gleich", "2026-09-10", "offen", None),
413 )
414 .unwrap();
415 std::fs::write(
416 own.join("a-gleich.md"),
417 obligation_md("A Gleich", "2026-09-10", "offen", None),
418 )
419 .unwrap();
420 std::fs::write(
422 foreign.join("fremd-frist.md"),
423 obligation_md(
424 "Fremd Frist",
425 "2026-06-15",
426 "offen",
427 Some("Nur zur Kenntnis"),
428 ),
429 )
430 .unwrap();
431
432 let own_writer = FilesystemMemWriter::new(own.clone());
433 let foreign_writer = FilesystemMemWriter::new(foreign.clone());
434 let engine = Engine::from_mounts_with_schemas_dir(
435 vec![
436 (
437 mount_with("own", own, MountCapability::Write),
438 Box::new(own_writer) as Box<dyn MemBackend>,
439 ),
440 (
441 mount_with("foreign", foreign, MountCapability::ReadOnly),
442 Box::new(foreign_writer) as Box<dyn MemBackend>,
443 ),
444 ],
445 Some(&tmp.path().join("schemas")),
446 )
447 .unwrap();
448
449 let brief = engine
450 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
451 .unwrap();
452
453 for present in [
456 "own--wartung",
457 "own--frist-alt",
458 "foreign--fremd-frist",
459 "own--a-gleich",
460 "own--b-gleich",
461 ] {
462 assert!(brief.contains(present), "{present} missing:\n{brief}");
463 }
464 for absent in ["weit-weg", "erledigt", "plain-note"] {
465 assert!(!brief.contains(absent), "{absent} leaked:\n{brief}");
466 }
467
468 let pos = |needle: &str| brief.find(needle).unwrap();
471 assert!(
472 pos("foreign--fremd-frist") < pos("own--frist-alt"),
473 "{brief}"
474 );
475 assert!(pos("own--frist-alt") < pos("own--wartung"), "{brief}");
476 assert!(pos("own--wartung") < pos("own--a-gleich"), "{brief}");
477 assert!(pos("own--a-gleich") < pos("own--b-gleich"), "{brief}");
478
479 assert!(brief.contains("**2026-07-01** **OVERDUE**"), "{brief}");
481 assert!(brief.contains("Handwerker beauftragen"), "{brief}");
482 assert!(brief.contains("status: offen"), "{brief}");
483
484 assert!(brief.contains("[third-party]"), "{brief}");
486 assert!(brief.contains("vorlauf (third-party, quoted):"), "{brief}");
487 assert!(brief.contains("> Nur zur Kenntnis"), "{brief}");
488 assert!(brief.contains(" Handwerker beauftragen"), "{brief}");
490
491 let again = engine
493 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
494 .unwrap();
495 assert_eq!(brief, again);
496
497 let own_only = engine
499 .render_due_brief("2026-08-10", &DueWindow::Days(90), Some("own"))
500 .unwrap();
501 assert!(!own_only.contains("foreign--fremd-frist"), "{own_only}");
502 assert!(own_only.contains("own--wartung"), "{own_only}");
503 }
504
505 #[test]
507 fn window_parse_and_calendar_math() {
508 assert_eq!(parse_due_window("90d").unwrap(), DueWindow::Days(90));
509 assert_eq!(parse_due_window("6m").unwrap(), DueWindow::Months(6));
510 assert_eq!(parse_due_window("2y").unwrap(), DueWindow::Years(2));
511 for bad in ["", "d", "90", "90w", "-1d", "1.5m"] {
512 let err = parse_due_window(bad).unwrap_err();
513 assert!(err.contains("<N>d"), "error names accepted forms: {err}");
514 }
515 assert_eq!(
517 window_end((2026, 1, 31), &DueWindow::Months(1)),
518 "2026-02-28"
519 );
520 assert_eq!(
521 window_end((2024, 1, 31), &DueWindow::Months(1)),
522 "2024-02-29"
523 );
524 assert_eq!(
525 window_end((2026, 8, 10), &DueWindow::Days(90)),
526 "2026-11-08"
527 );
528 assert_eq!(
529 window_end((2026, 11, 15), &DueWindow::Months(2)),
530 "2027-01-15"
531 );
532 assert_eq!(
533 window_end((2024, 2, 29), &DueWindow::Years(1)),
534 "2025-02-28"
535 );
536 }
537
538 #[test]
541 fn no_declaring_schema_renders_honest_empty_brief() {
542 let tmp = TempDir::new().unwrap();
543 let mem_dir = tmp.path().to_path_buf();
544 let writer = FilesystemMemWriter::new(mem_dir.clone());
545 let engine = Engine::from_mounts(vec![(
546 crate::engine::test_helpers::folder_mount("specs", mem_dir),
547 Box::new(writer) as Box<dyn MemBackend>,
548 )])
549 .unwrap();
550 let brief = engine
551 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
552 .unwrap();
553 assert!(
554 brief.contains("No mounted mem's schema declares a due axis"),
555 "{brief}"
556 );
557 }
558}
559
560#[cfg(test)]
561mod obligation_builtin_tests {
562 use super::*;
563 use tempfile::TempDir;
564
565 use crate::backend::MemBackend;
566 use crate::engine::test_helpers::{cli_actor, empty_create_args};
567 use crate::storage::FilesystemMemWriter;
568 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
569
570 fn obligation_mount(mem: &str, path: std::path::PathBuf) -> Mount {
571 Mount {
572 mem: mem.to_string(),
573 schema: Some("obligation@0.1.0".parse().unwrap()),
574 storage: MountStorage::Folder { path },
575 capability: MountCapability::Write,
576 lifecycle: MountLifecycle::Eager,
577 cross_linkable: true,
578 migration_target: None,
579 }
580 }
581
582 fn obligation_engine(tmp: &TempDir) -> Engine {
583 let mem_dir = tmp.path().join("duties");
584 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
585 std::fs::write(
586 mem_dir.join(".memstead/config.json"),
587 "{\n \"version\": \"1.0.0\",\n \"description\": \"obligation fixture\",\n \"schema\": \"obligation@0.1.0\"\n}",
588 )
589 .unwrap();
590 let writer = FilesystemMemWriter::new(mem_dir.clone());
591 Engine::from_mounts(vec![(
592 obligation_mount("duties", mem_dir),
593 Box::new(writer) as Box<dyn MemBackend>,
594 )])
595 .unwrap()
596 }
597
598 fn obligation_args(
599 mem: &str,
600 title: &str,
601 due_date: &str,
602 status: &str,
603 ) -> crate::engine::CreateEntityArgs {
604 let mut args = empty_create_args(mem, title);
605 args.entity_type = "obligation".to_string();
606 args.sections = indexmap::IndexMap::from_iter([
607 ("duty".to_string(), "Who owes what.".to_string()),
608 ("consequence".to_string(), "What forfeits.".to_string()),
609 ]);
610 args.metadata = indexmap::IndexMap::from_iter([
611 ("due_date".to_string(), due_date.to_string()),
612 ("status".to_string(), status.to_string()),
613 ]);
614 args.relations = vec![crate::ops::RelateArg {
615 to: crate::entity::EntityId::new(mem, "some-subject"),
616 rel_type: "CONCERNS".to_string(),
617 description: None,
618 }];
619 args
620 }
621
622 #[test]
627 fn shipped_obligation_schema_accepts_refuses_and_renders_due() {
628 let tmp = TempDir::new().unwrap();
629 let mut engine = obligation_engine(&tmp);
630 let (actor, client) = cli_actor();
631
632 engine
634 .create_entity(
635 obligation_args(
636 "duties",
637 "Renew Registration No. 4711 & File Proof",
638 "2026-09-01",
639 "open",
640 ),
641 actor,
642 Some(&client),
643 None,
644 )
645 .expect("conformant obligation lands");
646 engine
647 .create_entity(
648 obligation_args("duties", "Overdue Filing", "2026-07-01", "in_progress"),
649 actor,
650 Some(&client),
651 None,
652 )
653 .expect("second obligation lands");
654 engine
655 .create_entity(
656 obligation_args("duties", "Done Duty", "2026-08-01", "done"),
657 actor,
658 Some(&client),
659 None,
660 )
661 .map(|_| ())
662 .unwrap_err(); let err = engine
667 .create_entity(
668 obligation_args("duties", "Bad Status", "2026-09-01", "unknown"),
669 actor,
670 Some(&client),
671 None,
672 )
673 .unwrap_err();
674 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "{err}");
675
676 let brief = engine
678 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
679 .unwrap();
680 let pos = |n: &str| brief.find(n).unwrap_or(usize::MAX);
681 assert!(brief.contains("duties--overdue-filing"), "{brief}");
682 assert!(
683 brief.contains("duties--renew-registration-no-4711-file-proof"),
684 "{brief}"
685 );
686 assert!(pos("duties--overdue-filing") < pos("duties--renew-registration-no-4711"));
687 assert!(brief.contains("**OVERDUE**"), "{brief}");
688 }
689
690 #[test]
694 fn shipped_constraints_refuse_like_the_field_schema() {
695 let tmp = TempDir::new().unwrap();
696 let mut engine = obligation_engine(&tmp);
697 let (actor, client) = cli_actor();
698
699 let err = engine
701 .create_entity(
702 obligation_args("duties", "Done Without Date", "2026-08-01", "done"),
703 actor,
704 Some(&client),
705 None,
706 )
707 .unwrap_err();
708 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED", "{err}");
709 assert!(err.to_string().contains("completed_on"), "{err}");
710
711 let mut args = obligation_args("duties", "Critical Unowned", "2026-09-01", "open");
713 args.metadata
714 .insert("criticality".to_string(), "high".to_string());
715 let err = engine
716 .create_entity(args, actor, Some(&client), None)
717 .unwrap_err();
718 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED", "{err}");
719 assert!(err.to_string().contains("responsible"), "{err}");
720
721 let mut args = obligation_args("duties", "About Nothing", "2026-09-01", "open");
723 args.relations.clear();
724 let err = engine
725 .create_entity(args, actor, Some(&client), None)
726 .unwrap_err();
727 assert!(
728 err.to_string().contains("CONCERNS") || err.code().contains("REQUIRED_OUTGOING"),
729 "required_outgoing must refuse: {err} ({})",
730 err.code()
731 );
732
733 let mut args = obligation_args("duties", "Done Properly", "2026-08-01", "done");
736 args.metadata
737 .insert("completed_on".to_string(), "2026-08-01".to_string());
738 engine
739 .create_entity(args, actor, Some(&client), None)
740 .expect("done with completed_on lands");
741 let mut args = obligation_args("duties", "Critical Owned", "2026-09-01", "open");
742 args.metadata
743 .insert("criticality".to_string(), "high".to_string());
744 args.metadata
745 .insert("responsible".to_string(), "Operations".to_string());
746 engine
747 .create_entity(args, actor, Some(&client), None)
748 .expect("high with responsible lands");
749 }
750}