Skip to main content

majordomus_cli/capability/
handler.rs

1//! What a capability's behaviour is, and what it is allowed to know.
2//!
3//! A handler is a typed function — `fn(&Context, Input) -> Result<Output, CapabilityError>`
4//! — placed behind a JSON boundary by [`handler`]. The boundary is what lets one registry
5//! hold capabilities with different input and output types; the typing is what keeps the
6//! handler from doing its own deserialisation.
7//!
8//! # What a handler may know
9//!
10//! [`Context`] is the whole of it: the index of the repository, the registry it belongs to,
11//! the board of peers attached to this process, the Why catalogue, the executor, the
12//! resolved web topology, and — only when the call arrived through an MCP session — which
13//! peer made it. A handler never learns whether MCP, HTTP or the command line called it,
14//! and that is deliberate: an answer that depended on the transport would be a second
15//! implementation hiding inside the first.
16//!
17//! Index, registry and topology are immutable for the life of a process and are shared by
18//! `Arc`, so a request never rebuilds canonical state. The peer board is the one thing that
19//! changes, and it lives in memory only.
20//!
21//! # The error model
22//!
23//! [`CapabilityError`] has four cases and names no status code and no JSON-RPC code. Each
24//! transport maps them into its own vocabulary, so adding a transport cannot change what a
25//! handler is able to say.
26//!
27//! ```
28//! use majordomus_cli::capability::{CapabilityError, Context, handler::handler};
29//! use serde::{Deserialize, Serialize};
30//!
31//! #[derive(Deserialize)]
32//! struct In { n: u32 }
33//! #[derive(Serialize)]
34//! struct Out { doubled: u32 }
35//!
36//! fn double(_: &Context, input: In) -> Result<Out, CapabilityError> {
37//!     if input.n > 100 {
38//!         return Err(CapabilityError::Refused("n is above the limit".into()));
39//!     }
40//!     Ok(Out { doubled: input.n * 2 })
41//! }
42//!
43//! // the JSON boundary: the same handler, called the way every transport calls it
44//! let boxed = handler::<In, Out, _>(double);
45//! assert_eq!(
46//!     CapabilityError::Refused("n is above the limit".into()).to_string(),
47//!     "refused: n is above the limit"
48//! );
49//! // an input the type cannot read is the caller's fault, and says so
50//! assert!(matches!(
51//!     serde_json::from_value::<In>(serde_json::json!({ "n": "twelve" })),
52//!     Err(_)
53//! ));
54//! let _ = boxed;
55//! ```
56
57use std::marker::PhantomData;
58use std::sync::Arc;
59
60use serde::de::DeserializeOwned;
61use serde::Serialize;
62use serde_json::Value;
63
64use crate::index::Index;
65use crate::peers::{PeerBoard, PeerId};
66use crate::web::Topology;
67
68use super::executor::CapabilityExecutor;
69
70use super::model::Capability;
71use super::registry::CapabilityRegistry;
72
73/// Why a call did not produce an output. Transport adapters map these to their own
74/// vocabularies; nothing here names a status code or a JSON-RPC code.
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76pub enum CapabilityError {
77    #[error("invalid input: {0}")]
78    /// The input did not deserialize into the handler's type, or a filter named something the repository does not have.
79    InvalidInput(String),
80    #[error("not found: {0}")]
81    /// The thing asked for does not exist: an object, a capability.
82    NotFound(String),
83    #[error("refused: {0}")]
84    /// The input is well-formed and the call is declined, with the reason (a blank query).
85    Refused(String),
86    #[error("internal: {0}")]
87    /// The handler itself failed; never the caller's fault.
88    Internal(String),
89    #[error("forbidden: {0}")]
90    /// The caller may not run this from where it is: a request from another host for a
91    /// capability that changes something and does not authenticate its input (ADR 0126).
92    Forbidden(String),
93}
94
95impl CapabilityError {
96    /// The category as one word, the same on every transport: the `error.code` of an HTTP
97    /// answer and the `_meta.majordomus.error.code` of an MCP tool result that is an error.
98    /// A caller branches on this and reads the message; it never has to parse the message
99    /// to learn which of the five happened.
100    ///
101    /// ```
102    /// use majordomus_cli::capability::CapabilityError;
103    /// assert_eq!(CapabilityError::InvalidInput("x".into()).code(), "invalid_input");
104    /// assert_eq!(CapabilityError::NotFound("x".into()).code(), "not_found");
105    /// assert_eq!(CapabilityError::Refused("x".into()).code(), "refused");
106    /// assert_eq!(CapabilityError::Internal("x".into()).code(), "internal");
107    /// assert_eq!(CapabilityError::Forbidden("x".into()).code(), "forbidden");
108    /// ```
109    pub fn code(&self) -> &'static str {
110        match self {
111            CapabilityError::InvalidInput(_) => "invalid_input",
112            CapabilityError::NotFound(_) => "not_found",
113            CapabilityError::Refused(_) => "refused",
114            CapabilityError::Internal(_) => "internal",
115            CapabilityError::Forbidden(_) => "forbidden",
116        }
117    }
118
119    /// The refusal a caller on another host meets when it asks for a capability that
120    /// changes something and does not authenticate its input. One sentence on every
121    /// transport, so the HTTP route and the MCP tool refuse alike.
122    ///
123    /// ```
124    /// use majordomus_cli::capability::CapabilityError;
125    /// let e = CapabilityError::remote("plan.transition");
126    /// assert_eq!(e.code(), "forbidden");
127    /// assert!(e.to_string().contains("plan.transition"));
128    /// ```
129    pub fn remote(id: &str) -> Self {
130        CapabilityError::Forbidden(format!(
131            "'{id}' changes something, and this request came from another host: a caller \
132             beyond loopback reads and changes nothing, because this server cannot tell who \
133             it is (ADR 0126). Run it on this machine, or reach this server through a tunnel \
134             that ends on loopback"
135        ))
136    }
137}
138
139/// What a handler may read: the index of the repository, the registry it belongs to, the
140/// board of peers attached to this process, and, when the call came through an MCP
141/// session, which peer made it. Index and registry are immutable for the life of a
142/// process; the board is the one thing that changes, and it lives in memory only.
143#[derive(Clone)]
144pub struct Context {
145    /// The index of the repository's objects.
146    pub index: Arc<Index>,
147    /// The registry the handler belongs to, for introspection capabilities.
148    pub registry: Arc<CapabilityRegistry>,
149    /// The peers attached to this process.
150    pub peers: Arc<PeerBoard>,
151    /// The execution episodes this process holds: one per client that asked for one, keyed
152    /// by the client's own durable identity and driven by the connection (ADR 0103).
153    ///
154    /// Beside the peer board and not inside it, because a peer and an episode are not the
155    /// same thing: a connection holds zero or one episode, and an episode outlives the
156    /// connection that opened it so that a client which reconnects comes back to its own.
157    pub episodes: Arc<crate::episodes::EpisodeBoard>,
158    /// The Why catalogue, derived from the index once when this context is composed and
159    /// shared by every projection that reads it. A request never rebuilds it.
160    pub why: Arc<crate::why::Catalogue>,
161    /// The product model: the features of the layer with every fact a surface may say
162    /// about them derived from the registry, the index, the catalogue and the topology.
163    /// Built once, after the three it reads, and shared the same way.
164    pub product: Arc<crate::product::ProductModel>,
165    /// The one execution path: counters, cache, handler dispatch. Shared by every
166    /// transport and every session of this process.
167    pub executor: Arc<CapabilityExecutor>,
168    /// The peer this call came from, when it came through an MCP session; `None` for the
169    /// command line and for a plain HTTP request.
170    pub caller: Option<PeerId>,
171    /// The executions this process is running, and the engine that starts them.
172    ///
173    /// One per process, so that an execution started from a browser is the same execution
174    /// an MCP client asks about and the command line lists. A handler reads it only when
175    /// it is itself the execution plane's own capability; every other handler reports
176    /// through [`Context::progress`] and never learns whether anything was listening.
177    pub executions: Arc<crate::execution::ExecutionEngine>,
178    /// What this call reports its steps and progress to, and how it learns it should stop.
179    ///
180    /// Silent unless the call came through the execution engine, which is why a handler
181    /// may report unconditionally and a benchmark sample costs nothing.
182    pub progress: crate::execution::Progress,
183    /// The repository's web surfaces, resolved once for this process.
184    ///
185    /// Resolved here and never again: a projection that serves a subset of it narrows this
186    /// value rather than discovering its own, so what the HTTP router serves, what the home
187    /// page lists and what `web.surfaces` answers are three readings of one resolution.
188    pub web: Arc<Topology>,
189    /// The mesh runtime: the discovered nodes of this process, and the machinery that
190    /// observes them. Inactive (an empty registry with a reason) until a shared server
191    /// activates it from the repository's mesh declaration; the capabilities of the
192    /// `mesh` module are its projections.
193    pub mesh: Arc<crate::mesh::MeshRuntime>,
194    /// Which reading of the repository this is: a number no other [`Context::new`] of this
195    /// process was given, shared by every view of it ([`Context::for_caller`],
196    /// [`Context::reporting`], [`Context::with_web`]). A reload is a new reading. It is what
197    /// a [`crate::capability::CachePolicy::Generation`] entry is kept under, because the
198    /// registry fingerprint covers what is declared and not the git state an index was read
199    /// at.
200    pub reading: u64,
201}
202
203/// The number the next [`Context::new`] takes as its [`Context::reading`].
204static READINGS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
205
206impl Context {
207    /// A context over an index and a registry, with an empty board, a fresh executor and
208    /// no caller.
209    pub fn new(index: Arc<Index>, registry: Arc<CapabilityRegistry>) -> Self {
210        let index_root = std::path::PathBuf::from(&index.repository.root);
211        let web = Arc::new(resolve_web(&index));
212        let why = Arc::new(crate::why::Catalogue::build(&index, &registry));
213        let product = Arc::new(crate::product::ProductModel::build(
214            &index, &registry, &why, &web,
215        ));
216        Context {
217            index,
218            registry,
219            why,
220            product,
221            peers: Arc::new(PeerBoard::new()),
222            // The board drives the repository the index was read from: the episode a client
223            // opens here is an episode of *this* checkout, written by the same command a
224            // provider hook runs. A checkout whose tool cannot be found says so in every
225            // episode rather than recording nothing quietly.
226            episodes: Arc::new(crate::episodes::EpisodeBoard::for_repository(
227                index_root.clone(),
228            )),
229            executor: Arc::new(CapabilityExecutor::new()),
230            executions: Arc::new(crate::execution::ExecutionEngine::default()),
231            progress: crate::execution::Progress::silent(),
232            caller: None,
233            web,
234            mesh: Arc::new(crate::mesh::MeshRuntime::new()),
235            reading: READINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
236        }
237    }
238
239    /// This context's reading of the repository, carrying on the in-memory life of an
240    /// earlier one: what [`crate::live::Live`] hands out after the repository has moved.
241    ///
242    /// A reload is not a restart. The index, the registry and everything derived from them
243    /// — the Why catalogue, the product model, the web topology — are pictures of the
244    /// repository and are replaced wholesale, which is the point. The peer board, the
245    /// executions this process is running, the episodes its clients opened and the capability
246    /// executor are not pictures of
247    /// anything: they are this process's own life, and a peer that announced its intent
248    /// must not vanish from the board because somebody else committed. The executor is
249    /// carried because its cache is keyed by the registry's fingerprint, so the entries of
250    /// an older generation are already unreachable and the ones of an unchanged capability
251    /// are still worth having.
252    ///
253    /// ```
254    /// # use std::sync::Arc;
255    /// # use majordomus_cli::capability::Context;
256    /// # fn example(before: Arc<Context>, after: Arc<Context>) {
257    /// let renewed = after.continuing(&before);
258    /// assert!(Arc::ptr_eq(&renewed.peers, &before.peers), "the board survives a reload");
259    /// assert!(Arc::ptr_eq(&renewed.episodes, &before.episodes), "so do the episodes");
260    /// assert!(Arc::ptr_eq(&renewed.index, &after.index), "the picture does not");
261    /// # }
262    /// ```
263    pub fn continuing(&self, previous: &Context) -> Self {
264        Context {
265            peers: Arc::clone(&previous.peers),
266            executions: Arc::clone(&previous.executions),
267            executor: Arc::clone(&previous.executor),
268            // The episode board is this process's life too: the episodes its clients opened
269            // and the ones a lost connection detached. A reload that handed out a fresh board
270            // would forget every one of them because the repository moved, and an index
271            // refresh by `git status` is enough to move it.
272            episodes: Arc::clone(&previous.episodes),
273            // The mesh runtime is this process's, not this generation's: it holds the
274            // sockets that are already announcing and the registry every observation has
275            // converged into. A rebuild that handed the surfaces a fresh one would leave
276            // the server announcing from a runtime nothing could read.
277            mesh: Arc::clone(&previous.mesh),
278            ..self.same()
279        }
280    }
281
282    /// The same context, seen from one peer: what a session hands its handlers.
283    pub fn for_caller(&self, caller: PeerId) -> Self {
284        Context {
285            caller: Some(caller),
286            ..self.same()
287        }
288    }
289
290    /// The same context, seen by one execution: what the engine hands the handler it runs.
291    ///
292    /// Everything else is shared, the engine included, so a capability that reads the
293    /// executions of this process sees its own while it runs.
294    pub fn reporting(&self, progress: crate::execution::Progress) -> Self {
295        Context {
296            progress,
297            ..self.same()
298        }
299    }
300
301    /// The same context with the web topology narrowed to what one projection serves.
302    ///
303    /// The narrowing is a filter over the value this context already holds, never a second
304    /// discovery: a projection cannot serve a surface the process did not resolve.
305    pub fn with_web(&self, web: Arc<Topology>) -> Self {
306        Context { web, ..self.same() }
307    }
308
309    fn same(&self) -> Self {
310        Context {
311            index: Arc::clone(&self.index),
312            registry: Arc::clone(&self.registry),
313            why: Arc::clone(&self.why),
314            product: Arc::clone(&self.product),
315            peers: Arc::clone(&self.peers),
316            episodes: Arc::clone(&self.episodes),
317            executor: Arc::clone(&self.executor),
318            executions: Arc::clone(&self.executions),
319            progress: self.progress.clone(),
320            caller: self.caller.clone(),
321            web: Arc::clone(&self.web),
322            mesh: Arc::clone(&self.mesh),
323            reading: self.reading,
324        }
325    }
326
327    /// Execute a capability by id: the one way anything calls a handler.
328    pub fn execute(&self, id: &str, input: Value) -> Result<Value, CapabilityError> {
329        self.executor.execute(self, id, input)
330    }
331
332    /// The same, with the cache stepped over: what an execution runs.
333    ///
334    /// See [`CapabilityExecutor::execute_uncached`] for why watching something happen and
335    /// being handed a remembered answer are not the same request.
336    pub fn execute_observed(&self, id: &str, input: Value) -> Result<Value, CapabilityError> {
337        self.executor.execute_uncached(self, id, input)
338    }
339
340    /// The latest reading of a capability, at once, with how old it is: what a page shows.
341    ///
342    /// Never an answer: see [`crate::capability::readings`] for what it is instead, and
343    /// what it costs.
344    ///
345    /// ```
346    /// use majordomus_cli::capability::readings::Freshness;
347    /// use majordomus_cli::synthetic::SyntheticRepository;
348    ///
349    /// let repo = SyntheticRepository::small().unwrap();
350    /// let ctx = repo.context().unwrap();
351    /// let reading = ctx.read("plan.status", serde_json::json!({}));
352    /// // a first reading is waited for, so it holds what `execute` answers, and says how it
353    /// // stands: read just now, in the repository as it is
354    /// assert_eq!(reading.value.ok(), ctx.execute("plan.status", serde_json::json!({})).ok());
355    /// assert_eq!(reading.freshness, Freshness::Current);
356    /// assert!(reading.age.is_some());
357    /// ```
358    pub fn read(&self, id: &str, input: Value) -> super::readings::Latest {
359        super::readings::read(self, id, input, super::readings::Patience::of_process())
360    }
361}
362
363/// The topology of the repository the index was read from.
364///
365/// Discovery reads the site's configuration, the generated web root and the executable's
366/// own constants; a malformed producer declaration is reported and leaves the process with
367/// the surfaces it answers itself, which is what lets a broken report be diagnosed over
368/// the very surfaces that would otherwise be unreachable.
369fn resolve_web(index: &Index) -> Topology {
370    let root = std::path::Path::new(&index.repository.root);
371    match crate::web::discover::discover(root, crate::web::discover::Runtime::full()) {
372        Ok(topology) => topology,
373        Err(e) => {
374            tracing::warn!(
375                "a web surface could not be resolved: {e}; this process serves its own routes only"
376            );
377            Topology::new(crate::web::discover::native(
378                crate::web::discover::Runtime::full(),
379            ))
380        }
381    }
382}
383
384/// The JSON boundary of a handler.
385pub trait Handler: Send + Sync {
386    /// Run the handler on a JSON input and answer with a JSON output.
387    fn call(&self, ctx: &Context, input: Value) -> Result<Value, CapabilityError>;
388}
389
390struct Typed<I, O, F> {
391    f: F,
392    _io: PhantomData<fn(I) -> O>,
393}
394
395impl<I, O, F> Handler for Typed<I, O, F>
396where
397    I: DeserializeOwned,
398    O: Serialize,
399    F: Fn(&Context, I) -> Result<O, CapabilityError> + Send + Sync,
400{
401    fn call(&self, ctx: &Context, input: Value) -> Result<Value, CapabilityError> {
402        let input: I = serde_json::from_value(input)
403            .map_err(|e| CapabilityError::InvalidInput(e.to_string()))?;
404        let output = (self.f)(ctx, input)?;
405        serde_json::to_value(output).map_err(|e| CapabilityError::Internal(e.to_string()))
406    }
407}
408
409/// Wrap a typed function as a handler.
410pub fn handler<I, O, F>(f: F) -> Arc<dyn Handler>
411where
412    I: DeserializeOwned + 'static,
413    O: Serialize + 'static,
414    F: Fn(&Context, I) -> Result<O, CapabilityError> + Send + Sync + 'static,
415{
416    Arc::new(Typed {
417        f,
418        _io: PhantomData,
419    })
420}
421
422/// A descriptor with its behaviour and its benchmark cases: what the builtin source
423/// contributes.
424pub struct Executable {
425    /// The descriptor.
426    pub capability: Capability,
427    /// The behaviour, behind the JSON boundary.
428    pub handler: Arc<dyn Handler>,
429    /// The representative inputs, from the input type's `BenchmarkCases`.
430    pub cases: super::benchmark::CaseProvider,
431}
432
433impl Executable {
434    /// Declare that this handler looks at its cancellation flag and stops.
435    ///
436    /// The one thing about a capability that its kind cannot decide, because it is a fact
437    /// about the handler's own code rather than about what the call means. Everything a
438    /// client does with it — whether the Cockpit offers a Cancel button, what
439    /// `executions.cancel` reports — is read from here, so a handler that ignores its flag
440    /// never produces a control that lies.
441    ///
442    /// ```
443    /// use majordomus_cli::capability;
444    /// use majordomus_cli::capability::{BenchmarkCases, CaseContext, Context, CapabilityError, Exposure, NamedCase, Stability};
445    /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
446    /// struct In {}
447    /// impl BenchmarkCases for In {
448    ///     fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", In {})] }
449    /// }
450    /// #[derive(serde::Serialize, schemars::JsonSchema)]
451    /// struct Out { ok: bool }
452    /// fn walk(ctx: &Context, _: In) -> Result<Out, CapabilityError> {
453    ///     if ctx.progress.cancelled() { return Err(ctx.progress.cancellation()); }
454    ///     Ok(Out { ok: true })
455    /// }
456    /// let e = capability! {
457    ///     id: "demo.walk", title: "Walk", description: "Walks.", input: In, output: Out,
458    ///     stability: Stability::Experimental, exposure: Exposure::default(), tags: [],
459    ///     handler: walk,
460    /// }.cancellable();
461    /// assert!(e.capability.execution.cancellable);
462    /// ```
463    pub fn cancellable(mut self) -> Self {
464        self.capability.execution = self.capability.execution.stoppable();
465        self
466    }
467
468    /// Declare that this handler writes the repository's own tracked files.
469    ///
470    /// The second thing a kind cannot decide, and for the same reason: two commands are the
471    /// same kind whether one announces a peer into this process's memory and the other
472    /// stamps a field into a record a commit will carry. The difference is the whole of what
473    /// a caller needs before saying yes, and every projection reads it from here — the
474    /// Cockpit's confirmation, and the exposure ceiling that decides which surfaces may
475    /// reach it at all.
476    ///
477    /// Declaring it is the handler author's obligation, not an optimisation. A handler that
478    /// writes a tracked file without saying so is projected as though it did not, onto
479    /// surfaces whose ceiling exists to exclude exactly that.
480    ///
481    /// ```
482    /// use majordomus_cli::capability;
483    /// use majordomus_cli::capability::{BenchmarkCases, CapabilityKind, CaseContext, Context, CapabilityError, Effect, Exposure, NamedCase, Stability};
484    /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
485    /// struct In {}
486    /// impl BenchmarkCases for In {
487    ///     fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", In {})] }
488    /// }
489    /// #[derive(serde::Serialize, schemars::JsonSchema)]
490    /// struct Out { ok: bool }
491    /// fn write(_: &Context, _: In) -> Result<Out, CapabilityError> { Ok(Out { ok: true }) }
492    /// let e = capability! {
493    ///     id: "demo.write", kind: CapabilityKind::Command, title: "Write", description: "Writes.",
494    ///     input: In, output: Out, stability: Stability::Experimental,
495    ///     exposure: Exposure::default(), tags: [], handler: write,
496    /// }.writes_repository();
497    /// assert_eq!(e.capability.execution.effect, Effect::RepositoryMutation);
498    /// ```
499    pub fn writes_repository(mut self) -> Self {
500        self.capability.execution = self.capability.execution.writes_repository();
501        self
502    }
503
504    /// Declare the short name a person invokes this by from an agent's prompt, and the
505    /// input field the text typed after it fills. See [`crate::capability::SkillExposure`];
506    /// the registry refuses a name that is malformed, reserved or taken, and an argument
507    /// the input does not have.
508    ///
509    /// ```
510    /// use majordomus_cli::capability;
511    /// use majordomus_cli::capability::{BenchmarkCases, CapabilityKind, CaseContext, Context, CapabilityError, Exposure, NamedCase, Stability};
512    /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
513    /// struct In { text: String }
514    /// impl BenchmarkCases for In {
515    ///     fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![] }
516    /// }
517    /// #[derive(serde::Serialize, schemars::JsonSchema)]
518    /// struct Out {}
519    /// fn say(_: &Context, _: In) -> Result<Out, CapabilityError> { Ok(Out {}) }
520    /// let e = capability! {
521    ///     id: "demo.say", kind: CapabilityKind::Command, title: "Say", description: "Says.",
522    ///     input: In, output: Out, stability: Stability::Experimental,
523    ///     exposure: Exposure::default(), tags: [], handler: say,
524    /// }.skill("say", "text");
525    /// let skill = e.capability.skill.unwrap();
526    /// assert_eq!((skill.alias.as_str(), skill.argument.as_str()), ("say", "text"));
527    /// ```
528    pub fn skill(mut self, alias: &str, argument: &str) -> Self {
529        self.capability.skill = Some(crate::capability::SkillExposure {
530            alias: alias.into(),
531            argument: argument.into(),
532        });
533        self
534    }
535
536    /// Declare that the input is a signed message the handler verifies before it changes
537    /// anything, so a caller on another host is admitted by its key rather than refused by
538    /// its address. See [`crate::capability::ExecutionPolicy::authenticates_its_input`].
539    ///
540    /// ```
541    /// use majordomus_cli::capability;
542    /// use majordomus_cli::capability::{BenchmarkCases, CapabilityKind, CaseContext, Context, CapabilityError, Effect, Exposure, NamedCase, Stability};
543    /// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
544    /// struct Signed {}
545    /// impl BenchmarkCases for Signed {
546    ///     fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", Signed {})] }
547    /// }
548    /// #[derive(serde::Serialize, schemars::JsonSchema)]
549    /// struct Out { ok: bool }
550    /// fn hello(_: &Context, _: Signed) -> Result<Out, CapabilityError> { Ok(Out { ok: true }) }
551    /// let e = capability! {
552    ///     id: "demo.hello", kind: CapabilityKind::Command, title: "Hello", description: "Verifies a signed hello.",
553    ///     input: Signed, output: Out, stability: Stability::Experimental,
554    ///     exposure: Exposure::default(), tags: [], handler: hello,
555    /// }.authenticates_its_input();
556    /// assert!(e.capability.execution.signed_input && e.capability.execution.admits_remote());
557    /// assert_eq!(e.capability.execution.effect, Effect::ProcessState);
558    /// ```
559    pub fn authenticates_its_input(mut self) -> Self {
560        self.capability.execution = self.capability.execution.authenticates_its_input();
561        self
562    }
563}
564
565/// The one canonical declaration of an executable capability. From it every projection is
566/// derived: the MCP tool and its schemas, the HTTP route and its OpenAPI operation, the
567/// Swagger UI entry, the command line's introspection, the benchmark targets for every
568/// transport it is exposed on, the cache policy the executor applies, and the generated
569/// reference. Nothing is declared a second time anywhere.
570///
571/// Fields, in this order: `id`, an optional `kind` (a query by default;
572/// `CapabilityKind::Command` for one that changes this process's memory), `title`,
573/// `description`, `input`, `output`, `stability`, `exposure`, `tags`, an optional `cache`
574/// (`CachePolicy::Disabled` by default), an optional `benchmark`
575/// (`BenchmarkPolicy::Required` by default), and `handler`. `$input` and `$output` derive
576/// `schemars::JsonSchema` (and serde), and `$input` implements
577/// [`super::benchmark::BenchmarkCases`]: their schemas become the canonical schemas,
578/// their doc comments the descriptions a client reads, and the input's cases the
579/// benchmark inputs of every transport. The provenance is the Rust module the macro is
580/// expanded in; the capability's module is stamped by [`crate::module!`]. Nothing here
581/// registers anything: the result is composed explicitly into a module.
582///
583/// ```
584/// use majordomus_cli::capability;
585/// use majordomus_cli::capability::{BenchmarkCases, CachePolicy, CaseContext, Context, CapabilityError, Exposure, NamedCase, Stability};
586/// #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
587/// struct In {}
588/// impl BenchmarkCases for In {
589///     fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> { vec![NamedCase::new("default", In {})] }
590/// }
591/// #[derive(serde::Serialize, schemars::JsonSchema)]
592/// struct Out { ok: bool }
593/// fn ping(_: &Context, _: In) -> Result<Out, CapabilityError> { Ok(Out { ok: true }) }
594/// let e = capability! {
595///     id: "demo.ping", title: "Ping", description: "Answers.", input: In, output: Out,
596///     stability: Stability::Experimental, exposure: Exposure::default(), tags: ["demo"],
597///     cache: CachePolicy::Process { max_entries: 8, ttl_seconds: None },
598///     handler: ping,
599/// };
600/// assert_eq!(e.capability.id.as_str(), "demo.ping");
601/// assert!(e.capability.cache.is_enabled());
602/// ```
603#[macro_export]
604macro_rules! capability {
605    // ---- the full form: every field present
606    (
607        id: $id:expr,
608        kind: $kind:expr,
609        title: $title:expr,
610        description: $description:expr,
611        input: $input:ty,
612        output: $output:ty,
613        stability: $stability:expr,
614        exposure: $exposure:expr,
615        tags: [$($tag:expr),* $(,)?],
616        cache: $cache:expr,
617        benchmark: $benchmark:expr,
618        handler: $handler:expr $(,)?
619    ) => {{
620        // classified here, from the kind and the exposure this declaration already
621        // carries, so that no declaration restates what it has just said and no
622        // projection decides it later
623        let kind = $kind;
624        let exposure = $exposure;
625        $crate::capability::Executable {
626            capability: $crate::capability::Capability {
627                id: $crate::capability::CapabilityId::unchecked($id),
628                module: $crate::capability::ModuleId::unchecked(""),
629                kind,
630                title: String::from($title),
631                description: String::from($description),
632                input: $crate::capability::CanonicalSchema::of::<$input>(),
633                output: $crate::capability::CanonicalSchema::of::<$output>(),
634                provenance: $crate::capability::Provenance::Builtin { module: String::from(module_path!()) },
635                availability: $crate::capability::Availability::classify(kind, &exposure),
636                visibility: $crate::capability::Visibility::classify(&exposure),
637                exposure,
638                stability: $stability,
639                tags: vec![$(String::from($tag)),*],
640                benchmark: $benchmark,
641                cache: $cache,
642                execution: $crate::capability::ExecutionPolicy::classify(kind),
643                skill: None,
644            },
645            handler: $crate::capability::handler::handler::<$input, $output, _>($handler),
646            cases: <$input as $crate::capability::BenchmarkCases>::benchmark_cases_json,
647        }
648    }};
649    // ---- defaults: kind Query, cache Disabled, benchmark Required, in every combination
650    ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], handler: $h:expr $(,)? ) => {
651        $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
652    };
653    ( id: $id:expr, kind: $k:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], handler: $h:expr $(,)? ) => {
654        $crate::capability! { id: $id, kind: $k, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
655    };
656    ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], cache: $c:expr, handler: $h:expr $(,)? ) => {
657        $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $c, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
658    };
659    ( id: $id:expr, kind: $k:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], cache: $c:expr, handler: $h:expr $(,)? ) => {
660        $crate::capability! { id: $id, kind: $k, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $c, benchmark: $crate::capability::BenchmarkPolicy::Required, handler: $h }
661    };
662    ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], benchmark: $b:expr, handler: $h:expr $(,)? ) => {
663        $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $b, handler: $h }
664    };
665    ( id: $id:expr, kind: $k:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], benchmark: $b:expr, handler: $h:expr $(,)? ) => {
666        $crate::capability! { id: $id, kind: $k, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $crate::capability::CachePolicy::Disabled, benchmark: $b, handler: $h }
667    };
668    ( id: $id:expr, title: $title:expr, description: $d:expr, input: $i:ty, output: $o:ty, stability: $s:expr, exposure: $e:expr, tags: [$($t:expr),* $(,)?], cache: $c:expr, benchmark: $b:expr, handler: $h:expr $(,)? ) => {
669        $crate::capability! { id: $id, kind: $crate::capability::CapabilityKind::Query, title: $title, description: $d, input: $i, output: $o, stability: $s, exposure: $e, tags: [$($t),*], cache: $c, benchmark: $b, handler: $h }
670    };
671}