Skip to main content

majordomus_cli/capability/builtin/
objects.rs

1//! The `objects` module: the declarative objects of the repository's layer, listed, read
2//! and searched, and the one resolution of a `majordomus://` URI that `objects.get` and
3//! the MCP resource read share.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8
9use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
10use crate::capability::handler::{CapabilityError, Context};
11use crate::capability::model::{
12    CachePolicy, Capability, CapabilityKind, Exposure, McpResource, Provenance, Stability,
13};
14use crate::capability::module::ModuleDescriptor;
15use crate::model::Object;
16use crate::{capability, module};
17
18use super::repository::REPOSITORY_URI;
19use super::{get, mcp, ObjectSummary, ObjectView};
20
21// ---------------------------------------------------------------- objects.list
22
23/// Filters; both optional.
24#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct ListInput {
27    /// Only objects of this kind; the kinds present are listed by `repository.info`. A kind
28    /// the repository does not have is an invalid input, not an empty answer.
29    #[serde(default)]
30    pub kind: Option<String>,
31    /// Only objects whose metadata tags include this tag.
32    #[serde(default)]
33    pub tag: Option<String>,
34}
35
36impl BenchmarkCases for ListInput {
37    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
38        let mut cases = vec![NamedCase::new("all", ListInput::default())];
39        if let Some((kind, _)) = ctx.index.kinds().into_iter().next() {
40            cases.push(NamedCase::new(
41                "first-kind",
42                ListInput {
43                    kind: Some(kind.to_string()),
44                    tag: None,
45                },
46            ));
47        }
48        cases
49    }
50}
51
52#[derive(Debug, Serialize, Deserialize, JsonSchema)]
53/// The answer of `objects.list`: the matching objects, summarised, in URI order.
54pub struct ObjectList {
55    /// How many objects matched.
56    pub count: usize,
57    /// The objects, one summary each.
58    pub objects: Vec<ObjectSummary>,
59}
60
61/// A kind filter must name a kind the index holds.
62fn known_kind(ctx: &Context, kind: Option<&str>) -> Result<(), CapabilityError> {
63    let Some(kind) = kind else { return Ok(()) };
64    let kinds = ctx.index.kinds();
65    if kinds.contains_key(kind) {
66        Ok(())
67    } else {
68        Err(CapabilityError::InvalidInput(format!(
69            "kind '{kind}' is not among the kinds this repository has: {}",
70            kinds.keys().copied().collect::<Vec<_>>().join(", ")
71        )))
72    }
73}
74
75fn objects_list(ctx: &Context, input: ListInput) -> Result<ObjectList, CapabilityError> {
76    known_kind(ctx, input.kind.as_deref())?;
77    let objects: Vec<ObjectSummary> = ctx
78        .index
79        .objects
80        .iter()
81        .filter(|o| input.kind.as_deref().is_none_or(|k| o.kind == k))
82        .filter(|o| input.tag.as_deref().is_none_or(|t| o.tags().contains(&t)))
83        .map(ObjectSummary::of)
84        .collect();
85    Ok(ObjectList {
86        count: objects.len(),
87        objects,
88    })
89}
90
91// ---------------------------------------------------------------- resolution
92
93/// What a `majordomus://` URI names, resolved through the registry. This is the one rule
94/// `objects.get` and the MCP `resources/read` share, so that the tool, the HTTP route and
95/// the resource cannot answer one URI differently.
96#[derive(Debug)]
97pub enum Resolved<'a> {
98    /// A declarative object of the layer, as the index holds it.
99    Object(&'a Object),
100    /// A query with a resource exposure ([`REPOSITORY_URI`]), executed through the
101    /// executor like any other call.
102    Answer {
103        /// The capability that answered.
104        capability: &'a Capability,
105        /// The resource exposure the URI matched.
106        resource: &'a McpResource,
107        /// The answer, as the capability's output schema describes it.
108        value: Value,
109    },
110}
111
112impl Resolved<'_> {
113    /// IANA media type of [`Resolved::text`]: the object's, or `application/json`.
114    pub fn media_type(&self) -> String {
115        match self {
116            Resolved::Object(o) => o.media_type.to_string(),
117            Resolved::Answer { .. } => "application/json".to_string(),
118        }
119    }
120
121    /// The content as `resources/read` returns it: the file as read, or the answer
122    /// pretty-printed.
123    pub fn text(&self) -> String {
124        match self {
125            Resolved::Object(o) => o.content.clone(),
126            Resolved::Answer { value, .. } => pretty_json(value),
127        }
128    }
129}
130
131/// A JSON value as the text a resource read returns.
132fn pretty_json(v: &Value) -> String {
133    serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
134}
135
136/// Resolve a URI: the capability the registry projects at it, then the object behind a
137/// resource or the answer of a query. An unknown URI is `NotFound`; a registry naming an
138/// object the index does not hold is `Internal`; a query that refuses passes its refusal
139/// through, and any other failure of the query is `Internal`, naming the capability.
140///
141/// ```
142/// use majordomus_cli::capability::builtin::{resolve, Resolved, REPOSITORY_URI};
143/// use majordomus_cli::capability::{builtin, CapabilityError, CapabilityRegistry, Context};
144/// use majordomus_cli::git::GitState;
145/// use majordomus_cli::index::{Index, RepositoryInfo, State};
146/// use std::sync::Arc;
147/// // an index with no objects: only the repository report answers
148/// let index = Index {
149///     repository: RepositoryInfo {
150///         root: "/tmp/doc".into(), layer_schema: "ai-repository/v1".into(),
151///         sections: Default::default(), git: GitState::Unavailable { reason: "doc".into() },
152///         discovery: "filesystem".into(), source_classes: vec![], kind_sources: vec![],
153///         scope_origin: majordomus_cli::scope::Origin::Distribution, scope_path: String::new(),
154///         observed: Default::default(),
155///     },
156///     objects: vec![], diagnostics: vec![], state: State::Ok, fingerprint: String::new(),
157///     scoped: Default::default(), distribution: None, providers: Default::default(),
158///     share: None,
159/// };
160/// let registry = CapabilityRegistry::builder().with_builtin(builtin::all()).with_index(&index).build().unwrap();
161/// let ctx = Context::new(Arc::new(index), Arc::new(registry));
162/// match resolve(&ctx, REPOSITORY_URI).unwrap() {
163///     Resolved::Answer { capability, resource, value } => {
164///         assert_eq!(capability.id.as_str(), "repository.info");
165///         assert_eq!(resource.name, "repository");
166///         assert_eq!(value["objects"], 0);
167///     }
168///     other => panic!("{other:?}"),
169/// }
170/// assert!(matches!(resolve(&ctx, "majordomus://rule/none@1"), Err(CapabilityError::NotFound(_))));
171/// ```
172pub fn resolve<'a>(ctx: &'a Context, uri: &str) -> Result<Resolved<'a>, CapabilityError> {
173    let (capability, resource) = ctx
174        .registry
175        .by_mcp_uri(uri)
176        .ok_or_else(|| CapabilityError::NotFound(format!("unknown resource: {uri}")))?;
177    if !capability.kind.is_executable() {
178        return ctx.index.get(uri).map(Resolved::Object).ok_or_else(|| {
179            CapabilityError::Internal(format!(
180                "{uri}: the registry names {} and the index holds no such object",
181                capability.id
182            ))
183        });
184    }
185    let value = ctx
186        .execute(capability.id.as_str(), json!({}))
187        .map_err(|e| match e {
188            CapabilityError::Refused(reason) => CapabilityError::Refused(reason),
189            other => CapabilityError::Internal(format!(
190                "{uri}: {} did not answer: {other}",
191                capability.id
192            )),
193        })?;
194    Ok(Resolved::Answer {
195        capability,
196        resource,
197        value,
198    })
199}
200
201// ---------------------------------------------------------------- objects.get
202
203#[derive(Debug, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205/// The input of `objects.get`: which object.
206pub struct GetInput {
207    /// `majordomus://<kind>/<identity>`, or a URI a query projects
208    /// (`majordomus://repository`).
209    pub uri: String,
210}
211
212impl BenchmarkCases for GetInput {
213    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
214        let mut cases: Vec<NamedCase<Self>> = ctx
215            .index
216            .objects
217            .first()
218            .map(|o| {
219                vec![NamedCase::new(
220                    "first-object",
221                    GetInput { uri: o.uri.clone() },
222                )]
223            })
224            .unwrap_or_default();
225        cases.push(NamedCase::new(
226            "repository",
227            GetInput {
228                uri: REPOSITORY_URI.into(),
229            },
230        ));
231        cases
232    }
233}
234
235/// A URI a query projects (`majordomus://repository`), answered: the same fields a client
236/// reads on an [`ObjectView`] where they apply, the answer itself as data, and the text
237/// `resources/read` returns for the URI.
238#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
239pub struct AnswerView {
240    /// The URI as given.
241    pub uri: String,
242    /// The capability that answered (`repository.info`).
243    pub id: String,
244    /// Its kind: `query`.
245    pub kind: CapabilityKind,
246    /// The resource name a client lists (`repository`).
247    pub identity: String,
248    /// The capability's title.
249    pub title: String,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    /// The capability's description, when it has one.
252    pub description: Option<String>,
253    /// The answer, as the capability's output schema describes it (`capabilities.describe`
254    /// carries that schema).
255    pub answer: Value,
256    /// Where the capability comes from: the Rust module it is composed in.
257    pub provenance: Provenance,
258    /// `application/json`.
259    pub media_type: String,
260    /// The answer as text: byte for byte what `resources/read` returns for the URI.
261    pub content: String,
262}
263
264/// The answer of `objects.get`: what the URI resolved to, tagged by `source` the way a
265/// capability's provenance is.
266#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
267#[serde(tag = "source", rename_all = "lowercase")]
268pub enum ResourceView {
269    /// A file of the layer, read as it is.
270    Declarative(ObjectView),
271    /// A query with a resource exposure, executed and rendered as a JSON document.
272    Builtin(AnswerView),
273}
274
275fn objects_get(ctx: &Context, input: GetInput) -> Result<ResourceView, CapabilityError> {
276    let resolved = resolve(ctx, &input.uri)?;
277    let (media_type, content) = (resolved.media_type(), resolved.text());
278    Ok(match resolved {
279        Resolved::Object(o) => ResourceView::Declarative(ObjectView::of(o)),
280        Resolved::Answer {
281            capability,
282            resource,
283            value,
284        } => ResourceView::Builtin(AnswerView {
285            uri: input.uri,
286            id: capability.id.to_string(),
287            kind: capability.kind,
288            identity: resource.name.clone(),
289            title: capability.title.clone(),
290            description: (!capability.description.is_empty())
291                .then(|| capability.description.clone()),
292            answer: value,
293            provenance: capability.provenance.clone(),
294            media_type,
295            content,
296        }),
297    })
298}
299
300// ---------------------------------------------------------------- objects.search
301
302/// Hits returned by `objects.search` when no limit is given.
303pub const SEARCH_DEFAULT_LIMIT: u64 = 20;
304/// The most hits `objects.search` returns; a larger limit is clamped to this.
305pub const SEARCH_MAX_LIMIT: u64 = 200;
306
307#[derive(Debug, Serialize, Deserialize, JsonSchema)]
308#[serde(deny_unknown_fields)]
309/// The input of `objects.search`: what to look for, where, how much.
310pub struct SearchInput {
311    /// Case-insensitive substring, matched against identity, title, description and content.
312    pub query: String,
313    #[serde(default)]
314    /// Only objects of this kind; a kind the repository does not have is an invalid input.
315    pub kind: Option<String>,
316    /// At most this many hits (default 20, at most 200).
317    #[serde(default)]
318    #[schemars(range(min = 1, max = 200))]
319    pub limit: Option<u64>,
320}
321
322impl BenchmarkCases for SearchInput {
323    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
324        vec![
325            NamedCase::new(
326                "common-word",
327                SearchInput {
328                    query: "the".into(),
329                    kind: None,
330                    limit: None,
331                },
332            ),
333            NamedCase::new(
334                "no-hit",
335                SearchInput {
336                    query: "zqx-nothing-carries-this".into(),
337                    kind: None,
338                    limit: Some(5),
339                },
340            ),
341        ]
342    }
343}
344
345#[derive(Debug, Serialize, Deserialize, JsonSchema)]
346/// One search hit: the object, summarised, and the first matching line of its content.
347pub struct SearchHit {
348    #[serde(flatten)]
349    /// The object that matched.
350    pub object: ObjectSummary,
351    /// The first line of content that matched, when one did.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub snippet: Option<String>,
354}
355
356#[derive(Debug, Serialize, Deserialize, JsonSchema)]
357/// The answer of `objects.search`.
358pub struct SearchResult {
359    /// The query as given.
360    pub query: String,
361    /// How many hits were returned.
362    pub count: usize,
363    /// The limit that applied.
364    pub limit: u64,
365    /// The hits, in URI order, at most `limit` of them.
366    pub hits: Vec<SearchHit>,
367}
368
369fn objects_search(ctx: &Context, input: SearchInput) -> Result<SearchResult, CapabilityError> {
370    if input.query.trim().is_empty() {
371        return Err(CapabilityError::Refused(
372            "argument 'query' is required and must not be blank".into(),
373        ));
374    }
375    known_kind(ctx, input.kind.as_deref())?;
376    let limit = input
377        .limit
378        .map(|l| l.clamp(1, SEARCH_MAX_LIMIT))
379        .unwrap_or(SEARCH_DEFAULT_LIMIT);
380    let needle = input.query.to_lowercase();
381    let mut hits = Vec::new();
382    for o in ctx
383        .index
384        .objects
385        .iter()
386        .filter(|o| input.kind.as_deref().is_none_or(|k| o.kind == k))
387    {
388        let head = [
389            o.identity.as_str(),
390            o.title.as_deref().unwrap_or(""),
391            o.description.as_deref().unwrap_or(""),
392        ]
393        .iter()
394        .any(|s| s.to_lowercase().contains(&needle));
395        let line = o
396            .content
397            .lines()
398            .find(|l| l.to_lowercase().contains(&needle));
399        if head || line.is_some() {
400            hits.push(SearchHit {
401                object: ObjectSummary::of(o),
402                snippet: line.map(|l| l.trim().chars().take(200).collect()),
403            });
404            if hits.len() as u64 == limit {
405                break;
406            }
407        }
408    }
409    Ok(SearchResult {
410        query: input.query,
411        count: hits.len(),
412        limit,
413        hits,
414    })
415}
416
417// ---------------------------------------------------------------- objects.verify
418
419#[derive(Debug, Serialize, Deserialize, JsonSchema)]
420#[serde(deny_unknown_fields)]
421/// The input of `objects.verify`.
422pub struct VerifyInput {
423    /// Only objects of this kind.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub kind: Option<String>,
426    /// How many objects to read at most; every one of them when absent.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub limit: Option<usize>,
429}
430
431impl BenchmarkCases for VerifyInput {
432    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
433        // a bounded case: this reads files, and a benchmark of it should measure the
434        // reading rather than the size of whichever repository it happens to run in
435        vec![NamedCase::new(
436            "bounded",
437            VerifyInput {
438                kind: None,
439                limit: Some(25),
440            },
441        )]
442    }
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
446#[serde(rename_all = "snake_case")]
447/// How one file stands against what the index read from it.
448pub enum ObjectStanding {
449    /// It is what the index read.
450    Current,
451    /// It is there and it has changed since the index was built.
452    Changed,
453    /// The file the index read is no longer there.
454    Missing,
455    /// It is there and could not be read.
456    Unreadable,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
460#[serde(rename_all = "snake_case")]
461/// How closely a file could be compared with what the index holds.
462///
463/// A file that is one object is compared byte for byte, because the index kept its whole
464/// content. A collection file holds one object per member, and what the index kept for each
465/// is that member as JSON rather than the file's text — so the strongest thing that can be
466/// said without re-parsing it is whether its size is what it was. The report says which
467/// comparison was made rather than implying the stronger one.
468pub enum Comparison {
469    /// Byte for byte against the content the index holds.
470    Content,
471    /// By size against the size the index recorded.
472    Size,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
476/// One file of the layer that is no longer what the index read.
477pub struct DriftedObject {
478    /// Its repository-relative path.
479    pub path: String,
480    /// How it stands.
481    pub standing: ObjectStanding,
482    /// How it was compared.
483    pub comparison: Comparison,
484    /// How many objects of the index came from it.
485    pub objects: usize,
486    /// One of the URIs it holds, so a reader can see what is affected.
487    pub example_uri: String,
488    /// What is wrong, for a person.
489    pub detail: String,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
493/// The answer of `objects.verify`.
494pub struct VerifyReport {
495    /// How many files were read.
496    pub files: usize,
497    /// How many of them are what the index read.
498    pub current: usize,
499    /// How many are not, of any kind of not.
500    pub drifted: usize,
501    /// How many objects of the index those files carry.
502    pub objects: usize,
503    /// How many were compared byte for byte rather than by size.
504    pub compared_by_content: usize,
505    /// The index fingerprint this process is serving.
506    pub fingerprint: String,
507    /// Whether the index is still a true picture of the working tree, as far as this
508    /// comparison can tell.
509    pub index_is_current: bool,
510    /// The files that are not, with what is wrong with each.
511    pub findings: Vec<DriftedObject>,
512}
513
514/// Read every file the layer was built from and compare it with what this process is
515/// serving.
516///
517/// The index is built once, when the process starts, and kept: that is the contract, and
518/// it is what makes every request cost nothing. The price of it is that a file edited
519/// afterwards is served as it was, and until now nothing could say so without restarting
520/// the server.
521///
522/// The unit of work is the file, not the object, because a collection file holds many
523/// objects and is read once. Each file's [`Comparison`] says how strongly it could be
524/// checked.
525fn objects_verify(ctx: &Context, input: VerifyInput) -> Result<VerifyReport, CapabilityError> {
526    if let Some(kind) = &input.kind {
527        if !ctx.index.objects.iter().any(|o| &o.kind == kind) {
528            return Err(CapabilityError::InvalidInput(format!(
529                "no objects of kind '{kind}'; the kinds are listed by objects.list"
530            )));
531        }
532    }
533    // one entry per file, in the index's order, with what the index knows about it
534    let mut files: Vec<FileSubject> = Vec::new();
535    let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
536    for object in ctx
537        .index
538        .objects
539        .iter()
540        .filter(|o| input.kind.as_ref().is_none_or(|k| &o.kind == k))
541    {
542        let path = object.provenance.path.as_str();
543        match seen.get(path) {
544            Some(at) => files[*at].objects += 1,
545            None => {
546                seen.insert(path, files.len());
547                files.push(FileSubject {
548                    path: path.to_string(),
549                    bytes: object.provenance.bytes,
550                    objects: 1,
551                    example_uri: object.uri.clone(),
552                    // a member's content is the member as JSON, not the file's text: only a
553                    // file that is one whole object can be compared byte for byte
554                    content: object
555                        .provenance
556                        .member
557                        .is_none()
558                        .then(|| object.content.clone()),
559                });
560            }
561        }
562    }
563    let subjects: Vec<FileSubject> = files
564        .into_iter()
565        .take(input.limit.unwrap_or(usize::MAX))
566        .collect();
567
568    let root = std::path::Path::new(&ctx.index.repository.root);
569    let total = subjects.len() as u64;
570    let p = &ctx.progress;
571    p.step("read", &format!("Reading {total} file(s) of the layer"));
572
573    let mut findings = Vec::new();
574    let mut current = 0usize;
575    let mut objects = 0usize;
576    let mut by_content = 0usize;
577    for (n, subject) in subjects.iter().enumerate() {
578        if p.cancelled() {
579            p.step_done(
580                "read",
581                false,
582                Some(format!("cancelled after {n} of {total}")),
583            );
584            return Err(p.cancellation());
585        }
586        objects += subject.objects;
587        let comparison = if subject.content.is_some() {
588            by_content += 1;
589            Comparison::Content
590        } else {
591            Comparison::Size
592        };
593        let standing = match std::fs::read_to_string(root.join(&subject.path)) {
594            Ok(text) => match &subject.content {
595                Some(known) if &text == known => ObjectStanding::Current,
596                Some(_) => ObjectStanding::Changed,
597                None if text.len() as u64 == subject.bytes => ObjectStanding::Current,
598                None => ObjectStanding::Changed,
599            },
600            Err(e) if e.kind() == std::io::ErrorKind::NotFound => ObjectStanding::Missing,
601            Err(_) => ObjectStanding::Unreadable,
602        };
603        match standing {
604            ObjectStanding::Current => current += 1,
605            other => {
606                let detail = match other {
607                    ObjectStanding::Changed => match comparison {
608                        Comparison::Content => {
609                            "the file has changed since this process built its index".to_string()
610                        }
611                        Comparison::Size => format!(
612                            "the file's size is no longer the {} bytes this process read",
613                            subject.bytes
614                        ),
615                    },
616                    ObjectStanding::Missing => "the file is gone".to_string(),
617                    _ => "the file could not be read".to_string(),
618                };
619                p.diagnostic(crate::execution::ExecutionDiagnostic {
620                    severity: crate::model::Severity::Warning,
621                    code: format!("layer_file_{}", enum_word(&other)),
622                    summary: format!("{}: {detail}", subject.path),
623                    detail: Some(format!(
624                        "{} object(s) of the index came from it, such as {}",
625                        subject.objects, subject.example_uri
626                    )),
627                    suggestion: Some("restart this server to serve the layer as it is now".into()),
628                });
629                findings.push(DriftedObject {
630                    path: subject.path.clone(),
631                    standing: other,
632                    comparison,
633                    objects: subject.objects,
634                    example_uri: subject.example_uri.clone(),
635                    detail,
636                });
637            }
638        }
639        p.progress(
640            n as u64 + 1,
641            Some(total),
642            format!("{} file(s) read; last {}", n + 1, subject.path),
643        );
644    }
645    p.step_done(
646        "read",
647        findings.is_empty(),
648        Some(format!("{current} current, {} drifted", findings.len())),
649    );
650    Ok(VerifyReport {
651        files: subjects.len(),
652        current,
653        drifted: findings.len(),
654        objects,
655        compared_by_content: by_content,
656        fingerprint: ctx.index.fingerprint.clone(),
657        index_is_current: findings.is_empty(),
658        findings,
659    })
660}
661
662/// One file of the layer, and what the index knows about it.
663struct FileSubject {
664    path: String,
665    bytes: u64,
666    objects: usize,
667    example_uri: String,
668    /// The file's whole text, when the index kept it: a file that is one object.
669    content: Option<String>,
670}
671
672/// The serialised word of a small enum, for a diagnostic code.
673fn enum_word<T: Serialize>(value: &T) -> String {
674    serde_json::to_value(value)
675        .ok()
676        .and_then(|v| v.as_str().map(str::to_string))
677        .unwrap_or_default()
678}
679
680/// The module.
681pub fn module() -> ModuleDescriptor {
682    module! {
683        id: "objects",
684        title: "Objects",
685        description: "The declarative objects of the repository's AI layer: rules, prompts, profiles, policy, documents, milestones, issues, claims, and whatever kinds the repository adds; listed, read by URI, and searched.",
686        stability: Stability::BehaviorallyVerified,
687        capabilities: [
688            capability! {
689                id: "objects.list",
690                title: "List objects",
691                description: "List the declarative objects of the repository's AI layer, optionally by kind or tag.",
692                input: ListInput,
693                output: ObjectList,
694                stability: Stability::BehaviorallyVerified,
695                exposure: Exposure { mcp: mcp("majordomus_list"), http: get("/api/v1/objects"), cli: None },
696                tags: ["objects"],
697                handler: objects_list,
698            },
699            capability! {
700                id: "objects.get",
701                title: "Get one object",
702                description: "One object by URI (majordomus://<kind>/<identity>): metadata, provenance and content; a URI a query projects (majordomus://repository) answers that query as a JSON document. The same resolution serves the MCP resource read.",
703                input: GetInput,
704                output: ResourceView,
705                stability: Stability::BehaviorallyVerified,
706                exposure: Exposure { mcp: mcp("majordomus_get"), http: get("/api/v1/object"), cli: None },
707                tags: ["objects"],
708                handler: objects_get,
709            },
710            capability! {
711                id: "objects.search",
712                title: "Search objects",
713                description: "Case-insensitive substring search over identities, titles, descriptions and content.",
714                input: SearchInput,
715                output: SearchResult,
716                stability: Stability::BehaviorallyVerified,
717                exposure: Exposure { mcp: mcp("majordomus_search"), http: get("/api/v1/search"), cli: None },
718                tags: ["objects"],
719                cache: CachePolicy::Process { max_entries: 64, ttl_seconds: None },
720                handler: objects_search,
721            },
722            capability! {
723                id: "objects.verify",
724                title: "Verify the index against the working tree",
725                description: "Read every file the layer was built from and compare it with what this process is serving. The index is built once at start-up and kept, which is what makes every other request cost nothing and what makes a file edited afterwards be served as it was; this is how a running server says whether that has happened, without being restarted to find out. A file that is one object is compared byte for byte; a collection file, whose objects the index keeps as members rather than as text, is compared by size, and every finding says which comparison was made. It reads every file of the layer, so it reports its progress file by file and stops when it is asked to.",
726                input: VerifyInput,
727                output: VerifyReport,
728                stability: Stability::BehaviorallyVerified,
729                exposure: Exposure { mcp: mcp("majordomus_verify_objects"), http: get("/api/v1/objects/verify"), cli: None },
730                tags: ["objects", "diagnostic"],
731                handler: objects_verify,
732            }
733            .cancellable(),
734        ],
735    }
736}