1use std::collections::{BTreeMap, BTreeSet};
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use serde_json::json;
42
43use crate::intent::{
44 IntentPreflight, IntentPreflightCause, IntentPreflightIntent, IntentPreflightIssue,
45 IntentPreflightVerdict, IntentStage, Intents, RepositoryEvidence,
46};
47use crate::intent_review::{CritiqueRecord, GapRecord};
48use crate::plan::{overlap, Plan, PlanIssue};
49
50use crate::index::Index;
51use crate::policy::{sha256_hex, BindingMode, IntentPolicy};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum BindingStanding {
64 Bound,
67 Maintenance,
70 Exempt,
72 Refused,
74}
75
76impl BindingStanding {
77 pub fn as_str(self) -> &'static str {
84 match self {
85 BindingStanding::Bound => "bound",
86 BindingStanding::Maintenance => "maintenance",
87 BindingStanding::Exempt => "exempt",
88 BindingStanding::Refused => "refused",
89 }
90 }
91
92 pub fn refuses(self) -> bool {
99 self == BindingStanding::Refused
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
117#[serde(rename_all = "snake_case")]
118pub enum BindingCause {
119 UnknownIssue,
121 NoIssueCoversPaths,
123 IssueServesNothing,
125 ServesAnotherIntent,
127 ServesUnknownCriterion,
129 IntentNotCritiqued,
131 OpenBlockingFinding,
133 NothingNamed,
135 UnknownIntent,
137 IntentRetired,
139 IntentHasNoOpenWork,
141 IssueOutsideIntent,
143 AmbiguousIntent,
145 UnknownExemption,
147 ExemptionWithoutReason,
149 ExemptionNamesWork,
151}
152
153impl From<IntentPreflightCause> for BindingCause {
154 fn from(c: IntentPreflightCause) -> Self {
155 match c {
156 IntentPreflightCause::UnknownIssue => BindingCause::UnknownIssue,
157 IntentPreflightCause::NoIssueCoversPaths => BindingCause::NoIssueCoversPaths,
158 IntentPreflightCause::IssueServesNothing => BindingCause::IssueServesNothing,
159 IntentPreflightCause::ServesAnotherIntent => BindingCause::ServesAnotherIntent,
160 IntentPreflightCause::ServesUnknownCriterion => BindingCause::ServesUnknownCriterion,
161 IntentPreflightCause::IntentNotCritiqued => BindingCause::IntentNotCritiqued,
162 IntentPreflightCause::OpenBlockingFinding => BindingCause::OpenBlockingFinding,
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct BindingRefusal {
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub issue: Option<String>,
180 pub cause: BindingCause,
182 pub message: String,
184}
185
186impl BindingRefusal {
187 pub fn new(issue: Option<&str>, cause: BindingCause, message: String) -> Self {
195 BindingRefusal {
196 issue: issue.map(str::to_string),
197 cause,
198 message,
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
216pub struct BindingRequest {
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub issue: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub intent: Option<String>,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub paths: Vec<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub exemption: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub because: Option<String>,
232}
233
234impl BindingRequest {
235 pub fn trimmed(self) -> Self {
244 let clean = |v: Option<String>| v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
245 BindingRequest {
246 issue: clean(self.issue),
247 intent: clean(self.intent),
248 paths: self
249 .paths
250 .iter()
251 .map(|p| p.trim().to_string())
252 .filter(|p| !p.is_empty())
253 .collect(),
254 exemption: clean(self.exemption),
255 because: clean(self.because),
256 }
257 }
258
259 pub fn names_nothing(&self) -> bool {
266 self.issue.is_none()
267 && self.intent.is_none()
268 && self.paths.is_empty()
269 && self.exemption.is_none()
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286pub struct BindingExemption {
287 pub class: String,
289 #[serde(default, skip_serializing_if = "String::is_empty")]
291 pub description: String,
292 pub because: String,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
321pub struct IntentBinding {
322 pub standing: BindingStanding,
324 pub named: BindingRequest,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub exemption: Option<BindingExemption>,
329 pub issues: Vec<IntentPreflightIssue>,
331 pub intents: Vec<IntentPreflightIntent>,
334 pub governance: Vec<String>,
336 pub plan_revision: String,
338 pub evidence_standing: String,
341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub notes: Vec<String>,
344 pub refusals: Vec<BindingRefusal>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub refusal: Option<String>,
349}
350
351impl IntentBinding {
352 fn refused(named: BindingRequest, refusals: Vec<BindingRefusal>) -> Self {
353 let refusal = Some(
354 refusals
355 .iter()
356 .map(|r| r.message.as_str())
357 .collect::<Vec<_>>()
358 .join("; "),
359 );
360 IntentBinding {
361 standing: BindingStanding::Refused,
362 named,
363 exemption: None,
364 issues: Vec::new(),
365 intents: Vec::new(),
366 governance: Vec::new(),
367 plan_revision: String::new(),
368 evidence_standing: String::new(),
369 notes: Vec::new(),
370 refusals,
371 refusal,
372 }
373 }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
387pub struct BindingDrift {
388 pub plan_changed: bool,
390 pub evidence_changed: bool,
392}
393
394impl BindingDrift {
395 pub fn between(pinned_plan: &str, pinned_evidence: &str, plan: &str, evidence: &str) -> Self {
402 BindingDrift {
403 plan_changed: pinned_plan != plan,
404 evidence_changed: pinned_evidence != evidence,
405 }
406 }
407
408 pub fn as_str(self) -> &'static str {
416 match (self.plan_changed, self.evidence_changed) {
417 (true, _) => "plan_changed",
418 (false, true) => "evidence_moved",
419 (false, false) => "unchanged",
420 }
421 }
422}
423
424pub fn bind(
461 intents: &Intents,
462 plan: &Plan,
463 gaps: &[GapRecord],
464 critiques: &[CritiqueRecord],
465 policy: &IntentPolicy,
466 request: BindingRequest,
467) -> IntentBinding {
468 let named = request.trimmed();
469 if named.names_nothing() {
470 return IntentBinding::refused(
471 named,
472 vec![BindingRefusal::new(
473 None,
474 BindingCause::NothingNamed,
475 "nothing was named: give the issue the work executes, the intent it serves, \
476 the paths it will touch, or an exemption the policy declares with its reason"
477 .into(),
478 )],
479 );
480 }
481 if let Some(class) = named.exemption.clone() {
482 return exempt(policy, named, &class);
483 }
484
485 let mut early: Vec<BindingRefusal> = Vec::new();
486 let mut by_paths = false;
487 let issues: Vec<&PlanIssue> = match (named.issue.as_deref(), named.intent.as_deref()) {
488 (Some(id), wanted) => match plan.issue(id) {
489 Some(i) => {
490 if let Some(intent) = wanted {
491 judge_named_intent(intents, intent, &mut early);
492 if early.is_empty() && !serves_intent(i, intent) {
493 early.push(BindingRefusal::new(
494 Some(id),
495 BindingCause::IssueOutsideIntent,
496 format!("{id} serves no criterion of intent {intent}"),
497 ));
498 }
499 }
500 vec![i]
501 }
502 None => {
503 early.push(BindingRefusal::new(
504 Some(id),
505 BindingCause::UnknownIssue,
506 format!("`{id}` is not an issue under .ai/repo/project/issues/"),
507 ));
508 Vec::new()
509 }
510 },
511 (None, Some(intent)) => {
512 judge_named_intent(intents, intent, &mut early);
513 let open: Vec<&PlanIssue> = plan
514 .issues
515 .iter()
516 .filter(|i| is_open(i) && serves_intent(i, intent))
517 .collect();
518 if early.is_empty() && open.is_empty() {
519 early.push(BindingRefusal::new(
520 None,
521 BindingCause::IntentHasNoOpenWork,
522 format!(
523 "no open issue serves intent {intent}; work under it needs an issue \
524 that names the criterion it serves"
525 ),
526 ));
527 }
528 open
529 }
530 (None, None) => {
531 by_paths = true;
532 let covering: Vec<&PlanIssue> = plan
533 .issues
534 .iter()
535 .filter(|i| is_open(i) && covers(i, &named.paths))
536 .collect();
537 if covering.is_empty() {
538 early.push(BindingRefusal::new(
539 None,
540 BindingCause::NoIssueCoversPaths,
541 format!(
542 "no open issue's scope covers {}; work that names no issue serves no \
543 declared intent",
544 named.paths.join(", ")
545 ),
546 ));
547 }
548 covering
549 }
550 };
551 if !early.is_empty() {
552 return IntentBinding::refused(named, early);
553 }
554
555 let pre = intents.preflight_issues(plan, gaps, critiques, &issues);
556 let mut refusals: Vec<BindingRefusal> = pre
557 .refusals
558 .iter()
559 .map(|r| BindingRefusal::new(r.issue.as_deref(), r.cause.into(), r.message.clone()))
560 .collect();
561 if by_paths && pre.intents.len() > 1 {
562 refusals.push(BindingRefusal::new(
563 None,
564 BindingCause::AmbiguousIntent,
565 format!(
566 "the paths reach {} intents ({}); name the issue the work executes",
567 pre.intents.len(),
568 pre.intents
569 .iter()
570 .map(|i| i.id.as_str())
571 .collect::<Vec<_>>()
572 .join(", ")
573 ),
574 ));
575 }
576
577 let mut notes = Vec::new();
578 if !by_paths && !named.paths.is_empty() {
579 let outside: Vec<&str> = named
580 .paths
581 .iter()
582 .filter(|p| !issues.iter().any(|i| covers(i, std::slice::from_ref(p))))
583 .map(String::as_str)
584 .collect();
585 if !outside.is_empty() {
586 notes.push(format!(
587 "{} lie outside the scope of {}",
588 outside.join(", "),
589 issues
590 .iter()
591 .map(|i| i.id.as_str())
592 .collect::<Vec<_>>()
593 .join(", ")
594 ));
595 }
596 }
597
598 let standing = if !refusals.is_empty() {
599 BindingStanding::Refused
600 } else if pre.verdict == IntentPreflightVerdict::Serves {
601 BindingStanding::Bound
602 } else {
603 BindingStanding::Maintenance
604 };
605 let refusal = (!refusals.is_empty()).then(|| {
606 refusals
607 .iter()
608 .map(|r| r.message.as_str())
609 .collect::<Vec<_>>()
610 .join("; ")
611 });
612 let plan_revision = plan_revision(&pre, &issues, critiques);
613 let evidence_standing = evidence_standing(&pre);
614 IntentBinding {
615 standing,
616 named,
617 exemption: None,
618 issues: pre.issues,
619 intents: pre.intents,
620 governance: pre.governance,
621 plan_revision,
622 evidence_standing,
623 notes,
624 refusals,
625 refusal,
626 }
627}
628
629pub fn start_refusal(index: &Index, plan: &Plan, issue: &str) -> Option<String> {
649 let root = std::path::PathBuf::from(&index.repository.root);
650 let policy = crate::repository::Repository::open(&root)
651 .ok()
652 .and_then(|repo| crate::policy::LoadedPolicy::load(&repo).ok())
653 .map(|loaded| loaded.policy.intent)
654 .unwrap_or_default();
655 if policy.binding != BindingMode::Required {
656 return None;
657 }
658 let evidence = match RepositoryEvidence::load(index) {
659 Ok(e) => e,
660 Err(e) => {
661 return Some(format!(
662 "{issue} may not start: the evidence ledger could not be read ({e}); the \
663 binding is unknown, and unknown is never a pass"
664 ))
665 }
666 };
667 let intents = Intents::build(index, plan, &evidence);
668 let binding = bind(
669 &intents,
670 plan,
671 &GapRecord::all(index),
672 &CritiqueRecord::all(index),
673 &policy,
674 BindingRequest {
675 issue: Some(issue.to_string()),
676 ..Default::default()
677 },
678 );
679 binding
680 .refusal
681 .filter(|_| binding.standing.refuses())
682 .map(|why| format!("{issue} may not start: {why}"))
683}
684
685fn exempt(policy: &IntentPolicy, named: BindingRequest, class: &str) -> IntentBinding {
686 let mut refusals = Vec::new();
687 if named.issue.is_some() || named.intent.is_some() {
688 refusals.push(BindingRefusal::new(
689 named.issue.as_deref(),
690 BindingCause::ExemptionNamesWork,
691 format!(
692 "exemption {class} was given beside named work; a task serves what it names \
693 or is exempt, never both"
694 ),
695 ));
696 }
697 let declared = policy.exemption(class);
698 if declared.is_none() {
699 let known: Vec<&str> = policy.exemptions.iter().map(|c| c.id.as_str()).collect();
700 refusals.push(BindingRefusal::new(
701 None,
702 BindingCause::UnknownExemption,
703 if known.is_empty() {
704 format!(
705 "`{class}` is not an exemption: the policy declares none under \
706 intent.exemptions"
707 )
708 } else {
709 format!(
710 "`{class}` is not an exemption the policy declares ({})",
711 known.join(", ")
712 )
713 },
714 ));
715 }
716 if named.because.is_none() {
717 refusals.push(BindingRefusal::new(
718 None,
719 BindingCause::ExemptionWithoutReason,
720 format!("exemption {class} needs a reason: say why this work serves no intent"),
721 ));
722 }
723 match (declared, named.because.clone()) {
724 (Some(c), Some(because)) if refusals.is_empty() => IntentBinding {
725 standing: BindingStanding::Exempt,
726 exemption: Some(BindingExemption {
727 class: c.id.clone(),
728 description: c.description.clone(),
729 because,
730 }),
731 named,
732 issues: Vec::new(),
733 intents: Vec::new(),
734 governance: Vec::new(),
735 plan_revision: String::new(),
736 evidence_standing: String::new(),
737 notes: Vec::new(),
738 refusals: Vec::new(),
739 refusal: None,
740 },
741 _ => IntentBinding::refused(named, refusals),
742 }
743}
744
745fn judge_named_intent(intents: &Intents, id: &str, refusals: &mut Vec<BindingRefusal>) {
746 match intents.intent(id) {
747 None => refusals.push(BindingRefusal::new(
748 None,
749 BindingCause::UnknownIntent,
750 format!("`{id}` is not an intent under .ai/repo/project/intents/"),
751 )),
752 Some(view) if matches!(view.stage, IntentStage::Cancelled | IntentStage::Superseded) => {
753 refusals.push(BindingRefusal::new(
754 None,
755 BindingCause::IntentRetired,
756 format!(
757 "intent {id} is {}; work is not started under a retired intent",
758 view.stage.as_str()
759 ),
760 ))
761 }
762 Some(_) => {}
763 }
764}
765
766fn is_open(i: &PlanIssue) -> bool {
767 !matches!(i.status.as_str(), "DONE" | "CANCELLED")
768}
769
770fn covers(i: &PlanIssue, paths: &[String]) -> bool {
771 i.scope.iter().any(|s| paths.iter().any(|p| overlap(s, p)))
772}
773
774fn serves_intent(i: &PlanIssue, intent: &str) -> bool {
775 i.serves
776 .iter()
777 .filter_map(|t| t.split_once('#'))
778 .any(|(iid, _)| iid == intent)
779}
780
781fn plan_revision(
785 pre: &IntentPreflight,
786 issues: &[&PlanIssue],
787 critiques: &[CritiqueRecord],
788) -> String {
789 if pre.intents.is_empty() {
790 return String::new();
791 }
792 let held: BTreeMap<&str, serde_json::Value> = pre
794 .intents
795 .iter()
796 .map(|i| {
797 let critique = critiques.iter().find(|c| c.intent == i.id).map(|c| {
798 let findings: BTreeMap<&str, serde_json::Value> = c
799 .findings
800 .iter()
801 .map(|f| {
802 let finding = json!({
803 "class": f.class_text,
804 "subject": f.subject,
805 "finding": f.finding,
806 "blocking": f.blocking,
807 "resolution": f.resolution_text,
808 "issue": f.issue,
809 });
810 (f.id.as_str(), finding)
811 })
812 .collect();
813 json!({ "reviewed_at": c.reviewed_at, "findings": findings })
814 });
815 let intent = json!({
816 "statement": i.statement,
817 "invariants": i.invariants,
818 "non_goals": i.non_goals,
819 "criteria": i.criteria.iter().map(|c| json!({
820 "id": c.id,
821 "criterion": c.criterion,
822 "evidence": c.evidence,
823 "ref": c.reference,
824 })).collect::<Vec<_>>(),
825 "critique": critique,
826 });
827 (i.id.as_str(), intent)
828 })
829 .collect();
830 let links: BTreeMap<&str, serde_json::Value> = issues
831 .iter()
832 .map(|i| {
833 let serves: BTreeSet<&str> = i.serves.iter().map(String::as_str).collect();
834 (
835 i.id.as_str(),
836 json!({ "milestone": i.milestone, "serves": serves }),
837 )
838 })
839 .collect();
840 sha256_hex(&json!({ "intents": held, "links": links }).to_string())
841}
842
843fn evidence_standing(pre: &IntentPreflight) -> String {
845 if pre.intents.is_empty() {
846 return String::new();
847 }
848 let states: BTreeSet<String> = pre
849 .intents
850 .iter()
851 .flat_map(|i| {
852 i.criteria
853 .iter()
854 .map(move |c| format!("{}#{}={}", i.id, c.id, c.state.as_str()))
855 })
856 .collect();
857 sha256_hex(&states.into_iter().collect::<Vec<_>>().join("\n"))
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::policy::ExemptionClass;
864
865 fn empty_plan() -> Plan {
866 serde_json::from_value(json!({
867 "project": {"name": "p", "repository": "o/p", "default_branch": "master",
868 "active_milestone": ""},
869 "statuses": {"issue": [], "milestone": []},
870 "milestones": [], "issues": [], "waves": [], "edges": [],
871 "milestone_edges": [], "findings": []
872 }))
873 .unwrap()
874 }
875
876 fn none() -> Intents {
877 Intents {
878 intents: vec![],
879 findings: vec![],
880 }
881 }
882
883 fn policy(classes: &[&str]) -> IntentPolicy {
884 IntentPolicy {
885 exemptions: classes
886 .iter()
887 .map(|c| ExemptionClass {
888 id: (*c).into(),
889 description: String::new(),
890 })
891 .collect(),
892 ..Default::default()
893 }
894 }
895
896 #[test]
897 fn an_exemption_beside_named_work_is_refused_even_when_declared() {
898 let b = bind(
899 &none(),
900 &empty_plan(),
901 &[],
902 &[],
903 &policy(&["emergency"]),
904 BindingRequest {
905 issue: Some("I0001".into()),
906 exemption: Some("emergency".into()),
907 because: Some("red trunk".into()),
908 ..Default::default()
909 },
910 );
911 assert_eq!(b.standing, BindingStanding::Refused);
912 assert_eq!(b.refusals[0].cause, BindingCause::ExemptionNamesWork);
913 assert!(b.exemption.is_none());
914 }
915
916 #[test]
917 fn every_fault_of_an_exemption_is_named_at_once() {
918 let b = bind(
919 &none(),
920 &empty_plan(),
921 &[],
922 &[],
923 &policy(&[]),
924 BindingRequest {
925 exemption: Some("whim".into()),
926 ..Default::default()
927 },
928 );
929 let causes: Vec<BindingCause> = b.refusals.iter().map(|r| r.cause).collect();
930 assert_eq!(
931 causes,
932 [
933 BindingCause::UnknownExemption,
934 BindingCause::ExemptionWithoutReason
935 ]
936 );
937 assert!(b.refusal.unwrap().contains("declares none"));
938 }
939
940 #[test]
941 fn an_unknown_intent_and_an_unknown_issue_are_different_refusals() {
942 let ask = |r: BindingRequest| {
943 bind(
944 &none(),
945 &empty_plan(),
946 &[],
947 &[],
948 &IntentPolicy::default(),
949 r,
950 )
951 };
952 let by_intent = ask(BindingRequest {
953 intent: Some("absent".into()),
954 ..Default::default()
955 });
956 assert_eq!(by_intent.refusals[0].cause, BindingCause::UnknownIntent);
957 let by_issue = ask(BindingRequest {
958 issue: Some("I9999".into()),
959 ..Default::default()
960 });
961 assert_eq!(by_issue.refusals[0].cause, BindingCause::UnknownIssue);
962 let by_paths = ask(BindingRequest {
963 paths: vec!["README.md".into()],
964 ..Default::default()
965 });
966 assert_eq!(by_paths.refusals[0].cause, BindingCause::NoIssueCoversPaths);
967 }
968
969 #[test]
970 fn drift_names_the_plan_before_the_evidence() {
971 assert_eq!(
972 BindingDrift::between("p1", "e1", "p2", "e2").as_str(),
973 "plan_changed"
974 );
975 assert_eq!(
976 BindingDrift::between("p1", "e1", "p1", "e2").as_str(),
977 "evidence_moved"
978 );
979 assert_eq!(
980 BindingDrift::between("p1", "e1", "p1", "e1").as_str(),
981 "unchanged"
982 );
983 }
984 fn planned() -> crate::synthetic::SyntheticRepository {
987 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
988 let root = repo.root().to_path_buf();
989 let write = |rel: &str, text: &str| {
990 let path = root.join(rel);
991 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
992 std::fs::write(path, text).unwrap();
993 };
994 let mut sources =
995 std::fs::read_to_string(root.join(".ai/repo/knowledge/sources.yaml")).unwrap();
996 for kind in ["milestone", "issue", "intent", "critique"] {
997 sources.push_str(&format!(
998 " - id: {kind}\n kind: {kind}\n discovery: vcs\n pathspec: ':(glob).ai/repo/project/{kind}s/*.yaml'\n required: false\n"
999 ));
1000 }
1001 write(".ai/repo/knowledge/sources.yaml", &sources);
1002 write(
1003 ".ai/repo/policy.yaml",
1004 "version: 1\ncontext:\n always_loaded_budget_lines: 150\nintent:\n binding: required\n",
1005 );
1006 write(
1007 ".ai/repo/project/project.yaml",
1008 "schema_version: 1\nname: Synthetic\nrepository: example/synthetic\ndefault_branch: master\n",
1009 );
1010 for m in ["m", "ops"] {
1011 write(
1012 &format!(".ai/repo/project/milestones/{m}.yaml"),
1013 &format!("id: {m}\ntitle: Milestone {m}\nslug: {m}\norder: 0\npriority: p1\nproblem: \"A problem.\"\noutcome: \"An outcome.\"\nacceptance_criteria:\n - It is reached\nvalidation:\n - \"true\"\nevidence_required: []\n"),
1014 );
1015 }
1016 let issue = |id: &str, milestone: &str, serves: &str| {
1017 format!("id: {id}\nmilestone: {milestone}\ntitle: Issue {id}\nslug: issue-{id}\npriority: p1\nprofile: implementation\nobjective: \"Do it.\"\nscope:\n - lib\n{serves}acceptance_criteria:\n - It is done\nvalidation:\n - \"true\"\nevidence_required:\n - proof\n")
1018 };
1019 write(
1020 ".ai/repo/project/issues/I0001.yaml",
1021 &issue("I0001", "m", "serves:\n - x#case\n"),
1022 );
1023 write(
1024 ".ai/repo/project/issues/I0002.yaml",
1025 &issue("I0002", "ops", ""),
1026 );
1027 write(
1028 ".ai/repo/project/intents/x.yaml",
1029 "id: x\ntitle: The x is true\nstatement: \"x holds.\"\ninvariants:\n - Nothing else breaks\nmilestones:\n - m\nsatisfaction:\n - id: case\n criterion: The case passes\n evidence: test\n ref: docs/DOC_0.md\n",
1030 );
1031 repo
1032 }
1033
1034 #[test]
1035 fn a_required_policy_refuses_the_start_of_work_whose_binding_is_refused() {
1036 use crate::plan::{check, Transition, TransitionError};
1037 let repo = planned();
1038 let index = repo.index().unwrap();
1039 let plan = Plan::build(&index);
1040 assert!(
1041 plan.issue("I0001").is_some(),
1042 "the fixture's plan is not indexed"
1043 );
1044
1045 let why = start_refusal(&index, &plan, "I0001").expect("a refusal");
1047 assert!(why.starts_with("I0001 may not start: "), "{why}");
1048 assert!(why.contains("never critiqued"), "{why}");
1049 assert_eq!(
1050 check(
1051 &index,
1052 &plan,
1053 "I0001",
1054 Transition::Start,
1055 "2026-10-07T00:00:00Z"
1056 ),
1057 Err(TransitionError::Refused(why))
1058 );
1059 assert_eq!(start_refusal(&index, &plan, "I0002"), None);
1061
1062 std::fs::create_dir_all(repo.root().join(".ai/repo/project/critiques")).unwrap();
1064 std::fs::write(
1065 repo.root().join(".ai/repo/project/critiques/x.yaml"),
1066 "intent: x\nreviewed_at: c0ffee\nreviewed_by: the test\nfindings: []\n",
1067 )
1068 .unwrap();
1069 let index = repo.index().unwrap();
1070 let plan = Plan::build(&index);
1071 assert_eq!(start_refusal(&index, &plan, "I0001"), None);
1072
1073 let ctx = repo.context().unwrap();
1075 let bound = ctx
1076 .execute("intents.binding", json!({ "issue": "I0001", "paths": "" }))
1077 .unwrap();
1078 assert_eq!(bound["standing"], "bound");
1079 assert_eq!(bound["intents"][0]["id"], "x");
1080
1081 std::fs::create_dir_all(repo.root().join(".ai/repo/evidence")).unwrap();
1083 std::fs::write(
1084 repo.root().join(".ai/repo/evidence/ledger.json"),
1085 "{ not json",
1086 )
1087 .unwrap();
1088 let why = start_refusal(&index, &plan, "I0001").expect("unknown is never a pass");
1089 assert!(why.contains("could not be read"), "{why}");
1090 assert!(why.contains("unknown is never a pass"), "{why}");
1091 }
1092}