Skip to main content

majordomus_cli/capability/builtin/
entity.rs

1//! The `entity` module: every object of the layer as an addressable, cross-linked node.
2//!
3//! `objects.get` answers what a file *is*. This module answers the three further questions
4//! a governance object is actually read for, and answers them for every kind at once
5//! because none of them is asked of a kind by name:
6//!
7//! - **Where does it live?** Its route, derived from its kind and identity by
8//!   [`crate::entity::slug`], the same function the Cockpit resolves a request with.
9//! - **What is it joined to?** Its outgoing references and — the half nothing declared —
10//!   the references that resolve to it, both read from the one relation table the graph
11//!   and the dangling-reference check already share.
12//! - **Where is it published?** Its public documentation page, when the repository's site
13//!   publishes its kind: [`crate::entity::Publication`] reads `site/data/publication.toml`
14//!   and the site's `base_url`, the same declaration the site generator builds the pages
15//!   from, so the address named here is the page the site holds.
16//! - **What can be said about its enforcement?** Which of the executable artefacts it
17//!   names are in the tree, stated as a state a page may not round up, and a pointer to the
18//!   capability that can prove more where the registry holds one.
19//!
20//! Nothing here enumerates kinds, entities or routes. A file added under `.ai/` becomes an
21//! object, an object has a route, and this capability answers for it — which is the whole
22//! of `project.entities-are-routable`.
23//!
24//! One entity is asked for either way a caller already holds it — the URI the layer indexes
25//! it by, or the address a reader was given — and the two name the same object:
26//!
27//! ```
28//! use majordomus_cli::capability::builtin::entity::{EntityInput, EvidenceState};
29//!
30//! let by_uri: EntityInput =
31//!     serde_json::from_value(serde_json::json!({ "uri": "majordomus://rule/project.x@1" }))
32//!         .unwrap();
33//! assert_eq!(by_uri.uri.as_deref(), Some("majordomus://rule/project.x@1"));
34//!
35//! let by_route: EntityInput =
36//!     serde_json::from_value(serde_json::json!({ "kind": "rule", "slug": "project-x-1" }))
37//!         .unwrap();
38//! assert_eq!(by_route.kind.as_deref(), Some("rule"));
39//!
40//! // a key nobody declared is an error, not a silently ignored field
41//! assert!(serde_json::from_value::<EntityInput>(serde_json::json!({ "url": "x" })).is_err());
42//!
43//! // and what the answer may say about enforcement is a state, never a run
44//! let unclaimed = serde_json::to_value(EvidenceState::Unclaimed).unwrap();
45//! assert_eq!(unclaimed, serde_json::json!("unclaimed"));
46//! ```
47
48use schemars::JsonSchema;
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51
52use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
53use crate::capability::handler::{CapabilityError, Context};
54use crate::capability::model::{CliExposure, Exposure, Stability};
55use crate::capability::module::ModuleDescriptor;
56use crate::entity::{self, Collision, Documentation, Edge, Publication, Surface};
57use crate::model::Object;
58use crate::{capability, module};
59
60use super::{get, mcp, ObjectSummary};
61
62// ---------------------------------------------------------------- input
63
64/// Which entity: by URI, or by the address it is served at.
65///
66/// Two spellings of one question, because two kinds of caller ask it: MCP and the API hold
67/// the `majordomus://` URI the index is keyed by, and the Cockpit's router has a `kind` and
68/// a `slug` out of a path. Nothing converts one form into the other at the edge — the
69/// capability resolves either, so neither caller has to know the other's spelling.
70///
71/// ```
72/// use majordomus_cli::capability::builtin::entity::EntityInput;
73///
74/// let by_route: EntityInput =
75///     serde_json::from_value(serde_json::json!({ "kind": "adr", "slug": "adr-0056" }))
76///         .unwrap();
77/// assert_eq!(by_route.kind.as_deref(), Some("adr"));
78/// assert_eq!(by_route.slug.as_deref(), Some("adr-0056"));
79/// assert_eq!(by_route.uri, None);
80///
81/// // absent is a shape, not an error: which fields are required is the handler's question
82/// assert_eq!(EntityInput::default().kind, None);
83/// ```
84#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct EntityInput {
87    /// `majordomus://<kind>/<identity>`. Either this, or `kind` and `slug` together.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub uri: Option<String>,
90    /// The kind, when addressing by route.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub kind: Option<String>,
93    /// The last segment of the entity's route, when addressing by route.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub slug: Option<String>,
96}
97
98impl BenchmarkCases for EntityInput {
99    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
100        ctx.index
101            .objects
102            .first()
103            .map(|o| {
104                vec![NamedCase::new(
105                    "first-object",
106                    EntityInput {
107                        uri: Some(o.uri.clone()),
108                        ..Default::default()
109                    },
110                )]
111            })
112            .unwrap_or_default()
113    }
114}
115
116// ---------------------------------------------------------------- evidence
117
118/// What this repository can say about the executable artefacts an object names, and only
119/// that. The word is chosen so that no reader can mistake it for a run.
120///
121/// The three states are ordered by how much they permit a reader to conclude, and the
122/// strongest of them still concludes nothing about a run: `Resolved` means the files an
123/// object points at exist. `proof` on [`Evidence`] is where "did it ever pass" is asked.
124///
125/// ```
126/// use majordomus_cli::capability::builtin::entity::EvidenceState;
127///
128/// // the wire spellings, which the Cockpit badge and the site template both read
129/// for (state, word) in [
130///     (EvidenceState::Unclaimed, "unclaimed"),
131///     (EvidenceState::Dangling, "dangling"),
132///     (EvidenceState::Resolved, "resolved"),
133/// ] {
134///     assert_eq!(serde_json::to_value(state).unwrap(), serde_json::json!(word));
135/// }
136/// ```
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138#[serde(rename_all = "snake_case")]
139#[schemars(rename = "EntityEvidenceState")]
140pub enum EvidenceState {
141    /// The object names no executable artefact. Most kinds name none, and that is not a
142    /// defect: a prompt is not enforced by anything.
143    Unclaimed,
144    /// It names artefacts and at least one of them is not in the tree. A claim that points
145    /// at nothing is worse than no claim, because it reads as enforcement.
146    Dangling,
147    /// Every artefact it names is in the tree. This says the claim resolves — never that it
148    /// passed. Whether it ever ran is a different question, and `proof` is where it is
149    /// asked.
150    Resolved,
151}
152
153/// One executable artefact an object names, and whether the tree holds it.
154///
155/// `field` is kept beside the path because the same object can name artefacts under several
156/// keys, and a reader fixing a dangling one needs to know which line of the front matter to
157/// look at rather than which file is missing.
158///
159/// ```
160/// use majordomus_cli::capability::builtin::entity::ClaimedArtifact;
161///
162/// let a = ClaimedArtifact {
163///     field: "x-majordomus.tests".into(),
164///     path: "test/cases/277_entities_are_routable.sh".into(),
165///     present: true,
166/// };
167/// let wire = serde_json::to_value(&a).unwrap();
168/// assert_eq!(wire["field"], serde_json::json!("x-majordomus.tests"));
169/// assert_eq!(wire["present"], serde_json::json!(true));
170/// ```
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct ClaimedArtifact {
173    /// The front matter key it was named under (`x-majordomus.tests`, `test`, ...).
174    pub field: String,
175    /// The repository-relative path as named.
176    pub path: String,
177    /// Whether the tree holds it.
178    pub present: bool,
179}
180
181/// Where a fuller answer about this entity's enforcement lives.
182///
183/// A pointer, never an answer. This module states what it can see in the tree and then
184/// names the capability that can say more, so that an entity page does not grow a second,
185/// weaker implementation of proof beside the one that already exists.
186///
187/// ```
188/// use majordomus_cli::capability::builtin::entity::ProofRef;
189///
190/// let p = ProofRef {
191///     capability: "rule.proves".into(),
192///     title: "What proves a rule".into(),
193///     address: "majordomus rule proves project.entities-are-routable@1".into(),
194/// };
195/// assert_eq!(
196///     serde_json::to_value(&p).unwrap()["capability"],
197///     serde_json::json!("rule.proves")
198/// );
199/// ```
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201#[schemars(rename = "EntityProofRef")]
202pub struct ProofRef {
203    /// The capability that answers it.
204    pub capability: String,
205    /// Its title.
206    pub title: String,
207    /// How to ask it here.
208    pub address: String,
209}
210
211/// What can be said about an object's enforcement without running anything.
212///
213/// `meaning` travels with `state` so that every surface shows the same sentence about it:
214/// a badge alone invites each renderer to write its own gloss, and the gloss is where a
215/// "resolved" quietly becomes a "passing". A kind that names no artefact is `Unclaimed`
216/// with an empty `artifacts`, which is an answer and not an omission.
217///
218/// ```
219/// use majordomus_cli::capability::builtin::entity::{Evidence, EvidenceState};
220///
221/// let e = Evidence {
222///     state: EvidenceState::Unclaimed,
223///     meaning: "This object names no executable artefact.".into(),
224///     artifacts: Vec::new(),
225///     proof: None,
226/// };
227///
228/// // the empty collections are omitted from the wire, so absence reads as absence
229/// let wire = serde_json::to_value(&e).unwrap();
230/// assert_eq!(wire["state"], serde_json::json!("unclaimed"));
231/// assert!(wire.get("artifacts").is_none());
232/// assert!(wire.get("proof").is_none());
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
235#[schemars(rename = "EntityEvidence")]
236pub struct Evidence {
237    /// The state, which is never rounded up.
238    pub state: EvidenceState,
239    /// That state in one sentence, for a reader.
240    pub meaning: String,
241    /// Every artefact named, with whether the tree holds it.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub artifacts: Vec<ClaimedArtifact>,
244    /// The capability that can say more, when this kind has one.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub proof: Option<ProofRef>,
247}
248
249/// The front matter keys that name an executable artefact. Not a list of kinds: a key, on
250/// whatever kind carries it. `x-majordomus` is the extension block the layer's schemas
251/// reserve for exactly this, and the others are the keys the claim and rule schemas already
252/// use for the same purpose.
253const ARTIFACT_FIELDS: &[&str] = &["tests", "test", "implementation", "validator"];
254
255/// Collect the paths an object names as executable artefacts, from its front matter and
256/// from its `x-majordomus` extension block.
257fn claimed(metadata: &Value) -> Vec<(String, String)> {
258    let mut out = Vec::new();
259    let mut take = |prefix: &str, source: &Value| {
260        for field in ARTIFACT_FIELDS {
261            let key = if prefix.is_empty() {
262                (*field).to_string()
263            } else {
264                format!("{prefix}.{field}")
265            };
266            match source.get(field) {
267                Some(Value::Array(a)) => out.extend(
268                    a.iter()
269                        .filter_map(Value::as_str)
270                        .filter(|s| looks_like_a_path(s))
271                        .map(|s| (key.clone(), s.to_string())),
272                ),
273                Some(Value::String(s)) if looks_like_a_path(s) => {
274                    out.push((key.clone(), s.clone()))
275                }
276                _ => {}
277            }
278        }
279    };
280    take("", metadata);
281    if let Some(x) = metadata.get("x-majordomus") {
282        take("x-majordomus", x);
283    }
284    out
285}
286
287/// A value that names a file rather than a symbol. `-` is the layer's written absence, and
288/// a validator is named by its function rather than its path; neither is a path to look for.
289fn looks_like_a_path(s: &str) -> bool {
290    s.contains('/') && s != "-"
291}
292
293fn evidence(ctx: &Context, o: &Object) -> Evidence {
294    let root = &ctx.index.repository.root;
295    let artifacts: Vec<ClaimedArtifact> = claimed(&o.metadata)
296        .into_iter()
297        .map(|(field, path)| ClaimedArtifact {
298            present: std::path::Path::new(root).join(&path).exists(),
299            field,
300            path,
301        })
302        .collect();
303    // one formula, no table: a kind whose module answers `<kind>s.show` can prove more
304    let proof = ctx
305        .registry
306        .get(&format!("{}s.show", o.kind))
307        .map(|c| ProofRef {
308            capability: c.id.to_string(),
309            title: c.title.clone(),
310            address: match &c.exposure.http {
311                Some(h) => format!("{} {}", h.method.as_str(), h.path),
312                None => c.id.to_string(),
313            },
314        });
315    let missing = artifacts.iter().filter(|a| !a.present).count();
316    let (state, meaning) = if artifacts.is_empty() {
317        (
318            EvidenceState::Unclaimed,
319            "This object names no executable artefact, so nothing here claims it is enforced."
320                .to_string(),
321        )
322    } else if missing > 0 {
323        (
324            EvidenceState::Dangling,
325            format!(
326                "{missing} of the {} artefact(s) this object names are not in the tree: it reads as enforced and is not.",
327                artifacts.len()
328            ),
329        )
330    } else {
331        (
332            EvidenceState::Resolved,
333            format!(
334                "Every one of the {} artefact(s) this object names is in the tree. That they resolve is not that they ran.",
335                artifacts.len()
336            ),
337        )
338    };
339    Evidence {
340        state,
341        meaning,
342        artifacts,
343        proof,
344    }
345}
346
347// ---------------------------------------------------------------- the view
348
349/// One object of the layer as an addressable node: what it is, where it is served, what it
350/// is joined to, and what can be said about its enforcement.
351///
352/// The whole answer in one value, because it is one question: every surface that shows an
353/// entity — the Cockpit page, `GET /api/v1/entity`, `majordomus_entity`, `majordomus entity
354/// show` — renders this and composes nothing of its own. A field a reader sees on one
355/// surface and not another would be a surface holding an opinion, which is what ADR 0004
356/// forecloses.
357///
358/// ```
359/// use majordomus_cli::capability::builtin::entity::EntityView;
360///
361/// // it round-trips through the wire shape every projection reads it as
362/// let view: EntityView = serde_json::from_value(serde_json::json!({
363///     "uri": "majordomus://adr/adr-0056",
364///     "id": "adr.adr-0056",
365///     "kind": "adr",
366///     "identity": "adr-0056",
367///     "slug": "adr-0056",
368///     "route": "/cockpit/objects/adr/adr-0056",
369///     "kind_route": "/cockpit/objects/adr",
370///     "title": "Every object of the layer has a derived address",
371///     "provenance": { "path": "x.md", "directory": "x", "source_class": "adr", "bytes": 0 },
372///     "metadata": {},
373///     "media_type": "text/markdown",
374///     "content": "",
375///     "relations": [],
376///     "surfaces": [],
377///     "evidence": { "state": "unclaimed", "meaning": "nothing named" },
378///     "documentation": {
379///         "projection": "entity",
380///         "route": "/adrs/adr-0056/",
381///         "url": "https://majordomus.dev/adrs/adr-0056/",
382///         "declared_in": "site/data/publication.toml",
383///     },
384/// }))
385/// .unwrap();
386///
387/// assert_eq!(view.route, "/cockpit/objects/adr/adr-0056");
388/// // the Cockpit route is the object's; the public page is the site's, and both are named
389/// let public = view.documentation.as_ref().and_then(|d| d.url.as_deref());
390/// assert_eq!(public, Some("https://majordomus.dev/adrs/adr-0056/"));
391/// // the kind's index is the entity's route with the last segment removed
392/// assert!(view.route.starts_with(&view.kind_route));
393/// ```
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
395pub struct EntityView {
396    /// `majordomus://<kind>/<identity>`.
397    pub uri: String,
398    /// The capability id, `<kind>.<identity>`.
399    pub id: String,
400    /// The kind.
401    pub kind: String,
402    /// The identity within the kind.
403    pub identity: String,
404    /// The last segment of its route, derived from the identity.
405    pub slug: String,
406    /// Its route in the Cockpit.
407    pub route: String,
408    /// The route of its kind's index.
409    pub kind_route: String,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    /// The title, when the kind's title rule found one.
412    pub title: Option<String>,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    /// The one-line description, when the kind holds one.
415    pub description: Option<String>,
416    /// Where it came from.
417    pub provenance: crate::model::Provenance,
418    /// The parsed front matter or YAML, keys in the file's order.
419    pub metadata: Value,
420    /// IANA media type of `content`.
421    pub media_type: String,
422    /// The file as read.
423    pub content: String,
424    /// The references it declares, and the references that resolve to it.
425    pub relations: Vec<Edge>,
426    /// Every surface that answers for it.
427    pub surfaces: Vec<Surface>,
428    /// What can be said about its enforcement without running anything.
429    pub evidence: Evidence,
430    /// Its public documentation page, derived from the repository's publication declaration
431    /// (`site/data/publication.toml`) and the site's `base_url` by the same function the site
432    /// generator writes the page with. Null when the repository declares no publication or
433    /// does not declare this kind; a kind declared as not published carries the reason.
434    #[serde(default)]
435    pub documentation: Option<Documentation>,
436}
437
438fn subject<'a>(ctx: &'a Context, input: &EntityInput) -> Result<&'a Object, CapabilityError> {
439    match (&input.uri, &input.kind, &input.slug) {
440        (Some(uri), None, None) => ctx.index.get(uri).ok_or_else(|| {
441            CapabilityError::NotFound(format!("no object of this layer has the URI {uri}"))
442        }),
443        (None, Some(kind), Some(slug)) => {
444            entity::find(&ctx.index.objects, kind, slug).ok_or_else(|| {
445                CapabilityError::NotFound(format!(
446                    "no object of kind '{kind}' is served at {}",
447                    entity::route(kind, slug)
448                ))
449            })
450        }
451        _ => Err(CapabilityError::InvalidInput(
452            "name the entity either by 'uri' or by 'kind' and 'slug' together".into(),
453        )),
454    }
455}
456
457fn objects_entity(ctx: &Context, input: EntityInput) -> Result<EntityView, CapabilityError> {
458    let o = subject(ctx, &input)?;
459    Ok(EntityView {
460        uri: o.uri.clone(),
461        id: format!("{}.{}", o.kind, o.identity),
462        kind: o.kind.clone(),
463        identity: o.identity.clone(),
464        slug: entity::slug(&o.identity),
465        route: entity::object_route(o),
466        kind_route: entity::kind_route(&o.kind),
467        title: o.title.clone(),
468        description: o.description.clone(),
469        provenance: o.provenance.clone(),
470        metadata: o.metadata.clone(),
471        media_type: o.media_type.to_string(),
472        content: o.content.clone(),
473        relations: entity::edges(&ctx.registry, &ctx.index.objects, o),
474        surfaces: entity::surfaces(&ctx.registry, o),
475        evidence: evidence(ctx, o),
476        // read at request time: the declaration is the repository's, and a repository
477        // without a site has none
478        documentation: Publication::read(std::path::Path::new(&ctx.index.repository.root))
479            .and_then(|p| p.documentation(&o.kind, &entity::slug(&o.identity))),
480    })
481}
482
483// ---------------------------------------------------------------- kinds
484
485/// One kind of the layer, with its address and how many objects it holds.
486///
487/// `example` carries one of the kind's objects so that a catalogue of kinds is somewhere a
488/// reader can go from rather than a list of words: a kind with a count and no way in is a
489/// dead row. It is `None` only for a kind the index holds no object of.
490///
491/// ```
492/// use majordomus_cli::capability::builtin::entity::KindEntry;
493///
494/// let e: KindEntry = serde_json::from_value(serde_json::json!({
495///     "kind": "adr",
496///     "count": 52,
497///     "route": "/cockpit/objects/adr",
498/// }))
499/// .unwrap();
500///
501/// assert_eq!(e.kind, "adr");
502/// // the route is the kind's, derived — never a per-kind entry in a table of routes
503/// assert_eq!(e.route, majordomus_cli::entity::kind_route(&e.kind));
504/// assert!(e.example.is_none());
505/// ```
506#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
507pub struct KindEntry {
508    /// The kind.
509    pub kind: String,
510    /// How many objects of it the index holds.
511    pub count: usize,
512    /// Where its index is served.
513    pub route: String,
514    /// One of its objects, so a reader has somewhere to go.
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub example: Option<ObjectSummary>,
517}
518
519/// Every kind of the layer, each with its route, and every route collision there is.
520///
521/// The two numbers are what `scripts/ci/entity-check` compares: the objects the index holds
522/// against the objects that have an address. They are reported together, in one answer, so
523/// that the gate asks one question instead of joining two lists and deciding for itself
524/// what the difference means.
525///
526/// ```
527/// use majordomus_cli::capability::builtin::entity::KindList;
528///
529/// let healthy: KindList = serde_json::from_value(serde_json::json!({
530///     "count": 1,
531///     "kinds": [{ "kind": "adr", "count": 52, "route": "/cockpit/objects/adr" }],
532///     "routable": 52,
533/// }))
534/// .unwrap();
535///
536/// let held: usize = healthy.kinds.iter().map(|k| k.count).sum();
537/// // every object addressable, and no collision: the only state the gate accepts
538/// assert_eq!(held, healthy.routable);
539/// assert!(healthy.collisions.is_empty());
540/// ```
541#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
542pub struct KindList {
543    /// How many kinds.
544    pub count: usize,
545    /// The kinds, in the index's order.
546    pub kinds: Vec<KindEntry>,
547    /// How many objects have a route. Every object does, unless it is in `collisions`.
548    pub routable: usize,
549    /// Two objects of one kind whose identities reduce to one route, which is the only way
550    /// an object can fail to be addressable. Empty is the healthy answer and the only one
551    /// `scripts/ci/entity-check` accepts.
552    #[serde(default, skip_serializing_if = "Vec::is_empty")]
553    pub collisions: Vec<Collision>,
554}
555
556fn objects_kinds(ctx: &Context, _: super::Empty) -> Result<KindList, CapabilityError> {
557    let kinds: Vec<KindEntry> = ctx
558        .index
559        .kinds()
560        .into_iter()
561        .map(|(kind, count)| KindEntry {
562            route: entity::kind_route(kind),
563            example: ctx
564                .index
565                .objects
566                .iter()
567                .find(|o| o.kind == kind)
568                .map(ObjectSummary::of),
569            kind: kind.to_string(),
570            count,
571        })
572        .collect();
573    let collisions = entity::collisions(&ctx.index.objects);
574    let colliding: usize = collisions.iter().map(|c| c.identities.len()).sum();
575    Ok(KindList {
576        count: kinds.len(),
577        kinds,
578        routable: ctx.index.objects.len().saturating_sub(colliding),
579        collisions,
580    })
581}
582
583/// The canonical declaration of the `entity` module: the two capabilities that answer for
584/// an object and for the catalogue of kinds, with every projection each is exposed through.
585///
586/// This is the only place either capability is written down. The MCP tool, the HTTP route,
587/// the command-line path, the generated OpenAPI and the Cockpit's page are all read from
588/// here, which is why adding a surface to `entity.show` is an edit to this function and
589/// never to a transport registry or a documentation table (ADR 0004).
590///
591/// ```
592/// use majordomus_cli::capability::builtin::entity;
593///
594/// let m = entity::module();
595/// assert_eq!(m.id.as_str(), "entity");
596///
597/// // both capabilities are namespaced by the module id, with no second spelling of it
598/// let ids: Vec<String> = m
599///     .capabilities
600///     .iter()
601///     .map(|c| c.capability.id.to_string())
602///     .collect();
603/// assert!(ids.iter().any(|id| id == "entity.show"));
604/// assert!(ids.iter().any(|id| id == "entity.kinds"));
605///
606/// // and each declares where it is answered, rather than being registered elsewhere
607/// let show = m
608///     .capabilities
609///     .iter()
610///     .find(|c| c.capability.id.to_string() == "entity.show")
611///     .expect("entity.show is declared here");
612/// assert_eq!(
613///     show.capability.exposure.http.as_ref().map(|h| h.path.as_str()),
614///     Some("/api/v1/entity")
615/// );
616/// ```
617pub fn module() -> ModuleDescriptor {
618    module! {
619        id: "entity",
620        title: "Entities",
621        description: "Every object of the layer as an addressable, cross-linked node: its route, its outgoing references, the references that resolve to it, the surfaces that answer for it, and what can be said about its enforcement without running anything. Nothing here enumerates kinds, entities or routes — an object of the index has a route because it is an object.",
622        stability: Stability::Implemented,
623        capabilities: [
624            capability! {
625                id: "entity.show",
626                title: "Read one entity",
627                description: "One object of the layer as an addressable node, by URI or by the address it is served at: identity and route, provenance, front matter and content, every reference it declares and every reference that resolves to it, the surfaces that answer for it, the state of the executable artefacts it names, and its public documentation page as the repository's site/data/publication.toml derives it (null when the repository declares none).",
628                input: EntityInput,
629                output: EntityView,
630                stability: Stability::Implemented,
631                exposure: Exposure {
632                    mcp: mcp("majordomus_entity"),
633                    http: get("/api/v1/entity"),
634                    cli: Some(CliExposure { path: vec!["entity".into(), "show".into()] }),
635                },
636                tags: ["objects", "governance"],
637                handler: objects_entity,
638            },
639            capability! {
640                id: "entity.kinds",
641                title: "Every kind, and whether every object of it is addressable",
642                description: "The kinds the layer holds, each with the route of its index and how many objects it carries, and every route collision there is. A collision is the only way an object of the index can fail to have an address of its own; the healthy answer is none.",
643                input: super::Empty,
644                output: KindList,
645                stability: Stability::Implemented,
646                exposure: Exposure {
647                    mcp: mcp("majordomus_kinds"),
648                    http: get("/api/v1/entity/kinds"),
649                    cli: Some(CliExposure { path: vec!["entity".into(), "kinds".into()] }),
650                },
651                tags: ["objects", "governance"],
652                handler: objects_kinds,
653            },
654        ],
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use serde_json::json;
662
663    #[test]
664    fn only_a_path_counts_as_a_named_artefact() {
665        assert!(looks_like_a_path("scripts/ci/x"));
666        assert!(!looks_like_a_path("-"));
667        assert!(
668            !looks_like_a_path("scope_is_declared"),
669            "a validator named by its function is not a file to look for"
670        );
671    }
672
673    #[test]
674    fn artefacts_are_collected_from_the_front_matter_and_the_extension_block() {
675        let m = json!({
676            "test": "test/cases/1_a.sh",
677            "implementation": "-",
678            "x-majordomus": { "tests": ["scripts/ci/a", "scripts/ci/b"] },
679        });
680        assert_eq!(
681            claimed(&m),
682            vec![
683                ("test".to_string(), "test/cases/1_a.sh".to_string()),
684                ("x-majordomus.tests".to_string(), "scripts/ci/a".to_string()),
685                ("x-majordomus.tests".to_string(), "scripts/ci/b".to_string()),
686            ],
687            "the written absence is not an artefact, and the extension block is read too"
688        );
689    }
690
691    #[test]
692    fn an_object_that_names_nothing_is_unclaimed_rather_than_enforced() {
693        assert!(claimed(&json!({"title": "x"})).is_empty());
694        assert!(claimed(&Value::Null).is_empty());
695    }
696}