Skip to main content

majordomus_cli/capability/
model.rs

1//! The descriptor: everything a projection may say about a capability, and nothing else.
2//!
3//! # What this module owns
4//!
5//! [`Capability`] is the value the whole executable is a projection of. It carries identity
6//! ([`CapabilityId`], [`ModuleId`]), what the thing is ([`CapabilityKind`]), the canonical
7//! schemas of its input and output, where it came from ([`Provenance`]), where it is
8//! projected ([`Exposure`]), where it means anything ([`Availability`]), who it is for
9//! ([`Visibility`]), where it stands ([`Stability`]), and the two operational policies a
10//! call is subject to ([`CachePolicy`], [`BenchmarkPolicy`]).
11//!
12//! # What it deliberately does not own
13//!
14//! There is no handler here and no transport type. A descriptor is plain data: it
15//! serialises, it round-trips, and it can be read by something that cannot execute
16//! anything — which is what lets the generated reference, the site dataset and the OpenAPI
17//! document be built from the same value the executor dispatches on. Execution lives in
18//! [`super::handler`]; the collection and its invariants live in [`super::registry`].
19//!
20//! # Declared against classified
21//!
22//! Two fields are *classified* rather than declared: [`Availability::classify`] and
23//! [`Visibility::classify`] compute their values from the kind and the exposures the
24//! declaration already carries. Asking each declaration to restate them would be the same
25//! knowledge written twice, and the second copy is the one that goes wrong. Both matches are
26//! exhaustive with no fallback arm on purpose, so a new kind or a new transport is a compile
27//! error here rather than a silent default in a page that then links to nothing.
28//!
29//! # Invariants
30//!
31//! Nothing in this module enforces anything: a descriptor can be built wrong and the
32//! registry is what refuses it. What lives here is the vocabulary each check is written
33//! against — the grammar of an id, the shape of a route, the states a policy may be in —
34//! each with its own validator returning the reason it failed rather than a boolean.
35//!
36//! ```
37//! use majordomus_cli::capability::{
38//!     Availability, CapabilityId, CapabilityKind, CliExposure, Exposure, Visibility,
39//! };
40//!
41//! let id = CapabilityId::parse("repository.info").unwrap();
42//! assert_eq!(id.namespace(), "repository");
43//!
44//! // a capability offered only on the command line is developer-facing, and needs a
45//! // process to answer at all — neither of which its declaration had to say
46//! let exposure = Exposure {
47//!     cli: Some(CliExposure { path: vec!["scope".into()] }),
48//!     ..Default::default()
49//! };
50//! assert_eq!(Visibility::classify(&exposure), Visibility::Developer);
51//! assert_eq!(
52//!     Availability::classify(CapabilityKind::Query, &exposure),
53//!     Availability::Runtime
54//! );
55//! ```
56
57use std::fmt;
58
59use schemars::JsonSchema;
60use serde::{Deserialize, Serialize};
61
62use super::schema::CanonicalSchema;
63
64/// A stable, globally meaningful identity: a namespace, a dot, and a local part.
65/// `repository.info` and `objects.get` for executables; `<kind>.<identity>` for a
66/// declarative object (`rule.majordomus.scope-integrity@1`, `document.docs/CLI.md`,
67/// `policy..ai/repo/policy.yaml`).
68///
69/// Grammar: the namespace matches `[a-z][a-z0-9_-]*`; the local part is non-empty and
70/// carries no whitespace or control character, any other Unicode included, because it is
71/// opaque: a path, a versioned identity, or a name, as the kind's identity rule produced it.
72#[derive(
73    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
74)]
75#[serde(transparent)]
76pub struct CapabilityId(String);
77
78impl CapabilityId {
79    /// Parse and validate.
80    ///
81    /// ```
82    /// use majordomus_cli::capability::CapabilityId;
83    /// let id = CapabilityId::parse("rule.majordomus.scope-integrity@1").unwrap();
84    /// assert_eq!(id.namespace(), "rule");
85    /// assert!(CapabilityId::parse("Repository.info").is_err(), "namespace is lowercase");
86    /// assert!(CapabilityId::parse("repository").is_err(), "a dot is required");
87    /// assert!(CapabilityId::parse("document.docs/Příručka.md").is_ok(), "the local part is opaque");
88    /// ```
89    pub fn parse(text: &str) -> Result<Self, String> {
90        let bytes = text.as_bytes();
91        if bytes.is_empty() {
92            return Err("empty".into());
93        }
94        if text.chars().any(|c| c.is_whitespace() || c.is_control()) {
95            return Err("carries whitespace or a control character".into());
96        }
97        let Some((namespace, local)) = text.split_once('.') else {
98            return Err("needs a namespace, a dot, and a local part".into());
99        };
100        let mut chars = namespace.chars();
101        let ok_namespace = chars.next().is_some_and(|c| c.is_ascii_lowercase())
102            && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
103        if !ok_namespace {
104            return Err(format!("namespace '{namespace}' is not [a-z][a-z0-9_-]*"));
105        }
106        if local.is_empty() {
107            return Err("the local part after the dot is empty".into());
108        }
109        Ok(CapabilityId(text.to_string()))
110    }
111
112    /// For descriptors written in code (the `capability!` macro); validated when the
113    /// registry is built, never before.
114    pub fn unchecked(text: &str) -> Self {
115        CapabilityId(text.to_string())
116    }
117
118    /// The id as text.
119    pub fn as_str(&self) -> &str {
120        &self.0
121    }
122
123    /// The first segment.
124    pub fn namespace(&self) -> &str {
125        self.0.split('.').next().unwrap_or_default()
126    }
127}
128
129impl fmt::Display for CapabilityId {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.write_str(&self.0)
132    }
133}
134
135/// A module identity: the namespace of every capability the module composes, matching
136/// `[a-z][a-z0-9_-]*`. Builtin modules declare theirs in `module!`; a declarative
137/// object's module is its kind.
138#[derive(
139    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
140)]
141#[serde(transparent)]
142pub struct ModuleId(String);
143
144impl ModuleId {
145    /// Parse and validate.
146    ///
147    /// ```
148    /// use majordomus_cli::capability::ModuleId;
149    /// assert!(ModuleId::parse("repository").is_ok());
150    /// assert!(ModuleId::parse("Repository").is_err());
151    /// assert!(ModuleId::parse("").is_err());
152    /// ```
153    pub fn parse(text: &str) -> Result<Self, String> {
154        let mut chars = text.chars();
155        let ok = chars.next().is_some_and(|c| c.is_ascii_lowercase())
156            && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
157        if ok {
158            Ok(ModuleId(text.to_string()))
159        } else {
160            Err(format!("module id '{text}' is not [a-z][a-z0-9_-]*"))
161        }
162    }
163
164    /// For descriptors written in code; validated when the registry is built.
165    pub fn unchecked(text: &str) -> Self {
166        ModuleId(text.to_string())
167    }
168
169    /// The id as text.
170    pub fn as_str(&self) -> &str {
171        &self.0
172    }
173}
174
175impl fmt::Display for ModuleId {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(&self.0)
178    }
179}
180
181/// Why an executable capability is not benchmarked. Typed, so that a waiver is a
182/// reviewable statement and never a convenience; `not_executable` is the registry's own
183/// reason for resources and is never written by hand.
184#[derive(
185    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
186)]
187#[serde(rename_all = "snake_case")]
188pub enum WaiverReason {
189    /// A resource: read, never executed; nothing to time but `objects.get`, which is.
190    NotExecutable,
191    /// The capability changes something outside this process and cannot be run in a loop.
192    Destructive,
193    /// The capability talks to something the benchmark host cannot provide.
194    ExternalDependency,
195    /// The capability starts, or answers about, work that exists only while it is running.
196    /// A benchmark host cannot stage an execution to read, and running the operation in a
197    /// loop would measure the work rather than the operation.
198    TransientState,
199    /// The capability answers about what this repository has *published*, so its input is a
200    /// release: a version tag, the commit it points at, and the projection committed there.
201    /// A benchmark fixture is a repository that has published nothing, and the honest answer
202    /// in one is a refusal — so the case would time an error path and call it coverage.
203    /// Distinct from [`WaiverReason::ExternalDependency`]: nothing outside this process is
204    /// consulted, and distinct from [`WaiverReason::TransientState`]: the history is durable,
205    /// it simply does not exist yet where the benchmark runs.
206    PublishedHistory,
207}
208
209/// What a repository must hold before a conditional benchmark requirement applies to it.
210/// Typed, like a waiver's reason, so that the condition is a reviewable statement on the
211/// descriptor and never a provider quietly returning nothing.
212///
213/// ```
214/// use majordomus_cli::capability::BenchmarkPrecondition;
215/// let p: BenchmarkPrecondition = serde_json::from_str(r#""plan_holds_an_issue""#).unwrap();
216/// assert_eq!(p, BenchmarkPrecondition::PlanHoldsAnIssue);
217/// ```
218#[derive(
219    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
220)]
221#[serde(rename_all = "snake_case")]
222pub enum BenchmarkPrecondition {
223    /// The plan holds at least one issue. A command whose every honest case is a refusal
224    /// chosen from an issue's own status has no such refusal in a plan with no issue: the
225    /// only answer there is a lookup miss, which times an error path and is not the
226    /// operation.
227    PlanHoldsAnIssue,
228}
229
230impl BenchmarkPrecondition {
231    /// Does the repository this index describes hold what the precondition names?
232    ///
233    /// It is answered from the index, so the same capability can be a requirement in one
234    /// repository and inapplicable in another without any provider returning nothing.
235    ///
236    /// ```
237    /// use majordomus_cli::capability::BenchmarkPrecondition;
238    /// use majordomus_cli::synthetic::SyntheticRepository;
239    ///
240    /// let repo = SyntheticRepository::small().unwrap();
241    /// let p = BenchmarkPrecondition::PlanHoldsAnIssue;
242    ///
243    /// // a repository whose plan holds nothing: the requirement does not apply here
244    /// assert!(!p.holds(&repo.index().unwrap()));
245    ///
246    /// // declare where issues live, as `majordomus init` does, and put one issue in the plan
247    /// let sources = repo.root().join(".ai/repo/knowledge/sources.yaml");
248    /// let mut text = std::fs::read_to_string(&sources).unwrap();
249    /// text.push_str("  - id: issue\n    kind: issue\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/project/issues/*.yaml'\n    required: false\n");
250    /// std::fs::write(&sources, text).unwrap();
251    /// let issues = repo.root().join(".ai/repo/project/issues");
252    /// std::fs::create_dir_all(&issues).unwrap();
253    /// std::fs::write(
254    ///     issues.join("I0001.yaml"),
255    ///     "id: I0001\nmilestone: M000\ntitle: A first issue\nslug: a-first-issue\n\
256    ///      priority: p1\nprofile: implementation\nobjective: \"Do the first thing.\"\n\
257    ///      scope:\n  - lib\nacceptance_criteria:\n  - It is done\nvalidation:\n  - \"true\"\n\
258    ///      evidence_required:\n  - proof\n",
259    /// )
260    /// .unwrap();
261    ///
262    /// // the same precondition now holds, answered from the index alone
263    /// assert!(p.holds(&repo.index().unwrap()));
264    /// ```
265    pub fn holds(self, index: &crate::index::Index) -> bool {
266        match self {
267            Self::PlanHoldsAnIssue => index.objects.iter().any(|o| o.kind == crate::plan::ISSUE),
268        }
269    }
270}
271
272/// Whether the capability is a benchmark target. `Required` is the default and the norm:
273/// every executable capability is timed directly and through every transport it is
274/// exposed on, with the cases its input type provides.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
276#[serde(tag = "policy", rename_all = "snake_case")]
277pub enum BenchmarkPolicy {
278    /// Timed directly and through every exposure; coverage fails without a case.
279    Required,
280    /// Required exactly like `Required` in a repository where the precondition holds, and
281    /// reported as inapplicable — never covered, never waived — where it does not. Not a
282    /// waiver: with the precondition met and no case, the requirement is missing.
283    RequiredWhen {
284        /// What the repository must hold.
285        precondition: BenchmarkPrecondition,
286    },
287    /// Not timed, for the typed reason; coverage reports it as waived, never as covered.
288    Waived {
289        /// Why.
290        reason: WaiverReason,
291    },
292}
293
294impl BenchmarkPolicy {
295    /// Is the capability a benchmark target at all — required, unconditionally or on a
296    /// precondition? Only a waiver says no.
297    ///
298    /// A conditional requirement is a target even where its precondition does not hold:
299    /// being inapplicable to one repository is not the same as being waived, and coverage
300    /// only decides applicability after this has said the capability is timed at all.
301    ///
302    /// ```
303    /// use majordomus_cli::capability::{BenchmarkPolicy, BenchmarkPrecondition, WaiverReason};
304    ///
305    /// assert!(BenchmarkPolicy::Required.is_target());
306    ///
307    /// // conditional, and still a target: the precondition decides applicability, not this
308    /// let conditional = BenchmarkPolicy::RequiredWhen {
309    ///     precondition: BenchmarkPrecondition::PlanHoldsAnIssue,
310    /// };
311    /// assert!(conditional.is_target());
312    ///
313    /// // only a waiver takes a capability out of the benchmark set
314    /// let waived = BenchmarkPolicy::Waived { reason: WaiverReason::PublishedHistory };
315    /// assert!(!waived.is_target());
316    /// ```
317    pub fn is_target(self) -> bool {
318        !matches!(self, Self::Waived { .. })
319    }
320}
321
322/// Whether, and how, the executor keeps results of this capability. Cache lives in the
323/// executor and nowhere else, so MCP, HTTP and the command line share one; the key is the
324/// canonical id, the normalised input and the registry fingerprint, so a changed
325/// repository never answers from an old entry. The two narrower policies add to the key
326/// what the registry fingerprint cannot see: the context's own reading of the repository
327/// ([`CachePolicy::Generation`]) and the state of the checkout itself
328/// ([`CachePolicy::Checkout`]).
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330#[serde(tag = "policy", rename_all = "snake_case")]
331pub enum CachePolicy {
332    /// Every call runs the handler.
333    Disabled,
334    /// Results are kept in this process's memory, bounded, for equal inputs.
335    Process {
336        /// The most entries kept for this capability; the oldest is evicted first.
337        max_entries: usize,
338        /// Seconds an entry stays valid; `None` for the life of the process.
339        #[serde(skip_serializing_if = "Option::is_none")]
340        ttl_seconds: Option<u64>,
341    },
342    /// Results are kept for equal inputs while the context that computed them is the one
343    /// asked: for a capability that is a function of the index and the registry alone,
344    /// including the git state the index was read at, which the registry fingerprint does
345    /// not cover. A reload of the repository is a new context, and so a new key.
346    Generation {
347        /// The most entries kept for this capability; the oldest is evicted first.
348        max_entries: usize,
349    },
350    /// Results are kept for equal inputs while the context is the same *and* the checkout
351    /// is as it was: HEAD, the staging index, the refs and every changed or untracked path
352    /// of the work tree, fingerprinted again on every call
353    /// (`capability::checkout`). For a capability that reads git, the index and the
354    /// files of the work tree, and nothing that fingerprint cannot see: no ignored file, no
355    /// other process, no network, no clock. A checkout git cannot fingerprint is not cached.
356    Checkout {
357        /// The most entries kept for this capability; the oldest is evicted first.
358        max_entries: usize,
359    },
360}
361
362impl CachePolicy {
363    /// Is the policy well-formed? The reason when it is not.
364    ///
365    /// ```
366    /// use majordomus_cli::capability::CachePolicy;
367    /// assert!(CachePolicy::Disabled.validate().is_ok());
368    /// assert!(CachePolicy::Process { max_entries: 0, ttl_seconds: None }.validate().is_err());
369    /// assert!(CachePolicy::Checkout { max_entries: 0 }.validate().is_err());
370    /// assert!(CachePolicy::Generation { max_entries: 4 }.validate().is_ok());
371    /// ```
372    pub fn validate(&self) -> Result<(), String> {
373        match self {
374            CachePolicy::Disabled => Ok(()),
375            _ if self.max_entries() == Some(0) => {
376                Err("a process cache with max_entries 0 keeps nothing".into())
377            }
378            CachePolicy::Process {
379                ttl_seconds: Some(0),
380                ..
381            } => Err("a process cache with ttl_seconds 0 keeps nothing".into()),
382            _ => Ok(()),
383        }
384    }
385
386    /// Does the policy keep anything?
387    pub fn is_enabled(&self) -> bool {
388        !matches!(self, CachePolicy::Disabled)
389    }
390
391    /// The most entries the policy keeps per capability; `None` when it keeps nothing.
392    ///
393    /// ```
394    /// use majordomus_cli::capability::CachePolicy;
395    /// assert_eq!(CachePolicy::Disabled.max_entries(), None);
396    /// assert_eq!(CachePolicy::Checkout { max_entries: 2 }.max_entries(), Some(2));
397    /// ```
398    pub fn max_entries(&self) -> Option<usize> {
399        match *self {
400            CachePolicy::Disabled => None,
401            CachePolicy::Process { max_entries, .. }
402            | CachePolicy::Generation { max_entries }
403            | CachePolicy::Checkout { max_entries } => Some(max_entries),
404        }
405    }
406
407    /// Seconds an entry stays valid; `None` for as long as its key holds.
408    ///
409    /// ```
410    /// use majordomus_cli::capability::CachePolicy;
411    /// let p = CachePolicy::Process { max_entries: 2, ttl_seconds: Some(5) };
412    /// assert_eq!(p.ttl_seconds(), Some(5));
413    /// assert_eq!(CachePolicy::Generation { max_entries: 2 }.ttl_seconds(), None);
414    /// ```
415    pub fn ttl_seconds(&self) -> Option<u64> {
416        match *self {
417            CachePolicy::Process { ttl_seconds, .. } => ttl_seconds,
418            _ => None,
419        }
420    }
421
422    /// The policy in a few words, as the Cockpit and the generated catalogue print it.
423    ///
424    /// ```
425    /// use majordomus_cli::capability::CachePolicy;
426    /// assert_eq!(CachePolicy::Disabled.summary(), "—");
427    /// assert_eq!(
428    ///     CachePolicy::Process { max_entries: 8, ttl_seconds: Some(5) }.summary(),
429    ///     "process, 8 entries, 5s"
430    /// );
431    /// assert_eq!(
432    ///     CachePolicy::Checkout { max_entries: 4 }.summary(),
433    ///     "process, 4 entries, per checkout state"
434    /// );
435    /// ```
436    pub fn summary(&self) -> String {
437        match *self {
438            CachePolicy::Disabled => "—".into(),
439            CachePolicy::Process {
440                max_entries,
441                ttl_seconds: Some(ttl),
442            } => format!("process, {max_entries} entries, {ttl}s"),
443            CachePolicy::Process { max_entries, .. } => format!("process, {max_entries} entries"),
444            CachePolicy::Generation { max_entries } => {
445                format!("process, {max_entries} entries, per generation")
446            }
447            CachePolicy::Checkout { max_entries } => {
448                format!("process, {max_entries} entries, per checkout state")
449            }
450        }
451    }
452}
453
454/// What a capability is. Three kinds exist because three semantics exist: something that
455/// is executed and changes nothing, something that is executed and changes this process's
456/// own memory, and something that is read. A command may also write the repository's own
457/// tracked records, which its kind does not say and its execution policy does
458/// ([`ExecutionPolicy::writes_repository`]).
459///
460/// How *long* a call takes is not a kind. A read that walks every file of the layer is
461/// still a read, and the thing that makes it worth watching — that it reports as it goes
462/// and stops when it is asked to — is one property of its handler, declared with
463/// [`crate::capability::Executable::cancellable`] and carried on [`ExecutionPolicy`].
464#[derive(
465    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
466)]
467#[serde(rename_all = "lowercase")]
468pub enum CapabilityKind {
469    /// Executable and read-only: a typed handler, an input schema, an output schema.
470    Query,
471    /// Executable and changing something: a typed handler, bound to `POST` over HTTP, and
472    /// announced to MCP clients as not read-only. Most reach no further than this process's
473    /// own memory (a peer announcing itself); one that writes the repository's tracked
474    /// records says so on its execution policy, which is where a projection reads it.
475    Command,
476    /// Declarative content the repository holds: read as it is, never executed.
477    Resource,
478}
479
480impl CapabilityKind {
481    /// Is a capability of this kind called, with a handler, rather than read?
482    ///
483    /// ```
484    /// use majordomus_cli::capability::CapabilityKind;
485    /// assert!(CapabilityKind::Query.is_executable() && CapabilityKind::Command.is_executable());
486    /// assert!(!CapabilityKind::Resource.is_executable());
487    /// ```
488    pub fn is_executable(self) -> bool {
489        !matches!(self, CapabilityKind::Resource)
490    }
491
492    /// Does a call of this kind leave the process as it found it?
493    ///
494    /// ```
495    /// use majordomus_cli::capability::CapabilityKind;
496    /// assert!(CapabilityKind::Query.is_read_only());
497    /// assert!(!CapabilityKind::Command.is_read_only());
498    /// ```
499    pub fn is_read_only(self) -> bool {
500        !matches!(self, CapabilityKind::Command)
501    }
502
503    /// The word as serialised.
504    pub fn as_str(self) -> &'static str {
505        match self {
506            CapabilityKind::Query => "query",
507            CapabilityKind::Command => "command",
508            CapabilityKind::Resource => "resource",
509        }
510    }
511}
512
513/// What running a capability changes outside the caller.
514///
515/// Classified, never declared: it follows from the kind, which is the field a declaration
516/// already carries. A projection reads this to decide whether to ask before running
517/// something — the Cockpit's confirmation is derived from it — instead of naming
518/// capabilities it must treat carefully, which is a list that goes stale the day after it
519/// is written.
520#[derive(
521    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
522)]
523#[serde(rename_all = "snake_case")]
524// the component namespace of the OpenAPI document is flat, and `deploy` already has a
525// `Concurrency`; each says which it is
526#[schemars(rename = "ExecutionEffect")]
527pub enum Effect {
528    /// Nothing changes. Every query and every resource of this executable.
529    Read,
530    /// This process's own memory changes, and nothing outside it.
531    ProcessState,
532    /// The repository changes.
533    ///
534    /// For most of this executable's life nothing classified to this, because no capability
535    /// of any kind wrote to the repository. That day arrived with [ADR 0040]: development
536    /// semantics are capabilities of this runtime, and the first of them — `plan.transition`
537    /// — stamps a field into an issue's own tracked record. The variant was put on the model
538    /// in advance so that when one did, it would say so *here*, where a projection already
539    /// reads it and a client already asks before running it, rather than in whichever page
540    /// happens to render its button.
541    ///
542    /// It is not classified from the kind, because the kind cannot know it: a command that
543    /// announces a peer and a command that writes a tracked file are the same kind. A
544    /// handler that writes the repository says so with
545    /// [`ExecutionPolicy::writes_repository`], the way one that stops says so with
546    /// [`ExecutionPolicy::stoppable`].
547    ///
548    /// [ADR 0040]: https://github.com/korczis/prismatic-majordomus/blob/master/.ai/repo/adrs/0040-development-semantics-are-capabilities-of-one-runtime.md
549    RepositoryMutation,
550}
551
552/// Whether two executions of one capability may overlap.
553#[derive(
554    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
555)]
556#[serde(rename_all = "snake_case")]
557#[schemars(rename = "ExecutionConcurrency")]
558pub enum Concurrency {
559    /// Any number at once. Every read is one of these: the index and the registry are
560    /// immutable for the life of the process, so concurrent readers cannot interfere.
561    Unrestricted,
562    /// One at a time. A second execution of the same capability waits for the first, which
563    /// is what a capability that changes anything — this process's own memory included —
564    /// needs in order to be reasoned about at all.
565    Serial,
566}
567
568/// What running a capability as an execution means: what it changes, whether asking it to
569/// stop achieves anything, and whether two of them may overlap.
570///
571/// The effect and the concurrency are classified from the kind by
572/// [`ExecutionPolicy::classify`], for the same reason [`Availability`] is: the facts are
573/// already on the declaration, and asking each `capability!` block to restate them would be
574/// one more thing that can disagree with itself.
575///
576/// Cancellability is the one thing the kind cannot decide, because it is a fact about the
577/// handler: whether it looks at its cancellation flag and stops. A handler that does says
578/// so with [`crate::capability::Executable::cancellable`], and a client is then told
579/// whether a Cancel button will achieve anything instead of being given one that lies.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
581pub struct ExecutionPolicy {
582    /// What it changes.
583    pub effect: Effect,
584    /// Whether asking it to stop does anything. A task looks at its cancellation flag; a
585    /// query and a command do not, and a client is told so rather than being given a
586    /// button that lies.
587    pub cancellable: bool,
588    /// Whether two of them may overlap.
589    pub concurrency: Concurrency,
590    /// Whether the input authenticates its caller.
591    ///
592    /// A handler whose input is a signed message, and which verifies the signature against
593    /// the trust policy before anything changes, admits its caller by that signature and not
594    /// by the address the request came from. Only such a handler changes state for a caller
595    /// on another host; see [`ExecutionPolicy::admits_remote`].
596    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
597    pub signed_input: bool,
598}
599
600impl ExecutionPolicy {
601    /// The policy of a kind.
602    ///
603    /// ```
604    /// use majordomus_cli::capability::{CapabilityKind, Concurrency, Effect, ExecutionPolicy};
605    /// let query = ExecutionPolicy::classify(CapabilityKind::Query);
606    /// assert_eq!(query.effect, Effect::Read);
607    /// assert_eq!(query.concurrency, Concurrency::Unrestricted);
608    /// assert!(!query.cancellable, "until its handler says it looks at the flag");
609    /// assert!(query.stoppable().cancellable);
610    /// let command = ExecutionPolicy::classify(CapabilityKind::Command);
611    /// assert_eq!(command.effect, Effect::ProcessState);
612    /// assert_eq!(command.concurrency, Concurrency::Serial);
613    /// ```
614    pub fn classify(kind: CapabilityKind) -> Self {
615        match kind {
616            // an immutable index and an immutable registry: readers cannot interfere
617            CapabilityKind::Query | CapabilityKind::Resource => ExecutionPolicy {
618                effect: Effect::Read,
619                cancellable: false,
620                concurrency: Concurrency::Unrestricted,
621                signed_input: false,
622            },
623            // it changes this process's memory, so two of them are made to take turns
624            CapabilityKind::Command => ExecutionPolicy {
625                effect: Effect::ProcessState,
626                cancellable: false,
627                concurrency: Concurrency::Serial,
628                signed_input: false,
629            },
630        }
631    }
632
633    /// The same policy, for a handler that looks at its cancellation flag and stops.
634    ///
635    /// ```
636    /// use majordomus_cli::capability::{CapabilityKind, ExecutionPolicy};
637    /// let p = ExecutionPolicy::classify(CapabilityKind::Query).stoppable();
638    /// assert!(p.cancellable);
639    /// assert_eq!(p.effect, ExecutionPolicy::classify(CapabilityKind::Query).effect);
640    /// ```
641    pub fn stoppable(self) -> Self {
642        ExecutionPolicy {
643            cancellable: true,
644            ..self
645        }
646    }
647
648    /// The same policy, for a handler that writes the repository's own tracked files.
649    ///
650    /// The second and last thing the kind cannot decide. Two commands are the same kind
651    /// whether one announces a peer into this process's memory and the other stamps a field
652    /// into a record a commit will carry, and the difference is the whole of what a caller
653    /// needs to know before saying yes. It is stated by the handler because only the handler
654    /// knows it.
655    ///
656    /// This is a widening and never a narrowing: a query cannot reach for it, because the
657    /// registry accepts a declared policy only when it is the kind's own or one of these two
658    /// documented additions to it.
659    ///
660    /// ```
661    /// use majordomus_cli::capability::{CapabilityKind, Effect, ExecutionPolicy};
662    /// let p = ExecutionPolicy::classify(CapabilityKind::Command).writes_repository();
663    /// assert_eq!(p.effect, Effect::RepositoryMutation);
664    /// assert!(p.needs_confirmation());
665    /// ```
666    pub fn writes_repository(self) -> Self {
667        ExecutionPolicy {
668            effect: Effect::RepositoryMutation,
669            ..self
670        }
671    }
672
673    /// The same policy, for a command whose input is a signed message the handler verifies
674    /// against the trust policy before it changes anything.
675    ///
676    /// The third documented addition, and the narrowest: the mesh's link and registration
677    /// handlers, whose callers are other machines by design and are admitted by a key the
678    /// trust policy names. It never combines with writing the repository: a signature
679    /// proves who sent a message, not that its sender may change tracked files here.
680    ///
681    /// ```
682    /// use majordomus_cli::capability::{CapabilityKind, ExecutionPolicy};
683    /// let p = ExecutionPolicy::classify(CapabilityKind::Command).authenticates_its_input();
684    /// assert!(p.signed_input && p.admits_remote());
685    /// ```
686    pub fn authenticates_its_input(self) -> Self {
687        ExecutionPolicy {
688            signed_input: true,
689            ..self
690        }
691    }
692
693    /// May a caller on another host run this?
694    ///
695    /// A request that arrives from an address that is not loopback has proved nothing about
696    /// who sent it: the server has no accounts and no tokens, and the `Origin` check stops a
697    /// browser, not a program. Such a caller reads, and changes nothing, unless the input
698    /// itself authenticates it ([`ExecutionPolicy::authenticates_its_input`]). A command
699    /// that only changes this process's memory is refused too, because some of that memory
700    /// is signed by this node and replicated to every runtime that trusts it (ADR 0126).
701    ///
702    /// ```
703    /// use majordomus_cli::capability::{CapabilityKind, ExecutionPolicy};
704    /// assert!(ExecutionPolicy::classify(CapabilityKind::Query).admits_remote());
705    /// assert!(!ExecutionPolicy::classify(CapabilityKind::Command).admits_remote());
706    /// assert!(!ExecutionPolicy::classify(CapabilityKind::Command).writes_repository().admits_remote());
707    /// ```
708    pub fn admits_remote(self) -> bool {
709        matches!(self.effect, Effect::Read) || self.signed_input
710    }
711
712    /// Should a client ask before running this? True for anything that changes something.
713    ///
714    /// ```
715    /// use majordomus_cli::capability::{CapabilityKind, ExecutionPolicy};
716    /// assert!(!ExecutionPolicy::classify(CapabilityKind::Query).needs_confirmation());
717    /// assert!(ExecutionPolicy::classify(CapabilityKind::Command).needs_confirmation());
718    /// ```
719    pub fn needs_confirmation(self) -> bool {
720        !matches!(self.effect, Effect::Read)
721    }
722
723    /// What a caller may assume about a call before making it, classified from the effect.
724    ///
725    /// A projection that tells a client how careful to be — MCP's tool annotations, the
726    /// site's table of tools, the Cockpit's — renders this value and states none of its
727    /// own. Until it existed the MCP projection wrote `destructiveHint: false` and
728    /// `idempotentHint: true` as constants, which was true of every tool on the day they
729    /// were written and false of the first one that wrote the repository: a sweep that
730    /// unlinks files was announced as safe to repeat and unable to destroy anything.
731    ///
732    /// The classification is conservative in the one direction a caller can survive.
733    /// Anything that changes something is not assumed repeatable, because whether a second
734    /// call is a no-op is a fact about the handler and no handler has declared it; and
735    /// anything that writes the repository may overwrite or remove what was there.
736    /// A change to this process's own memory destroys nothing outside it.
737    ///
738    /// ```
739    /// use majordomus_cli::capability::{CapabilityKind, ExecutionPolicy};
740    /// let read = ExecutionPolicy::classify(CapabilityKind::Query).hints();
741    /// assert!(read.read_only && read.idempotent && !read.destructive);
742    /// let memory = ExecutionPolicy::classify(CapabilityKind::Command).hints();
743    /// assert!(!memory.read_only && !memory.idempotent && !memory.destructive);
744    /// let writer = ExecutionPolicy::classify(CapabilityKind::Command).writes_repository().hints();
745    /// assert!(!writer.read_only && !writer.idempotent && writer.destructive);
746    /// assert!(!writer.open_world, "no handler of this executable reaches beyond the machine");
747    /// ```
748    pub fn hints(self) -> Hints {
749        match self.effect {
750            Effect::Read => Hints {
751                read_only: true,
752                destructive: false,
753                idempotent: true,
754                open_world: false,
755            },
756            Effect::ProcessState => Hints {
757                read_only: false,
758                destructive: false,
759                idempotent: false,
760                open_world: false,
761            },
762            Effect::RepositoryMutation => Hints {
763                read_only: false,
764                destructive: true,
765                idempotent: false,
766                open_world: false,
767            },
768        }
769    }
770}
771
772/// What a caller may assume about a call before making it: the four facts MCP names tool
773/// annotations, held once on the model so that no projection states its own.
774///
775/// Classified by [`ExecutionPolicy::hints`], never declared.
776///
777/// ```
778/// use majordomus_cli::capability::{CapabilityKind, ExecutionPolicy, Hints};
779/// let read: Hints = ExecutionPolicy::classify(CapabilityKind::Query).hints();
780/// assert_eq!(
781///     serde_json::to_value(read).unwrap(),
782///     serde_json::json!({ "read_only": true, "destructive": false, "idempotent": true, "open_world": false })
783/// );
784/// ```
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
786#[schemars(rename = "ExecutionHints")]
787pub struct Hints {
788    /// The call changes nothing, anywhere.
789    pub read_only: bool,
790    /// The call may overwrite or remove something that outlives this process.
791    pub destructive: bool,
792    /// A second identical call changes nothing the first did not.
793    pub idempotent: bool,
794    /// The call reaches beyond this machine. No handler of this executable does: observing
795    /// the forge and talking to an advisor are commands a person runs, not capabilities.
796    pub open_world: bool,
797}
798
799/// Where a capability stands, in the repository's own vocabulary for claims. A capability
800/// that is `Planned` or `Unsupported` may be listed but is never executable through any
801/// projection; the registry refuses to build otherwise.
802#[derive(
803    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
804)]
805#[serde(rename_all = "snake_case")]
806pub enum Stability {
807    /// Implemented, and no behavioural test names it yet.
808    Implemented,
809    /// Implemented and proved by a behavioural test.
810    BehaviorallyVerified,
811    /// Implemented, executable, and expected to change.
812    Experimental,
813    /// Specified and not implemented: listed, never executable.
814    Planned,
815    /// Considered and refused: listed with the reason, never executable.
816    Unsupported,
817}
818
819impl Stability {
820    /// May this capability be exposed as executable through any projection?
821    pub fn executable(self) -> bool {
822        matches!(
823            self,
824            Stability::Implemented | Stability::BehaviorallyVerified | Stability::Experimental
825        )
826    }
827}
828
829/// Where a capability came from. Never an absolute path.
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
831#[serde(tag = "source", rename_all = "lowercase")]
832#[schemars(rename = "CapabilityProvenance")]
833pub enum Provenance {
834    /// Written in Rust, in the named module of this executable.
835    /// Written in Rust, composed in `builtin.rs`.
836    Builtin {
837        /// The Rust module the descriptor was composed in.
838        module: String,
839    },
840    /// Read from the repository's layer.
841    Declarative {
842        /// Repository-relative path.
843        path: String,
844        /// The directory the path sits in, repository-relative; `.` for the root.
845        directory: String,
846        /// The `sources.yaml` class that discovered the file.
847        source_class: String,
848        #[serde(skip_serializing_if = "Option::is_none")]
849        /// The manifest section the path falls under, when it falls under one.
850        section: Option<String>,
851        /// IANA media type of the object's content.
852        media_type: String,
853        /// For one member of a collection file, its key path in the file (`claims.3`).
854        #[serde(skip_serializing_if = "Option::is_none")]
855        member: Option<String>,
856    },
857}
858
859/// Where this crate lives in the repository, so that a builtin descriptor's Rust module
860/// path names a file a reader can open. A fact of the tree's layout; the site generator
861/// refuses a path that does not exist.
862pub const CRATE_DIR: &str = "apps/majordomus-cli";
863
864impl Provenance {
865    /// The repository-relative path of the source: a declarative object's file, or the
866    /// Rust file a builtin descriptor was composed in, by the crate's layout
867    /// (`majordomus_cli::capability::builtin::objects` is
868    /// `apps/majordomus-cli/src/capability/builtin/objects.rs`).
869    ///
870    /// ```
871    /// use majordomus_cli::capability::Provenance;
872    /// let p = Provenance::Builtin { module: "majordomus_cli::capability::builtin::objects".into() };
873    /// assert_eq!(p.source_path(), "apps/majordomus-cli/src/capability/builtin/objects.rs");
874    /// ```
875    pub fn source_path(&self) -> String {
876        match self {
877            Provenance::Builtin { module } => {
878                let inner = module
879                    .strip_prefix("majordomus_cli::")
880                    .unwrap_or(module.as_str());
881                format!("{CRATE_DIR}/src/{}.rs", inner.replace("::", "/"))
882            }
883            Provenance::Declarative { path, .. } => path.clone(),
884        }
885    }
886}
887
888impl fmt::Display for Provenance {
889    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
890        match self {
891            Provenance::Builtin { module } => write!(f, "builtin {module}"),
892            Provenance::Declarative {
893                path,
894                member: Some(m),
895                ..
896            } => write!(f, "{path}#{m}"),
897            Provenance::Declarative {
898                path, member: None, ..
899            } => write!(f, "{path}"),
900        }
901    }
902}
903
904#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
905/// An MCP resource: its URI and the short name a client lists.
906pub struct McpResource {
907    /// `majordomus://<kind>/<identity>`, or `majordomus://repository`.
908    pub uri: String,
909    /// The short name a client lists; the identity for a declarative object.
910    pub name: String,
911}
912
913/// How, if at all, a capability appears to an MCP client.
914#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
915pub struct McpExposure {
916    /// As a tool with this name (`[a-z0-9_]+`).
917    #[serde(skip_serializing_if = "Option::is_none")]
918    pub tool: Option<String>,
919    /// As a readable resource.
920    #[serde(skip_serializing_if = "Option::is_none")]
921    pub resource: Option<McpResource>,
922}
923
924#[derive(
925    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
926)]
927#[serde(rename_all = "UPPERCASE")]
928/// The HTTP methods a capability may be bound to.
929pub enum HttpMethod {
930    /// Read-only; the input is bound from the query string.
931    Get,
932    /// The input is bound from the JSON body. Every mutating capability uses it.
933    Post,
934}
935
936impl HttpMethod {
937    /// The method as it appears on the wire.
938    pub fn as_str(self) -> &'static str {
939        match self {
940            HttpMethod::Get => "GET",
941            HttpMethod::Post => "POST",
942        }
943    }
944    /// The method for a wire name; `None` for one this projection does not serve.
945    ///
946    /// ```
947    /// use majordomus_cli::capability::HttpMethod;
948    /// assert_eq!(HttpMethod::parse("GET"), Some(HttpMethod::Get));
949    /// assert_eq!(HttpMethod::parse("DELETE"), None);
950    /// ```
951    pub fn parse(text: &str) -> Option<Self> {
952        match text {
953            "GET" => Some(HttpMethod::Get),
954            "POST" => Some(HttpMethod::Post),
955            _ => None,
956        }
957    }
958}
959
960/// How a capability appears over HTTP. `GET` binds every top-level input property as a
961/// query parameter; `POST` binds the input as the JSON request body. Paths are absolute
962/// and live under [`HttpExposure::PREFIX`].
963#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
964pub struct HttpExposure {
965    /// The method.
966    pub method: HttpMethod,
967    /// The absolute path, under [`HttpExposure::PREFIX`].
968    pub path: String,
969}
970
971impl HttpExposure {
972    /// Every capability route starts here; the version is part of the contract.
973    pub const PREFIX: &'static str = "/api/v1/";
974
975    /// Is the path one this projection can serve? The reason when it is not.
976    ///
977    /// ```
978    /// use majordomus_cli::capability::{HttpExposure, HttpMethod};
979    /// let ok = HttpExposure { method: HttpMethod::Get, path: "/api/v1/objects".into() };
980    /// assert!(ok.validate().is_ok());
981    /// let bad = HttpExposure { method: HttpMethod::Get, path: "/objects".into() };
982    /// assert!(bad.validate().unwrap_err().contains("/api/v1/"));
983    /// ```
984    pub fn validate(&self) -> Result<(), String> {
985        let p = &self.path;
986        if !p.starts_with(Self::PREFIX) {
987            return Err(format!("path '{p}' is not under {}", Self::PREFIX));
988        }
989        if p.ends_with('/') || p.contains("//") || p.contains('?') || p.contains('#') {
990            return Err(format!(
991                "path '{p}' has a trailing slash, an empty segment, or a query or fragment"
992            ));
993        }
994        if !p
995            .bytes()
996            .all(|b| b.is_ascii_alphanumeric() || b"/-_.".contains(&b))
997        {
998            return Err(format!(
999                "path '{p}' carries a character outside [A-Za-z0-9/-_.]"
1000            ));
1001        }
1002        Ok(())
1003    }
1004}
1005
1006/// How a capability appears on the command line: the words after `majordomus`.
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1008pub struct CliExposure {
1009    /// The words after `majordomus`, e.g. `["capabilities", "list"]`.
1010    pub path: Vec<String>,
1011}
1012
1013/// A capability as a skill a person invokes by a short name from an agent's prompt:
1014/// `/intent <sentence>` in Claude Code. The name and the one input field the typed text
1015/// fills are declared here, once; the file an agent product reads is generated from them
1016/// (the `editors` module) and carries no description of what the capability does beyond
1017/// the capability's own.
1018///
1019/// ```
1020/// use majordomus_cli::capability::SkillExposure;
1021/// let s = SkillExposure { alias: "intent".into(), argument: "statement".into() };
1022/// assert!(s.validate().is_ok());
1023/// // a name an agent product already owns is refused, and so is one that is not a name
1024/// let taken = SkillExposure { alias: "review".into(), argument: "x".into() };
1025/// assert!(taken.validate().unwrap_err().contains("reserved"));
1026/// let shouted = SkillExposure { alias: "Intent".into(), argument: "x".into() };
1027/// assert!(shouted.validate().is_err());
1028/// ```
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1030pub struct SkillExposure {
1031    /// The name typed after the slash: lowercase letters, digits and hyphens, at most 64.
1032    pub alias: String,
1033    /// The input field the text typed after the name fills, verbatim.
1034    pub argument: String,
1035}
1036
1037/// Names a skill may not take: commands and bundled skills of the agent products that read
1038/// the projection (Claude Code 2.1), and the words the repository's own workflow skills are
1039/// most likely to want. A capability that would be one of these is reached by its MCP tool.
1040pub const RESERVED_SKILL_NAMES: &[&str] = &[
1041    "add-dir",
1042    "agents",
1043    "bug",
1044    "clear",
1045    "compact",
1046    "config",
1047    "context",
1048    "cost",
1049    "doctor",
1050    "exit",
1051    "export",
1052    "help",
1053    "hooks",
1054    "ide",
1055    "init",
1056    "login",
1057    "logout",
1058    "mcp",
1059    "memory",
1060    "model",
1061    "peers",
1062    "permissions",
1063    "plan",
1064    "pr-comments",
1065    "release-notes",
1066    "resume",
1067    "review",
1068    "rewind",
1069    "security-review",
1070    "skills",
1071    "status",
1072    "terminal-setup",
1073    "todos",
1074    "usage",
1075    "vim",
1076];
1077
1078impl SkillExposure {
1079    /// Is the alias a name an agent product will accept and does not already own, and is
1080    /// an argument named?
1081    ///
1082    /// ```
1083    /// use majordomus_cli::capability::{SkillExposure, RESERVED_SKILL_NAMES};
1084    /// let skill = |alias: &str, argument: &str| SkillExposure {
1085    ///     alias: alias.into(),
1086    ///     argument: argument.into(),
1087    /// };
1088    /// assert!(skill("propose-intent", "statement").validate().is_ok());
1089    /// assert!(skill("-intent", "statement").validate().is_err());
1090    /// assert!(skill("intent", "").validate().unwrap_err().contains("no input field"));
1091    /// // every reserved name is refused, by name
1092    /// for reserved in RESERVED_SKILL_NAMES {
1093    ///     assert!(skill(reserved, "x").validate().unwrap_err().contains("reserved"));
1094    /// }
1095    /// ```
1096    pub fn validate(&self) -> Result<(), String> {
1097        let a = self.alias.as_str();
1098        if a.is_empty()
1099            || a.len() > 64
1100            || a.starts_with('-')
1101            || a.ends_with('-')
1102            || !a
1103                .bytes()
1104                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1105        {
1106            return Err(format!(
1107                "alias '{a}' is not lowercase letters, digits and inner hyphens, at most 64"
1108            ));
1109        }
1110        if RESERVED_SKILL_NAMES.contains(&a) {
1111            return Err(format!(
1112                "alias '{a}' is reserved: an agent product already answers to it"
1113            ));
1114        }
1115        if self.argument.is_empty() {
1116            return Err("names no input field for the typed text".into());
1117        }
1118        Ok(())
1119    }
1120}
1121
1122/// The projections a capability declares. Absence is explicit: `None` means not exposed
1123/// there, and nothing infers an exposure a descriptor did not declare.
1124#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1125pub struct Exposure {
1126    #[serde(skip_serializing_if = "Option::is_none")]
1127    /// The MCP projection, when declared.
1128    pub mcp: Option<McpExposure>,
1129    #[serde(skip_serializing_if = "Option::is_none")]
1130    /// The HTTP projection, when declared.
1131    pub http: Option<HttpExposure>,
1132    #[serde(skip_serializing_if = "Option::is_none")]
1133    /// The command-line projection, when declared.
1134    pub cli: Option<CliExposure>,
1135}
1136
1137impl Exposure {
1138    /// Exposed nowhere?
1139    pub fn is_empty(&self) -> bool {
1140        self.mcp.is_none() && self.http.is_none() && self.cli.is_none()
1141    }
1142}
1143
1144/// Where a capability means anything: the environment a caller must be in for it to
1145/// answer at all.
1146///
1147/// The published site and the running server are genuinely different places. Without this
1148/// on the model, every template grows its own idea of what works where — and the usual
1149/// shape that takes is a condition on the page's own address, which is a rule hidden
1150/// where nobody will find it and nothing can test it. This is the only thing a projection
1151/// may ask.
1152///
1153/// It is classified rather than declared: the facts that decide it — what kind of thing
1154/// this is and which transports it is projected through — are already on the descriptor,
1155/// and asking each declaration to restate them would be the same knowledge written twice.
1156/// [`Availability::classify`] is the one place the rule lives.
1157#[derive(
1158    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1159)]
1160#[serde(rename_all = "snake_case")]
1161// Three things in this executable answer to `Availability` — a capability's, a
1162// surface's, and whether a surface is answering right now. The schema component namespace
1163// is flat, so each says which it is.
1164#[schemars(rename = "CapabilityAvailability")]
1165pub enum Availability {
1166    /// True in every environment, a published page with no server included: the layer's
1167    /// own content, which a build renders and a process serves from the same index.
1168    Always,
1169    /// A process must be running to answer: everything with a handler, whether it is
1170    /// reached over HTTP, over MCP or from the command line.
1171    Runtime,
1172    /// A value captured when the site was generated, rendered afterwards as the capture
1173    /// it is. Nothing classifies to this yet; the static projection of the graph is what
1174    /// will declare it, and it is on the model so that a captured value can be labelled
1175    /// as captured instead of being shown as current.
1176    BuildTime,
1177    /// A process must be running and the caller must be one it has authenticated. Nothing
1178    /// in this repository authenticates a caller yet; a surface that does will say so
1179    /// here rather than in the template that renders its link.
1180    Authenticated,
1181}
1182
1183impl Availability {
1184    /// Classify from what the descriptor already declares.
1185    ///
1186    /// The match is exhaustive on purpose and has no fallback arm: a new kind, or a
1187    /// transport that changes what an environment can offer, is a compile error here
1188    /// rather than a silent `Always` in a page that then links to nothing.
1189    ///
1190    /// ```
1191    /// use majordomus_cli::capability::{Availability, CapabilityKind, Exposure};
1192    /// let nowhere = Exposure::default();
1193    /// assert_eq!(Availability::classify(CapabilityKind::Resource, &nowhere), Availability::Always);
1194    /// assert_eq!(Availability::classify(CapabilityKind::Query, &nowhere), Availability::Runtime);
1195    /// ```
1196    pub fn classify(kind: CapabilityKind, _exposure: &Exposure) -> Availability {
1197        match kind {
1198            // the content is the layer's, and the index that holds it is read the same way
1199            // by a build and by a process
1200            CapabilityKind::Resource => Availability::Always,
1201            // a handler answers, and a handler needs a process to run in — the transport
1202            // decides who may call it, not whether anything can
1203            CapabilityKind::Query | CapabilityKind::Command => Availability::Runtime,
1204        }
1205    }
1206}
1207
1208/// Who a capability is for, and whether anything offers it.
1209///
1210/// Internal is a statement, not an omission: a capability nothing projects is invisible
1211/// either way, and the difference between deliberate and forgotten is exactly what this
1212/// records.
1213#[derive(
1214    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1215)]
1216#[serde(rename_all = "snake_case")]
1217#[schemars(rename = "CapabilityVisibility")]
1218pub enum Visibility {
1219    /// Offered to anyone who can reach the process: an HTTP route or an MCP entry.
1220    Public,
1221    /// Offered to whoever runs the executable, and to nobody over a network.
1222    Developer,
1223    /// Projected nowhere. It exists, it is listed as existing, and no surface offers it.
1224    Internal,
1225}
1226
1227impl Visibility {
1228    /// Classify from the transports the descriptor declares.
1229    ///
1230    /// ```
1231    /// use majordomus_cli::capability::{CliExposure, Exposure, Visibility};
1232    /// assert_eq!(Visibility::classify(&Exposure::default()), Visibility::Internal);
1233    /// let cli = Exposure { cli: Some(CliExposure { path: vec!["scope".into()] }), ..Default::default() };
1234    /// assert_eq!(Visibility::classify(&cli), Visibility::Developer);
1235    /// ```
1236    pub fn classify(exposure: &Exposure) -> Visibility {
1237        if exposure.mcp.is_some() || exposure.http.is_some() {
1238            Visibility::Public
1239        } else if exposure.cli.is_some() {
1240            Visibility::Developer
1241        } else {
1242            Visibility::Internal
1243        }
1244    }
1245}
1246
1247/// The canonical descriptor. Everything a projection may say about a capability is here.
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1249pub struct Capability {
1250    /// The canonical identity.
1251    pub id: CapabilityId,
1252    /// The module that composes it: the id's namespace for a builtin, the kind for a
1253    /// declarative object.
1254    pub module: ModuleId,
1255    /// Query, command or resource.
1256    pub kind: CapabilityKind,
1257    /// The short name every projection shows.
1258    pub title: String,
1259    /// The one-paragraph description every projection shows.
1260    pub description: String,
1261    /// The canonical schema of the input; an empty object for a resource.
1262    pub input: CanonicalSchema,
1263    /// The canonical schema of the output; the object view for a resource.
1264    pub output: CanonicalSchema,
1265    /// Where it came from.
1266    pub provenance: Provenance,
1267    /// Where it is projected; absence is explicit.
1268    pub exposure: Exposure,
1269    /// Where it means anything: classified from the kind and the transports above, so
1270    /// that a projection reads a field instead of deciding for itself.
1271    pub availability: Availability,
1272    /// Who it is for: classified from the same transports.
1273    pub visibility: Visibility,
1274    /// Where it stands.
1275    pub stability: Stability,
1276    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1277    /// Free tags, from the declarative object's `tags` or the descriptor.
1278    pub tags: Vec<String>,
1279    /// Whether it is a benchmark target; the cases come from the input type.
1280    pub benchmark: BenchmarkPolicy,
1281    /// Whether the executor keeps its results.
1282    pub cache: CachePolicy,
1283    /// What running it as an execution means: classified from the kind, so that a client
1284    /// reads a fact rather than deciding for itself.
1285    pub execution: ExecutionPolicy,
1286    #[serde(default, skip_serializing_if = "Option::is_none")]
1287    /// The short name a person invokes it by from an agent's prompt, when it has one.
1288    pub skill: Option<SkillExposure>,
1289}
1290
1291/// A capability is presented by its id: the command line's table, the site's registry pages
1292/// and the closure matrix all list it that way, and a title is neither unique nor stable
1293/// enough to be the thing a reader navigates by.
1294impl crate::order::Ordered for Capability {
1295    fn order_key(&self) -> crate::order::OrderKey<'_> {
1296        crate::order::OrderKey::plain(self.id.as_str(), self.id.as_str())
1297    }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303
1304    #[test]
1305    fn id_grammar() {
1306        assert!(CapabilityId::parse("repository.info").is_ok());
1307        assert!(CapabilityId::parse("rule.majordomus.scope-integrity@1").is_ok());
1308        assert!(CapabilityId::parse("document.docs/CLI.md").is_ok());
1309        for bad in [
1310            "",
1311            "repository",
1312            "Repository.info",
1313            "a.",
1314            "a b.c",
1315            "1a.b",
1316            "a.b\u{7}",
1317        ] {
1318            assert!(CapabilityId::parse(bad).is_err(), "{bad:?} accepted");
1319        }
1320        assert_eq!(
1321            CapabilityId::parse("objects.get").unwrap().namespace(),
1322            "objects"
1323        );
1324    }
1325
1326    #[test]
1327    fn http_exposure_validation() {
1328        let ok = HttpExposure {
1329            method: HttpMethod::Get,
1330            path: "/api/v1/objects".into(),
1331        };
1332        assert!(ok.validate().is_ok());
1333        for bad in [
1334            "/objects",
1335            "/api/v1/objects/",
1336            "/api/v1//x",
1337            "/api/v1/x?y",
1338            "/api/v1/x y",
1339        ] {
1340            assert!(
1341                HttpExposure {
1342                    method: HttpMethod::Get,
1343                    path: bad.into()
1344                }
1345                .validate()
1346                .is_err(),
1347                "{bad}"
1348            );
1349        }
1350    }
1351    #[test]
1352    fn the_model_draws_the_four_environments_apart() {
1353        // the distinctions exist so a surface can say which environment it means; two of
1354        // them have no member yet, and the classifier says so rather than pretending
1355        let all = [
1356            Availability::Always,
1357            Availability::Runtime,
1358            Availability::BuildTime,
1359            Availability::Authenticated,
1360        ];
1361        let names: Vec<String> = all
1362            .iter()
1363            .map(|a| serde_json::to_string(a).unwrap())
1364            .collect();
1365        assert_eq!(
1366            names,
1367            [
1368                "\"always\"",
1369                "\"runtime\"",
1370                "\"build_time\"",
1371                "\"authenticated\""
1372            ]
1373        );
1374        for a in all {
1375            assert_eq!(
1376                serde_json::from_str::<Availability>(&serde_json::to_string(&a).unwrap()).unwrap(),
1377                a
1378            );
1379        }
1380    }
1381
1382    #[test]
1383    fn what_is_projected_nowhere_is_internal_and_says_so() {
1384        let nowhere = Exposure::default();
1385        assert!(nowhere.is_empty());
1386        assert_eq!(Visibility::classify(&nowhere), Visibility::Internal);
1387        let over_http = Exposure {
1388            http: Some(HttpExposure {
1389                method: HttpMethod::Get,
1390                path: "/api/v1/thing".into(),
1391            }),
1392            ..Default::default()
1393        };
1394        assert_eq!(Visibility::classify(&over_http), Visibility::Public);
1395        assert_eq!(
1396            Availability::classify(CapabilityKind::Query, &over_http),
1397            Availability::Runtime
1398        );
1399    }
1400}