Skip to main content

majordomus_cli/capability/
registry.rs

1//! The registry: every capability, once, validated, in a deterministic order, with the
2//! lookups projections need. Built at one place per process from the builtin executables
3//! and the repository's declarative objects; nothing else adds to it.
4
5use std::collections::BTreeMap;
6use std::sync::Arc;
7
8use serde::Serialize;
9use serde_json::Value;
10
11use crate::index::Index;
12
13use super::benchmark::CaseProvider;
14use super::handler::{CapabilityError, Context, Executable, Handler};
15use super::model::{
16    Availability, BenchmarkPolicy, Capability, CapabilityId, CapabilityKind, ExecutionPolicy,
17    HttpMethod, McpResource, ModuleId, Provenance, Stability, Visibility, WaiverReason,
18};
19use super::module::ModuleDescriptor;
20
21/// A capability with, for an executable, its handler and its benchmark cases.
22pub struct Entry {
23    /// The descriptor.
24    pub capability: Capability,
25    handler: Option<Arc<dyn Handler>>,
26    cases: Option<CaseProvider>,
27}
28
29/// Where a module's entry in the registry came from.
30#[derive(
31    Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum ModuleSource {
35    /// Declared with `module!` and composed with `compose_modules!`.
36    Builtin,
37    /// Derived from the namespace of executables composed without a module descriptor.
38    Derived,
39    /// One kind of declarative objects of the repository's layer.
40    Declarative,
41}
42
43/// One module as the registry knows it.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema)]
45pub struct ModuleInfo {
46    /// The identity.
47    pub id: ModuleId,
48    /// The short name.
49    pub title: String,
50    /// One paragraph.
51    pub description: String,
52    /// Where the module stands, when it declared it.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub stability: Option<Stability>,
55    /// Where the entry came from.
56    pub source: ModuleSource,
57    /// How many capabilities it composes.
58    pub capabilities: usize,
59}
60
61impl std::fmt::Debug for Entry {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("Entry")
64            .field("capability", &self.capability)
65            .field("handler", &self.handler.is_some())
66            .finish()
67    }
68}
69
70/// Why the registry could not be built. Every variant names the provenance of every party.
71#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize)]
72#[serde(tag = "code", rename_all = "snake_case")]
73pub enum RegistryError {
74    #[error("capability '{id}' ({provenance}): invalid id: {reason}")]
75    /// The id does not satisfy the grammar.
76    InvalidId {
77        /// The id as written.
78        id: String,
79        /// Where the descriptor came from.
80        provenance: String,
81        /// What the grammar objects to.
82        reason: String,
83    },
84    #[error("capability '{id}' is defined twice: {first} and {second}")]
85    /// Two descriptors claim one id.
86    DuplicateId {
87        /// The id.
88        id: String,
89        /// The provenance of the descriptor seen first, in id order.
90        first: String,
91        /// The provenance of the other.
92        second: String,
93    },
94    #[error("MCP tool '{name}' is claimed by '{first}' and '{second}'")]
95    /// Two capabilities claim one MCP tool name.
96    DuplicateMcpName {
97        /// The tool name.
98        name: String,
99        /// The id of the capability seen first.
100        first: String,
101        /// The id of the other.
102        second: String,
103    },
104    #[error("MCP resource '{uri}' is claimed by '{first}' and '{second}'")]
105    /// Two capabilities claim one MCP resource URI.
106    DuplicateMcpUri {
107        /// The URI.
108        uri: String,
109        /// The id of the capability seen first.
110        first: String,
111        /// The id of the other.
112        second: String,
113    },
114    #[error("HTTP route {method} {path} is claimed by '{first}' and '{second}'")]
115    /// Two capabilities claim one HTTP method and path.
116    DuplicateHttpRoute {
117        /// The method.
118        method: String,
119        /// The path.
120        path: String,
121        /// The id of the capability seen first.
122        first: String,
123        /// The id of the other.
124        second: String,
125    },
126    #[error("CLI path '{path}' is claimed by '{first}' and '{second}'")]
127    /// Two capabilities claim one CLI path.
128    DuplicateCliPath {
129        /// The words, joined with spaces.
130        path: String,
131        /// The id of the capability seen first.
132        first: String,
133        /// The id of the other.
134        second: String,
135    },
136    #[error("capability '{id}' ({provenance}): invalid {projection} exposure: {reason}")]
137    /// A declared exposure cannot be served by its projection.
138    InvalidExposure {
139        /// The id.
140        id: String,
141        /// Where the descriptor came from.
142        provenance: String,
143        /// `MCP`, `HTTP` or `CLI`.
144        projection: String,
145        /// What is wrong with the exposure.
146        reason: String,
147    },
148    #[error("capability '{id}' ({provenance}) is {stability} and cannot be exposed as executable through {projection}")]
149    /// A planned or unsupported capability declares an executable exposure.
150    NotExecutable {
151        /// The id.
152        id: String,
153        /// Where the descriptor came from.
154        provenance: String,
155        /// The stability that forbids execution.
156        stability: String,
157        /// The projection the exposure is for.
158        projection: String,
159    },
160    #[error("module '{id}': invalid id: {reason}")]
161    /// A module id does not satisfy the grammar.
162    InvalidModuleId {
163        /// The id as written.
164        id: String,
165        /// What the grammar objects to.
166        reason: String,
167    },
168    #[error("module '{id}' is composed twice ({first} and {second})")]
169    /// Two modules claim one id, or a builtin module's id is a declarative kind.
170    DuplicateModule {
171        /// The id.
172        id: String,
173        /// The source seen first.
174        first: String,
175        /// The other.
176        second: String,
177    },
178    #[error("capability '{id}' ({provenance}) is composed in module '{module}' but its namespace is '{namespace}'")]
179    /// A capability's id does not belong to the module that composes it.
180    ModuleMismatch {
181        /// The capability id.
182        id: String,
183        /// Where the descriptor came from.
184        provenance: String,
185        /// The module that composed it.
186        module: String,
187        /// The id's namespace.
188        namespace: String,
189    },
190    #[error("capability '{id}' ({provenance}): invalid cache policy: {reason}")]
191    /// The cache policy keeps nothing or contradicts itself.
192    InvalidCachePolicy {
193        /// The id.
194        id: String,
195        /// Where the descriptor came from.
196        provenance: String,
197        /// What is wrong.
198        reason: String,
199    },
200    #[error("capability '{id}' ({provenance}): {reason}")]
201    /// The descriptor's shape contradicts its kind: a query without a handler, a resource with one or with a callable exposure.
202    Shape {
203        /// The id.
204        id: String,
205        /// Where the descriptor came from.
206        provenance: String,
207        /// What is contradictory.
208        reason: String,
209    },
210}
211
212#[derive(Debug, Default)]
213/// Every capability, once, validated, in id order, with the lookups projections need.
214pub struct CapabilityRegistry {
215    entries: BTreeMap<CapabilityId, Entry>,
216    modules: BTreeMap<ModuleId, ModuleInfo>,
217    fingerprint: String,
218    by_mcp_tool: BTreeMap<String, CapabilityId>,
219    by_mcp_uri: BTreeMap<String, CapabilityId>,
220    by_http: BTreeMap<(HttpMethod, String), CapabilityId>,
221    by_cli: BTreeMap<Vec<String>, CapabilityId>,
222    by_skill: BTreeMap<String, CapabilityId>,
223}
224
225/// The one composition point.
226#[derive(Default)]
227pub struct Builder {
228    pending: Vec<Entry>,
229    modules: Vec<ModuleInfo>,
230    index_fingerprint: String,
231}
232
233impl Builder {
234    /// Add the application's modules, as `compose_modules!` produced them: their metadata
235    /// and every executable they compose.
236    pub fn with_modules(mut self, modules: Vec<ModuleDescriptor>) -> Self {
237        for m in modules {
238            self.modules.push(ModuleInfo {
239                id: m.id.clone(),
240                title: m.title,
241                description: m.description,
242                stability: Some(m.stability),
243                source: ModuleSource::Builtin,
244                capabilities: m.capabilities.len(),
245            });
246            self = self.with_builtin(m.capabilities);
247        }
248        self
249    }
250
251    /// Add executables without a module descriptor; each one's module is the namespace of
252    /// its id.
253    pub fn with_builtin(mut self, executables: Vec<Executable>) -> Self {
254        for e in executables {
255            self.pending.push(Entry {
256                capability: e.capability,
257                handler: Some(e.handler),
258                cases: Some(e.cases),
259            });
260        }
261        self
262    }
263
264    /// Every object of the index becomes a resource capability; the index's fingerprint
265    /// joins the registry's.
266    pub fn with_index(mut self, index: &Index) -> Self {
267        self.index_fingerprint = index.fingerprint.clone();
268        for object in &index.objects {
269            self.pending.push(Entry {
270                capability: super::declarative::capability_of(object),
271                handler: None,
272                cases: None,
273            });
274        }
275        self
276    }
277
278    /// Validate every invariant and build. Errors are collected, not stopped at the first,
279    /// and reported in a deterministic order.
280    pub fn build(self) -> Result<CapabilityRegistry, Vec<RegistryError>> {
281        let _phase = crate::perf::phase(crate::perf::Phase::RegistryBuild);
282        crate::perf::Counters::bump(&crate::perf::COUNTERS.registry_builds);
283        let mut errors = Vec::new();
284        let mut registry = CapabilityRegistry::default();
285        for m in self.modules {
286            let id = m.id.as_str().to_string();
287            if let Err(reason) = ModuleId::parse(&id) {
288                errors.push(RegistryError::InvalidModuleId { id, reason });
289                continue;
290            }
291            if let Some(first) = registry.modules.get(&m.id) {
292                errors.push(RegistryError::DuplicateModule {
293                    id,
294                    first: format!("{:?}", first.source).to_lowercase(),
295                    second: "builtin".into(),
296                });
297                continue;
298            }
299            registry.modules.insert(m.id.clone(), m);
300        }
301        let mut pending = self.pending;
302        pending.sort_by(|a, b| {
303            a.capability.id.cmp(&b.capability.id).then_with(|| {
304                a.capability
305                    .provenance
306                    .to_string()
307                    .cmp(&b.capability.provenance.to_string())
308            })
309        });
310        for mut entry in pending {
311            let c = &entry.capability;
312            let id = c.id.as_str().to_string();
313            let prov = c.provenance.to_string();
314            if let Err(reason) = CapabilityId::parse(&id) {
315                errors.push(RegistryError::InvalidId {
316                    id: id.clone(),
317                    provenance: prov.clone(),
318                    reason,
319                });
320                continue;
321            }
322            if let Some(first) = registry.entries.get(&c.id) {
323                errors.push(RegistryError::DuplicateId {
324                    id: id.clone(),
325                    first: first.capability.provenance.to_string(),
326                    second: prov.clone(),
327                });
328                continue;
329            }
330            // the module: stamped by `module!`, derived from the namespace otherwise
331            let namespace = c.id.namespace().to_string();
332            if entry.capability.module.as_str().is_empty() {
333                entry.capability.module = ModuleId::unchecked(&namespace);
334            }
335            let c = &entry.capability;
336            let is_builtin = matches!(c.provenance, Provenance::Builtin { .. });
337            if is_builtin && c.module.as_str() != namespace {
338                errors.push(RegistryError::ModuleMismatch {
339                    id: id.clone(),
340                    provenance: prov.clone(),
341                    module: c.module.as_str().to_string(),
342                    namespace,
343                });
344                continue;
345            }
346            match registry.modules.get_mut(&c.module) {
347                Some(m) if m.source == ModuleSource::Builtin && is_builtin => {}
348                Some(m) if m.source == ModuleSource::Builtin => {
349                    errors.push(RegistryError::DuplicateModule {
350                        id: c.module.as_str().to_string(),
351                        first: "builtin".into(),
352                        second: format!("declarative kind of {prov}"),
353                    });
354                    continue;
355                }
356                Some(m) => m.capabilities += 1,
357                None => {
358                    if let Err(reason) = ModuleId::parse(c.module.as_str()) {
359                        errors.push(RegistryError::InvalidModuleId {
360                            id: c.module.as_str().to_string(),
361                            reason,
362                        });
363                        continue;
364                    }
365                    let (source, title, description) = if is_builtin {
366                        (
367                            ModuleSource::Derived,
368                            c.module.as_str().to_string(),
369                            format!("Executables in the '{}' namespace, composed without a module descriptor.", c.module),
370                        )
371                    } else {
372                        (
373                            ModuleSource::Declarative,
374                            c.module.as_str().to_string(),
375                            format!(
376                                "Declarative objects of kind '{}' from the repository's AI layer.",
377                                c.module
378                            ),
379                        )
380                    };
381                    registry.modules.insert(
382                        c.module.clone(),
383                        ModuleInfo {
384                            id: c.module.clone(),
385                            title,
386                            description,
387                            stability: None,
388                            source,
389                            capabilities: 1,
390                        },
391                    );
392                }
393            }
394            if let Err(reason) = c.cache.validate() {
395                errors.push(RegistryError::InvalidCachePolicy {
396                    id: id.clone(),
397                    provenance: prov.clone(),
398                    reason,
399                });
400                continue;
401            }
402            // classification is not a value a descriptor may hold an opinion about: it
403            // follows from the kind and the transports it declares, and a capability
404            // arriving from outside this crate with a different one is refused rather
405            // than believed. A projection reads these fields; if they could drift, every
406            // surface downstream would be reading a claim instead of a fact.
407            let availability = Availability::classify(c.kind, &c.exposure);
408            let visibility = Visibility::classify(&c.exposure);
409            if c.availability != availability || c.visibility != visibility {
410                errors.push(RegistryError::Shape {
411                    id: id.clone(),
412                    provenance: prov.clone(),
413                    reason: format!(
414                        "declares availability {:?} and visibility {:?}; what it exposes makes it {availability:?} and {visibility:?}. Classification follows the kind and the transports, so change those or let the declaration classify itself",
415                        c.availability, c.visibility
416                    ),
417                });
418                continue;
419            }
420            match (c.kind, c.benchmark) {
421                (
422                    CapabilityKind::Resource,
423                    BenchmarkPolicy::Waived {
424                        reason: WaiverReason::NotExecutable,
425                    },
426                ) => {}
427                (CapabilityKind::Resource, _) => {
428                    errors.push(RegistryError::Shape {
429                        id: id.clone(),
430                        provenance: prov.clone(),
431                        reason: "a resource is not a benchmark target: its policy is waived as not executable".into(),
432                    });
433                    continue;
434                }
435                (
436                    _,
437                    BenchmarkPolicy::Waived {
438                        reason: WaiverReason::NotExecutable,
439                    },
440                ) => {
441                    errors.push(RegistryError::Shape {
442                        id: id.clone(),
443                        provenance: prov.clone(),
444                        reason:
445                            "an executable cannot be waived as not executable; name a real reason"
446                                .into(),
447                    });
448                    continue;
449                }
450                _ => {}
451            }
452            if c.cache.is_enabled() && c.kind == CapabilityKind::Command {
453                errors.push(RegistryError::InvalidCachePolicy {
454                    id: id.clone(),
455                    provenance: prov.clone(),
456                    reason: "a command changes state and is never cached".into(),
457                });
458                continue;
459            }
460            // classification is the registry's, not the declaration's, for the same reason
461            // availability and visibility are: a descriptor arriving from outside this
462            // crate with a policy of its own would be a claim a projection then reads as a
463            // fact
464            let policy = ExecutionPolicy::classify(c.kind);
465            // The documented additions, and their combination: cancellation, which any kind
466            // may declare because any handler may look at its flag, and writing the
467            // repository, which only a command may — a query that wrote tracked files would
468            // be a query in name only, bound to GET and announced to MCP as read-only.
469            // Everything else about the policy still follows the kind.
470            let mut permitted = vec![policy, policy.stoppable()];
471            if c.kind == CapabilityKind::Command {
472                permitted.push(policy.writes_repository());
473                permitted.push(policy.stoppable().writes_repository());
474                permitted.push(policy.authenticates_its_input());
475            }
476            if !permitted.contains(&c.execution) {
477                errors.push(RegistryError::Shape {
478                    id: id.clone(),
479                    provenance: prov.clone(),
480                    reason: format!(
481                        "declares the execution policy {:?}; its kind makes it {policy:?}. A declaration may add cancellation (`.cancellable()`), a command may add writing the repository (`.writes_repository()`) or a signed input (`.authenticates_its_input()`), never both; the effect of a query and the concurrency of anything follow the kind",
482                        c.execution
483                    ),
484                });
485                continue;
486            }
487            match (c.kind, entry.handler.is_some()) {
488                (CapabilityKind::Query, false) => {
489                    errors.push(RegistryError::Shape {
490                        id: id.clone(),
491                        provenance: prov.clone(),
492                        reason: "a query needs a handler".into(),
493                    });
494                    continue;
495                }
496                (CapabilityKind::Command, false) => {
497                    errors.push(RegistryError::Shape {
498                        id: id.clone(),
499                        provenance: prov.clone(),
500                        reason: "a command needs a handler".into(),
501                    });
502                    continue;
503                }
504                (CapabilityKind::Resource, true) => {
505                    errors.push(RegistryError::Shape {
506                        id: id.clone(),
507                        provenance: prov.clone(),
508                        reason: "a resource has no handler".into(),
509                    });
510                    continue;
511                }
512                _ => {}
513            }
514            if let Some(mcp) = &c.exposure.mcp {
515                if let Some(tool) = &mcp.tool {
516                    if !c.stability.executable() {
517                        errors.push(not_executable(c, "MCP tool"));
518                    }
519                    if tool.is_empty()
520                        || !tool
521                            .bytes()
522                            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
523                    {
524                        errors.push(RegistryError::InvalidExposure {
525                            id: id.clone(),
526                            provenance: prov.clone(),
527                            projection: "MCP".into(),
528                            reason: format!("tool name '{tool}' is not [a-z0-9_]+"),
529                        });
530                    } else if let Some(first) = registry.by_mcp_tool.get(tool) {
531                        errors.push(RegistryError::DuplicateMcpName {
532                            name: tool.clone(),
533                            first: first.to_string(),
534                            second: id.clone(),
535                        });
536                    } else {
537                        registry.by_mcp_tool.insert(tool.clone(), c.id.clone());
538                    }
539                }
540                if let Some(res) = &mcp.resource {
541                    if !res.uri.starts_with("majordomus://")
542                        || res.uri.contains(char::is_whitespace)
543                    {
544                        errors.push(RegistryError::InvalidExposure {
545                            id: id.clone(),
546                            provenance: prov.clone(),
547                            projection: "MCP".into(),
548                            reason: format!(
549                                "resource uri '{}' is not a majordomus:// uri",
550                                res.uri
551                            ),
552                        });
553                    } else if let Some(first) = registry.by_mcp_uri.get(&res.uri) {
554                        errors.push(RegistryError::DuplicateMcpUri {
555                            uri: res.uri.clone(),
556                            first: first.to_string(),
557                            second: id.clone(),
558                        });
559                    } else {
560                        registry.by_mcp_uri.insert(res.uri.clone(), c.id.clone());
561                    }
562                    // a read supplies no input: a query read as a resource must accept `{}`
563                    let (_, required) = c.input.properties();
564                    if c.kind.is_executable() && !required.is_empty() {
565                        errors.push(RegistryError::Shape {
566                            id: id.clone(),
567                            provenance: prov.clone(),
568                            reason: format!(
569                                "a query read as the resource '{}' is called with no input, and its input requires {}",
570                                res.uri,
571                                required.join(", ")
572                            ),
573                        });
574                    }
575                }
576            }
577            if let Some(http) = &c.exposure.http {
578                if !c.stability.executable() {
579                    errors.push(not_executable(c, "HTTP"));
580                }
581                if let Err(reason) = http.validate() {
582                    errors.push(RegistryError::InvalidExposure {
583                        id: id.clone(),
584                        provenance: prov.clone(),
585                        projection: "HTTP".into(),
586                        reason,
587                    });
588                } else if let Some(first) = registry.by_http.get(&(http.method, http.path.clone()))
589                {
590                    errors.push(RegistryError::DuplicateHttpRoute {
591                        method: http.method.as_str().into(),
592                        path: http.path.clone(),
593                        first: first.to_string(),
594                        second: id.clone(),
595                    });
596                } else {
597                    registry
598                        .by_http
599                        .insert((http.method, http.path.clone()), c.id.clone());
600                }
601            }
602            if let Some(cli) = &c.exposure.cli {
603                if !c.stability.executable() {
604                    errors.push(not_executable(c, "CLI"));
605                }
606                if cli.path.is_empty()
607                    || cli
608                        .path
609                        .iter()
610                        .any(|w| w.is_empty() || w.contains(char::is_whitespace))
611                {
612                    errors.push(RegistryError::InvalidExposure {
613                        id: id.clone(),
614                        provenance: prov.clone(),
615                        projection: "CLI".into(),
616                        reason: format!("path {:?} has an empty or blank word", cli.path),
617                    });
618                } else if let Some(first) = registry.by_cli.get(&cli.path) {
619                    errors.push(RegistryError::DuplicateCliPath {
620                        path: cli.path.join(" "),
621                        first: first.to_string(),
622                        second: id.clone(),
623                    });
624                } else {
625                    registry.by_cli.insert(cli.path.clone(), c.id.clone());
626                }
627            }
628            if let Some(skill) = &c.skill {
629                // A skill is a second way to call the capability, so everything it says is
630                // held to the declaration it adapts: the name must be one an agent product
631                // accepts and no other capability took, and the typed text must have exactly
632                // one place to go — a string field, with nothing else required.
633                let refuse = |reason: String| RegistryError::InvalidExposure {
634                    id: id.clone(),
635                    provenance: prov.clone(),
636                    projection: "skill".into(),
637                    reason,
638                };
639                let (properties, required) = c.input.properties();
640                let argument = properties.iter().find(|(name, _)| *name == skill.argument);
641                if !c.stability.executable() {
642                    errors.push(not_executable(c, "skill"));
643                }
644                if let Err(reason) = skill.validate() {
645                    errors.push(refuse(reason));
646                } else if let Some(first) = registry.by_skill.get(&skill.alias) {
647                    errors.push(refuse(format!(
648                        "alias '{}' is already the skill of {first}",
649                        skill.alias
650                    )));
651                } else if !argument.is_some_and(|(_, schema)| {
652                    schema.get("type").and_then(|t| t.as_str()) == Some("string")
653                }) {
654                    errors.push(refuse(format!(
655                        "argument '{}' is not a string field of the input",
656                        skill.argument
657                    )));
658                } else if let Some(other) = required.iter().find(|r| **r != skill.argument) {
659                    errors.push(refuse(format!(
660                        "the input also requires '{other}', which the typed text cannot supply"
661                    )));
662                } else {
663                    registry.by_skill.insert(skill.alias.clone(), c.id.clone());
664                }
665            }
666            if c.kind == CapabilityKind::Resource
667                && (c.exposure.http.is_some()
668                    || c.exposure.cli.is_some()
669                    || c.exposure.mcp.as_ref().is_some_and(|m| m.tool.is_some()))
670            {
671                errors.push(RegistryError::Shape {
672                    id: id.clone(),
673                    provenance: prov.clone(),
674                    reason: "a resource is read, not called: only an MCP resource exposure applies"
675                        .into(),
676                });
677            }
678            if c.kind == CapabilityKind::Command {
679                let word = c.kind.as_str();
680                if c.exposure
681                    .mcp
682                    .as_ref()
683                    .is_some_and(|m| m.resource.is_some())
684                {
685                    errors.push(RegistryError::Shape {
686                        id: id.clone(),
687                        provenance: prov.clone(),
688                        reason: format!(
689                            "a {word} is called, not read: it has no MCP resource exposure"
690                        ),
691                    });
692                }
693                if let Some(http) = &c.exposure.http {
694                    if http.method != HttpMethod::Post {
695                        errors.push(RegistryError::InvalidExposure {
696                            id: id.clone(),
697                            provenance: prov.clone(),
698                            projection: "HTTP".into(),
699                            reason: format!(
700                                "a {word} changes state and is bound to POST, not {}",
701                                http.method.as_str()
702                            ),
703                        });
704                    }
705                }
706            }
707            registry.entries.insert(c.id.clone(), entry);
708        }
709        if errors.is_empty() {
710            registry.fingerprint = fingerprint(&self.index_fingerprint, &registry);
711            Ok(registry)
712        } else {
713            Err(errors)
714        }
715    }
716}
717
718/// The registry's fingerprint: a hash of the index's fingerprint (every declarative
719/// object's path and content), of every id, and of every builtin descriptor in full, in
720/// id order. Stable across processes for the same code and the same repository state.
721fn fingerprint(index: &str, registry: &CapabilityRegistry) -> String {
722    use sha2::{Digest, Sha256};
723    let mut h = Sha256::new();
724    h.update(index.as_bytes());
725    for c in registry.iter() {
726        h.update(c.id.as_str().as_bytes());
727        h.update(b"\0");
728        // a declarative descriptor is a function of the object the index fingerprint
729        // already covers; hashing its (large, shared) schema per object would only cost
730        if matches!(c.provenance, Provenance::Builtin { .. }) {
731            h.update(serde_json::to_string(c).unwrap_or_default().as_bytes());
732        }
733        h.update(b"\n");
734    }
735    format!("{:x}", h.finalize())
736}
737
738fn not_executable(c: &Capability, projection: &str) -> RegistryError {
739    RegistryError::NotExecutable {
740        id: c.id.to_string(),
741        provenance: c.provenance.to_string(),
742        stability: serde_json::to_value(c.stability)
743            .ok()
744            .and_then(|v| v.as_str().map(str::to_string))
745            .unwrap_or_default(),
746        projection: projection.into(),
747    }
748}
749
750impl CapabilityRegistry {
751    /// Start composing a registry.
752    ///
753    /// ```
754    /// use majordomus_cli::capability::{builtin, CapabilityRegistry, RegistryError};
755    /// let registry = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
756    /// assert!(registry.get("repository.info").is_some());
757    /// assert_eq!(registry.by_mcp_tool("majordomus_get").unwrap().id.as_str(), "objects.get");
758    /// // the same executables twice: every id is claimed twice, and the registry says so
759    /// let twice = CapabilityRegistry::builder().with_builtin(builtin::all()).with_builtin(builtin::all()).build();
760    /// let errors = twice.unwrap_err();
761    /// assert!(errors.iter().all(|e| matches!(e, RegistryError::DuplicateId { .. })));
762    /// ```
763    pub fn builder() -> Builder {
764        Builder::default()
765    }
766
767    /// Every capability, by id.
768    pub fn iter(&self) -> impl Iterator<Item = &Capability> {
769        self.entries.values().map(|e| &e.capability)
770    }
771
772    /// How many capabilities the registry holds.
773    pub fn len(&self) -> usize {
774        self.entries.len()
775    }
776
777    /// Does the registry hold nothing?
778    pub fn is_empty(&self) -> bool {
779        self.entries.is_empty()
780    }
781
782    /// Every module, by id: the composed ones, the derived ones, and one per declarative kind.
783    pub fn modules(&self) -> impl Iterator<Item = &ModuleInfo> {
784        self.modules.values()
785    }
786
787    /// One module by id.
788    pub fn module(&self, id: &str) -> Option<&ModuleInfo> {
789        self.modules.get(&ModuleId::unchecked(id))
790    }
791
792    /// The benchmark case provider of an executable; `None` for a resource or an unknown id.
793    pub fn cases(&self, id: &str) -> Option<CaseProvider> {
794        self.entries
795            .get(&CapabilityId::unchecked(id))
796            .and_then(|e| e.cases)
797    }
798
799    /// A capability by canonical id.
800    pub fn get(&self, id: &str) -> Option<&Capability> {
801        self.entries
802            .get(&CapabilityId::unchecked(id))
803            .map(|e| &e.capability)
804    }
805
806    /// The capability an MCP tool name projects.
807    pub fn by_mcp_tool(&self, name: &str) -> Option<&Capability> {
808        self.by_mcp_tool
809            .get(name)
810            .and_then(|id| self.get(id.as_str()))
811    }
812
813    /// The capability an MCP resource URI projects, with the resource exposure it matched:
814    /// a declarative object, or a query read as a document (`majordomus://repository`).
815    ///
816    /// ```
817    /// use majordomus_cli::capability::{builtin, CapabilityKind, CapabilityRegistry};
818    /// let r = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
819    /// let (c, res) = r.by_mcp_uri(builtin::REPOSITORY_URI).unwrap();
820    /// assert_eq!((c.id.as_str(), c.kind, res.name.as_str()), ("repository.info", CapabilityKind::Query, "repository"));
821    /// assert!(r.by_mcp_uri("majordomus://rule/none@1").is_none());
822    /// ```
823    pub fn by_mcp_uri(&self, uri: &str) -> Option<(&Capability, &McpResource)> {
824        let c = self
825            .by_mcp_uri
826            .get(uri)
827            .and_then(|id| self.get(id.as_str()))?;
828        let res = c.exposure.mcp.as_ref()?.resource.as_ref()?;
829        Some((c, res))
830    }
831
832    /// The capability an HTTP method and path project.
833    pub fn by_http(&self, method: HttpMethod, path: &str) -> Option<&Capability> {
834        self.by_http
835            .get(&(method, path.to_string()))
836            .and_then(|id| self.get(id.as_str()))
837    }
838
839    /// The capability a CLI path projects.
840    pub fn by_cli(&self, path: &[String]) -> Option<&Capability> {
841        self.by_cli.get(path).and_then(|id| self.get(id.as_str()))
842    }
843
844    /// The capability a skill alias names, when one declares it.
845    ///
846    /// ```
847    /// use majordomus_cli::capability::{builtin, CapabilityRegistry};
848    /// let r = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
849    /// // `/intent <sentence>` is intents.propose, because its declaration says so
850    /// assert_eq!(r.by_skill("intent").map(|c| c.id.as_str()), Some("intents.propose"));
851    /// assert!(r.by_skill("no-such-alias").is_none());
852    /// ```
853    pub fn by_skill(&self, alias: &str) -> Option<&Capability> {
854        self.by_skill
855            .get(alias)
856            .and_then(|id| self.get(id.as_str()))
857    }
858
859    /// Every capability that declares a skill, by alias.
860    ///
861    /// ```
862    /// use majordomus_cli::capability::{builtin, CapabilityRegistry};
863    /// let r = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
864    /// let aliases: Vec<&str> =
865    ///     r.skills().filter_map(|c| c.skill.as_ref()).map(|s| s.alias.as_str()).collect();
866    /// // few by design: a skill is for what a person types by name, not for every tool
867    /// assert!(aliases.contains(&"intent"));
868    /// let mut ordered = aliases.clone();
869    /// ordered.sort_unstable();
870    /// assert_eq!(aliases, ordered, "by alias");
871    /// ```
872    pub fn skills(&self) -> impl Iterator<Item = &Capability> {
873        self.by_skill
874            .values()
875            .filter_map(|id| self.get(id.as_str()))
876    }
877
878    /// The fingerprint of this registry and the repository state it was built from.
879    pub fn fingerprint(&self) -> &str {
880        &self.fingerprint
881    }
882
883    /// Run the handler of an executable by id, with no counters and no cache: the raw
884    /// dispatch the executor wraps. Everything else calls [`Context::execute`]. A
885    /// resource, or an unknown id, is `NotFound`.
886    pub fn dispatch(
887        &self,
888        ctx: &Context,
889        id: &str,
890        input: Value,
891    ) -> Result<Value, CapabilityError> {
892        let entry = self
893            .entries
894            .get(&CapabilityId::unchecked(id))
895            .ok_or_else(|| CapabilityError::NotFound(format!("capability {id}")))?;
896        let handler = entry.handler.as_ref().ok_or_else(|| {
897            CapabilityError::NotFound(format!("capability {id} is a resource, not a query"))
898        })?;
899        tracing::debug!(capability_id = id, "call");
900        handler.call(ctx, input)
901    }
902
903    /// Counts by kind, by stability and by projection, for introspection.
904    pub fn summary(&self) -> Summary {
905        let mut s = Summary {
906            modules: self.modules.len(),
907            ..Default::default()
908        };
909        for c in self.iter() {
910            *s.by_kind
911                .entry(format!("{:?}", c.kind).to_lowercase())
912                .or_default() += 1;
913            let st = serde_json::to_value(c.stability)
914                .ok()
915                .and_then(|v| v.as_str().map(str::to_string))
916                .unwrap_or_default();
917            *s.by_stability.entry(st).or_default() += 1;
918            if c.exposure.mcp.as_ref().is_some_and(|m| m.tool.is_some()) {
919                s.mcp_tools += 1;
920            }
921            if c.exposure
922                .mcp
923                .as_ref()
924                .is_some_and(|m| m.resource.is_some())
925            {
926                s.mcp_resources += 1;
927            }
928            if c.exposure.http.is_some() {
929                s.http_routes += 1;
930            }
931            if c.exposure.cli.is_some() {
932                s.cli_commands += 1;
933            }
934            match c.benchmark {
935                BenchmarkPolicy::Required | BenchmarkPolicy::RequiredWhen { .. } => {
936                    s.benchmark_required += 1
937                }
938                BenchmarkPolicy::Waived {
939                    reason: WaiverReason::NotExecutable,
940                } => {}
941                BenchmarkPolicy::Waived { .. } => s.benchmark_waived += 1,
942            }
943            if c.cache.is_enabled() {
944                s.cached += 1;
945            }
946            match c.provenance {
947                Provenance::Builtin { .. } => s.builtin += 1,
948                Provenance::Declarative { .. } => s.declarative += 1,
949            }
950        }
951        s.total = self.len();
952        s
953    }
954}
955
956#[derive(
957    Debug, Default, Clone, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema,
958)]
959/// The registry counted: by source, kind, stability and projection.
960pub struct Summary {
961    /// Every capability.
962    pub total: usize,
963    /// Composed in Rust.
964    pub builtin: usize,
965    /// Read from the layer.
966    pub declarative: usize,
967    /// By kind (`query`, `resource`).
968    pub by_kind: BTreeMap<String, usize>,
969    /// By stability.
970    pub by_stability: BTreeMap<String, usize>,
971    /// With an MCP tool exposure.
972    pub mcp_tools: usize,
973    /// With an MCP resource exposure.
974    pub mcp_resources: usize,
975    /// With an HTTP exposure.
976    pub http_routes: usize,
977    /// With a CLI exposure.
978    pub cli_commands: usize,
979    /// Modules: composed, derived, and one per declarative kind.
980    pub modules: usize,
981    /// Executables whose benchmark policy is required.
982    pub benchmark_required: usize,
983    /// Executables waived from benchmarking for a typed reason.
984    pub benchmark_waived: usize,
985    /// Executables the executor caches.
986    pub cached: usize,
987}