Skip to main content

majordomus_cli/capability/builtin/
gates.rs

1//! The `gates` module: completion as a state the repository decides.
2//!
3//! Two capabilities, and the split is the same one [`super::obligations`] makes for the
4//! same reason.
5//!
6//! **The model is declared.** `.ai/repo/ci/gates.yaml` is this repository's CI model: every
7//! validation gate, the job that runs it, the command it runs, and the path classes that
8//! decide which change selects which gate. `gates.model` reads it and answers in any
9//! checkout that has one, whether or not the lifecycle has ever run.
10//!
11//! **The completion is local.** Whether *this* task may be called finished depends on the
12//! task record and the ledger under `.ai/local/state/`, which is one checkout's own state
13//! and is never tracked or published. `gates.completion` answers about the checkout the
14//! process was started in and about no other.
15//!
16//! **It is read, never written.** `majordomus evidence --gate` records a run; this judges
17//! the record. A second writer for the same ledger would be a second account of events,
18//! which is what ADR 0030 refuses.
19//!
20//! **One judgement, and every surface reads it.** The shell tool's `check` and `finish`
21//! call this executable rather than re-deriving the answer, the HTTP route serves the same
22//! execution, MCP exposes the same tool, and the Cockpit renders the same document. That is
23//! the property the module exists for: a worker cannot get a different verdict by asking a
24//! different surface, and no surface can be persuaded that "done" is true because an agent
25//! said so.
26//!
27//! ```
28//! use majordomus_cli::capability::builtin::gates::module;
29//!
30//! // the whole module, as every projection reads it: two capabilities, and neither has a
31//! // command-line arm of its own because `check` and `finish` are the command line's answer
32//! let m = module();
33//! let ids: Vec<&str> = m.capabilities.iter().map(|c| c.capability.id.as_str()).collect();
34//! assert_eq!(ids, ["gates.model", "gates.completion"]);
35//! for entry in &m.capabilities {
36//!     assert!(entry.capability.exposure.cli.is_none());
37//!     assert!(entry.capability.exposure.http.is_some());
38//! }
39//! ```
40
41use std::collections::BTreeMap;
42use std::path::{Path, PathBuf};
43
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46
47use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
48use crate::capability::handler::{CapabilityError, Context};
49use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
50use crate::capability::module::ModuleDescriptor;
51use crate::gates::{self, judge, model, Completion};
52use crate::{capability, module};
53
54use super::get;
55use super::obligations::Obligation;
56use super::views::Empty;
57
58/// The URI under which the CI model is read as an MCP resource.
59pub const GATES_URI: &str = "majordomus://gates";
60
61/// The URI under which this checkout's completion state is read as an MCP resource.
62pub const COMPLETION_URI: &str = "majordomus://gates/completion";
63
64// ---------------------------------------------------------------- output types
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67/// The CI model this repository declares: what may refuse a change, and what selects each.
68///
69/// Two things about this report are worth knowing before reading one. `count` is measured
70/// from `gates` rather than written down, so the two can never disagree the way a
71/// hand-maintained total does. And `unreadable` is how a reader tells "this repository
72/// declares no CI model" from "the call failed": a repository of the layer is not obliged
73/// to declare gates, so the empty model is an answer, and it says why it is empty.
74///
75/// ```
76/// use majordomus_cli::capability::builtin::gates::GateModelReport;
77/// use serde_json::json;
78///
79/// // the shape every surface serves: `unreadable` is skipped when the model was read
80/// let r: GateModelReport = serde_json::from_value(json!({
81///     "version": 1,
82///     "source": ".ai/repo/ci/gates.yaml",
83///     "count": 0,
84///     "on_demand": [],
85///     "gates": [],
86///     "classes": [],
87/// })).unwrap();
88/// assert_eq!(r.count, r.gates.len(), "the total is measured, never declared");
89/// assert!(r.unreadable.is_none(), "read, and it declares nothing");
90///
91/// // and the other empty answer, which a reader must not confuse with that one
92/// let absent: GateModelReport = serde_json::from_value(json!({
93///     "version": 0,
94///     "source": ".ai/repo/ci/gates.yaml",
95///     "count": 0,
96///     "on_demand": [],
97///     "gates": [],
98///     "classes": [],
99///     "unreadable": "no such file",
100/// })).unwrap();
101/// assert_eq!(absent.unreadable.as_deref(), Some("no such file"));
102/// ```
103pub struct GateModelReport {
104    /// The schema version the file states.
105    pub version: u32,
106    /// Where it was read from, repository-relative.
107    pub source: String,
108    /// How many gates it declares; measured, not written down.
109    pub count: usize,
110    /// The gates whose runner cannot be had on demand, so no routine plan selects them.
111    /// A gate that never runs protects nothing, and this is where that is said out loud.
112    pub on_demand: Vec<String>,
113    /// Every gate, in declaration order, with the pathspecs its evidence is taken over.
114    pub gates: Vec<GateModelEntry>,
115    /// Every path class, in declaration order.
116    pub classes: Vec<model::GateClass>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    /// Why there is no model to report, when there is none. A repository of the layer does
119    /// not have to declare a CI model, and a reader is entitled to the difference between
120    /// "this repository declares no gates" and "this call failed".
121    pub unreadable: Option<String>,
122}
123
124/// ```
125/// use majordomus_cli::capability::builtin::gates::GateModelEntry;
126/// use majordomus_cli::gates::GateDecl;
127///
128/// let decl: GateDecl = serde_json::from_value(serde_json::json!({
129///     "id": "site-check",
130///     "job": "site",
131///     "runs": "scripts/site-check",
132///     "summary": "the site is whole"
133/// }))
134/// .unwrap();
135/// let entry = GateModelEntry { declared: decl, inputs: vec!["site/**".into()] };
136/// // the declaration as the file carries it, plus the paths its evidence is taken over —
137/// // derived from the classes that select it, never written down a second time
138/// assert_eq!(entry.declared.id, "site-check");
139/// assert_eq!(entry.inputs, ["site/**"]);
140/// ```
141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142/// One gate of the model, with what the model implies about it.
143pub struct GateModelEntry {
144    #[serde(flatten)]
145    /// The declaration as the file carries it.
146    pub declared: model::GateDecl,
147    /// The pathspecs a run of this gate is evidence over, derived from the classes that
148    /// select it. A change to any of them makes a recorded run stale.
149    pub inputs: Vec<String>,
150}
151
152// ---------------------------------------------------------------- input
153
154/// ```
155/// use majordomus_cli::capability::builtin::gates::CompletionInput;
156///
157/// // the default asks about the active task's own change set
158/// let mine = CompletionInput::default();
159/// assert!(mine.changed.is_none() && mine.base.is_none());
160///
161/// // and a caller may name the change instead, which is how a test drives it
162/// let named = CompletionInput {
163///     changed: Some(vec!["docs/CLI.md".into()]),
164///     ..Default::default()
165/// };
166/// assert_eq!(named.changed.unwrap(), ["docs/CLI.md"]);
167/// ```
168#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170/// Which change to judge completion over.
171pub struct CompletionInput {
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    /// The changed paths, repository-relative. Absent means the task's own change set:
174    /// everything between the commit the task started at and the working tree.
175    pub changed: Option<Vec<String>>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    /// The commit to compare with, when the task's own starting commit is not wanted.
178    pub base: Option<String>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    /// Also plan the gates the model marks on-demand. Off by default, because a plan that
181    /// selects a gate whose runner is unavailable is a plan whose verdict never arrives.
182    pub on_demand: Option<bool>,
183}
184
185impl BenchmarkCases for CompletionInput {
186    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
187        vec![
188            NamedCase::new("this-task", CompletionInput::default()),
189            NamedCase::new(
190                "one-source-file",
191                CompletionInput {
192                    changed: Some(vec!["apps/majordomus-cli/src/lib.rs".into()]),
193                    ..Default::default()
194                },
195            ),
196            NamedCase::new(
197                "a-document",
198                CompletionInput {
199                    changed: Some(vec!["docs/CLI.md".into()]),
200                    ..Default::default()
201                },
202            ),
203            NamedCase::new(
204                "everything-on-demand",
205                CompletionInput {
206                    changed: Some(vec![".ai/repo/ci/gates.yaml".into()]),
207                    on_demand: Some(true),
208                    ..Default::default()
209                },
210            ),
211        ]
212    }
213}
214
215// ---------------------------------------------------------------- handlers
216
217/// The vocabulary the distribution ships, read the way every other reader locates it.
218fn vocabulary(ctx: &Context) -> Result<Vec<Obligation>, String> {
219    let root = PathBuf::from(&ctx.index.repository.root);
220    let share = crate::share::Share::locate(ctx.index.share.as_deref(), &root)
221        .map_err(|e| format!("no distribution to read the obligation vocabulary from: {e}"))?;
222    let path = share.dir().join("obligations.yaml");
223    let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
224    #[derive(Deserialize)]
225    struct File {
226        obligations: Vec<Obligation>,
227    }
228    let file: File =
229        crate::metadata::yaml::parse_into(&text).map_err(|e| format!("{}: {e}", path.display()))?;
230    Ok(file.obligations)
231}
232
233/// The CI model, or an empty one and the reason there is none.
234///
235/// A repository of the layer does not have to declare gates, and every reader of this module
236/// already says so in its own way: `lib/gates.sh` skips with "this repository declares no
237/// readable CI model" rather than failing. Refusing the call instead would make a capability
238/// that cannot answer about most repositories — and the generic tests that replay every
239/// capability's benchmark cases against a fixture are exactly where that showed.
240fn load_model(root: &Path) -> (model::GateModel, Option<String>) {
241    match model::GateModel::load(root) {
242        Ok(m) => (m, None),
243        Err(reason) => (
244            model::GateModel {
245                version: 0,
246                gates: Vec::new(),
247                classes: Vec::new(),
248            },
249            Some(reason),
250        ),
251    }
252}
253
254fn gates_model(ctx: &Context, _: Empty) -> Result<GateModelReport, CapabilityError> {
255    let root = PathBuf::from(&ctx.index.repository.root);
256    let (m, unreadable) = load_model(&root);
257    Ok(GateModelReport {
258        version: m.version,
259        source: model::MODEL_PATH.to_string(),
260        count: m.gates.len(),
261        on_demand: m
262            .gates
263            .iter()
264            .filter(|g| g.on_demand)
265            .map(|g| g.id.clone())
266            .collect(),
267        gates: m
268            .gates
269            .iter()
270            .map(|g| GateModelEntry {
271                inputs: m.inputs_of(&g.id),
272                declared: g.clone(),
273            })
274            .collect(),
275        classes: m.classes.clone(),
276        unreadable,
277    })
278}
279
280fn gates_completion(ctx: &Context, input: CompletionInput) -> Result<Completion, CapabilityError> {
281    let root = PathBuf::from(&ctx.index.repository.root);
282    let mut findings: Vec<String> = Vec::new();
283
284    let (m, unreadable) = load_model(&root);
285    if let Some(reason) = unreadable {
286        // no model is not no answer: every gate is unknown, said out loud, and `finishable`
287        // is left to the obligations rather than granted by a silence
288        findings.push(format!(
289            "this repository declares no readable CI model at {}, so no gate can be judged              here — unknown, never a pass: {reason}",
290            model::MODEL_PATH
291        ));
292    }
293    let task = super::continuity::read_task(&gates::task_path(&root));
294    if task.is_none() {
295        findings.push(format!(
296            "no active task in this checkout ({}); the gates are reported against the \
297             working tree and nothing is attributed to a task",
298            gates::task_path(&root)
299                .strip_prefix(&root)
300                .unwrap_or(&gates::task_path(&root))
301                .display()
302        ));
303    }
304
305    // the change set: what was asked for, or the task's own — from its starting commit to
306    // the working tree, the uncommitted half included
307    let changed = match input.changed {
308        Some(list) => list,
309        None => {
310            let base = input
311                .base
312                .clone()
313                .or_else(|| task.as_ref().map(|t| t.head.clone()));
314            match gates::changed_paths(&root, base.as_deref()) {
315                Ok(paths) => paths,
316                Err(reason) => {
317                    findings.push(format!(
318                        "the change set could not be read from git, so applicability is \
319                         computed over nothing and every gate is reported as it stands: {reason}"
320                    ));
321                    Vec::new()
322                }
323            }
324        }
325    };
326
327    let vocab = match vocabulary(ctx) {
328        Ok(v) => v,
329        Err(reason) => {
330            findings.push(format!(
331                "the obligation vocabulary could not be read, so no obligation can be \
332                 implied from the change: {reason}"
333            ));
334            Vec::new()
335        }
336    };
337
338    let (runs, skipped) = match &task {
339        Some(t) => judge::runs_for(&gates::ledger_path(&root), &t.id),
340        None => (BTreeMap::new(), 0),
341    };
342    if skipped > 0 {
343        findings.push(format!(
344            "{skipped} ledger line(s) could not be read and were skipped; run `majordomus doctor`"
345        ));
346    }
347
348    // one hash per gate, over the files that select it. Taken here rather than inside the
349    // judgement so that a test drives the same function with hashes of its own.
350    // The gates overlap, so each file's digest is taken once for the whole report.
351    let mut hashes: BTreeMap<String, Option<String>> = BTreeMap::new();
352    let mut digests = super::obligations::FileHashes::default();
353    for gate in &m.gates {
354        let specs = m.inputs_of(&gate.id);
355        let hash =
356            super::obligations::inputs_hash_with(&root, &specs, &mut digests).map(|(h, _)| h);
357        // a token with no inputs hashes to the empty string in the ledger, and is compared
358        // as such; `None` here means the hash could not be taken at all
359        hashes.insert(
360            gate.id.clone(),
361            if specs.is_empty() {
362                Some(String::new())
363            } else {
364                hash
365            },
366        );
367    }
368
369    // The obligation half of the done invariant is `obligations.closure`'s judgement, asked
370    // for through the same executor MCP and the HTTP routes use, so this report cannot
371    // disagree with `majordomus check` about whether an obligation stands. Nothing is
372    // re-judged here; the state word comes back and is translated once.
373    let mut standing: BTreeMap<String, gates::ObligationStanding> = BTreeMap::new();
374    let mut closure_reachable = false;
375    match ctx.execute("obligations.closure", serde_json::json!({})) {
376        Ok(value) => match serde_json::from_value::<super::obligations::Closure>(value) {
377            Ok(closure) => {
378                closure_reachable = true;
379                for o in closure.obligations {
380                    standing.insert(
381                        o.id.clone(),
382                        gates::ObligationStanding {
383                            state: o.state.as_str().to_string(),
384                            detail: o.detail,
385                            reproduce: o.reproduce,
386                        },
387                    );
388                }
389            }
390            Err(e) => findings.push(format!(
391                "obligations.closure answered something this report cannot read, so every \
392                 obligation question is unknown rather than passing: {e}"
393            )),
394        },
395        Err(e) => findings.push(format!(
396            "obligations.closure could not be executed, so every obligation question is \
397             unknown rather than passing: {e}"
398        )),
399    }
400
401    // The topology half of the done invariant — "is a branch, worktree or pull request left
402    // behind?" — is `convergence.report`'s verdict, asked through the same executor for the
403    // same reason: one measurement of where this repository's work is held, not two.
404    let convergence = match ctx.execute("convergence.report", serde_json::json!({})) {
405        Ok(value) => match serde_json::from_value::<crate::convergence::ConvergenceReport>(value) {
406            Ok(report) => Some(report),
407            Err(e) => {
408                findings.push(format!(
409                    "convergence.report answered something this report cannot read, so whether \
410                     work is left behind is unknown rather than passing: {e}"
411                ));
412                None
413            }
414        },
415        Err(e) => {
416            findings.push(format!(
417                "convergence.report could not be executed, so whether work is left behind is \
418                 unknown rather than passing: {e}"
419            ));
420            None
421        }
422    };
423
424    // The closing half of the done invariant — did the recorded debt grow, is a branch or a
425    // worktree created since the task started still here, is a pull request opened since
426    // still open — is read from git and the recorded forge observation, measured from where
427    // the task started. With no task there is no start, and the three stay unknown.
428    let closing = task
429        .as_ref()
430        .map(|t| gates::closing::read(&root, &t.head, &t.started_at));
431
432    Ok(gates::complete(
433        &m,
434        &changed,
435        task.as_ref(),
436        &vocab,
437        &runs,
438        &hashes,
439        &standing,
440        closure_reachable,
441        convergence.as_ref(),
442        closing.as_ref(),
443        input.on_demand.unwrap_or(false),
444        &crate::peers::rfc3339(std::time::SystemTime::now()),
445        findings,
446    ))
447}
448
449// ---------------------------------------------------------------- the module
450
451/// The `gates` module: the CI model every clone shares, and the completion state only this
452/// checkout can answer.
453///
454/// Neither has a command-line projection of its own, for the reason [`super::obligations`]
455/// states about the local half: `majordomus check` and `majordomus finish` are already the
456/// command line's answer to this question, and they read this module rather than deriving a
457/// second one. What is new is that they now have a gate half to read.
458///
459/// ```
460/// use majordomus_cli::capability::builtin::gates::module;
461/// let m = module();
462/// assert_eq!(m.id.as_str(), "gates");
463/// // every projection is derived from the declaration and written nowhere else
464/// let routes: Vec<&str> = m
465///     .capabilities
466///     .iter()
467///     .filter_map(|e| e.capability.exposure.http.as_ref().map(|h| h.path.as_str()))
468///     .collect();
469/// assert_eq!(routes, ["/api/v1/gates", "/api/v1/gates/completion"]);
470/// ```
471pub fn module() -> ModuleDescriptor {
472    module! {
473        id: "gates",
474        title: "Completion gates",
475        description: "Completion as a state the repository decides rather than a claim a worker makes: the validation gates this repository declares in its CI model, which of them a task's own change set selects, what each one last reported, whether that verdict still describes the tree it was taken over, and therefore whether the task may be called finished. A gate that has never reported is `queued` and not `pass`, a run whose files have changed since is `stale` and refuses exactly as a failure does, and a gate the change cannot affect is `exempt` rather than unknown.",
476        stability: Stability::BehaviorallyVerified,
477        capabilities: [
478            capability! {
479                id: "gates.model",
480                title: "The CI model",
481                description: "Every validation gate this repository declares — the job that runs it, the command it runs, what it proves, whether every plan selects it and whether its runner can be had on demand at all — with the path classes that decide which change selects which gate, and, derived from those classes, the pathspecs each gate's evidence is taken over.",
482                input: Empty,
483                output: GateModelReport,
484                stability: Stability::BehaviorallyVerified,
485                exposure: Exposure {
486                    mcp: Some(McpExposure {
487                        tool: Some("majordomus_gates".into()),
488                        resource: Some(McpResource { uri: GATES_URI.into(), name: "gates".into() }),
489                    }),
490                    http: get("/api/v1/gates"),
491                    cli: None,
492                },
493                tags: ["gates", "ci", "completion", "introspection"],
494                cache: CachePolicy::Process { max_entries: 4, ttl_seconds: Some(5) },
495                handler: gates_model,
496            },
497            capability! {
498                id: "gates.completion",
499                title: "Whether this task may be called finished",
500                description: "The active task's own change set — from the commit it started at to the working tree, uncommitted files included — put through the CI model: which gates it selects and why, what each one last reported and over which files, which verdicts have gone stale because the tree moved underneath them, which required gates have never reported at all, and which obligations the change implies whether or not the task declared them. `finishable` is false only when a required gate is known to be failing, stale or blocked by one that is; absence of a verdict is reported as unverified rather than silently accepted.",
501                input: CompletionInput,
502                output: Completion,
503                stability: Stability::BehaviorallyVerified,
504                exposure: Exposure {
505                    mcp: Some(McpExposure {
506                        tool: Some("majordomus_completion".into()),
507                        resource: Some(McpResource { uri: COMPLETION_URI.into(), name: "completion".into() }),
508                    }),
509                    http: get("/api/v1/gates/completion"),
510                    cli: None,
511                },
512                tags: ["gates", "completion", "tasks", "evidence"],
513                cache: CachePolicy::Disabled,
514                handler: gates_completion,
515            },
516        ],
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn the_module_declares_what_it_claims() {
526        let m = module();
527        assert_eq!(m.id.as_str(), "gates");
528        assert_eq!(m.capabilities.len(), 2);
529        for e in &m.capabilities {
530            assert_eq!(e.capability.id.namespace(), "gates");
531            assert!(
532                e.capability.exposure.mcp.is_some() && e.capability.exposure.http.is_some(),
533                "{} must be readable on every surface without one recomputing it",
534                e.capability.id
535            );
536        }
537    }
538
539    #[test]
540    fn the_completion_answer_is_never_cached() {
541        // a completion state cached for even a few seconds is a state that can say
542        // `finishable` about a tree that has already changed, which is the defect
543        let m = module();
544        let c = m
545            .capabilities
546            .iter()
547            .find(|e| e.capability.id.as_str() == "gates.completion")
548            .expect("declared");
549        assert!(matches!(c.capability.cache, CachePolicy::Disabled));
550    }
551
552    #[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
553    struct NotAVerdict {
554        converged: String,
555    }
556
557    fn not_a_verdict(_: &Context, _: super::super::Empty) -> Result<NotAVerdict, CapabilityError> {
558        Ok(NotAVerdict {
559            converged: "perhaps".into(),
560        })
561    }
562
563    /// The completion report over `modules`, asked of a synthetic repository with no git.
564    fn completion_with(
565        modules: Vec<crate::capability::module::ModuleDescriptor>,
566    ) -> crate::gates::Completion {
567        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
568        let index = repo.index().unwrap();
569        let registry = crate::capability::CapabilityRegistry::builder()
570            .with_modules(modules)
571            .with_index(&index)
572            .build()
573            .expect("the registry builds");
574        let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
575        let value = ctx
576            .execute("gates.completion", serde_json::json!({ "changed": [] }))
577            .expect("the completion report answers");
578        serde_json::from_value(value).expect("a completion report")
579    }
580
581    /// With a task record there is a start to measure from, so the closing readings are taken
582    /// and reach the report. Here there is no git, so the debt cannot be compared — and the
583    /// question says that, where before the task existed it said there was no start.
584    #[test]
585    fn a_task_brings_the_closing_readings_into_the_report() {
586        let debt_of = |c: &crate::gates::Completion| {
587            c.questions
588                .iter()
589                .find(|q| q.id == "no-new-debt")
590                .expect("the invariant asks it")
591                .clone()
592        };
593        let without = debt_of(&completion_with(super::super::modules()));
594        assert_eq!(without.status, crate::gates::GateStatus::Unknown);
595        assert!(
596            without.evidence.contains("no task record"),
597            "{}",
598            without.evidence
599        );
600
601        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
602        let record = crate::gates::task_path(repo.root());
603        std::fs::create_dir_all(record.parent().unwrap()).unwrap();
604        std::fs::write(
605            &record,
606            "id: t-1\ntask: \"closing\"\nprofile: implementation\noutcome: active\n\
607             started_at: 2026-10-10T12:00:00Z\nhead: 0123456789012345678901234567890123456789\n",
608        )
609        .unwrap();
610        let index = repo.index().unwrap();
611        let registry = crate::capability::CapabilityRegistry::builder()
612            .with_modules(super::super::modules())
613            .with_index(&index)
614            .build()
615            .expect("the registry builds");
616        let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
617        let value = ctx
618            .execute("gates.completion", serde_json::json!({ "changed": [] }))
619            .expect("the completion report answers");
620        let with: crate::gates::Completion = serde_json::from_value(value).unwrap();
621        assert!(with.present, "the task record was read");
622        let debt = debt_of(&with);
623        assert_eq!(
624            debt.status,
625            crate::gates::GateStatus::Unknown,
626            "unread is never a pass"
627        );
628        assert!(
629            debt.evidence.contains("could not be compared"),
630            "{}",
631            debt.evidence
632        );
633    }
634
635    /// Whether work is left behind is `convergence.report`'s answer. When that answer cannot
636    /// be had — the capability refused, or it answered in a shape this report cannot read —
637    /// the question is unknown and a finding says which, never a pass.
638    #[test]
639    fn a_convergence_answer_that_cannot_be_had_or_read_is_unknown_and_says_why() {
640        let stale = |c: &crate::gates::Completion| {
641            c.questions
642                .iter()
643                .find(|q| q.id == "no-stale-topology")
644                .expect("the invariant asks it")
645                .status
646        };
647
648        // no git here, so the real capability refuses
649        let refused = completion_with(super::super::modules());
650        assert!(
651            refused
652                .findings
653                .iter()
654                .any(|f| f.starts_with("convergence.report could not be executed")),
655            "{:?}",
656            refused.findings
657        );
658        assert_eq!(stale(&refused), crate::gates::GateStatus::Unknown);
659
660        let impostor = crate::module! {
661            id: "convergence",
662            title: "Convergence",
663            description: "Answers in a shape the completion report cannot read.",
664            stability: Stability::Experimental,
665            capabilities: [
666                crate::capability! {
667                    id: "convergence.report", title: "Not a verdict",
668                    description: "Something else.",
669                    input: super::super::Empty, output: NotAVerdict,
670                    stability: Stability::Experimental,
671                    exposure: crate::capability::model::Exposure::default(), tags: [],
672                    handler: not_a_verdict,
673                },
674            ],
675        };
676        let mut modules: Vec<_> = super::super::modules()
677            .into_iter()
678            .filter(|m| m.id.as_str() != "convergence")
679            .collect();
680        modules.push(impostor);
681        let unreadable = completion_with(modules);
682        assert!(
683            unreadable
684                .findings
685                .iter()
686                .any(|f| f
687                    .starts_with("convergence.report answered something this report cannot read")),
688            "{:?}",
689            unreadable.findings
690        );
691        assert_eq!(stale(&unreadable), crate::gates::GateStatus::Unknown);
692    }
693}