Skip to main content

majordomus_cli/gates/
done.rs

1//! The "done" invariant: the questions a task must answer before anybody may call it
2//! finished, each answered from the one place that already knows.
3//!
4//! # Why this is a projection and not a checklist
5//!
6//! Twenty-two questions get asked of every piece of work in this repository — is it tested,
7//! is it documented, are the projections current, did the version move, is it pushed, is it
8//! on the trunk, is it published, is CI green, was the deployment verified, is a stale
9//! branch left behind, did the recorded debt grow, is the repository bigger than the work
10//! found it. Written as a checklist they would be twenty-two new opinions, each able to
11//! disagree with the subsystem that already holds the fact, and a checklist is exactly the
12//! artifact a worker learns to tick.
13//!
14//! So every question here names a **source** and takes its answer from it:
15//!
16//! - an **obligation** of `share/obligations.yaml`, judged by
17//!   [`crate::capability::builtin::obligations`] — which is where `implemented`, `tested`,
18//!   `documented`, `generated`, `committed`, `pushed`, `integrated`, `published`,
19//!   `deployed` and `deployment-verified` come from. Nothing is re-judged here: the state
20//!   word comes back from that capability's own execution;
21//! - a **gate** of the CI model, judged by [`super::judge`] — which is where the CI verdict
22//!   and the release questions come from, when the model declares gates for them;
23//! - the **change set** itself, for the one question it settles: whether a test path is
24//!   among the files this task touched;
25//! - the **closing readings** of [`super::closing`], for the three questions a person asks
26//!   when a session ends: the debt baselines at the commit the task started at and now, the
27//!   branches and worktrees created since it started, and the pull requests opened since —
28//!   each read from the record that holds it, never from what a worker recalls doing;
29//! - or **nothing here**, in which case the answer is [`super::GateStatus::Unknown`] and
30//!   the question carries the command that would answer it. An honest gap beats a boolean
31//!   conjured by a report, and this is where the gaps are written down rather than left for
32//!   a reader to infer.
33//!
34//! # The rule about absence
35//!
36//! No question here invents a `pass`. A question whose source has not spoken is `queued`
37//! (it was asked and nothing answered) or `unknown` (it could not be asked), never `pass`;
38//! a question the change makes irrelevant is `exempt`. Those three are different findings
39//! and a reader is entitled to all three.
40
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43
44use super::closing::{Closing, DEBT_DIRECTORY, DEBT_SUFFIX};
45use super::judge::{Gate, GateStatus};
46use super::ImpliedObligation;
47use crate::convergence::ConvergenceReport;
48
49/// ```
50/// use majordomus_cli::gates::{DoneQuestion, GateStatus};
51///
52/// let q = DoneQuestion {
53///     id: "pushed".into(),
54///     question: "Has the commit reached the remote?".into(),
55///     status: GateStatus::Queued,
56///     evidence: "the obligation is owed".into(),
57///     source: "obligation push (obligations.closure)".into(),
58///     remediation: "git push".into(),
59/// };
60/// // every question names what answered it and what would settle it: a question with
61/// // neither is a checklist line, which is the artifact this type exists instead of
62/// assert!(!q.source.is_empty() && !q.remediation.is_empty());
63/// assert!(q.status.unverified());
64/// ```
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
66/// One question of the done invariant, and what answered it.
67pub struct DoneQuestion {
68    /// The question's identity, stable across reports.
69    pub id: String,
70    /// The question, as a person would ask it.
71    pub question: String,
72    /// The answer, in the same vocabulary every gate uses.
73    pub status: GateStatus,
74    /// What was actually read — never a restatement of the status.
75    pub evidence: String,
76    /// What answered it: an obligation token, a gate id, or the capability that would.
77    pub source: String,
78    /// What would settle it, as a command.
79    pub remediation: String,
80}
81
82/// How one question is answered.
83enum Answers {
84    /// An obligation token of `share/obligations.yaml`.
85    Obligation(&'static str),
86    /// The aggregate of the gates the change selects.
87    Gates,
88    /// A gate of the CI model, by id, when the model declares one.
89    Gate(&'static str),
90    /// The change set itself.
91    Change,
92    /// The repository's convergence: whether any work is held where it can be lost.
93    Convergence,
94    /// The debt baselines, at the commit the task started at and now.
95    Debt,
96    /// The branches and worktrees created since the task started that remain.
97    Accumulated,
98    /// The pull requests opened since the task started that are still open.
99    Backlog,
100    /// Nothing reachable from here; the command that would answer it.
101    Elsewhere(&'static str),
102}
103
104/// The invariant, declared once: the question, what answers it, and what a worker runs.
105///
106/// The order is the order the work happens in, so a reader meets the questions in the order
107/// they become answerable rather than in alphabetical order.
108#[allow(clippy::type_complexity)]
109fn invariant() -> Vec<(&'static str, &'static str, Answers, &'static str)> {
110    vec![
111        (
112            "implemented",
113            "Is all the work this task promised present in the tree?",
114            Answers::Obligation("implementation"),
115            "majordomus check",
116        ),
117        (
118            "tested",
119            "Were the cases the change obliges run, and did they pass?",
120            Answers::Obligation("tests"),
121            "majordomus usecase impact",
122        ),
123        (
124            "regression-tested",
125            "Does the change touch a test of its own?",
126            Answers::Change,
127            "add a case under test/cases/ or a test beside the code",
128        ),
129        (
130            "documented",
131            "Were the documents a reader needs updated?",
132            Answers::Obligation("docs"),
133            "scripts/ci/reference-check",
134        ),
135        (
136            "generated",
137            "Is every derived artifact current with its canonical source?",
138            Answers::Obligation("generated"),
139            "majordomus generate --check",
140        ),
141        (
142            "openapi",
143            "Are the OpenAPI document and the transport registries current?",
144            Answers::Obligation("generated"),
145            "majordomus generate --check",
146        ),
147        (
148            "parity",
149            "Does every surface — command line, HTTP, MCP, Cockpit — agree with the registry?",
150            Answers::Elsewhere("majordomus quality report"),
151            "majordomus quality report",
152        ),
153        (
154            "committed",
155            "Are the changes in the branch's history rather than in the working tree?",
156            Answers::Obligation("commit"),
157            "git commit",
158        ),
159        (
160            "pushed",
161            "Has the commit reached the remote?",
162            Answers::Obligation("push"),
163            "git push",
164        ),
165        (
166            "ci",
167            "Has every gate the change selects reported, and did it pass?",
168            Answers::Gates,
169            "majordomus evidence --gate <id> --exit <status>",
170        ),
171        (
172            "version",
173            "Has the declared version moved as far as the public surface has?",
174            Answers::Gate("version-surface"),
175            "majordomus release version",
176        ),
177        (
178            "changelog",
179            "Is the release's own record current with the tree?",
180            Answers::Gate("release-check"),
181            "scripts/ci/release-check",
182        ),
183        (
184            "integrated",
185            "Does the trunk reach this commit?",
186            Answers::Obligation("target"),
187            "open a pull request and land it",
188        ),
189        (
190            "published",
191            "Does the published site serve this commit?",
192            Answers::Obligation("pages"),
193            "scripts/pages verify --commit HEAD",
194        ),
195        (
196            "deployed",
197            "Is the environment this change targets running it?",
198            Answers::Obligation("deploy"),
199            "majordomus deployment",
200        ),
201        (
202            "deployment-verified",
203            "Was the deployed result looked at after it was deployed?",
204            Answers::Obligation("verify"),
205            "majordomus evidence --covers verify --command '<the live check>'",
206        ),
207        (
208            "no-stale-topology",
209            "Is a branch, worktree or pull request left behind?",
210            Answers::Convergence,
211            "majordomus convergence",
212        ),
213        (
214            "no-new-debt",
215            "Is the repository's recorded debt no larger than when this task started?",
216            Answers::Debt,
217            "take out what the change added to the baseline, or settle what the entry records",
218        ),
219        (
220            "nothing-accumulated",
221            "Has every branch and worktree created since this task started gone again?",
222            Answers::Accumulated,
223            "majordomus worktree cleanup",
224        ),
225        (
226            "backlog-not-grown",
227            "Has every pull request opened since this task started been closed?",
228            Answers::Backlog,
229            "majordomus prs status",
230        ),
231        (
232            "handover",
233            "Is the session's continuation record written?",
234            Answers::Elsewhere("majordomus handover"),
235            "majordomus handover < note.md",
236        ),
237        (
238            "issue",
239            "Do the issue and milestone this work belongs to say so?",
240            Answers::Elsewhere("majordomus plan status"),
241            "majordomus plan record",
242        ),
243    ]
244}
245
246/// ```
247/// use majordomus_cli::gates::ObligationStanding;
248///
249/// // the closure's own word, carried verbatim; nothing here re-judges it
250/// let standing = ObligationStanding {
251///     state: "owed".into(),
252///     detail: "no evidence covers it".into(),
253///     reproduce: "majordomus evidence --covers tests --command 'just test'".into(),
254/// };
255/// assert_eq!(standing.state, "owed");
256/// ```
257/// The state of one obligation as [`crate::capability::builtin::obligations`] judged it:
258/// the token, its state word, and the line the validator would refuse with.
259#[derive(Debug, Clone, Default)]
260pub struct ObligationStanding {
261    /// The state word: `discharged`, `owed`, `stale` or `undeclared`.
262    pub state: String,
263    /// What the closure said about it, verbatim.
264    pub detail: String,
265    /// The command that would discharge it, as the closure gave it.
266    pub reproduce: String,
267}
268
269/// Answer the invariant.
270///
271/// Every argument is a judgement somebody else already made: `standing` is the obligation
272/// closure's, `gates` is [`super::judge`]'s, `implied` is [`super::implied`]'s,
273/// `changed` is the change set, `convergence` is [`crate::convergence`]'s verdict over the
274/// repository's holdings and `closing` is [`super::closing`]'s readings of what the task
275/// leaves behind. Nothing is measured here — this composes.
276pub(crate) fn answer(
277    standing: &std::collections::BTreeMap<String, ObligationStanding>,
278    implied: &[ImpliedObligation],
279    gates: &[Gate],
280    changed: &[String],
281    closure_reachable: bool,
282    convergence: Option<&ConvergenceReport>,
283    closing: Option<&Closing>,
284) -> Vec<DoneQuestion> {
285    invariant()
286        .into_iter()
287        .map(|(id, question, from, remediation)| {
288            let (status, evidence, source) = match from {
289                Answers::Obligation(token) => {
290                    let applicable = implied
291                        .iter()
292                        .find(|o| o.id == token)
293                        .map(|o| (o.applicable, o.declared, o.reason.clone()));
294                    match (standing.get(token), applicable) {
295                        // the closure judged it: that word is the answer, translated once
296                        (Some(s), _) => (
297                            match s.state.as_str() {
298                                "discharged" => GateStatus::Pass,
299                                "stale" => GateStatus::Stale,
300                                "owed" => GateStatus::Queued,
301                                _ => GateStatus::Unknown,
302                            },
303                            s.detail.clone(),
304                            format!("obligation {token} (obligations.closure)"),
305                        ),
306                        // the change implies it and the task never promised it: owed by the
307                        // change and held by nothing, which is a debt and not a pass
308                        (None, Some((true, false, why))) => (
309                            GateStatus::Queued,
310                            format!(
311                                "the change implies this obligation and the task does not declare it, \
312                                 so nothing holds it to it: {why}"
313                            ),
314                            format!("obligation {token} (derived from its own inputs)"),
315                        ),
316                        (None, Some((false, _, why))) => (
317                            GateStatus::Exempt,
318                            why,
319                            format!("obligation {token} (derived from its own inputs)"),
320                        ),
321                        _ if !closure_reachable => (
322                            GateStatus::Unknown,
323                            "the obligation closure could not be read in this checkout".into(),
324                            format!("obligation {token}"),
325                        ),
326                        _ => (
327                            GateStatus::Unknown,
328                            format!("share/obligations.yaml declares no token '{token}' here"),
329                            format!("obligation {token}"),
330                        ),
331                    }
332                }
333                Answers::Gates => {
334                    let required: Vec<&Gate> = gates.iter().filter(|g| g.required).collect();
335                    let refused = required.iter().filter(|g| g.status.refuses()).count();
336                    let silent = required.iter().filter(|g| g.status.unverified()).count();
337                    let passing = required
338                        .iter()
339                        .filter(|g| g.status == GateStatus::Pass)
340                        .count();
341                    let status = if required.is_empty() {
342                        GateStatus::Exempt
343                    } else if refused > 0 {
344                        GateStatus::Fail
345                    } else if silent > 0 {
346                        GateStatus::Queued
347                    } else {
348                        GateStatus::Pass
349                    };
350                    (
351                        status,
352                        format!(
353                            "{} gate(s) selected: {passing} passing, {refused} refusing, \
354                             {silent} never reported",
355                            required.len()
356                        ),
357                        ".ai/repo/ci/gates.yaml".to_string(),
358                    )
359                }
360                Answers::Gate(gate) => match gates.iter().find(|g| g.id == gate) {
361                    Some(g) => (
362                        g.status,
363                        g.reason.clone(),
364                        format!("gate {gate} (.ai/repo/ci/gates.yaml)"),
365                    ),
366                    None => (
367                        GateStatus::Unknown,
368                        format!("this repository's CI model declares no gate '{gate}'"),
369                        format!("gate {gate}"),
370                    ),
371                },
372                Answers::Change => {
373                    let tests: Vec<&String> = changed
374                        .iter()
375                        .filter(|p| is_test_path(p))
376                        .take(3)
377                        .collect();
378                    if changed.is_empty() {
379                        (
380                            GateStatus::Exempt,
381                            "the task has changed nothing".into(),
382                            "the change set".to_string(),
383                        )
384                    } else if tests.is_empty() {
385                        (
386                            GateStatus::Queued,
387                            format!(
388                                "no test path among the {} file(s) this task touched",
389                                changed.len()
390                            ),
391                            "the change set (profile verification.regression_test_required)"
392                                .to_string(),
393                        )
394                    } else {
395                        (
396                            GateStatus::Pass,
397                            format!(
398                                "{} test path(s) touched, including {}",
399                                tests.len(),
400                                tests[0]
401                            ),
402                            "the change set (profile verification.regression_test_required)"
403                                .to_string(),
404                        )
405                    }
406                }
407                Answers::Convergence => match convergence {
408                    // the verdict is one word over every holding; the evidence names the
409                    // holdings themselves, because "3 at risk" is not something a person
410                    // can act on and `feature/x, /tmp/wt` is
411                    Some(report) => {
412                        let at_risk: Vec<&str> = report
413                            .holdings
414                            .iter()
415                            .filter(|h| h.at_risk)
416                            .map(|h| h.identity.as_str())
417                            .take(3)
418                            .collect();
419                        if report.converged {
420                            (
421                                GateStatus::Pass,
422                                report.summary(),
423                                "convergence.report".to_string(),
424                            )
425                        } else {
426                            (
427                                GateStatus::Fail,
428                                format!("{}: {}", report.summary(), at_risk.join(", ")),
429                                "convergence.report".to_string(),
430                            )
431                        }
432                    }
433                    None => (
434                        GateStatus::Unknown,
435                        "the repository's holdings could not be read in this checkout".into(),
436                        "convergence.report".to_string(),
437                    ),
438                },
439                Answers::Debt => debt_answer(closing),
440                Answers::Accumulated => accumulated_answer(closing),
441                Answers::Backlog => backlog_answer(closing),
442                Answers::Elsewhere(command) => (
443                    GateStatus::Unknown,
444                    format!(
445                        "nothing in this report reaches that fact; `{command}` is what answers it"
446                    ),
447                    command.to_string(),
448                ),
449            };
450            DoneQuestion {
451                id: id.to_string(),
452                question: question.to_string(),
453                status,
454                evidence,
455                source,
456                remediation: remediation.to_string(),
457            }
458        })
459        .collect()
460}
461
462/// The words for a closing question nothing could be measured for: there is no task, so
463/// there is no start to measure from.
464const NO_START: &str =
465    "no task record says where this work started, so there is nothing to measure it from";
466
467/// Did a debt baseline gain an entry since the task started?
468///
469/// Growth refuses: an entry added to a baseline is debt the change created and then
470/// permitted itself. A baseline that appears for the first time is not growth — the debt it
471/// lists was there unrecorded — and is named in the evidence so that it is not mistaken for
472/// nothing having happened.
473fn debt_answer(closing: Option<&Closing>) -> (GateStatus, String, String) {
474    let source = format!(
475        "the debt baselines ({DEBT_DIRECTORY}/*{DEBT_SUFFIX}) at the task's first commit and now"
476    );
477    let (status, evidence) = match closing.map(|c| &c.debt) {
478        None => (GateStatus::Unknown, NO_START.to_string()),
479        Some(Err(why)) => (
480            GateStatus::Unknown,
481            format!("the debt baselines could not be compared: {why}"),
482        ),
483        Some(Ok(baselines)) if baselines.is_empty() => (
484            GateStatus::Exempt,
485            "this repository keeps no debt baseline".to_string(),
486        ),
487        Some(Ok(baselines)) => {
488            let grown: Vec<String> = baselines
489                .iter()
490                .filter(|b| b.grown_by() > 0)
491                .map(|b| {
492                    format!(
493                        "{} {} -> {} (+{})",
494                        b.path,
495                        b.before.unwrap_or(0),
496                        b.after,
497                        b.grown_by()
498                    )
499                })
500                .collect();
501            let first: Vec<&str> = baselines
502                .iter()
503                .filter(|b| b.before.is_none())
504                .map(|b| b.path.as_str())
505                .collect();
506            let recorded = if first.is_empty() {
507                String::new()
508            } else {
509                format!("; recorded for the first time: {}", first.join(", "))
510            };
511            if grown.is_empty() {
512                let before: usize = baselines.iter().filter_map(|b| b.before).sum();
513                let after: usize = baselines
514                    .iter()
515                    .filter(|b| b.before.is_some())
516                    .map(|b| b.after)
517                    .sum();
518                (
519                    GateStatus::Pass,
520                    format!(
521                        "{} baseline(s) held {before} entries when the task started and hold {after} now{recorded}",
522                        baselines.len() - first.len()
523                    ),
524                )
525            } else {
526                (
527                    GateStatus::Fail,
528                    format!(
529                        "{} baseline(s) grew: {}{recorded}",
530                        grown.len(),
531                        grown.join("; ")
532                    ),
533                )
534            }
535        }
536    };
537    (status, evidence, source)
538}
539
540/// Is a branch or a worktree created since the task started still there?
541///
542/// One that is makes the question owed, not refused: the task's own branch is among them
543/// until the work has landed and been cleaned up, which is exactly the state the question
544/// exists to keep visible.
545fn accumulated_answer(closing: Option<&Closing>) -> (GateStatus, String, String) {
546    let source = "git's reflogs: when each local branch and each linked worktree began".to_string();
547    let (status, evidence) = match closing.map(|c| (&c.accumulated, c.since.as_str())) {
548        None => (GateStatus::Unknown, NO_START.to_string()),
549        Some((Err(why), _)) => (
550            GateStatus::Unknown,
551            format!("the branches and worktrees could not be read: {why}"),
552        ),
553        Some((Ok(found), since)) => {
554            let totals = format!(
555                "{} linked worktree(s) and {} local branch(es) now",
556                found.worktrees_now, found.branches_now
557            );
558            let undated = if found.undated == 0 {
559                String::new()
560            } else {
561                format!(
562                    "; {} have no creation record and are counted on neither side",
563                    found.undated
564                )
565            };
566            if found.worktrees.is_empty() && found.branches.is_empty() {
567                (
568                    GateStatus::Pass,
569                    format!("nothing created since {since} remains; {totals}{undated}"),
570                )
571            } else {
572                let named: Vec<&str> = found
573                    .worktrees
574                    .iter()
575                    .chain(found.branches.iter())
576                    .map(String::as_str)
577                    .take(3)
578                    .collect();
579                (
580                    GateStatus::Queued,
581                    format!(
582                        "{} worktree(s) and {} branch(es) created since {since} are still here, \
583                         among them {}; {totals}{undated}",
584                        found.worktrees.len(),
585                        found.branches.len(),
586                        named.join(", ")
587                    ),
588                )
589            }
590        }
591    };
592    (status, evidence, source)
593}
594
595/// Is a pull request opened since the task started still open?
596fn backlog_answer(closing: Option<&Closing>) -> (GateStatus, String, String) {
597    let source = "the recorded forge observation (majordomus prs refresh)".to_string();
598    let (status, evidence) = match closing.map(|c| (&c.backlog, c.since.as_str())) {
599        None => (GateStatus::Unknown, NO_START.to_string()),
600        Some((Err(why), _)) => (
601            GateStatus::Unknown,
602            format!("the backlog could not be read: {why}"),
603        ),
604        Some((Ok(backlog), since)) => {
605            let observed = format!(
606                "{} open when the forge was observed at {}",
607                backlog.open, backlog.observed_at
608            );
609            if backlog.opened_since.is_empty() {
610                (
611                    GateStatus::Pass,
612                    format!("no pull request opened since {since} is open; {observed}"),
613                )
614            } else {
615                let named: Vec<String> = backlog
616                    .opened_since
617                    .iter()
618                    .take(5)
619                    .map(|n| format!("#{n}"))
620                    .collect();
621                (
622                    GateStatus::Queued,
623                    format!(
624                        "{} pull request(s) opened since {since} are still open, among them {}; {observed}",
625                        backlog.opened_since.len(),
626                        named.join(" ")
627                    ),
628                )
629            }
630        }
631    };
632    (status, evidence, source)
633}
634
635/// Is this a path a test lives at? `mj_validate_profile_requirements`' predicate, which is
636/// the one this repository already refuses over; nothing stricter is invented here.
637fn is_test_path(path: &str) -> bool {
638    let name = path.rsplit('/').next().unwrap_or(path);
639    path.split('/')
640        .any(|seg| matches!(seg, "test" | "tests" | "spec" | "specs"))
641        || name.contains("_test.")
642        || name.contains(".test.")
643        || name.contains("_spec.")
644        || name.contains(".spec.")
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use std::collections::BTreeMap;
651
652    fn implied_of(id: &str, applicable: bool, declared: bool) -> ImpliedObligation {
653        ImpliedObligation {
654            id: id.into(),
655            title: id.into(),
656            applicable,
657            declared,
658            reason: "because".into(),
659            because: vec![],
660            remediation: "run it".into(),
661        }
662    }
663
664    fn gate(id: &str, status: GateStatus, required: bool) -> Gate {
665        Gate {
666            id: id.into(),
667            title: id.into(),
668            scope: "structure".into(),
669            required,
670            status,
671            reason: "r".into(),
672            remediation: "m".into(),
673            source: "s".into(),
674            evidence: None,
675            inputs: vec![],
676            inputs_hash: String::new(),
677            evaluated_at: "now".into(),
678        }
679    }
680
681    fn standing(state: &str) -> ObligationStanding {
682        ObligationStanding {
683            state: state.into(),
684            detail: format!("the closure said {state}"),
685            reproduce: "x".into(),
686        }
687    }
688
689    use crate::gates::closing::{Accumulated, Backlog, DebtReading};
690
691    /// Closing readings in which nothing was added: one baseline, unchanged.
692    fn settled() -> Closing {
693        Closing {
694            since: "2026-10-10T12:00:00Z".into(),
695            debt: Ok(vec![DebtReading {
696                path: ".ai/repo/doc-command-baseline.txt".into(),
697                before: Some(4),
698                after: 3,
699            }]),
700            accumulated: Ok(Accumulated {
701                worktrees_now: 2,
702                branches_now: 5,
703                ..Accumulated::default()
704            }),
705            backlog: Ok(Backlog {
706                observed_at: "2026-10-10T15:00:00Z".into(),
707                open: 7,
708                opened_since: Default::default(),
709            }),
710        }
711    }
712
713    fn closing_answers(closing: Option<&Closing>) -> [DoneQuestion; 3] {
714        let q = answer(&BTreeMap::new(), &[], &[], &[], true, None, closing);
715        let by = |id: &str| q.iter().find(|q| q.id == id).unwrap().clone();
716        [
717            by("no-new-debt"),
718            by("nothing-accumulated"),
719            by("backlog-not-grown"),
720        ]
721    }
722
723    #[test]
724    fn a_task_that_added_nothing_passes_the_three_closing_questions_with_what_was_read() {
725        let [debt, accumulated, backlog] = closing_answers(Some(&settled()));
726        assert_eq!(debt.status, GateStatus::Pass);
727        assert!(
728            debt.evidence
729                .contains("held 4 entries when the task started and hold 3 now"),
730            "{}",
731            debt.evidence
732        );
733        assert_eq!(accumulated.status, GateStatus::Pass);
734        assert!(accumulated
735            .evidence
736            .contains("nothing created since 2026-10-10T12:00:00Z remains"));
737        assert!(accumulated
738            .evidence
739            .contains("2 linked worktree(s) and 5 local branch(es) now"));
740        assert_eq!(backlog.status, GateStatus::Pass);
741        assert!(backlog
742            .evidence
743            .contains("7 open when the forge was observed at 2026-10-10T15:00:00Z"));
744    }
745
746    #[test]
747    fn a_baseline_that_gained_an_entry_refuses_and_names_the_file_and_the_numbers() {
748        let mut closing = settled();
749        closing.debt = Ok(vec![
750            DebtReading {
751                path: ".ai/repo/a-baseline.txt".into(),
752                before: Some(2),
753                after: 5,
754            },
755            DebtReading {
756                path: ".ai/repo/b-baseline.txt".into(),
757                before: Some(9),
758                after: 1,
759            },
760            DebtReading {
761                path: ".ai/repo/new-baseline.txt".into(),
762                before: None,
763                after: 6,
764            },
765        ]);
766        let [debt, ..] = closing_answers(Some(&closing));
767        assert_eq!(
768            debt.status,
769            GateStatus::Fail,
770            "shrinking one baseline does not pay for growing another"
771        );
772        assert!(
773            debt.evidence
774                .contains("1 baseline(s) grew: .ai/repo/a-baseline.txt 2 -> 5 (+3)"),
775            "{}",
776            debt.evidence
777        );
778        assert!(
779            debt.evidence
780                .contains("recorded for the first time: .ai/repo/new-baseline.txt"),
781            "{}",
782            debt.evidence
783        );
784
785        // a baseline that only appears is said, and is not growth
786        closing.debt = Ok(vec![DebtReading {
787            path: ".ai/repo/new-baseline.txt".into(),
788            before: None,
789            after: 6,
790        }]);
791        let [debt, ..] = closing_answers(Some(&closing));
792        assert_eq!(debt.status, GateStatus::Pass);
793        assert!(
794            debt.evidence.contains("0 baseline(s) held 0 entries"),
795            "{}",
796            debt.evidence
797        );
798        assert!(
799            debt.evidence.contains("recorded for the first time"),
800            "{}",
801            debt.evidence
802        );
803
804        // and a repository that keeps none is exempt rather than clean
805        closing.debt = Ok(vec![]);
806        assert_eq!(
807            closing_answers(Some(&closing))[0].status,
808            GateStatus::Exempt
809        );
810    }
811
812    #[test]
813    fn what_was_created_since_the_start_and_remains_is_owed_and_named() {
814        let mut closing = settled();
815        closing.accumulated = Ok(Accumulated {
816            worktrees: ["/w/feature/a".to_string()].into(),
817            branches: ["feature/a", "feature/b", "feature/c"]
818                .map(String::from)
819                .into(),
820            worktrees_now: 3,
821            branches_now: 8,
822            undated: 2,
823        });
824        let [_, accumulated, _] = closing_answers(Some(&closing));
825        assert_eq!(
826            accumulated.status,
827            GateStatus::Queued,
828            "owed, not refused: the task's own branch is among them"
829        );
830        let said = &accumulated.evidence;
831        assert!(
832            said.contains(
833                "1 worktree(s) and 3 branch(es) created since 2026-10-10T12:00:00Z are still here"
834            ),
835            "{said}"
836        );
837        assert!(
838            said.contains("among them /w/feature/a, feature/a, feature/b;"),
839            "three are named: {said}"
840        );
841        assert!(
842            said.contains("2 have no creation record and are counted on neither side"),
843            "{said}"
844        );
845    }
846
847    #[test]
848    fn a_pull_request_opened_since_the_start_and_still_open_is_owed_and_numbered() {
849        let mut closing = settled();
850        closing.backlog = Ok(Backlog {
851            observed_at: "2026-10-10T15:00:00Z".into(),
852            open: 52,
853            opened_since: [897, 898, 899].into(),
854        });
855        let [.., backlog] = closing_answers(Some(&closing));
856        assert_eq!(backlog.status, GateStatus::Queued);
857        assert!(backlog.evidence.contains("3 pull request(s) opened since 2026-10-10T12:00:00Z are still open, among them #897 #898 #899; 52 open"), "{}", backlog.evidence);
858    }
859
860    #[test]
861    fn a_closing_reading_that_was_not_taken_is_unknown_and_says_why() {
862        // no task: nothing to measure from
863        for q in closing_answers(None) {
864            assert_eq!(q.status, GateStatus::Unknown, "{}", q.id);
865            assert!(
866                q.evidence
867                    .contains("no task record says where this work started"),
868                "{}",
869                q.evidence
870            );
871        }
872        // a task, and three readings that failed: each says its own reason
873        let unread = Closing {
874            since: "2026-10-10T12:00:00Z".into(),
875            debt: Err("the commit the task started at (abc) is not in this repository".into()),
876            accumulated: Err("git could not list the local branches".into()),
877            backlog: Err("the forge has never been observed from this checkout".into()),
878        };
879        let [debt, accumulated, backlog] = closing_answers(Some(&unread));
880        for q in [&debt, &accumulated, &backlog] {
881            assert_eq!(
882                q.status,
883                GateStatus::Unknown,
884                "{}: an unread reading is never a pass",
885                q.id
886            );
887        }
888        assert!(debt.evidence.contains("is not in this repository"));
889        assert!(accumulated
890            .evidence
891            .contains("could not list the local branches"));
892        assert!(backlog.evidence.contains("never been observed"));
893    }
894
895    #[test]
896    fn every_question_carries_a_source_and_something_to_run() {
897        let q = answer(&BTreeMap::new(), &[], &[], &[], false, None, None);
898        assert_eq!(q.len(), 22, "the invariant is twenty-two questions");
899        for question in &q {
900            assert!(!question.source.is_empty(), "{} has no source", question.id);
901            assert!(!question.remediation.is_empty(), "{}", question.id);
902            assert!(!question.evidence.is_empty(), "{}", question.id);
903            assert_ne!(
904                question.status,
905                GateStatus::Pass,
906                "{} passed with nothing behind it",
907                question.id
908            );
909        }
910    }
911
912    #[test]
913    fn an_obligation_answer_is_the_closures_word_and_not_a_second_opinion() {
914        let mut s = BTreeMap::new();
915        s.insert("tests".to_string(), standing("discharged"));
916        s.insert("docs".to_string(), standing("stale"));
917        s.insert("push".to_string(), standing("owed"));
918        let q = answer(&s, &[], &[], &[], true, None, None);
919        let by = |id: &str| q.iter().find(|q| q.id == id).unwrap().clone();
920        assert_eq!(by("tested").status, GateStatus::Pass);
921        assert_eq!(by("documented").status, GateStatus::Stale);
922        assert_eq!(by("pushed").status, GateStatus::Queued);
923        assert!(by("tested")
924            .evidence
925            .contains("the closure said discharged"));
926        assert!(by("tested").source.contains("obligations.closure"));
927    }
928
929    #[test]
930    fn an_obligation_the_change_implies_and_nobody_declared_is_a_debt_not_a_pass() {
931        let implied = vec![
932            implied_of("tests", true, false),
933            implied_of("deploy", false, false),
934        ];
935        let q = answer(&BTreeMap::new(), &implied, &[], &[], true, None, None);
936        let by = |id: &str| q.iter().find(|q| q.id == id).unwrap().clone();
937        assert_eq!(by("tested").status, GateStatus::Queued);
938        assert!(by("tested").evidence.contains("does not declare it"));
939        assert_eq!(
940            by("deployed").status,
941            GateStatus::Exempt,
942            "a task that deploys nothing is not owed a deployment"
943        );
944    }
945
946    #[test]
947    fn the_ci_question_separates_refused_from_never_reported() {
948        let none = answer(&BTreeMap::new(), &[], &[], &[], true, None, None);
949        assert_eq!(
950            none.iter().find(|q| q.id == "ci").unwrap().status,
951            GateStatus::Exempt
952        );
953
954        let silent = vec![
955            gate("a", GateStatus::Queued, true),
956            gate("b", GateStatus::Pass, true),
957        ];
958        let q = answer(&BTreeMap::new(), &[], &silent, &[], true, None, None);
959        assert_eq!(
960            q.iter().find(|q| q.id == "ci").unwrap().status,
961            GateStatus::Queued
962        );
963
964        let red = vec![
965            gate("a", GateStatus::Fail, true),
966            gate("b", GateStatus::Queued, true),
967        ];
968        let q = answer(&BTreeMap::new(), &[], &red, &[], true, None, None);
969        let ci = q.iter().find(|q| q.id == "ci").unwrap();
970        assert_eq!(ci.status, GateStatus::Fail, "a refusal outranks a silence");
971        assert!(ci.evidence.contains("1 refusing") && ci.evidence.contains("1 never reported"));
972
973        let green = vec![
974            gate("a", GateStatus::Pass, true),
975            gate("b", GateStatus::Exempt, false),
976        ];
977        let q = answer(&BTreeMap::new(), &[], &green, &[], true, None, None);
978        assert_eq!(
979            q.iter().find(|q| q.id == "ci").unwrap().status,
980            GateStatus::Pass
981        );
982    }
983
984    #[test]
985    fn a_release_question_a_model_declares_no_gate_for_is_unknown_not_exempt() {
986        let q = answer(
987            &BTreeMap::new(),
988            &[],
989            &[gate("version-surface", GateStatus::Pass, true)],
990            &[],
991            true,
992            None,
993            None,
994        );
995        assert_eq!(
996            q.iter().find(|q| q.id == "version").unwrap().status,
997            GateStatus::Pass
998        );
999        let missing = q.iter().find(|q| q.id == "changelog").unwrap();
1000        assert_eq!(missing.status, GateStatus::Unknown);
1001        assert!(missing.evidence.contains("declares no gate"));
1002    }
1003
1004    #[test]
1005    fn regression_is_answered_from_the_change_set_alone() {
1006        let q = answer(
1007            &BTreeMap::new(),
1008            &[],
1009            &[],
1010            &["lib/a.sh".to_string()],
1011            true,
1012            None,
1013            None,
1014        );
1015        let r = q.iter().find(|q| q.id == "regression-tested").unwrap();
1016        assert_eq!(r.status, GateStatus::Queued);
1017        assert!(r.evidence.contains("no test path"));
1018
1019        let q = answer(
1020            &BTreeMap::new(),
1021            &[],
1022            &[],
1023            &[
1024                "lib/a.sh".to_string(),
1025                "test/cases/131_completion_gates.sh".to_string(),
1026            ],
1027            true,
1028            None,
1029            None,
1030        );
1031        assert_eq!(
1032            q.iter()
1033                .find(|q| q.id == "regression-tested")
1034                .unwrap()
1035                .status,
1036            GateStatus::Pass
1037        );
1038    }
1039
1040    #[test]
1041    fn a_test_path_is_recognised_the_way_the_validator_recognises_one() {
1042        assert!(is_test_path("test/cases/131.sh"));
1043        assert!(is_test_path("apps/majordomus-cli/tests/cli.rs"));
1044        assert!(is_test_path("src/foo_test.rs"));
1045        assert!(is_test_path("web/a.spec.ts"));
1046        assert!(!is_test_path("lib/check.sh"));
1047        assert!(
1048            !is_test_path("docs/latest.md"),
1049            "a substring is not a segment"
1050        );
1051    }
1052
1053    /// The verdict is the answer: a converged repository passes, and an unconverged one
1054    /// fails with the holdings named. Before this, the question answered "nothing in this
1055    /// report reaches that fact" no matter what the repository held.
1056    #[test]
1057    fn no_stale_topology_is_answered_from_the_convergence_verdict() {
1058        use crate::convergence::{ConvergenceReport, Disposition, Holding, HoldingKind};
1059
1060        let at_risk = ConvergenceReport {
1061            schema: "convergence/v1".into(),
1062            repository: "/tmp/r".into(),
1063            trunk: Some("master".into()),
1064            holdings: vec![Holding {
1065                kind: HoldingKind::Branch,
1066                identity: "feature/never-pushed".into(),
1067                disposition: Disposition::LocalOnly,
1068                evidence: "abc on no remote".into(),
1069                remedy: "git push -u origin feature/never-pushed".into(),
1070                at_risk: true,
1071            }],
1072            tallies: Default::default(),
1073            at_risk: 1,
1074            converged: false,
1075        };
1076        let q = answer(&BTreeMap::new(), &[], &[], &[], true, Some(&at_risk), None);
1077        let question = q.iter().find(|q| q.id == "no-stale-topology").unwrap();
1078        assert_eq!(question.status, GateStatus::Fail);
1079        assert!(
1080            question.evidence.contains("feature/never-pushed"),
1081            "the finding must name the holding, not only count it: {}",
1082            question.evidence
1083        );
1084        assert_eq!(question.source, "convergence.report");
1085
1086        let converged = ConvergenceReport {
1087            holdings: vec![],
1088            at_risk: 0,
1089            converged: true,
1090            ..at_risk
1091        };
1092        let q = answer(
1093            &BTreeMap::new(),
1094            &[],
1095            &[],
1096            &[],
1097            true,
1098            Some(&converged),
1099            None,
1100        );
1101        assert_eq!(
1102            q.iter()
1103                .find(|q| q.id == "no-stale-topology")
1104                .unwrap()
1105                .status,
1106            GateStatus::Pass
1107        );
1108    }
1109
1110    /// A verdict that could not be read is not a clean verdict: the question is unknown,
1111    /// never a pass. The rule about absence, applied to the one source that can be absent.
1112    #[test]
1113    fn an_unreadable_verdict_is_unknown_and_never_a_pass() {
1114        let q = answer(&BTreeMap::new(), &[], &[], &[], true, None, None);
1115        let question = q.iter().find(|q| q.id == "no-stale-topology").unwrap();
1116        assert_eq!(question.status, GateStatus::Unknown);
1117        assert!(!question.status.refuses());
1118        assert!(question.status.unverified());
1119    }
1120
1121    #[test]
1122    fn a_question_nothing_here_reaches_says_so_and_names_the_command() {
1123        let q = answer(&BTreeMap::new(), &[], &[], &[], true, None, None);
1124        for id in ["parity", "handover", "issue"] {
1125            let question = q.iter().find(|q| q.id == id).unwrap();
1126            assert_eq!(question.status, GateStatus::Unknown, "{id}");
1127            assert!(
1128                question.evidence.contains("majordomus"),
1129                "{id} names no command"
1130            );
1131        }
1132    }
1133}