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}