Skip to main content

majordomus_cli/capability/builtin/
why.rs

1//! The `why` module: the operational moments this tool answers, the audiences that
2//! recognise them, the areas they fall under, and what a reader's own symptoms imply.
3//!
4//! Every capability here reads [`crate::why::Catalogue`], which was built once from the
5//! index. Nothing in this file parses a file, holds a list of moments, or knows the name
6//! of one: a moment added to `.ai/repo/why/moments/` appears in every answer below
7//! because the index discovered it, and this module never learns its name.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
13use crate::capability::handler::{CapabilityError, Context};
14use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
15use crate::capability::module::ModuleDescriptor;
16use crate::model::Severity;
17use crate::why::{Area, Audience, Catalogue, Diagnosis, Facets, Finding, Moment, Query, STABLE};
18use crate::{capability, module};
19
20use super::{get, mcp, Empty};
21
22/// The URI under which the whole catalogue is read as an MCP resource.
23pub const WHY_URI: &str = "majordomus://why";
24/// The URI under which the audiences are read.
25pub const AUDIENCES_URI: &str = "majordomus://why/audiences";
26/// The URI under which the areas are read.
27pub const AREAS_URI: &str = "majordomus://why/areas";
28
29/// The dataset's own format version, so a consumer can refuse a shape it does not read.
30pub const SCHEMA: &str = "majordomus/why-catalogue/v1";
31
32// ---------------------------------------------------------------- views
33
34/// One moment as a listing shows it: everything a card needs and nothing a page needs.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36pub struct MomentSummary {
37    /// The identity and the slug.
38    pub id: String,
39    /// The moment as a heading.
40    pub title: String,
41    /// The name a narrow column shows: the short title, or the title.
42    pub label: String,
43    /// The first-person line an index shows.
44    pub hook: String,
45    /// One line: what is actually wrong.
46    pub summary: String,
47    /// `stable`, `draft` or `deprecated`.
48    pub status: String,
49    /// `low`, `medium` or `high`.
50    pub severity: String,
51    /// `rare`, `occasional`, `common` or `constant`.
52    pub frequency: String,
53    /// Presentation order.
54    pub weight: u32,
55    /// Whether the homepage shows it.
56    pub featured: bool,
57    /// The audiences that recognise it.
58    pub audiences: Vec<String>,
59    /// The operational areas it falls under.
60    pub areas: Vec<String>,
61    /// Free tags.
62    pub tags: Vec<String>,
63    /// Derived: `/why/<id>/`.
64    pub route: String,
65    /// How many observable symptoms it declares.
66    pub signals: usize,
67    /// How many concrete situations it carries.
68    pub examples: usize,
69}
70
71impl MomentSummary {
72    fn of(m: &Moment) -> Self {
73        MomentSummary {
74            id: m.id.clone(),
75            title: m.title.clone(),
76            label: m.label().to_string(),
77            hook: m.hook.clone(),
78            summary: m.summary.clone(),
79            status: m.status.clone(),
80            severity: m.severity.clone(),
81            frequency: m.frequency.clone(),
82            weight: m.weight,
83            featured: m.featured,
84            audiences: m.audiences.clone(),
85            areas: m.areas.clone(),
86            tags: m.tags.clone(),
87            route: m.route.clone(),
88            signals: m.signals.len(),
89            examples: m.examples.len(),
90        }
91    }
92}
93
94/// How much the catalogue holds. Every count anywhere — a page, a heading, a report — is
95/// one of these, so no number is ever written down.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97pub struct Counts {
98    /// Public moments.
99    pub moments: usize,
100    /// Moments of every status, drafts included.
101    pub moments_all: usize,
102    /// Public audiences.
103    pub audiences: usize,
104    /// Public areas.
105    pub areas: usize,
106    /// Signals across the public moments: the size of the questionnaire.
107    pub signals: usize,
108    /// Concrete situations across the public moments.
109    pub examples: usize,
110}
111
112/// The whole catalogue as a client reads it.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114pub struct CatalogueView {
115    /// [`SCHEMA`].
116    pub schema: String,
117    /// The hash of the catalogue's own sources: stable for a tree, and independent of the
118    /// rest of the index, so two runs over one tree agree.
119    pub fingerprint: String,
120    /// The counts.
121    pub counts: Counts,
122    /// The matching moments, in presentation order.
123    pub moments: Vec<MomentSummary>,
124    /// Every audience, with its derived membership.
125    pub audiences: Vec<AudienceView>,
126    /// Every area, with its derived membership.
127    pub areas: Vec<AreaView>,
128    /// The filters, derived from the records.
129    pub facets: Facets,
130}
131
132/// One audience, with the membership nobody authored.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134pub struct AudienceView {
135    /// The record as its file declares it, plus its route and source.
136    #[serde(flatten)]
137    pub audience: Audience,
138    /// Derived: the public moments that name it, in presentation order.
139    pub moments: Vec<String>,
140    /// Derived: how many.
141    pub count: usize,
142}
143
144/// One area, with the membership nobody authored.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
146pub struct AreaView {
147    /// The record as its file declares it, plus its route and source.
148    #[serde(flatten)]
149    pub area: Area,
150    /// Derived: the public moments that fall under it, in presentation order.
151    pub moments: Vec<String>,
152    /// Derived: how many.
153    pub count: usize,
154}
155
156/// One moment in full, with everything derived that a page shows and no file states.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
158pub struct MomentDetail {
159    /// The record as its file declares it, plus its route, source and body.
160    #[serde(flatten)]
161    pub moment: Moment,
162    /// Derived: the responsibilities the claims it names belong to.
163    pub responsibilities: Vec<String>,
164    /// Derived: the moments that name this one in their `related`.
165    pub backlinks: Vec<String>,
166    /// Derived: moments sharing an area or an audience with this one and not already
167    /// named by it, nearest first — most shared metadata, then presentation order.
168    pub similar: Vec<String>,
169    /// Derived: the explicitly related moments, summarised so a page needs one call.
170    pub related_detail: Vec<MomentSummary>,
171    /// Derived: what a reader should see next, in one list and in a stated order — what
172    /// this moment names, then what names it, then what is nearest by shared metadata,
173    /// each appearing once. The three lists above are the same relations kept apart for a
174    /// reader that wants to know which is which.
175    pub neighbours: Vec<String>,
176}
177
178/// Every audience.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
180pub struct AudienceList {
181    /// How many.
182    pub count: usize,
183    /// Each, with its derived membership, in presentation order.
184    pub audiences: Vec<AudienceView>,
185}
186
187/// Every area.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
189pub struct AreaList {
190    /// How many.
191    pub count: usize,
192    /// Each, with its derived membership, in presentation order.
193    pub areas: Vec<AreaView>,
194}
195
196/// What the catalogue's own validation found.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
198pub struct ValidationReport {
199    /// True when nothing is an error. Warnings do not make it false.
200    pub valid: bool,
201    /// How many findings are errors: a reference that resolves to nothing, a duplicate
202    /// identity, a file name that disagrees with its id.
203    pub errors: usize,
204    /// How many findings are warnings: a public record that does not meet its floor.
205    pub warnings: usize,
206    /// The counts the catalogue reached.
207    pub counts: Counts,
208    /// The findings, errors first, then by file.
209    pub findings: Vec<Finding>,
210}
211
212// ---------------------------------------------------------------- inputs
213
214impl BenchmarkCases for Query {
215    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
216        let mut cases = vec![NamedCase::new("all", Query::default())];
217        if let Some(a) = ctx
218            .index
219            .objects
220            .iter()
221            .find(|o| o.kind == crate::why::AUDIENCE)
222        {
223            cases.push(NamedCase::new(
224                "by-audience",
225                Query {
226                    audience: Some(a.identity.clone()),
227                    ..Query::default()
228                },
229            ));
230        }
231        cases.push(NamedCase::new(
232            "search",
233            Query {
234                q: Some("context".into()),
235                ..Query::default()
236            },
237        ));
238        cases
239    }
240}
241
242/// Which moment to read.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
244#[serde(deny_unknown_fields)]
245pub struct MomentInput {
246    /// The moment's id, as `why.list` gives it. This is also its slug and its route.
247    pub id: String,
248}
249
250impl BenchmarkCases for MomentInput {
251    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
252        ctx.index
253            .objects
254            .iter()
255            .find(|o| o.kind == crate::why::MOMENT)
256            .map(|o| {
257                vec![NamedCase::new(
258                    "first-moment",
259                    MomentInput {
260                        id: o.identity.clone(),
261                    },
262                )]
263            })
264            // a repository without a catalogue still answers the route, with the refusal:
265            // the case times that answer, and the required parameter keeps an example in
266            // the OpenAPI document, as `deploy.get` does for a repository without a
267            // deployment
268            .unwrap_or_else(|| {
269                vec![NamedCase::new(
270                    "absent",
271                    MomentInput {
272                        id: "absent".into(),
273                    },
274                )]
275            })
276    }
277}
278
279/// What a reader recognised, as a comma-separated list of names.
280///
281/// A name is a signal id or a moment id: a person ticking symptoms and a script naming
282/// moments reach the same answer, because a signal resolves to the moment that owns it.
283/// The parameter is one string rather than a list because a diagnosis changes nothing and
284/// is therefore a `GET`, and this repository binds a `GET` input to the query string.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286#[serde(deny_unknown_fields)]
287pub struct DiagnoseInput {
288    /// Signal ids or moment ids, separated by commas. Empty selects nothing and is
289    /// answered with an empty diagnosis rather than an error.
290    #[serde(default)]
291    pub signals: String,
292}
293
294impl DiagnoseInput {
295    /// The names, trimmed, in the order given, without empties.
296    pub fn names(&self) -> Vec<String> {
297        self.signals
298            .split(',')
299            .map(str::trim)
300            .filter(|s| !s.is_empty())
301            .map(str::to_string)
302            .collect()
303    }
304}
305
306impl BenchmarkCases for DiagnoseInput {
307    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
308        let ids: Vec<String> = ctx
309            .index
310            .objects
311            .iter()
312            .filter(|o| o.kind == crate::why::MOMENT)
313            .take(3)
314            .map(|o| o.identity.clone())
315            .collect();
316        if ids.is_empty() {
317            // a repository without a catalogue: the empty selection the input documents,
318            // answered with an empty diagnosis, so that the parameter keeps an example
319            return vec![NamedCase::new(
320                "nothing",
321                DiagnoseInput {
322                    signals: String::new(),
323                },
324            )];
325        }
326        vec![NamedCase::new(
327            "three-moments",
328            DiagnoseInput {
329                signals: ids.join(","),
330            },
331        )]
332    }
333}
334
335// ---------------------------------------------------------------- handlers
336
337fn counts(c: &Catalogue) -> Counts {
338    let public: Vec<&Moment> = c.all().iter().filter(|m| m.status == STABLE).collect();
339    Counts {
340        moments: public.len(),
341        moments_all: c.all().len(),
342        audiences: c.audiences().iter().filter(|a| a.status == STABLE).count(),
343        areas: c.areas().iter().filter(|a| a.status == STABLE).count(),
344        signals: public.iter().map(|m| m.signals.len()).sum(),
345        examples: public.iter().map(|m| m.examples.len()).sum(),
346    }
347}
348
349fn audience_views(c: &Catalogue) -> Vec<AudienceView> {
350    c.audiences()
351        .iter()
352        .map(|a| {
353            let moments: Vec<String> = c
354                .naming("audience", &a.id)
355                .into_iter()
356                .filter(|m| m.status == STABLE)
357                .map(|m| m.id.clone())
358                .collect();
359            AudienceView {
360                audience: a.clone(),
361                count: moments.len(),
362                moments,
363            }
364        })
365        .collect()
366}
367
368fn area_views(c: &Catalogue) -> Vec<AreaView> {
369    c.areas()
370        .iter()
371        .map(|a| {
372            let moments: Vec<String> = c
373                .naming("area", &a.id)
374                .into_iter()
375                .filter(|m| m.status == STABLE)
376                .map(|m| m.id.clone())
377                .collect();
378            AreaView {
379                area: a.clone(),
380                count: moments.len(),
381                moments,
382            }
383        })
384        .collect()
385}
386
387fn why_list(ctx: &Context, input: Query) -> Result<CatalogueView, CapabilityError> {
388    let c = &ctx.why;
389    for (field, value, known) in [
390        (
391            "audience",
392            &input.audience,
393            c.audiences()
394                .iter()
395                .map(|a| a.id.clone())
396                .collect::<Vec<_>>(),
397        ),
398        (
399            "area",
400            &input.area,
401            c.areas().iter().map(|a| a.id.clone()).collect::<Vec<_>>(),
402        ),
403    ] {
404        if let Some(v) = value {
405            if !known.contains(v) {
406                return Err(CapabilityError::InvalidInput(format!(
407                    "{field} '{v}' is not one this catalogue has: {}",
408                    known.join(", ")
409                )));
410            }
411        }
412    }
413    Ok(CatalogueView {
414        schema: SCHEMA.into(),
415        fingerprint: c.fingerprint().into(),
416        counts: counts(c),
417        moments: c
418            .select(&input)
419            .into_iter()
420            .map(MomentSummary::of)
421            .collect(),
422        audiences: audience_views(c),
423        areas: area_views(c),
424        facets: c.facets(),
425    })
426}
427
428fn why_moment(ctx: &Context, input: MomentInput) -> Result<MomentDetail, CapabilityError> {
429    let c = &ctx.why;
430    let m = c.moment(&input.id).ok_or_else(|| {
431        CapabilityError::NotFound(format!(
432            "no moment '{}'; `why.list` names every one this repository holds",
433            input.id
434        ))
435    })?;
436    // "similar" is a deterministic graph traversal, not a recommendation: a moment scores
437    // the number of areas and audiences it shares with this one, and ties break on the
438    // presentation order the catalogue already has.
439    let mut scored: Vec<(usize, &Moment)> = c
440        .all()
441        .iter()
442        .filter(|o| o.id != m.id && o.status == STABLE && !m.related.contains(&o.id))
443        .map(|o| {
444            let shared = o.areas.iter().filter(|a| m.areas.contains(a)).count()
445                + o.audiences
446                    .iter()
447                    .filter(|a| m.audiences.contains(a))
448                    .count()
449                + o.tags.iter().filter(|t| m.tags.contains(t)).count();
450            (shared, o)
451        })
452        .filter(|(n, _)| *n > 0)
453        .collect();
454    scored.sort_by(|a, b| {
455        b.0.cmp(&a.0)
456            .then(a.1.weight.cmp(&b.1.weight))
457            .then(a.1.id.cmp(&b.1.id))
458    });
459    let similar: Vec<String> = scored
460        .into_iter()
461        .take(6)
462        .map(|(_, o)| o.id.clone())
463        .collect();
464    let backlinks = c.backlinks(&m.id).to_vec();
465    // what a reader should see next, in one list and in a stated order: what this moment
466    // names, then what names it, then what is nearest by shared metadata, each once
467    let mut neighbours: Vec<String> = Vec::new();
468    for id in m
469        .related
470        .iter()
471        .chain(backlinks.iter())
472        .chain(similar.iter())
473    {
474        if id != &m.id && !neighbours.contains(id) {
475            neighbours.push(id.clone());
476        }
477    }
478    Ok(MomentDetail {
479        moment: m.clone(),
480        responsibilities: c.responsibilities(&m.id).to_vec(),
481        backlinks,
482        similar,
483        neighbours,
484        related_detail: m
485            .related
486            .iter()
487            .filter_map(|r| c.moment(r))
488            .map(MomentSummary::of)
489            .collect(),
490    })
491}
492
493fn why_audiences(ctx: &Context, _: Empty) -> Result<AudienceList, CapabilityError> {
494    let audiences = audience_views(&ctx.why);
495    Ok(AudienceList {
496        count: audiences.len(),
497        audiences,
498    })
499}
500
501fn why_areas(ctx: &Context, _: Empty) -> Result<AreaList, CapabilityError> {
502    let areas = area_views(&ctx.why);
503    Ok(AreaList {
504        count: areas.len(),
505        areas,
506    })
507}
508
509fn why_diagnose(ctx: &Context, input: DiagnoseInput) -> Result<Diagnosis, CapabilityError> {
510    Ok(ctx.why.diagnose(&input.names()))
511}
512
513fn why_validate(ctx: &Context, _: Empty) -> Result<ValidationReport, CapabilityError> {
514    let c = &ctx.why;
515    let errors = c.errors();
516    Ok(ValidationReport {
517        valid: errors == 0,
518        errors,
519        warnings: c
520            .findings()
521            .iter()
522            .filter(|f| f.severity == Severity::Warning)
523            .count(),
524        counts: counts(c),
525        findings: c.findings().to_vec(),
526    })
527}
528
529/// The module.
530pub fn module() -> ModuleDescriptor {
531    module! {
532        id: "why",
533        title: "Why",
534        description: "The operational failure modes this tool is a response to: the moments a reader recognises, the audiences that recognise them, the areas they fall under, and what a reader's own symptoms imply. Every entry is a file under the layer's why section; nothing here holds a list, and a moment added there is answered by all of these without a registration anywhere.",
535        stability: Stability::BehaviorallyVerified,
536        capabilities: [
537            capability! {
538                id: "why.list",
539                title: "The catalogue",
540                description: "Every operational moment this repository holds, narrowed by any of the facets the catalogue itself reports, with the audiences, the areas, the derived filters and the counts. The default is the public catalogue; pass status=any for the drafts too.",
541                input: Query,
542                output: CatalogueView,
543                stability: Stability::BehaviorallyVerified,
544                exposure: Exposure {
545                    mcp: Some(McpExposure {
546                        tool: Some("majordomus_why".into()),
547                        resource: Some(McpResource { uri: WHY_URI.into(), name: "why".into() }),
548                    }),
549                    http: get("/api/v1/why"),
550                    cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "list".into()] }),
551                },
552                tags: ["why", "catalogue"],
553                cache: CachePolicy::Process { max_entries: 32, ttl_seconds: None },
554                handler: why_list,
555            },
556            capability! {
557                id: "why.moment",
558                title: "One moment",
559                description: "One operational moment in full: what it looks like, why it happens, what it costs, what this tool does about it, and every relation derived from its metadata — the responsibilities its claims belong to, the moments that name it, and the moments nearest it by shared area, audience and tag.",
560                input: MomentInput,
561                output: MomentDetail,
562                stability: Stability::BehaviorallyVerified,
563                exposure: Exposure {
564                    mcp: mcp("majordomus_why_moment"),
565                    http: get("/api/v1/why/moment"),
566                    cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "show".into()] }),
567                },
568                tags: ["why"],
569                cache: CachePolicy::Process { max_entries: 64, ttl_seconds: None },
570                handler: why_moment,
571            },
572            capability! {
573                id: "why.audiences",
574                title: "Audiences",
575                description: "Every audience the catalogue declares, each with the public moments that name it. Membership is derived from the moments and is never listed in an audience's own file.",
576                input: Empty,
577                output: AudienceList,
578                stability: Stability::BehaviorallyVerified,
579                exposure: Exposure {
580                    mcp: Some(McpExposure {
581                        tool: Some("majordomus_why_audiences".into()),
582                        resource: Some(McpResource { uri: AUDIENCES_URI.into(), name: "why-audiences".into() }),
583                    }),
584                    http: get("/api/v1/why/audiences"),
585                    cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "audiences".into()] }),
586                },
587                tags: ["why", "catalogue"],
588                cache: CachePolicy::Process { max_entries: 4, ttl_seconds: None },
589                handler: why_audiences,
590            },
591            capability! {
592                id: "why.areas",
593                title: "Operational areas",
594                description: "Every operational area the catalogue declares, each with the public moments that fall under it. Membership is derived from the moments and is never listed in an area's own file.",
595                input: Empty,
596                output: AreaList,
597                stability: Stability::BehaviorallyVerified,
598                exposure: Exposure {
599                    mcp: Some(McpExposure {
600                        tool: Some("majordomus_why_areas".into()),
601                        resource: Some(McpResource { uri: AREAS_URI.into(), name: "why-areas".into() }),
602                    }),
603                    http: get("/api/v1/why/areas"),
604                    cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "areas".into()] }),
605                },
606                tags: ["why", "catalogue"],
607                cache: CachePolicy::Process { max_entries: 4, ttl_seconds: None },
608                handler: why_areas,
609            },
610            capability! {
611                id: "why.diagnose",
612                title: "Diagnose a selection",
613                description: "What a reader's own symptoms imply: the moments the selection resolves to, the operational areas and audiences they weigh towards, and the capabilities, commands, claims, rules and use cases that answer them — each carrying the moments that produced it. Counting, not inference: there is no weighting and no percentage.",
614                input: DiagnoseInput,
615                output: Diagnosis,
616                stability: Stability::BehaviorallyVerified,
617                exposure: Exposure {
618                    mcp: mcp("majordomus_why_diagnose"),
619                    http: get("/api/v1/why/diagnose"),
620                    cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "diagnose".into()] }),
621                },
622                tags: ["why", "diagnostics"],
623                cache: CachePolicy::Process { max_entries: 32, ttl_seconds: None },
624                handler: why_diagnose,
625            },
626            capability! {
627                id: "why.validate",
628                title: "Validate the catalogue",
629                description: "Every finding over the catalogue: a reference that resolves to nothing, with the nearest candidate; a duplicate identity; a file name that disagrees with its id; and a public record that does not meet the floor its status promises. Errors make the catalogue invalid; warnings do not.",
630                input: Empty,
631                output: ValidationReport,
632                stability: Stability::BehaviorallyVerified,
633                exposure: Exposure {
634                    mcp: mcp("majordomus_why_validate"),
635                    http: get("/api/v1/why/validate"),
636                    cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "validate".into()] }),
637                },
638                tags: ["why", "introspection"],
639                cache: CachePolicy::Process { max_entries: 2, ttl_seconds: None },
640                handler: why_validate,
641            },
642        ],
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    /// The declaration is the only place these names exist, and every projection — the MCP
651    /// tool, the HTTP route, the OpenAPI operation, the benchmark target — is derived from
652    /// it. A refactor that dropped an exposure or renamed a route would still compile, and
653    /// the suites that exercise the behaviour behind it would still pass. This is the
654    /// assertion that would not.
655    #[test]
656    fn the_declaration_yields_the_projections_it_claims() {
657        let m = module();
658        assert_eq!(m.id.as_str(), "why");
659        let expected: &[(&str, &str, &str)] = &[
660            ("why.list", "majordomus_why", "/api/v1/why"),
661            ("why.moment", "majordomus_why_moment", "/api/v1/why/moment"),
662            (
663                "why.audiences",
664                "majordomus_why_audiences",
665                "/api/v1/why/audiences",
666            ),
667            ("why.areas", "majordomus_why_areas", "/api/v1/why/areas"),
668            (
669                "why.diagnose",
670                "majordomus_why_diagnose",
671                "/api/v1/why/diagnose",
672            ),
673            (
674                "why.validate",
675                "majordomus_why_validate",
676                "/api/v1/why/validate",
677            ),
678        ];
679        let ids: Vec<&str> = m
680            .capabilities
681            .iter()
682            .map(|e| e.capability.id.as_str())
683            .collect();
684        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
685        assert_eq!(
686            ids, want,
687            "the module declares a different set of capabilities"
688        );
689        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
690            let exposure = &executable.capability.exposure;
691            assert_eq!(
692                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
693                Some(*tool),
694                "{id} lost or renamed its MCP tool"
695            );
696            assert_eq!(
697                exposure.http.as_ref().map(|h| h.path.as_str()),
698                Some(*path),
699                "{id} lost or renamed its HTTP route"
700            );
701        }
702    }
703}