Skip to main content

majordomus_cli/
intent_binding.rs

1//! The binding: what a piece of work serves, resolved once, before it starts (ADR 0111).
2//!
3//! [`crate::intent::Intents::preflight`] answers which intent an issue, or some paths, serve.
4//! A task needs slightly more than that and nothing different: it may name an **intent**
5//! instead of an issue, it may give an **exemption** the policy declares instead of either,
6//! paths that reach more than one intent are an **ambiguity** rather than two answers, and
7//! the worker who resumes the task later needs to know whether what it was bound to has
8//! **moved**. This module is that layer and only that layer: every link is still judged by
9//! the preflight, which is still judged by the coverage `intent validate` reports from.
10//!
11//! **Nothing here is stored and nothing here writes.** A task record keeps what its worker
12//! named — an issue id, an intent id, an exemption with its reason — and two pins. The
13//! pins are hashes compared on a later read; the intent, its stage, its verdict and its
14//! criteria are derived again every time, as rule `project.an-intent-outlives-its-sessions`
15//! requires.
16//!
17//! | pin                 | hashes                                                             | moves when |
18//! |---------------------|--------------------------------------------------------------------|------------|
19//! | `plan_revision`     | each served intent's statement, invariants, non-goals and criteria; the issues resolved with what each serves; each critique's findings and their resolutions | the intent, a link or the critique is edited |
20//! | `evidence_standing` | the evidence state of each served criterion                         | a criterion's evidence changes state |
21//!
22//! Two, because they answer different questions and fail differently: an ordinary commit can
23//! make evidence stale without anyone having touched what the work is for, and a worker
24//! told "your intent changed" on every commit stops reading the line.
25//!
26//! ```
27//! use majordomus_cli::intent_binding::{BindingCause, BindingRequest, BindingStanding};
28//! // naming nothing is not a binding
29//! assert!(BindingRequest::default().names_nothing());
30//! assert_eq!(BindingStanding::Exempt.as_str(), "exempt");
31//! assert_eq!(
32//!     serde_json::to_string(&BindingCause::AmbiguousIntent).unwrap(),
33//!     "\"ambiguous_intent\""
34//! );
35//! ```
36
37use 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/// Where a piece of work stands against the intents: the one word a start decides on.
54///
55/// ```
56/// use majordomus_cli::intent_binding::BindingStanding;
57/// assert_eq!(serde_json::to_string(&BindingStanding::Bound).unwrap(), "\"bound\"");
58/// assert!(BindingStanding::Refused.refuses());
59/// assert!(!BindingStanding::Maintenance.refuses());
60/// ```
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum BindingStanding {
64    /// The work serves at least one criterion of a live intent, through links that hold, and
65    /// each such intent's plan was critiqued with no blocking finding open.
66    Bound,
67    /// The issues the work resolves to sit under milestones no live intent names: derived
68    /// maintenance, as `intent validate` allows.
69    Maintenance,
70    /// The worker gave an exemption class the policy declares, with a reason.
71    Exempt,
72    /// The work may not start as bound work; every reason is in `refusals`.
73    Refused,
74}
75
76impl BindingStanding {
77    /// The word every surface and the task record use.
78    ///
79    /// ```
80    /// use majordomus_cli::intent_binding::BindingStanding;
81    /// assert_eq!(BindingStanding::Maintenance.as_str(), "maintenance");
82    /// ```
83    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    /// Whether a start that requires binding must not proceed.
93    ///
94    /// ```
95    /// use majordomus_cli::intent_binding::BindingStanding;
96    /// assert!(!BindingStanding::Exempt.refuses());
97    /// ```
98    pub fn refuses(self) -> bool {
99        self == BindingStanding::Refused
100    }
101}
102
103/// Why a binding was refused, as a word a reader greps for. The first seven are the
104/// preflight's own causes, carried through unchanged; the rest are what only a binding can
105/// find.
106///
107/// ```
108/// use majordomus_cli::intent::IntentPreflightCause;
109/// use majordomus_cli::intent_binding::BindingCause;
110/// // a preflight cause keeps its word
111/// assert_eq!(
112///     serde_json::to_string(&BindingCause::from(IntentPreflightCause::IntentNotCritiqued)).unwrap(),
113///     serde_json::to_string(&IntentPreflightCause::IntentNotCritiqued).unwrap(),
114/// );
115/// ```
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
117#[serde(rename_all = "snake_case")]
118pub enum BindingCause {
119    /// The issue named is not an issue of the plan.
120    UnknownIssue,
121    /// No open issue's scope covers any of the paths.
122    NoIssueCoversPaths,
123    /// The issue's milestone realises a live intent and the issue serves none of its criteria.
124    IssueServesNothing,
125    /// The issue serves a criterion of an intent whose milestones do not include its own.
126    ServesAnotherIntent,
127    /// The issue serves an intent or a criterion that does not exist.
128    ServesUnknownCriterion,
129    /// An intent the work serves has no critique.
130    IntentNotCritiqued,
131    /// The critique of an intent the work serves has a blocking finding still open.
132    OpenBlockingFinding,
133    /// Nothing was named: no issue, no intent, no paths and no exemption.
134    NothingNamed,
135    /// The intent named is not an intent of this repository.
136    UnknownIntent,
137    /// The intent named is cancelled or superseded.
138    IntentRetired,
139    /// The intent named has no open issue serving it, so there is no work to be bound to.
140    IntentHasNoOpenWork,
141    /// The issue named serves none of the criteria of the intent named beside it.
142    IssueOutsideIntent,
143    /// Paths alone reached more than one intent; the worker must name the issue.
144    AmbiguousIntent,
145    /// The exemption class is not one the policy declares.
146    UnknownExemption,
147    /// An exemption was given with no reason.
148    ExemptionWithoutReason,
149    /// An exemption was given beside an issue or an intent: work is one or the other.
150    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/// One reason a binding was refused.
168///
169/// ```
170/// use majordomus_cli::intent_binding::{BindingCause, BindingRefusal};
171/// let r = BindingRefusal::new(None, BindingCause::NothingNamed, "name the work".into());
172/// assert!(r.issue.is_none());
173/// assert_eq!(r.cause, BindingCause::NothingNamed);
174/// ```
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct BindingRefusal {
177    /// The issue it is about; absent when it is about no one issue.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub issue: Option<String>,
180    /// The cause.
181    pub cause: BindingCause,
182    /// What is wrong, in one sentence.
183    pub message: String,
184}
185
186impl BindingRefusal {
187    /// A refusal about `issue`, or about no one issue.
188    ///
189    /// ```
190    /// use majordomus_cli::intent_binding::{BindingCause, BindingRefusal};
191    /// let r = BindingRefusal::new(Some("I0001"), BindingCause::UnknownIssue, "x".into());
192    /// assert_eq!(r.issue.as_deref(), Some("I0001"));
193    /// ```
194    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/// What a worker named when it asked what its work serves. Every field is optional and the
204/// combinations are judged, not assumed: an issue alone, an intent alone, an issue beside
205/// the intent it should serve, paths alone, or an exemption with its reason.
206///
207/// ```
208/// use majordomus_cli::intent_binding::BindingRequest;
209/// let by_issue = BindingRequest { issue: Some("I0001".into()), ..Default::default() };
210/// assert!(!by_issue.names_nothing());
211/// // blanks are what a query string or a shell variable leaves behind, and name nothing
212/// let blank = BindingRequest { issue: Some("  ".into()), ..Default::default() }.trimmed();
213/// assert!(blank.names_nothing());
214/// ```
215#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
216pub struct BindingRequest {
217    /// The issue the work executes.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub issue: Option<String>,
220    /// The intent the work serves, when the worker names it directly.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub intent: Option<String>,
223    /// The paths the work will touch.
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub paths: Vec<String>,
226    /// The exemption class, when the work is under no intent on purpose.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub exemption: Option<String>,
229    /// Why the exemption applies.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub because: Option<String>,
232}
233
234impl BindingRequest {
235    /// The request with every blank value dropped and every value trimmed.
236    ///
237    /// ```
238    /// use majordomus_cli::intent_binding::BindingRequest;
239    /// let r = BindingRequest { intent: Some(" x ".into()), paths: vec!["".into(), " lib ".into()], ..Default::default() }.trimmed();
240    /// assert_eq!(r.intent.as_deref(), Some("x"));
241    /// assert_eq!(r.paths, ["lib"]);
242    /// ```
243    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    /// Whether the request names no issue, no intent, no path and no exemption.
260    ///
261    /// ```
262    /// use majordomus_cli::intent_binding::BindingRequest;
263    /// assert!(BindingRequest::default().names_nothing());
264    /// ```
265    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/// The exemption a binding accepted: the class as the policy declares it, and the reason
274/// the worker gave.
275///
276/// ```
277/// use majordomus_cli::intent_binding::BindingExemption;
278/// let e = BindingExemption {
279///     class: "emergency".into(),
280///     description: "Restoring a broken trunk".into(),
281///     because: "master is red on case 500".into(),
282/// };
283/// assert_eq!(e.class, "emergency");
284/// ```
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286pub struct BindingExemption {
287    /// The class id.
288    pub class: String,
289    /// What the policy says the class is for.
290    #[serde(default, skip_serializing_if = "String::is_empty")]
291    pub description: String,
292    /// The worker's reason.
293    pub because: String,
294}
295
296/// What a piece of work is bound to: its standing, what was named, the issues and intents
297/// it resolved to with what each intent asks of the worker, the two pins, and every refusal.
298///
299/// ```
300/// use majordomus_cli::intent::Intents;
301/// use majordomus_cli::intent_binding::{
302///     bind, BindingCause, BindingRequest, BindingStanding, IntentBinding,
303/// };
304/// use majordomus_cli::plan::Plan;
305/// use majordomus_cli::policy::IntentPolicy;
306/// # let plan: Plan = serde_json::from_value(serde_json::json!({
307/// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
308/// #                 "active_milestone": ""},
309/// #     "statuses": {"issue": [], "milestone": []},
310/// #     "milestones": [], "issues": [], "waves": [], "edges": [],
311/// #     "milestone_edges": [], "findings": []})).unwrap();
312/// let intents = Intents { intents: vec![], findings: vec![] };
313/// let nothing: IntentBinding =
314///     bind(&intents, &plan, &[], &[], &IntentPolicy::default(), BindingRequest::default());
315/// assert_eq!(nothing.standing, BindingStanding::Refused);
316/// assert_eq!(nothing.refusals[0].cause, BindingCause::NothingNamed);
317/// // nothing was reached, so there is nothing to pin
318/// assert!(nothing.plan_revision.is_empty() && nothing.evidence_standing.is_empty());
319/// ```
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
321pub struct IntentBinding {
322    /// `bound`, `maintenance`, `exempt` or `refused`.
323    pub standing: BindingStanding,
324    /// What the worker named, trimmed: the request this answers.
325    pub named: BindingRequest,
326    /// The exemption accepted, when the standing is `exempt`.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub exemption: Option<BindingExemption>,
329    /// The issues the work resolved to, each with the preflight's verdict.
330    pub issues: Vec<IntentPreflightIssue>,
331    /// The intents the work serves, each with the criteria served, their evidence state,
332    /// the invariants, the critique and the gap: what the worker is held to.
333    pub intents: Vec<IntentPreflightIntent>,
334    /// The governance of those intents, each entry once.
335    pub governance: Vec<String>,
336    /// The pin of what the work is for; empty when no intent was reached.
337    pub plan_revision: String,
338    /// The pin of where the served criteria's evidence stands; empty when no intent was
339    /// reached.
340    pub evidence_standing: String,
341    /// What a worker should know and no gate refuses on.
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub notes: Vec<String>,
344    /// Every reason the binding was refused; empty unless the standing is `refused`.
345    pub refusals: Vec<BindingRefusal>,
346    /// When refused: every refusal's sentence, joined.
347    #[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/// How a binding moved between two reads: what a resumed worker is told.
377///
378/// ```
379/// use majordomus_cli::intent_binding::BindingDrift;
380/// let d = BindingDrift::between("a", "s1", "a", "s2");
381/// assert!(!d.plan_changed && d.evidence_changed);
382/// assert_eq!(d.as_str(), "evidence_moved");
383/// assert_eq!(BindingDrift::between("a", "s", "a", "s").as_str(), "unchanged");
384/// assert_eq!(BindingDrift::between("a", "s", "b", "s").as_str(), "plan_changed");
385/// ```
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
387pub struct BindingDrift {
388    /// The intent, a link or the critique was edited since the pin was taken.
389    pub plan_changed: bool,
390    /// A served criterion's evidence changed state since the pin was taken.
391    pub evidence_changed: bool,
392}
393
394impl BindingDrift {
395    /// Compare the pins a record carries with the pins the binding answers now.
396    ///
397    /// ```
398    /// use majordomus_cli::intent_binding::BindingDrift;
399    /// assert!(BindingDrift::between("a", "s", "b", "t").plan_changed);
400    /// ```
401    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    /// One word: `unchanged`, `evidence_moved`, or `plan_changed` (which outranks the other,
409    /// because a worker whose criteria were rewritten must re-read them before anything).
410    ///
411    /// ```
412    /// use majordomus_cli::intent_binding::BindingDrift;
413    /// assert_eq!(BindingDrift::between("a", "s", "b", "t").as_str(), "plan_changed");
414    /// ```
415    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
424/// What `request` is bound to. One derivation: every link is judged by the preflight, and
425/// this adds only what a task needs beside it — an intent given by name, a declared
426/// exemption, ambiguity, the scope note and the two pins.
427///
428/// The order of judgement is fixed, so the same request always meets the same first refusal:
429/// an exemption is judged alone (and refuses to stand beside named work); then an issue, an
430/// intent, or paths, in that precedence, choose the issues; then the preflight judges them.
431///
432/// ```
433/// use majordomus_cli::intent::Intents;
434/// use majordomus_cli::intent_binding::{bind, BindingCause, BindingRequest, BindingStanding};
435/// use majordomus_cli::plan::Plan;
436/// use majordomus_cli::policy::{ExemptionClass, IntentPolicy};
437/// # let plan: Plan = serde_json::from_value(serde_json::json!({
438/// #     "project": {"name": "p", "repository": "o/p", "default_branch": "master",
439/// #                 "active_milestone": ""},
440/// #     "statuses": {"issue": [], "milestone": []},
441/// #     "milestones": [], "issues": [], "waves": [], "edges": [],
442/// #     "milestone_edges": [], "findings": []})).unwrap();
443/// let intents = Intents { intents: vec![], findings: vec![] };
444/// let policy = IntentPolicy {
445///     exemptions: vec![ExemptionClass { id: "emergency".into(), description: String::new() }],
446///     ..Default::default()
447/// };
448/// let ask = |exemption: &str, because: Option<&str>| {
449///     bind(&intents, &plan, &[], &[], &policy, BindingRequest {
450///         exemption: Some(exemption.into()),
451///         because: because.map(str::to_string),
452///         ..Default::default()
453///     })
454/// };
455/// // a declared class with a reason is the only exemption there is
456/// assert_eq!(ask("emergency", Some("master is red")).standing, BindingStanding::Exempt);
457/// assert_eq!(ask("emergency", None).refusals[0].cause, BindingCause::ExemptionWithoutReason);
458/// assert_eq!(ask("whim", Some("because")).refusals[0].cause, BindingCause::UnknownExemption);
459/// ```
460pub 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
629/// Why the start transition of `issue` is refused, or `None` when it is not this module's
630/// to refuse: the policy does not say `intent.binding: required`, or the issue's binding is
631/// bound or maintenance. The sentence is the one `majordomus plan start` prints in the shell
632/// engine, which asks the same capability; cases 133 and 962 hold the two equal.
633///
634/// A task is one way work begins and a transition is the other. Gating only the first would
635/// leave a door beside the gate, so [`crate::plan::check`] asks this before it lets an issue
636/// become ACTIVE.
637///
638/// ```
639/// use majordomus_cli::intent_binding::start_refusal;
640/// use majordomus_cli::plan::Plan;
641/// use majordomus_cli::synthetic::SyntheticRepository;
642/// let repo = SyntheticRepository::small().unwrap();
643/// let index = repo.index().unwrap();
644/// let plan = Plan::build(&index);
645/// // a repository whose policy does not require binding refuses nothing here
646/// assert_eq!(start_refusal(&index, &plan, "I0001"), None);
647/// ```
648pub 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
781/// The pin of what the work is for. Everything a worker would have to re-read if it
782/// changed, and nothing that changes without anyone editing the plan: no stage, no evidence
783/// state, no status of any issue.
784fn 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    // keyed maps, so the hash never depends on the order a record lists things in
793    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
843/// The pin of where the served criteria's evidence stands: one state word per criterion.
844fn 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    /// A synthetic repository with a plan: one intent `x` over milestone `m`, one issue
985    /// serving it, one maintenance issue, and a policy that requires binding.
986    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        // nobody critiqued the plan of x: the transition is refused, in the binding's words
1046        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        // maintenance is not this gate's to refuse
1060        assert_eq!(start_refusal(&index, &plan, "I0002"), None);
1061
1062        // critiqued, it starts
1063        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        // the capability answers the same binding in process
1074        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        // an evidence ledger nobody can read leaves the binding unknown, and unknown refuses
1082        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}