Skip to main content

majordomus_cli/capability/builtin/
environment.rs

1//! The `environment` module: what this checkout is, as a capability.
2//!
3//! One capability, one handler, and every surface a projection of it — the MCP tool and
4//! resource, the HTTP route, the OpenAPI operation, the Cockpit page and the command line
5//! all answer with the value [`crate::environment::resolve()`] built. The direnv banner is
6//! the same value rendered for a terminal.
7//!
8//! The handler runs inside a process that already holds the index, so it resolves in full:
9//! nothing here pays the index build, because whoever called it had already paid it. The
10//! fast, cached resolution belongs to the command line, which may be starting cold on a
11//! shell prompt.
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
17use crate::capability::handler::{CapabilityError, Context};
18use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
19use crate::capability::module::ModuleDescriptor;
20use crate::environment::preflight::{self, Preflight, Probe, RulesTally};
21use crate::environment::{
22    resolve, EnvironmentQuery, FieldSource, Inputs, RepositoryEnvironment, Resolution,
23};
24use crate::repository::Repository;
25use crate::{capability, module};
26
27use super::{get, mcp};
28
29/// The URI under which the environment is read as an MCP resource.
30pub const ENVIRONMENT_URI: &str = "majordomus://environment";
31
32/// The URI under which the preflight is read as an MCP resource.
33pub const PREFLIGHT_URI: &str = "majordomus://environment/preflight";
34
35/// What a served preflight may cost.
36///
37/// ```
38/// use majordomus_cli::capability::builtin::environment::PreflightInput;
39/// let input: PreflightInput = serde_json::from_str("{}").unwrap();
40/// assert!(!input.probe, "a served request asks no other process by default");
41/// ```
42#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct PreflightInput {
45    /// Ask the loopback address a server of this checkout published, and its peer board.
46    /// Off by default: a served request answers from what this process can see, and when
47    /// this process holds the checkout's lease that is itself the evidence.
48    #[serde(default)]
49    pub probe: bool,
50}
51
52impl BenchmarkCases for PreflightInput {
53    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
54        vec![NamedCase::new("sealed", PreflightInput { probe: false })]
55    }
56}
57
58fn preflight(ctx: &Context, input: PreflightInput) -> Result<Preflight, CapabilityError> {
59    let root = std::path::Path::new(&ctx.index.repository.root);
60    let repository = Repository::open(root).map_err(|e| {
61        CapabilityError::Internal(format!("the repository could not be re-read: {e}"))
62    })?;
63    // Located here rather than carried on the context, so that the provider projections are
64    // compared against their templates exactly as the command line compares them; without
65    // it they would read `unknown` on this surface and `verified` on that one.
66    let share = crate::share::Share::locate(None, root).ok();
67    let environment = resolve(
68        &Inputs {
69            repository: &repository,
70            share: share.as_ref(),
71            index: Some(&ctx.index),
72            registry: Some(&ctx.registry),
73            policy: None,
74        },
75        &EnvironmentQuery {
76            resolution: Resolution::Full,
77            probe_services: false,
78            use_cache: false,
79            write_cache: false,
80        },
81    );
82    let tally = RulesTally::of(&rules_report(ctx, root));
83    let policy = crate::policy::LoadedPolicy::load(&repository).map_err(|e| e.to_string());
84    let mut observations = preflight::observe(
85        root,
86        &environment,
87        policy.as_ref().map_err(Clone::clone),
88        Some(tally),
89        // never the cache: a served request writes nothing to the checkout it serves
90        Probe {
91            cache: false,
92            ..if input.probe {
93                Probe::asked()
94            } else {
95                Probe::sealed()
96            }
97        },
98    );
99    // The process that holds the lease holds the board: read it here rather than asking
100    // this process over its own socket, and the command line — which asks over the socket —
101    // reads the same peers.
102    if observations.server.this_process {
103        observations.peers = Ok(preflight::PeersObservation::of(&ctx.peers.list()));
104    }
105    Ok(preflight::derive(&observations))
106}
107
108/// The rule corpus counted against the ledger, as `rules.report` answers it: asked through
109/// the executor, so that a preflight and every other reader of the same checkout state share
110/// one computation of the most expensive thing a preflight reads. A ledger that cannot be
111/// read is counted as an empty one, as the preflight always counted it — `rules.report`
112/// refuses that ledger, so the refusal is the one case computed here.
113fn rules_report(ctx: &Context, root: &std::path::Path) -> crate::rules::RulesReport {
114    ctx.execute("rules.report", serde_json::json!({}))
115        .ok()
116        .and_then(|v| serde_json::from_value(v).ok())
117        .unwrap_or_else(|| {
118            let ledger = crate::evidence::Ledger::load(root)
119                .unwrap_or_else(|_| crate::evidence::Ledger::empty());
120            crate::rules::report(&ctx.index, &ledger)
121        })
122}
123
124/// What to include in the snapshot.
125#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct EnvironmentInput {
128    /// Contact the local address a running server published, to say whether it answers.
129    /// Off by default: a served request should not open a socket to another server on
130    /// behalf of its caller, and the caller usually is that server.
131    #[serde(default)]
132    pub probe_services: bool,
133}
134
135impl BenchmarkCases for EnvironmentInput {
136    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
137        vec![NamedCase::new(
138            "default",
139            EnvironmentInput {
140                probe_services: false,
141            },
142        )]
143    }
144}
145
146/// Which field's provenance to explain.
147#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct ExplainInput {
150    /// The field in dotted form (`services.url`, `capabilities.objects`); every field when
151    /// absent.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub field: Option<String>,
154}
155
156impl BenchmarkCases for ExplainInput {
157    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
158        vec![
159            NamedCase::new("all", ExplainInput { field: None }),
160            NamedCase::new(
161                "one-field",
162                ExplainInput {
163                    field: Some("project.version".into()),
164                },
165            ),
166        ]
167    }
168}
169
170/// Where the values of one snapshot came from.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct EnvironmentProvenance {
173    /// How the snapshot behind these entries was resolved.
174    pub resolution: Resolution,
175    /// One entry per field, in the order the resolver produced them.
176    pub fields: Vec<FieldSource>,
177}
178
179fn snapshot(
180    ctx: &Context,
181    input: EnvironmentInput,
182) -> Result<RepositoryEnvironment, CapabilityError> {
183    let root = std::path::Path::new(&ctx.index.repository.root);
184    let repository = Repository::open(root).map_err(|e| {
185        CapabilityError::Internal(format!("the repository could not be re-read: {e}"))
186    })?;
187    // The share directory is not carried on the context; without it the provider
188    // projections are still listed from the policy, and their state is `unknown` rather
189    // than guessed. `env status` on the command line has it and reports the state.
190    Ok(resolve(
191        &Inputs {
192            repository: &repository,
193            share: None,
194            index: Some(&ctx.index),
195            registry: Some(&ctx.registry),
196            policy: None,
197        },
198        &EnvironmentQuery {
199            resolution: Resolution::Full,
200            probe_services: input.probe_services,
201            use_cache: false,
202            // A served request writes nothing: the cache belongs to the checkout's own
203            // command line, and a request that wrote it would be a request with a side
204            // effect on the repository.
205            write_cache: false,
206        },
207    ))
208}
209
210fn explain(ctx: &Context, input: ExplainInput) -> Result<EnvironmentProvenance, CapabilityError> {
211    let environment = snapshot(
212        ctx,
213        EnvironmentInput {
214            probe_services: false,
215        },
216    )?;
217    let fields = match &input.field {
218        None => environment.provenance.clone(),
219        Some(field) => {
220            let found: Vec<FieldSource> = environment
221                .provenance
222                .iter()
223                .filter(|p| p.field == *field || p.field.starts_with(&format!("{field}.")))
224                .cloned()
225                .collect();
226            if found.is_empty() {
227                return Err(CapabilityError::NotFound(format!(
228                    "no field named '{field}'; `environment.explain` with no field lists every one"
229                )));
230            }
231            found
232        }
233    };
234    Ok(EnvironmentProvenance {
235        resolution: environment.resolution,
236        fields,
237    })
238}
239
240/// The module.
241pub fn module() -> ModuleDescriptor {
242    module! {
243        id: "environment",
244        title: "Repository environment",
245        description: "What this checkout is right now: the project and its version, the repository and its layer, version control, the toolchains it declares, what the layer holds, the workflows a person can run, the provider projections and the local services — one typed snapshot, with a provenance entry for every value in it. The direnv banner, the Cockpit's overview and this route are renderings of the same value.",
246        stability: Stability::BehaviorallyVerified,
247        capabilities: [
248            capability! {
249                id: "environment.status",
250                title: "The repository environment",
251                description: "One snapshot of this checkout: project identity, repository identity, version control, declared toolchains, what the layer holds counted per kind, the workflows the runner describes, the provider projections against the policy that renders them, and the local services with the address a running server published. Every value carries where it came from.",
252                input: EnvironmentInput,
253                output: RepositoryEnvironment,
254                stability: Stability::BehaviorallyVerified,
255                exposure: Exposure {
256                    mcp: Some(McpExposure {
257                        tool: Some("majordomus_environment".into()),
258                        resource: Some(McpResource { uri: ENVIRONMENT_URI.into(), name: "environment".into() }),
259                    }),
260                    http: get("/api/v1/environment"),
261                    cli: None,
262                },
263                tags: ["environment", "repository", "introspection"],
264                // Not cached, and deliberately so. This answer is a snapshot of *now*:
265                // it runs `git status`, reads the lease, and stamps the moment it was
266                // taken into `generated_at`, so two calls a second apart legitimately
267                // differ. The executor holds that a cached capability answers the same
268                // value from the handler, from a cold cache and from a warm one
269                // (`tests/executor.rs`, `docs/claims/execution-cache-equivalence.md`), and
270                // a cache that may change the answer is a cache that lies. For a value
271                // whose whole product is its freshness the honest declaration is no cache
272                // — and the window bought nothing this module was not already paying:
273                // `environment.explain` runs the same full resolution uncached, and
274                // nothing polls the route on a timer. `project.cache-is-invisible` is the
275                // rule: a cache key is a content hash of what it derives from and never a
276                // timestamp, and a cache a caller can observe is a defect. This value
277                // cannot be made invisible while it carries when it was taken.
278                cache: CachePolicy::Disabled,
279                handler: snapshot,
280            },
281            capability! {
282                id: "environment.explain",
283                title: "Where an environment value came from",
284                description: "The provenance of the snapshot: for each field, what decided it — a compile-time constant, a file, a command, or the cache — which resolver read it, and how far it can be trusted. Narrow it to one field, or to a prefix, by name.",
285                input: ExplainInput,
286                output: EnvironmentProvenance,
287                stability: Stability::BehaviorallyVerified,
288                exposure: Exposure {
289                    mcp: mcp("majordomus_environment_explain"),
290                    http: get("/api/v1/environment/explain"),
291                    cli: None,
292                },
293                tags: ["environment", "provenance", "introspection"],
294                handler: explain,
295            },
296            capability! {
297                id: "environment.preflight",
298                title: "Whether Majordomus is in force here, and what proves it",
299                description: "One verdict per claim about this checkout — git; the episode, its briefing, the task and the handover; the policy, the rule corpus and the ADRs; the shared server and the MCP, API and Cockpit surfaces it serves; the peer board; recorded test runs, rule enforcement, provider projections, generated documentation and the deployment — each `verified`, `active`, `fresh`, `stale`, `degraded`, `unavailable`, `failed`, `unknown` or `not_applicable`, with the evidence it rests on. A verdict that asserts something is in force cannot be produced without evidence. The same value the command line prints and the entry banner summarises.",
300                input: PreflightInput,
301                output: Preflight,
302                stability: Stability::BehaviorallyVerified,
303                exposure: Exposure {
304                    mcp: Some(McpExposure {
305                        tool: Some("majordomus_preflight".into()),
306                        resource: Some(McpResource { uri: PREFLIGHT_URI.into(), name: "preflight".into() }),
307                    }),
308                    http: get("/api/v1/environment/preflight"),
309                    cli: None,
310                },
311                tags: ["environment", "governance", "verification", "evidence"],
312                // A judgement of *now*, stamped with when it was taken: the same reason
313                // `environment.status` is not cached.
314                cache: CachePolicy::Disabled,
315                handler: preflight,
316            },
317        ],
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    /// The preflight counts the rules `rules.report` answers, and a ledger `rules.report`
326    /// refuses is counted as an empty one, as the preflight always counted it.
327    #[test]
328    fn the_preflight_counts_the_rules_rules_report_answers() {
329        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
330        let ctx = repo.context().expect("a context");
331        let root = std::path::Path::new(&ctx.index.repository.root);
332        let ledger = crate::evidence::Ledger::load(root).expect("a readable ledger");
333        assert_eq!(
334            rules_report(&ctx, root),
335            crate::rules::report(&ctx.index, &ledger)
336        );
337        let path = root.join(crate::evidence::LEDGER_PATH);
338        std::fs::create_dir_all(path.parent().expect("a parent")).expect("a directory");
339        std::fs::write(&path, "{ not a ledger").expect("an unreadable ledger");
340        assert!(ctx.execute("rules.report", serde_json::json!({})).is_err());
341        assert_eq!(
342            rules_report(&ctx, root),
343            crate::rules::report(&ctx.index, &crate::evidence::Ledger::empty())
344        );
345    }
346
347    /// The declaration is the only place these names exist, and every projection — the MCP
348    /// tool, the resource, the HTTP route, the OpenAPI operation, the benchmark target —
349    /// is derived from it. A refactor that dropped an exposure would still compile.
350    #[test]
351    fn the_declaration_yields_the_projections_it_claims() {
352        let m = module();
353        assert_eq!(m.id.as_str(), "environment");
354        let expected: &[(&str, &str, &str)] = &[
355            (
356                "environment.status",
357                "majordomus_environment",
358                "/api/v1/environment",
359            ),
360            (
361                "environment.explain",
362                "majordomus_environment_explain",
363                "/api/v1/environment/explain",
364            ),
365            (
366                "environment.preflight",
367                "majordomus_preflight",
368                "/api/v1/environment/preflight",
369            ),
370        ];
371        let ids: Vec<&str> = m
372            .capabilities
373            .iter()
374            .map(|e| e.capability.id.as_str())
375            .collect();
376        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
377        assert_eq!(
378            ids, want,
379            "the module declares a different set of capabilities"
380        );
381        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
382            let exposure = &executable.capability.exposure;
383            assert_eq!(
384                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
385                Some(*tool),
386                "{id} lost or renamed its MCP tool"
387            );
388            assert_eq!(
389                exposure.http.as_ref().map(|h| h.path.as_str()),
390                Some(*path),
391                "{id} lost or renamed its HTTP route"
392            );
393        }
394        let resource = m.capabilities[0]
395            .capability
396            .exposure
397            .mcp
398            .as_ref()
399            .and_then(|m| m.resource.as_ref())
400            .expect("the snapshot is readable as a resource");
401        assert_eq!(resource.uri, ENVIRONMENT_URI);
402    }
403
404    /// A snapshot of *now* must not be served from a cache. The value stamps the moment
405    /// it was taken into `generated_at`, and the executor holds that a cached capability
406    /// answers the same value from the handler, from a cold cache and from a warm one; the
407    /// two together made `tests/executor.rs` fail whenever a pair of calls straddled a
408    /// second boundary — a test that failed on the calendar. The declaration was the
409    /// defect, not the invariant.
410    #[test]
411    fn the_snapshot_is_not_cached_because_it_carries_the_moment_it_was_taken() {
412        let m = module();
413        let status = m
414            .capabilities
415            .iter()
416            .find(|e| e.capability.id.as_str() == "environment.status")
417            .expect("the module declares the snapshot");
418        assert!(
419            !status.capability.cache.is_enabled(),
420            "environment.status answers with a wall clock in it, so it cannot be cached"
421        );
422    }
423
424    /// A served request must not write to the checkout it is serving. The whole cache is
425    /// the command line's, and a handler that wrote it would give an HTTP GET a side
426    /// effect on the repository.
427    #[test]
428    fn a_served_snapshot_writes_nothing() {
429        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
430        let ctx = repo.context().expect("a context");
431        let cache = crate::environment::Cache::path(repo.root(), ".ai/local");
432        let environment = snapshot(
433            &ctx,
434            EnvironmentInput {
435                probe_services: false,
436            },
437        )
438        .expect("a snapshot");
439        assert_eq!(environment.resolution, Resolution::Full);
440        assert!(
441            !cache.exists(),
442            "a served request wrote the checkout's cache"
443        );
444    }
445
446    #[test]
447    fn a_served_snapshot_counts_the_layer_it_is_serving() {
448        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
449        let ctx = repo.context().expect("a context");
450        let environment = snapshot(&ctx, EnvironmentInput::default()).expect("a snapshot");
451        assert_eq!(environment.layer.objects, Some(ctx.index.objects.len()));
452        assert_eq!(
453            environment.layer.capabilities,
454            Some(ctx.registry.summary().total)
455        );
456    }
457
458    #[test]
459    fn explaining_a_field_that_does_not_exist_says_so_rather_than_answering_emptily() {
460        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
461        let ctx = repo.context().expect("a context");
462        match explain(
463            &ctx,
464            ExplainInput {
465                field: Some("nothing.like.this".into()),
466            },
467        ) {
468            Err(CapabilityError::NotFound(message)) => {
469                assert!(message.contains("nothing.like.this"))
470            }
471            other => panic!("{other:?}"),
472        }
473    }
474
475    #[test]
476    fn explaining_a_prefix_gives_every_field_under_it() {
477        let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
478        let ctx = repo.context().expect("a context");
479        let all = explain(&ctx, ExplainInput { field: None }).expect("every field");
480        let some = explain(
481            &ctx,
482            ExplainInput {
483                field: Some("project".into()),
484            },
485        )
486        .expect("the project fields");
487        assert!(!some.fields.is_empty());
488        assert!(some.fields.len() < all.fields.len());
489        assert!(some.fields.iter().all(|f| f.field.starts_with("project.")));
490    }
491}