Skip to main content

majordomus_cli/
graph.rs

1//! Graphs, canonically: a node and edge model in Rust, derived from the registry and the
2//! index, and projected to whatever a reader wants. Nothing here knows about Cytoscape,
3//! Mermaid or Graphviz; a rendering library is a consumer of [`Graph`], never its shape.
4//!
5//! Every graph is derived. None is authored: a node exists because a capability, an
6//! object or a projection exists, and an edge exists because one of them names another.
7//! A derivation that would need a file listing its own nodes belongs somewhere else.
8//!
9//! ```
10//! use majordomus_cli::graph::{Builder, Node};
11//!
12//! fn thing(id: &str) -> Node {
13//!     Node { id: id.into(), kind: "thing".into(), label: id.to_uppercase(), summary: None,
14//!            route: None, source: None, status: None, external: false, facts: Default::default() }
15//! }
16//!
17//! let mut builder = Builder::new("demo", "Demo", "Two things and the edge between them.", "example")
18//!     .node_kind("thing", "one of the things")
19//!     .edge_kind("names", "the source names the target");
20//! builder.node(thing("a"));
21//! builder.node(thing("b"));
22//! builder.edge("a", "b", "names");
23//! // an edge to something the derivation never added is dropped rather than dangling
24//! builder.edge("a", "never-added", "names");
25//!
26//! let graph = builder.finish();
27//! assert_eq!(graph.nodes.len(), 2);
28//! assert_eq!(graph.edges.len(), 1, "the dangling edge is gone");
29//! assert!(graph.nodes.windows(2).all(|w| w[0].id <= w[1].id), "sorted, so two runs agree");
30//! ```
31//!
32//! Determinism is a contract: the same tree and the same executable produce the same
33//! bytes. Nodes and edges are sorted by their identities, never by discovery order, and
34//! no derivation reads a clock.
35
36use std::collections::{BTreeMap, BTreeSet};
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40use serde_json::Value;
41
42use crate::capability::{Capability, CapabilityKind, CapabilityRegistry, Provenance};
43use crate::index::Index;
44use crate::model::Object;
45use crate::web::Topology;
46
47/// One node. `id` is unique within the graph; `kind` is what the graph's `node_kinds`
48/// declares it to be; `route` is where the Cockpit shows the thing itself, when it shows
49/// it anywhere.
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
51pub struct Node {
52    /// Unique within the graph.
53    pub id: String,
54    /// One of the graph's declared node kinds.
55    pub kind: String,
56    /// The short label a renderer draws.
57    pub label: String,
58    /// One line about the thing, when the source holds one.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub summary: Option<String>,
61    /// Where the Cockpit shows this thing, when it shows it.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub route: Option<String>,
64    /// The repository-relative file the node was derived from, when one file owns it.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub source: Option<String>,
67    /// A status word the graph's own vocabulary defines (`accepted`, `active`, `query`).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub status: Option<String>,
70    /// True when the node stands for something the graph names but does not hold: a
71    /// reference that resolves to nothing in this repository.
72    #[serde(default, skip_serializing_if = "is_false")]
73    pub external: bool,
74    /// What the object's own kind declares about it, as its schema validated it: the
75    /// fields of its front matter that are facts about the thing rather than the document
76    /// itself. A rule carries its class and its statement, a skill its inputs and
77    /// outputs, a decision its date — the vocabulary that makes each kind worth having,
78    /// rather than the four fields every kind happens to share.
79    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
80    pub facts: BTreeMap<String, Fact>,
81}
82
83/// One fact a node carries from its object's front matter, in the shapes a node can hold.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
85#[serde(untagged)]
86pub enum Fact {
87    /// A yes or a no.
88    Flag(bool),
89    /// A whole number: an order, a version, a weight.
90    Count(i64),
91    /// One short value.
92    Text(String),
93    /// Several short values.
94    List(Vec<String>),
95}
96
97/// The longest value a node carries as a fact. Beyond it the field is prose — an
98/// objective, a rationale, an acceptance criterion — and prose belongs to the object,
99/// which `objects.get` serves whole and which every node links to by its route. Copying
100/// it here would put the layer in the graph twice.
101///
102/// Two hundred is where the measurement puts it. Over this repository the facts are 39%
103/// of the generated artifact; the values themselves have a median length of 20 and a
104/// ninetieth percentile of 158, so a cut at 120 would save about a tenth of the document
105/// and truncate one value in eight — including short statements and outcomes that are the
106/// reason a node is worth reading. The lever worth pulling if a budget ever demands one is
107/// the list case, where the limit applies to each element rather than to the whole.
108const FACT_LIMIT: usize = 200;
109
110/// The fields the node already shows through `label`, `summary`, `status` and `kind`.
111/// Carrying them again would say the same thing twice in one node.
112const PROJECTED_FIELDS: &[&str] = &[
113    "id",
114    "version",
115    "kind",
116    "title",
117    "description",
118    "status",
119    "schema",
120];
121
122/// The facts of one object's front matter: every declared field that is a fact about the
123/// thing, in the object's own vocabulary. Nothing is named here — the fields are whatever
124/// the kind's schema let the object declare, minus the ones the node already shows.
125fn facts(metadata: &Value) -> BTreeMap<String, Fact> {
126    let Some(map) = metadata.as_object() else {
127        return BTreeMap::new();
128    };
129    let short = |s: &str| s.len() <= FACT_LIMIT;
130    map.iter()
131        .filter(|(k, _)| !PROJECTED_FIELDS.contains(&k.as_str()))
132        .filter_map(|(k, v)| {
133            let fact = match v {
134                Value::Bool(b) => Fact::Flag(*b),
135                Value::Number(n) => Fact::Count(n.as_i64()?),
136                Value::String(s) if short(s) => Fact::Text(s.clone()),
137                Value::Array(items) => {
138                    // a list is a fact only whole: half a list of criteria is a claim the
139                    // reader cannot check
140                    let texts: Option<Vec<String>> = items
141                        .iter()
142                        .map(|i| match i {
143                            Value::String(s) if short(s) => Some(s.clone()),
144                            Value::Bool(b) => Some(b.to_string()),
145                            Value::Number(n) => Some(n.to_string()),
146                            _ => None,
147                        })
148                        .collect();
149                    Fact::List(texts?)
150                }
151                _ => return None,
152            };
153            Some((k.clone(), fact))
154        })
155        .collect()
156}
157
158fn is_false(b: &bool) -> bool {
159    !*b
160}
161
162/// One directed edge. Both ends are node ids of the same graph; a derivation that cannot
163/// resolve an end adds an external node rather than a dangling edge.
164#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
165pub struct Edge {
166    /// The node the edge leaves.
167    pub source: String,
168    /// The node the edge enters.
169    pub target: String,
170    /// One of the graph's declared edge kinds.
171    pub kind: String,
172}
173
174/// What a reader needs to know about the graph as a whole before drawing it.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct GraphMetadata {
177    /// How many nodes.
178    pub nodes: usize,
179    /// How many edges.
180    pub edges: usize,
181    /// Whether the edges form a directed acyclic graph. Derived, never declared: a graph
182    /// whose contract is a DAG and whose answer here is `false` is a defect in the data.
183    pub acyclic: bool,
184    /// True when the derivation stopped at [`MAX_NODES`] and the graph is a prefix of
185    /// what the repository holds.
186    pub truncated: bool,
187}
188
189/// The most nodes a derivation emits. A graph past this is unreadable in a browser and
190/// unhelpful in a terminal; the derivation stops, says so in the metadata, and the page
191/// that shows it says so too.
192pub const MAX_NODES: usize = 2000;
193
194/// A derived graph: what it is, where it came from, what its vocabularies mean, and the
195/// nodes and edges themselves.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct Graph {
198    /// The identity, `[a-z][a-z0-9-]*`; the last segment of its route.
199    pub id: String,
200    /// The short name.
201    pub title: String,
202    /// One paragraph: what a reader learns from it.
203    pub description: String,
204    /// What it was derived from, in the repository's own words.
205    pub source: String,
206    /// Node kind to what that kind means.
207    pub node_kinds: BTreeMap<String, String>,
208    /// Edge kind to what that edge asserts.
209    pub edge_kinds: BTreeMap<String, String>,
210    /// Sorted by id.
211    pub nodes: Vec<Node>,
212    /// Sorted.
213    pub edges: Vec<Edge>,
214    /// Counts and invariants.
215    pub metadata: GraphMetadata,
216}
217
218/// A graph under construction. Adding an edge whose ends are not both present is refused
219/// by [`Builder::finish`], not silently dropped: an edge to nothing is a derivation bug.
220#[derive(Debug)]
221pub struct Builder {
222    id: &'static str,
223    title: &'static str,
224    description: &'static str,
225    source: String,
226    node_kinds: BTreeMap<String, String>,
227    edge_kinds: BTreeMap<String, String>,
228    nodes: BTreeMap<String, Node>,
229    edges: BTreeSet<Edge>,
230    truncated: bool,
231    /// The most nodes this graph takes: [`MAX_NODES`] for a graph a person is shown, none
232    /// for the composed graph read as data ([`composed_complete`]).
233    limit: usize,
234}
235
236impl Builder {
237    /// A builder for a graph with this identity and these words.
238    pub fn new(
239        id: &'static str,
240        title: &'static str,
241        description: &'static str,
242        source: impl Into<String>,
243    ) -> Self {
244        Builder {
245            id,
246            title,
247            description,
248            source: source.into(),
249            node_kinds: BTreeMap::new(),
250            edge_kinds: BTreeMap::new(),
251            nodes: BTreeMap::new(),
252            edges: BTreeSet::new(),
253            truncated: false,
254            limit: MAX_NODES,
255        }
256    }
257
258    /// Take at most `limit` nodes instead of [`MAX_NODES`].
259    ///
260    /// ```
261    /// use majordomus_cli::graph::{Builder, Node};
262    ///
263    /// let thing = |id: &str| Node { id: id.into(), kind: "thing".into(), label: id.into(),
264    ///     summary: None, route: None, source: None, status: None, external: false,
265    ///     facts: Default::default() };
266    /// let mut b = Builder::new("g", "G", "two things, room for one", "example")
267    ///     .node_kind("thing", "a thing")
268    ///     .limit(1);
269    /// assert!(b.node(thing("a")));
270    /// assert!(!b.node(thing("b")), "no room past the limit");
271    /// assert!(b.finish().metadata.truncated);
272    /// ```
273    pub fn limit(mut self, limit: usize) -> Self {
274        self.limit = limit;
275        self
276    }
277
278    /// Declare what a node kind means. Every kind a node carries must be declared.
279    pub fn node_kind(mut self, kind: &str, meaning: &str) -> Self {
280        self.node_kinds.insert(kind.into(), meaning.into());
281        self
282    }
283
284    /// Declare what an edge kind asserts. Every kind an edge carries must be declared.
285    pub fn edge_kind(mut self, kind: &str, meaning: &str) -> Self {
286        self.edge_kinds.insert(kind.into(), meaning.into());
287        self
288    }
289
290    /// Add a node, or keep the one already there. Answers whether there is room for more:
291    /// a derivation that walks a large collection stops when this says no.
292    pub fn node(&mut self, node: Node) -> bool {
293        if self.nodes.len() >= self.limit && !self.nodes.contains_key(&node.id) {
294            self.truncated = true;
295            return false;
296        }
297        self.nodes.entry(node.id.clone()).or_insert(node);
298        true
299    }
300
301    /// Add an edge. Both ends are checked when the graph is finished.
302    pub fn edge(&mut self, source: &str, target: &str, kind: &str) {
303        self.edges.insert(Edge {
304            source: source.into(),
305            target: target.into(),
306            kind: kind.into(),
307        });
308    }
309
310    /// Is this node already in the graph?
311    pub fn has(&self, id: &str) -> bool {
312        self.nodes.contains_key(id)
313    }
314
315    /// Finish: drop edges whose ends the derivation never added, sort everything, and
316    /// compute the invariants.
317    ///
318    /// An edge to a missing node is dropped rather than kept, because a renderer given a
319    /// dangling edge draws a phantom; the property suite proves no derivation produces
320    /// one, so the drop is a safety net and not a policy.
321    pub fn finish(self) -> Graph {
322        let nodes: Vec<Node> = self.nodes.into_values().collect();
323        let present: BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
324        let edges: Vec<Edge> = self
325            .edges
326            .into_iter()
327            .filter(|e| present.contains(e.source.as_str()) && present.contains(e.target.as_str()))
328            .collect();
329        let acyclic = is_acyclic(&nodes, &edges);
330        Graph {
331            id: self.id.into(),
332            title: self.title.into(),
333            description: self.description.into(),
334            source: self.source,
335            node_kinds: self.node_kinds,
336            edge_kinds: self.edge_kinds,
337            metadata: GraphMetadata {
338                nodes: nodes.len(),
339                edges: edges.len(),
340                acyclic,
341                truncated: self.truncated,
342            },
343            nodes,
344            edges,
345        }
346    }
347}
348
349/// Does the edge set form a directed acyclic graph? Kahn's algorithm over the node set.
350///
351/// ```
352/// use majordomus_cli::graph::{is_acyclic, Edge, Node};
353/// fn n(id: &str) -> Node {
354///     Node { id: id.into(), kind: "x".into(), label: id.into(), summary: None, route: None,
355///             source: None, status: None, external: false, facts: Default::default() }
356/// }
357/// fn e(a: &str, b: &str) -> Edge { Edge { source: a.into(), target: b.into(), kind: "k".into() } }
358/// assert!(is_acyclic(&[n("a"), n("b")], &[e("a", "b")]));
359/// assert!(!is_acyclic(&[n("a"), n("b")], &[e("a", "b"), e("b", "a")]));
360/// ```
361pub fn is_acyclic(nodes: &[Node], edges: &[Edge]) -> bool {
362    let mut outgoing: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
363    let mut indegree: BTreeMap<&str, usize> = nodes.iter().map(|n| (n.id.as_str(), 0)).collect();
364    for e in edges {
365        outgoing
366            .entry(e.source.as_str())
367            .or_default()
368            .push(e.target.as_str());
369        *indegree.entry(e.target.as_str()).or_insert(0) += 1;
370    }
371    let mut ready: Vec<&str> = indegree
372        .iter()
373        .filter(|(_, d)| **d == 0)
374        .map(|(id, _)| *id)
375        .collect();
376    let mut seen = 0usize;
377    while let Some(id) = ready.pop() {
378        seen += 1;
379        for target in outgoing.get(id).into_iter().flatten() {
380            let d = indegree.entry(target).or_insert(0);
381            *d = d.saturating_sub(1);
382            if *d == 0 {
383                ready.push(target);
384            }
385        }
386    }
387    seen == indegree.len()
388}
389
390/// One graph the executable can derive: what it is, without deriving it.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
392pub struct GraphInfo {
393    /// The identity.
394    pub id: String,
395    /// The short name.
396    pub title: String,
397    /// One paragraph.
398    pub description: String,
399    /// What it is derived from.
400    pub source: String,
401}
402
403/// Every graph this executable derives, by id, with the function that derives it. The one
404/// list there is: the capability that lists graphs and the one that reads them both read
405/// it, and a graph added here is a graph everywhere.
406type Derivation = fn(&CapabilityRegistry, &Index) -> Graph;
407
408/// The id of the composed graph: the one derivation that joins every registry, and the
409/// one a generated artifact carries. Named here because the generator asks for it by id.
410pub const COMPOSED: &str = "composed";
411
412const DERIVATIONS: &[(&str, Derivation)] = &[
413    ("registry", registry_graph),
414    ("layer", layer_graph),
415    ("rules", rules_graph),
416    ("adrs", adrs_graph),
417    ("use-cases", use_cases_graph),
418    ("why", why_graph),
419    ("product", product_graph),
420    (COMPOSED, composed_graph),
421];
422
423/// The ids of every graph, in the order they are listed.
424///
425/// ```
426/// assert!(majordomus_cli::graph::ids().contains(&"registry"));
427/// ```
428pub fn ids() -> Vec<&'static str> {
429    DERIVATIONS.iter().map(|(id, _)| *id).collect()
430}
431
432/// Derive one graph by id; `None` when no such graph exists.
433pub fn derive(id: &str, registry: &CapabilityRegistry, index: &Index) -> Option<Graph> {
434    let _phase = crate::perf::phase(crate::perf::Phase::GraphBuild);
435    crate::perf::Counters::bump(&crate::perf::COUNTERS.graph_builds);
436    DERIVATIONS
437        .iter()
438        .find(|(known, _)| *known == id)
439        .map(|(_, f)| f(registry, index))
440}
441
442/// A graph that is to be committed as data, refused if it is a prefix: a graph past
443/// [`MAX_NODES`] is shown to a person truncated, but a projection written to the repository
444/// that silently stopped at the bound would drop whatever sorted past it from every reader.
445///
446/// ```
447/// use majordomus_cli::graph::{whole, Builder};
448///
449/// let g = Builder::new("g", "G", "nothing yet", "example").finish();
450/// assert!(whole(g).is_ok(), "a graph that stopped nowhere is whole");
451/// ```
452pub fn whole(graph: Graph) -> Result<Graph, String> {
453    if graph.metadata.truncated {
454        return Err(format!(
455            "the `{}` graph stopped at {} nodes (the bound a person is shown is {MAX_NODES}) and would be committed as a prefix of the repository; derive it whole (graph::composed_complete) or raise the bound",
456            graph.id, graph.metadata.nodes
457        ));
458    }
459    Ok(graph)
460}
461
462/// [`derive()`], for a graph committed as data: `Err` when no such graph exists or when it
463/// stopped at [`MAX_NODES`] ([`whole`]).
464///
465/// ```
466/// use majordomus_cli::graph;
467/// use majordomus_cli::synthetic::SyntheticRepository;
468///
469/// let repo = SyntheticRepository::small().unwrap();
470/// let ctx = repo.context().unwrap();
471/// assert!(graph::derive_whole("why", &ctx.registry, ctx.index.as_ref()).is_ok());
472/// assert!(graph::derive_whole("no-such-graph", &ctx.registry, ctx.index.as_ref()).is_err());
473/// ```
474pub fn derive_whole(
475    id: &str,
476    registry: &CapabilityRegistry,
477    index: &Index,
478) -> Result<Graph, String> {
479    derive(id, registry, index)
480        .ok_or_else(|| format!("this executable derives no `{id}` graph"))
481        .and_then(whole)
482}
483
484/// Every graph, described but not derived. Deriving all of them to list them would read
485/// the whole index for a menu.
486pub fn list(registry: &CapabilityRegistry, index: &Index) -> Vec<GraphInfo> {
487    DERIVATIONS
488        .iter()
489        .map(|(_id, f)| {
490            // the derivation is the only place a graph's words live; describing one
491            // without deriving it would be a second copy of them, so the description
492            // comes from an empty derivation over the same sources
493            let g = f(registry, index);
494            GraphInfo {
495                id: g.id,
496                title: g.title,
497                description: g.description,
498                source: g.source,
499            }
500        })
501        .collect()
502}
503
504fn capability_route(id: &str) -> String {
505    format!(
506        "/cockpit/capabilities/{}",
507        crate::http::router::percent_encode(id)
508    )
509}
510
511/// Where the Cockpit shows an object of the index: its entity route, derived from its kind
512/// and identity, which is the one address it has on every surface.
513fn object_route(uri: &str) -> String {
514    match uri
515        .strip_prefix("majordomus://")
516        .and_then(|r| r.split_once('/'))
517    {
518        Some((kind, identity)) => crate::entity::route(kind, identity),
519        // a URI a query projects (`majordomus://repository`) is not an object and has no
520        // entity page; the reader that has always answered for it still does
521        None => format!(
522            "/cockpit/object?uri={}",
523            crate::http::router::percent_encode(uri)
524        ),
525    }
526}
527
528/// The registry as a graph: every module, every capability it composes, the file the
529/// capability was declared in, and one node per projection it is exposed through. This is
530/// the executable describing itself, and it is derived from the same descriptors every
531/// other projection is derived from.
532fn registry_graph(registry: &CapabilityRegistry, _index: &Index) -> Graph {
533    let mut b = Builder::new(
534        "registry",
535        "Capability registry",
536        "Every module, the capabilities it composes, the source file each was declared in, and the MCP, HTTP, command-line and Cockpit projections derived from it. The executable's own architecture, read back from the registry that produces every interface — this page among them.",
537        "the capability registry: builtin descriptors and the declarative objects of the layer",
538    )
539    .node_kind("module", "a module composed with module! and named in compose_modules!, or one kind of declarative object")
540    .node_kind("capability", "one canonical declaration: a query, a command, or a declarative resource")
541    .node_kind("source", "the file the declaration was read from")
542    .node_kind("projection", "an external interface the descriptor is projected onto, the Cockpit included")
543    .edge_kind("composes", "the module composes the capability")
544    .edge_kind("declared_in", "the capability was declared in that file")
545    .edge_kind("projects", "the capability is exposed through that interface");
546
547    for m in registry.modules() {
548        b.node(Node {
549            id: format!("module:{}", m.id),
550            kind: "module".into(),
551            label: m.id.to_string(),
552            summary: Some(m.title.clone()),
553            route: Some(format!(
554                "/cockpit/capabilities?module={}",
555                crate::http::router::percent_encode(m.id.as_str())
556            )),
557            source: None,
558            status: Some(enum_word(&m.source)),
559            external: false,
560            facts: BTreeMap::new(),
561        });
562    }
563
564    // one node per projection surface, so that "what does HTTP serve" is a neighbourhood
565    for (id, label, meaning) in [
566        (
567            "projection:mcp",
568            "MCP",
569            "tools and resources an MCP client sees",
570        ),
571        (
572            "projection:http",
573            "HTTP",
574            "routes under /api/v1/, and the OpenAPI document over them",
575        ),
576        ("projection:cli", "CLI", "the words after `majordomus`"),
577        (
578            "projection:cockpit",
579            "Cockpit",
580            "a page for every capability, and a form generated from the input schema for every one the Cockpit can call",
581        ),
582    ] {
583        b.node(Node {
584            id: id.into(),
585            kind: "projection".into(),
586            label: label.into(),
587            summary: Some(meaning.into()),
588            route: None,
589            source: None,
590            status: None,
591            external: false,
592            facts: BTreeMap::new(),
593        });
594    }
595
596    for c in registry.iter() {
597        // the declarative objects are the layer graph's subject; here they would add six
598        // hundred leaves that say only "this kind has members"
599        if !matches!(c.provenance, Provenance::Builtin { .. }) {
600            continue;
601        }
602        let node_id = format!("capability:{}", c.id);
603        if !b.node(Node {
604            id: node_id.clone(),
605            kind: "capability".into(),
606            label: c.id.to_string(),
607            summary: Some(c.title.clone()),
608            route: Some(capability_route(c.id.as_str())),
609            source: Some(c.provenance.source_path()),
610            status: Some(kind_word(c.kind)),
611            external: false,
612            facts: BTreeMap::new(),
613        }) {
614            break;
615        }
616        b.edge(&format!("module:{}", c.module), &node_id, "composes");
617
618        let path = c.provenance.source_path();
619        let source_id = format!("source:{path}");
620        b.node(Node {
621            id: source_id.clone(),
622            kind: "source".into(),
623            label: path.rsplit('/').next().unwrap_or(&path).to_string(),
624            summary: Some(path.clone()),
625            route: None,
626            source: Some(path.clone()),
627            status: None,
628            external: false,
629            facts: BTreeMap::new(),
630        });
631        b.edge(&node_id, &source_id, "declared_in");
632
633        if c.exposure.mcp.is_some() {
634            b.edge(&node_id, "projection:mcp", "projects");
635        }
636        if c.exposure.http.is_some() {
637            b.edge(&node_id, "projection:http", "projects");
638        }
639        if c.exposure.cli.is_some() {
640            b.edge(&node_id, "projection:cli", "projects");
641        }
642        // the Cockpit is a projection like the others, and it is on this graph so that the
643        // executable's own picture of itself is complete rather than complete-except-the-
644        // part-a-person-looks-at. Every capability has a page; the ones with an HTTP
645        // exposure also get a form that calls them.
646        b.edge(&node_id, "projection:cockpit", "projects");
647    }
648    b.finish()
649}
650
651/// The layer as a graph: the directories the objects were discovered in, nested, with one
652/// node per kind under the directory that holds it. The shape of `.ai/`, read back from
653/// what was actually indexed rather than from a listing of what should be there.
654fn layer_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
655    let mut b = Builder::new(
656        "layer",
657        "The layer's shape",
658        "Every directory an object was discovered in, nested as the tree nests them, and the kinds each directory holds with how many objects of that kind. Derived from the provenance of the indexed objects, so a directory appears here exactly when something in it was read.",
659        "the provenance of every object of the index",
660    )
661    .node_kind("directory", "a directory at least one indexed object sits in")
662    .node_kind("kind", "one kind of object, under the directory that holds it")
663    .edge_kind("contains", "the directory contains the directory or the kind")
664    .edge_kind("read_as", "the kind's objects in that directory");
665
666    let mut per_directory: BTreeMap<&str, BTreeMap<&str, usize>> = BTreeMap::new();
667    for o in &index.objects {
668        *per_directory
669            .entry(o.provenance.directory.as_str())
670            .or_default()
671            .entry(o.kind.as_str())
672            .or_insert(0) += 1;
673    }
674
675    for (dir, kinds) in &per_directory {
676        // every ancestor, so the tree is connected without a second walk of the filesystem
677        let mut prefix: Vec<&str> = Vec::new();
678        for segment in dir.split('/').filter(|s| !s.is_empty() && *s != ".") {
679            prefix.push(segment);
680            let path = prefix.join("/");
681            let id = format!("directory:{path}");
682            if !b.node(Node {
683                id: id.clone(),
684                kind: "directory".into(),
685                label: segment.to_string(),
686                summary: Some(path.clone()),
687                route: None,
688                source: Some(path.clone()),
689                status: None,
690                external: false,
691                facts: BTreeMap::new(),
692            }) {
693                break;
694            }
695            if prefix.len() > 1 {
696                let parent = prefix[..prefix.len() - 1].join("/");
697                b.edge(&format!("directory:{parent}"), &id, "contains");
698            }
699        }
700        for (kind, count) in kinds {
701            let id = format!("kind:{dir}:{kind}");
702            if !b.node(Node {
703                id: id.clone(),
704                kind: "kind".into(),
705                label: (*kind).to_string(),
706                summary: Some(format!("{count} object(s)")),
707                route: Some(format!(
708                    "/cockpit/objects?kind={}",
709                    crate::http::router::percent_encode(kind)
710                )),
711                source: Some((*dir).to_string()),
712                status: None,
713                external: false,
714                facts: BTreeMap::new(),
715            }) {
716                break;
717            }
718            if *dir != "." {
719                b.edge(&format!("directory:{dir}"), &id, "read_as");
720            }
721        }
722    }
723    b.finish()
724}
725
726/// The effective rule set as a graph: every rule of the index and the rules it declares
727/// it depends on. The dependency is the rule's own `depends_on`, resolved against the
728/// index; a dependency the repository does not hold becomes an external node, so a broken
729/// reference is visible rather than absent.
730fn rules_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
731    let mut b = Builder::new(
732        "rules",
733        "Rule dependencies",
734        "Every rule of the effective set and the rules it declares it depends on, with the enforcement class each carries. A rule whose dependency the repository does not hold is drawn against an external node rather than left dangling.",
735        "the `depends_on` front matter of every object of kind `rule`",
736    )
737    .node_kind("rule", "a rule of the effective set, vendored or the project's own")
738    .node_kind("missing", "a rule named as a dependency that the index does not hold")
739    .edge_kind("depends_on", "the rule states it depends on the other");
740
741    for o in index.objects.iter().filter(|o| o.kind == "rule") {
742        let id = format!("rule:{}", o.identity);
743        if !b.node(Node {
744            id: id.clone(),
745            kind: "rule".into(),
746            label: o.identity.clone(),
747            summary: o.title.clone(),
748            route: Some(object_route(&o.uri)),
749            source: Some(o.provenance.path.clone()),
750            status: metadata_string(&o.metadata, "class"),
751            external: false,
752            facts: BTreeMap::new(),
753        }) {
754            break;
755        }
756    }
757    for o in index.objects.iter().filter(|o| o.kind == "rule") {
758        for dep in metadata_strings(&o.metadata, "depends_on") {
759            let target = format!("rule:{dep}");
760            if !b.has(&target) {
761                b.node(Node {
762                    id: target.clone(),
763                    kind: "missing".into(),
764                    label: dep.clone(),
765                    summary: Some("named as a dependency; not in this index".into()),
766                    route: None,
767                    source: None,
768                    status: None,
769                    external: true,
770                    facts: BTreeMap::new(),
771                });
772            }
773            b.edge(&format!("rule:{}", o.identity), &target, "depends_on");
774        }
775    }
776    b.finish()
777}
778
779/// The decisions as a graph: every ADR, what it supersedes, and what it put in force.
780/// The `related` references are typed (`rule:`, `claim:`, `file:`, `test:`), so the
781/// reverse index — what a rule was decided by — is an edge and never a second document.
782fn adrs_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
783    let mut b = Builder::new(
784        "adrs",
785        "Decisions and what they put in force",
786        "Every architecture decision, the decisions it stands in for, and the rules, claims, files and tests it names as what it put in force. The reverse question — what decided this rule — is the same edge read backwards.",
787        "the front matter of every object of kind `adr`",
788    )
789    .node_kind("adr", "one architecture decision record")
790    .node_kind("rule", "a rule the decision put in force")
791    .node_kind("claim", "a claim of docs/CLAIMS.yaml the decision backs")
792    .node_kind("file", "a document or an implementation the decision names")
793    .node_kind("test", "a behavioural case the decision names")
794    .edge_kind("supersedes", "the decision stands in for the other")
795    .edge_kind("put_in_force", "the decision put the thing in force");
796
797    for o in index.objects.iter().filter(|o| o.kind == "adr") {
798        let id = format!("adr:{}", o.identity);
799        if !b.node(Node {
800            id: id.clone(),
801            kind: "adr".into(),
802            label: metadata_string(&o.metadata, "id").unwrap_or_else(|| o.identity.clone()),
803            summary: o.title.clone(),
804            route: Some(object_route(&o.uri)),
805            source: Some(o.provenance.path.clone()),
806            status: metadata_string(&o.metadata, "status"),
807            external: false,
808            facts: BTreeMap::new(),
809        }) {
810            break;
811        }
812    }
813    let by_adr_id: BTreeMap<String, String> = index
814        .objects
815        .iter()
816        .filter(|o| o.kind == "adr")
817        .filter_map(|o| {
818            metadata_string(&o.metadata, "id").map(|id| (id, format!("adr:{}", o.identity)))
819        })
820        .collect();
821
822    for o in index.objects.iter().filter(|o| o.kind == "adr") {
823        let from = format!("adr:{}", o.identity);
824        for superseded in metadata_strings(&o.metadata, "supersedes") {
825            if let Some(target) = by_adr_id.get(&superseded) {
826                b.edge(&from, target, "supersedes");
827            }
828        }
829        for reference in metadata_strings(&o.metadata, "related") {
830            let Some((prefix, rest)) = reference.split_once(':') else {
831                continue;
832            };
833            if !["rule", "claim", "file", "test"].contains(&prefix) {
834                continue;
835            }
836            let target = format!("{prefix}:{rest}");
837            if !b.has(&target) {
838                // a `rule:` reference carries no version; the index's rule identities do,
839                // so resolve by prefix and fall back to an external node
840                let resolved = index
841                    .objects
842                    .iter()
843                    .find(|c| {
844                        c.kind == prefix
845                            && (c.identity == rest || c.identity.starts_with(&format!("{rest}@")))
846                    })
847                    .or_else(|| index.objects.iter().find(|c| c.provenance.path == rest));
848                let node = match resolved {
849                    Some(c) => Node {
850                        id: target.clone(),
851                        kind: prefix.into(),
852                        label: rest.to_string(),
853                        summary: c.title.clone(),
854                        route: Some(object_route(&c.uri)),
855                        source: Some(c.provenance.path.clone()),
856                        status: None,
857                        external: false,
858                        facts: BTreeMap::new(),
859                    },
860                    None => Node {
861                        id: target.clone(),
862                        kind: prefix.into(),
863                        label: rest.to_string(),
864                        summary: Some("named by a decision; not an object of this index".into()),
865                        route: None,
866                        source: (prefix == "file" || prefix == "test").then(|| rest.to_string()),
867                        status: None,
868                        external: true,
869                        facts: BTreeMap::new(),
870                    },
871                };
872                if !b.node(node) {
873                    break;
874                }
875            }
876            b.edge(&from, &target, "put_in_force");
877        }
878    }
879    b.finish()
880}
881
882/// The use cases as a graph: every executable use case and the commands, doctrines and
883/// claims it names. What a command is proved by, and what a doctrine is exercised by, are
884/// this graph read backwards.
885fn use_cases_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
886    let mut b = Builder::new(
887        "use-cases",
888        "Use cases and what they exercise",
889        "Every executable use case with the commands it runs, the doctrines it exercises and the claims it stands behind. Read backwards it answers the coverage question: what proves this command, what exercises this doctrine.",
890        "the front matter of every object of kind `use-case`",
891    )
892    .node_kind("use-case", "one executable use case")
893    .node_kind("command", "a command the scenario runs")
894    .node_kind("doctrine", "a doctrine the scenario exercises")
895    .node_kind("claim", "a claim the use case stands behind")
896    .edge_kind("runs", "the scenario runs that command")
897    .edge_kind("exercises", "the scenario exercises that doctrine")
898    .edge_kind("evidences", "the use case is evidence for that claim");
899
900    for o in index.objects.iter().filter(|o| o.kind == "use-case") {
901        let id = format!("use-case:{}", o.identity);
902        if !b.node(Node {
903            id: id.clone(),
904            kind: "use-case".into(),
905            label: o.identity.clone(),
906            summary: o.title.clone(),
907            route: Some(object_route(&o.uri)),
908            source: Some(o.provenance.path.clone()),
909            status: metadata_string(&o.metadata, "status"),
910            external: false,
911            facts: BTreeMap::new(),
912        }) {
913            break;
914        }
915        for (field, kind, edge) in [
916            ("commands", "command", "runs"),
917            ("doctrines", "doctrine", "exercises"),
918            ("claims", "claim", "evidences"),
919        ] {
920            for name in metadata_strings(&o.metadata, field) {
921                let target = format!("{kind}:{name}");
922                if !b.has(&target)
923                    && !b.node(Node {
924                        id: target.clone(),
925                        kind: kind.into(),
926                        label: name.clone(),
927                        summary: None,
928                        route: None,
929                        source: None,
930                        status: None,
931                        external: false,
932                        facts: BTreeMap::new(),
933                    })
934                {
935                    break;
936                }
937                b.edge(&id, &target, edge);
938            }
939        }
940    }
941    b.finish()
942}
943
944/// Everything, composed: the capability registry and every object of the index in one
945/// model, with the node kinds taken from what was actually indexed rather than from a
946/// list written here. Adding a kind to the layer adds it to this graph; adding a
947/// capability module adds its capabilities. Nothing in this derivation names a kind.
948///
949/// The graph holds definitions only. What is true of a running process — whether a
950/// capability answered, what the health of a surface is now — is [`RuntimeState`], laid
951/// over these nodes by a consumer that has a process to ask. Keeping the two apart is
952/// what lets the published site render this graph with no server behind it.
953///
954/// The edges are the point. Beside the structure the registries assert — a module
955/// composes its capabilities, a capability is projected through the transports it
956/// declares, an object is of its kind — every reference the layer's own front matter
957/// already carries becomes a typed edge: a rule to the rules it depends on, a decision to
958/// what it put in force and what it stands in for, a use case to what it runs, exercises
959/// and evidences, a context document to what it tracks, a claim to the file that
960/// implements it and the case that proves it. The conventions are in [`RELATIONS`] and
961/// nowhere else, and
962/// [`unresolved_relations`] is the same resolution read as a verdict: a reference that
963/// names a kind this repository holds and resolves to nothing is a finding, not an edge.
964fn composed_graph(registry: &CapabilityRegistry, index: &Index) -> Graph {
965    compose(registry, &index.objects)
966}
967
968/// The composed graph whole, every object of the index in it: what the generated
969/// `docs/generated/graph.json` carries and what the development-context compiler selects
970/// from. [`MAX_NODES`] bounds a graph a person is shown; a reader of the graph as data —
971/// the site's entity pages and catalogue counts, a context selection — would take a prefix
972/// for the repository and miss whatever sorted past it.
973///
974/// It has no node bound, so its size is a measurement rather than a limit. Measured on
975/// master 97c4521f91 (2026-10-14): 2795 nodes, 6128 edges and 3,339,236 bytes of
976/// `docs/generated/graph.json`, already past [`MAX_NODES`]. Generation still passes it
977/// through [`whole`], so a bound that comes back refuses the derive instead of committing a
978/// prefix (I1966).
979///
980/// ```
981/// use majordomus_cli::graph;
982/// use majordomus_cli::synthetic::SyntheticRepository;
983///
984/// let repo = SyntheticRepository::small().unwrap();
985/// let ctx = repo.context().unwrap();
986/// let whole = graph::composed_complete(&ctx.registry, ctx.index.as_ref());
987/// assert_eq!(whole.id, graph::COMPOSED);
988/// assert!(!whole.metadata.truncated);
989/// ```
990pub fn composed_complete(registry: &CapabilityRegistry, index: &Index) -> Graph {
991    let _phase = crate::perf::phase(crate::perf::Phase::GraphBuild);
992    crate::perf::Counters::bump(&crate::perf::COUNTERS.graph_builds);
993    compose_with(registry, &index.objects, usize::MAX)
994}
995
996/// The composition itself, over the two registries rather than over an index: the same
997/// derivation, testable without a repository on disk.
998fn compose(registry: &CapabilityRegistry, objects: &[Object]) -> Graph {
999    compose_with(registry, objects, MAX_NODES)
1000}
1001
1002/// [`compose`], taking at most `limit` nodes.
1003fn compose_with(registry: &CapabilityRegistry, objects: &[Object], limit: usize) -> Graph {
1004    let mut b = Builder::new(
1005        "composed",
1006        "Everything, composed",
1007        "The capability registry and every object of the layer in one graph: each module with the capabilities it composes, each capability with the transports it is projected through, and every indexed object under the kind that owns it. The node kinds are the kinds that were indexed, so a kind added to the layer appears here without this derivation being edited.",
1008        "the capability registry and every object of the index",
1009    )
1010    .limit(limit)
1011    .node_kind("module", "a capability module of this executable")
1012    .node_kind("capability", "one capability, declared once and projected")
1013    .node_kind(
1014        "projection",
1015        "a transport a capability is projected through",
1016    )
1017    .node_kind("kind", "one kind of object the layer indexed")
1018    .node_kind("file", "a file the layer names and does not hold as an object")
1019    .node_kind("test", "a behavioural case the layer names")
1020    .node_kind("command", "a command a scenario runs")
1021    .node_kind("doctrine", "a doctrine a scenario exercises")
1022    .node_kind("claim", "a claim the layer stands behind")
1023    .edge_kind("composes", "the module composes the capability")
1024    .edge_kind("projects", "the capability is projected through the transport")
1025    .edge_kind("is_a", "the object is of that kind")
1026    .edge_kind("depends_on", "the rule states it depends on the other")
1027    .edge_kind("supersedes", "the decision stands in for the other")
1028    .edge_kind("put_in_force", "the decision put the thing in force")
1029    .edge_kind("related_to", "the object names the other as related")
1030    .edge_kind("runs", "the scenario runs that command")
1031    .edge_kind("exercises", "the scenario exercises that doctrine")
1032    .edge_kind("evidences", "the object is evidence for that claim")
1033    .edge_kind("tracks", "the document tracks that file")
1034    .edge_kind("defined_in", "the claim is defined by that file")
1035    .edge_kind("implemented_by", "the claim is implemented by that file")
1036    .edge_kind("tested_by", "the claim is proved by that case")
1037    .edge_kind("belongs_to", "the issue belongs to that milestone")
1038    .edge_kind("serves", "the use case serves that application")
1039    .edge_kind("governed_by", "the application is governed by that doctrine");
1040
1041    // the kinds are read off what was indexed; a kind named here would be a second
1042    // declaration of something share/kinds.yaml already owns
1043    let kinds: BTreeSet<&str> = objects.iter().map(|o| o.kind.as_str()).collect();
1044    for kind in &kinds {
1045        b = b.node_kind(kind, &format!("an object of kind `{kind}`"));
1046    }
1047
1048    for kind in &kinds {
1049        let id = format!("kind:{kind}");
1050        if !b.node(Node {
1051            id,
1052            kind: "kind".into(),
1053            label: (*kind).to_string(),
1054            summary: Some(format!("every object of kind `{kind}`")),
1055            route: None,
1056            source: None,
1057            status: None,
1058            external: false,
1059            facts: BTreeMap::new(),
1060        }) {
1061            break;
1062        }
1063    }
1064
1065    for projection in ["mcp", "http", "cli", "cockpit"] {
1066        b.node(Node {
1067            id: format!("projection:{projection}"),
1068            kind: "projection".into(),
1069            label: projection.into(),
1070            summary: Some(format!("the {projection} projection of the registry")),
1071            route: None,
1072            source: None,
1073            status: None,
1074            external: false,
1075            facts: BTreeMap::new(),
1076        });
1077    }
1078
1079    for c in registry.iter() {
1080        // the objects of the layer are on this graph as themselves; the resource
1081        // capabilities that read them would say the same thing a second time
1082        if !matches!(c.provenance, Provenance::Builtin { .. }) {
1083            continue;
1084        }
1085        let module = format!("module:{}", c.module);
1086        if !b.has(&module)
1087            && !b.node(Node {
1088                id: module.clone(),
1089                kind: "module".into(),
1090                label: c.module.to_string(),
1091                summary: None,
1092                route: None,
1093                source: None,
1094                status: None,
1095                external: false,
1096                facts: BTreeMap::new(),
1097            })
1098        {
1099            break;
1100        }
1101        let id = format!("capability:{}", c.id);
1102        if !b.node(Node {
1103            id: id.clone(),
1104            kind: "capability".into(),
1105            label: c.id.to_string(),
1106            summary: Some(c.title.clone()),
1107            route: Some(capability_route(c.id.as_str())),
1108            source: Some(c.provenance.source_path()),
1109            status: Some(kind_word(c.kind)),
1110            external: false,
1111            facts: BTreeMap::new(),
1112        }) {
1113            break;
1114        }
1115        b.edge(&module, &id, "composes");
1116        if c.exposure.mcp.is_some() {
1117            b.edge(&id, "projection:mcp", "projects");
1118        }
1119        if c.exposure.http.is_some() {
1120            b.edge(&id, "projection:http", "projects");
1121        }
1122        if c.exposure.cli.is_some() {
1123            b.edge(&id, "projection:cli", "projects");
1124        }
1125        b.edge(&id, "projection:cockpit", "projects");
1126    }
1127
1128    for o in objects {
1129        // the URI is the identity: it survives a retitling, which a label does not
1130        if !b.node(Node {
1131            id: o.uri.clone(),
1132            kind: o.kind.clone(),
1133            label: o.identity.clone(),
1134            summary: o.title.clone().or_else(|| o.description.clone()),
1135            route: Some(object_route(&o.uri)),
1136            source: Some(o.provenance.path.clone()),
1137            status: metadata_string(&o.metadata, "status"),
1138            external: false,
1139            facts: facts(&o.metadata),
1140        }) {
1141            break;
1142        }
1143        b.edge(&o.uri, &format!("kind:{}", o.kind), "is_a");
1144    }
1145
1146    // the references the layer already carries, resolved through the same table the
1147    // check reads: an edge appears here exactly when `unresolved_relations` says nothing
1148    // about it
1149    let resolver = Resolver::new(registry, objects);
1150    for o in objects {
1151        for rel in RELATIONS {
1152            if !rel.kinds.is_empty() && !rel.kinds.contains(&o.kind.as_str()) {
1153                continue;
1154            }
1155            for reference in metadata_strings(&o.metadata, rel.field) {
1156                if is_absence(&reference) {
1157                    continue;
1158                }
1159                // the edge runs the way the relationship does, which is not always the
1160                // way the file that declared it is read
1161                let target = match resolver.resolve(rel, &reference) {
1162                    Outcome::Node(target) => Some(target),
1163                    Outcome::External(node) => {
1164                        let id = node.id.clone();
1165                        if !b.has(&id) && !b.node(node) {
1166                            break;
1167                        }
1168                        Some(id)
1169                    }
1170                    // a reference that resolves to nothing is a finding, and a finding is
1171                    // not drawn: an edge to a phantom reads as an answer
1172                    Outcome::Missing(_) => None,
1173                };
1174                if let Some(target) = target {
1175                    if rel.inverted {
1176                        b.edge(&target, &o.uri, rel.edge);
1177                    } else {
1178                        b.edge(&o.uri, &target, rel.edge);
1179                    }
1180                }
1181            }
1182        }
1183    }
1184
1185    b.finish()
1186}
1187
1188/// What a front matter field means when an object of some kind declares it: the one place
1189/// the layer's reference conventions are written down, read both by the composition that
1190/// draws the edges and by the check that refuses the ones that resolve to nothing.
1191///
1192/// A convention is here exactly when the metadata already carries it. Nothing in this
1193/// table asks a contributor to restate a relation the layer implies, and nothing in it
1194/// matches on a title or a substring: every reference resolves through a stable identity,
1195/// a declared id or a repository-relative path.
1196pub(crate) struct Relation {
1197    /// The kinds that declare the field; empty means any kind that carries it.
1198    pub(crate) kinds: &'static [&'static str],
1199    /// The front matter key.
1200    pub(crate) field: &'static str,
1201    /// The edge the reference asserts.
1202    pub(crate) edge: &'static str,
1203    /// How the reference names its target.
1204    pub(crate) target: Target,
1205    /// True when the edge runs from the thing named towards the object that named it.
1206    /// Two kinds sometimes declare one relationship from both ends — an application names
1207    /// the use cases that serve it and each of those names the application — and one
1208    /// relationship deserves one edge in one direction rather than two half-truths.
1209    pub(crate) inverted: bool,
1210}
1211
1212/// How a reference names what it points at.
1213#[derive(Clone, Copy)]
1214pub(crate) enum Target {
1215    /// An object of one kind, by identity; a versioned identity resolves by its stem.
1216    Object(&'static str),
1217    /// An architecture decision, by the `id` it declares rather than by its file name.
1218    DeclaredAdr,
1219    /// `<kind>:<name>`, where the kind is one the layer holds or `file` or `test`.
1220    Prefixed,
1221    /// An object of one kind by identity, or a bare name when the layer holds no such
1222    /// object: a doctrine of the vendored package the index did not read.
1223    ObjectOrName(&'static str, &'static str),
1224    /// A name that stands for something outside the layer: a command, a claim.
1225    Name(&'static str),
1226    /// A repository-relative path.
1227    Path,
1228    /// A capability of the registry, by the MCP tool name it is exposed under.
1229    McpTool,
1230}
1231
1232/// The layer writes a reference that points at nothing on purpose as `-`: a claim with no
1233/// implementation yet says so rather than omitting the field. That is an absence, not a
1234/// reference, and neither an edge nor a finding follows from it.
1235pub(crate) fn is_absence(reference: &str) -> bool {
1236    reference == "-"
1237}
1238
1239pub(crate) const RELATIONS: &[Relation] = &[
1240    Relation {
1241        kinds: &["rule"],
1242        field: "depends_on",
1243        edge: "depends_on",
1244        target: Target::Object("rule"),
1245        inverted: false,
1246    },
1247    Relation {
1248        kinds: &["adr"],
1249        field: "supersedes",
1250        edge: "supersedes",
1251        target: Target::DeclaredAdr,
1252        inverted: false,
1253    },
1254    Relation {
1255        kinds: &["adr"],
1256        field: "related",
1257        edge: "put_in_force",
1258        target: Target::Prefixed,
1259        inverted: false,
1260    },
1261    // a skill names its siblings by their bare identity, not as `<kind>:<name>`: the
1262    // convention is the layer's, and this table follows it rather than correcting it
1263    Relation {
1264        kinds: &["skill"],
1265        field: "related",
1266        edge: "related_to",
1267        target: Target::Object("skill"),
1268        inverted: false,
1269    },
1270    Relation {
1271        kinds: &["use-case"],
1272        field: "commands",
1273        edge: "runs",
1274        target: Target::Name("command"),
1275        inverted: false,
1276    },
1277    Relation {
1278        kinds: &["use-case"],
1279        field: "doctrines",
1280        edge: "exercises",
1281        target: Target::ObjectOrName("rule", "doctrine"),
1282        inverted: false,
1283    },
1284    // the claims of docs/CLAIMS.yaml are indexed objects, so a use case's claim is the
1285    // claim itself rather than a string that looks like one
1286    Relation {
1287        kinds: &["use-case"],
1288        field: "claims",
1289        edge: "evidences",
1290        target: Target::Object("claim"),
1291        inverted: false,
1292    },
1293    Relation {
1294        kinds: &[],
1295        field: "tracks",
1296        edge: "tracks",
1297        target: Target::Path,
1298        inverted: false,
1299    },
1300    // A claim names the file that defines it, the one that implements it and the case
1301    // that proves it. The layer indexes all three as objects for exactly this reason:
1302    // sources.yaml says the implementation and test classes exist so that the chain a
1303    // claim declares resolves to real nodes instead of to three strings.
1304    Relation {
1305        kinds: &["claim"],
1306        field: "source",
1307        edge: "defined_in",
1308        target: Target::Path,
1309        inverted: false,
1310    },
1311    Relation {
1312        kinds: &["claim"],
1313        field: "implementation",
1314        edge: "implemented_by",
1315        target: Target::Path,
1316        inverted: false,
1317    },
1318    Relation {
1319        kinds: &["claim"],
1320        field: "test",
1321        edge: "tested_by",
1322        target: Target::Path,
1323        inverted: false,
1324    },
1325    // The plan is a graph the repository already draws for itself; until now it was the
1326    // one graph missing from the graph.
1327    Relation {
1328        kinds: &["issue"],
1329        field: "milestone",
1330        edge: "belongs_to",
1331        target: Target::Object("milestone"),
1332        inverted: false,
1333    },
1334    Relation {
1335        kinds: &["issue"],
1336        field: "depends_on",
1337        edge: "depends_on",
1338        target: Target::Object("issue"),
1339        inverted: false,
1340    },
1341    Relation {
1342        kinds: &["milestone"],
1343        field: "depends_on",
1344        edge: "depends_on",
1345        target: Target::Object("milestone"),
1346        inverted: false,
1347    },
1348    Relation {
1349        kinds: &["milestone"],
1350        field: "claims",
1351        edge: "evidences",
1352        target: Target::Object("claim"),
1353        inverted: false,
1354    },
1355    // An application and a use case name each other; the relationship is one, and it runs
1356    // from the scenario to the situation it serves.
1357    Relation {
1358        kinds: &["use-case"],
1359        field: "applications",
1360        edge: "serves",
1361        target: Target::Object("application"),
1362        inverted: false,
1363    },
1364    Relation {
1365        kinds: &["application"],
1366        field: "use_cases",
1367        edge: "serves",
1368        target: Target::Object("use-case"),
1369        inverted: true,
1370    },
1371    Relation {
1372        kinds: &["application"],
1373        field: "doctrines",
1374        edge: "governed_by",
1375        target: Target::ObjectOrName("rule", "doctrine"),
1376        inverted: false,
1377    },
1378    // A use case that names an MCP tool names a capability of this registry, under the
1379    // name the registry exposes it by.
1380    Relation {
1381        kinds: &["use-case"],
1382        field: "mcp_tools",
1383        edge: "exercises",
1384        target: Target::McpTool,
1385        inverted: false,
1386    },
1387];
1388
1389/// One reference that names something this repository does not hold.
1390///
1391/// A reference to a file or a test outside the layer is not one of these: it resolves to
1392/// an external node, which is what an edge out of the layer looks like. This is the other
1393/// case — a rule, a decision or a capability named by identity that no object and no
1394/// declaration answers to, which is a defect in the naming rather than a boundary.
1395#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
1396pub struct Unresolved {
1397    /// The repository-relative file that declared it.
1398    pub declared_in: String,
1399    /// The front matter key it was declared under.
1400    pub key: String,
1401    /// What was named.
1402    pub reference: String,
1403    /// What to do about it.
1404    pub correction: String,
1405}
1406
1407/// Every reference the layer declares that resolves to nothing this repository holds.
1408///
1409/// Empty is the healthy answer. A consumer that generates an artifact from the graph
1410/// refuses to write one while this is not empty, which is why the finding names the file,
1411/// the key and the correction rather than only the missing name.
1412pub fn unresolved_relations(registry: &CapabilityRegistry, objects: &[Object]) -> Vec<Unresolved> {
1413    let r = Resolver::new(registry, objects);
1414    let mut out = Vec::new();
1415    for o in objects {
1416        for rel in RELATIONS {
1417            if !rel.kinds.is_empty() && !rel.kinds.contains(&o.kind.as_str()) {
1418                continue;
1419            }
1420            for reference in metadata_strings(&o.metadata, rel.field) {
1421                if is_absence(&reference) {
1422                    continue;
1423                }
1424                if let Outcome::Missing(correction) = r.resolve(rel, &reference) {
1425                    out.push(Unresolved {
1426                        declared_in: o.provenance.path.clone(),
1427                        key: rel.field.into(),
1428                        reference,
1429                        correction,
1430                    });
1431                }
1432            }
1433        }
1434    }
1435    out.sort();
1436    out.dedup();
1437    out
1438}
1439
1440/// What a reference resolved to.
1441pub(crate) enum Outcome {
1442    /// A node of the graph, by id.
1443    Node(String),
1444    /// Something outside the layer, drawn as an external node.
1445    External(Node),
1446    /// Nothing, with what to do about it.
1447    Missing(String),
1448}
1449
1450/// The index read once into the lookups every reference needs, so that resolving three
1451/// hundred references does not walk eight hundred objects three hundred times.
1452pub(crate) struct Resolver<'a> {
1453    registry: &'a CapabilityRegistry,
1454    by_kind_identity: BTreeMap<(&'a str, &'a str), &'a Object>,
1455    by_kind_stem: BTreeMap<(&'a str, &'a str), &'a Object>,
1456    by_path: BTreeMap<&'a str, &'a Object>,
1457    adr_by_declared_id: BTreeMap<String, &'a Object>,
1458    kinds: BTreeSet<&'a str>,
1459}
1460
1461impl<'a> Resolver<'a> {
1462    pub(crate) fn new(registry: &'a CapabilityRegistry, objects: &'a [Object]) -> Self {
1463        let mut by_kind_identity = BTreeMap::new();
1464        let mut by_kind_stem = BTreeMap::new();
1465        let mut by_path = BTreeMap::new();
1466        let mut adr_by_declared_id = BTreeMap::new();
1467        let mut kinds = BTreeSet::new();
1468        for o in objects {
1469            kinds.insert(o.kind.as_str());
1470            by_kind_identity.insert((o.kind.as_str(), o.identity.as_str()), o);
1471            // an identity carries its version (`project.thing@1`); a reference names the
1472            // rule, not the version it was written against
1473            if let Some((stem, _)) = o.identity.split_once('@') {
1474                by_kind_stem.entry((o.kind.as_str(), stem)).or_insert(o);
1475            }
1476            by_path.insert(o.provenance.path.as_str(), o);
1477            if o.kind == "adr" {
1478                if let Some(id) = metadata_string(&o.metadata, "id") {
1479                    adr_by_declared_id.insert(id, o);
1480                }
1481            }
1482        }
1483        Resolver {
1484            registry,
1485            by_kind_identity,
1486            by_kind_stem,
1487            by_path,
1488            adr_by_declared_id,
1489            kinds,
1490        }
1491    }
1492
1493    fn object(&self, kind: &str, name: &str) -> Option<&'a Object> {
1494        self.by_kind_identity
1495            .get(&(kind, name))
1496            .or_else(|| self.by_kind_stem.get(&(kind, name)))
1497            .copied()
1498    }
1499
1500    pub(crate) fn resolve(&self, rel: &Relation, reference: &str) -> Outcome {
1501        match rel.target {
1502            Target::Object(kind) => match self.object(kind, reference) {
1503                Some(o) => Outcome::Node(o.uri.clone()),
1504                None => Outcome::Missing(format!(
1505                    "no object of kind `{kind}` has the identity `{reference}`; register it or correct the name"
1506                )),
1507            },
1508            Target::DeclaredAdr => match self.adr_by_declared_id.get(reference) {
1509                Some(o) => Outcome::Node(o.uri.clone()),
1510                None => Outcome::Missing(format!(
1511                    "no decision declares the id `{reference}`; correct the name or record the decision"
1512                )),
1513            },
1514            Target::Prefixed => {
1515                let Some((prefix, rest)) = reference.split_once(':') else {
1516                    return Outcome::Missing(format!(
1517                        "`{reference}` is not `<kind>:<name>`; a reference names what kind of thing it points at"
1518                    ));
1519                };
1520                // a path out of the layer is a boundary, not a defect
1521                if prefix == "file" || prefix == "test" {
1522                    return match self.by_path.get(rest) {
1523                        Some(o) => Outcome::Node(o.uri.clone()),
1524                        None => Outcome::External(external_node(prefix, rest, Some(rest))),
1525                    };
1526                }
1527                match self.object(prefix, rest) {
1528                    Some(o) => Outcome::Node(o.uri.clone()),
1529                    None if !self.kinds.contains(prefix) => {
1530                        // a kind this repository holds no objects of at all: the claims of
1531                        // docs/CLAIMS.yaml are named this way and are not indexed objects
1532                        Outcome::External(external_node(prefix, rest, None))
1533                    }
1534                    None => Outcome::Missing(format!(
1535                        "no object of kind `{prefix}` has the identity `{rest}`; register it or correct the reference"
1536                    )),
1537                }
1538            }
1539            Target::ObjectOrName(kind, fallback) => match self.object(kind, reference) {
1540                Some(o) => Outcome::Node(o.uri.clone()),
1541                None => Outcome::External(external_node(fallback, reference, None)),
1542            },
1543            Target::Name(kind) => Outcome::External(external_node(kind, reference, None)),
1544            Target::Path => match self.by_path.get(reference) {
1545                Some(o) => Outcome::Node(o.uri.clone()),
1546                None => Outcome::External(external_node("file", reference, Some(reference))),
1547            },
1548            Target::McpTool => match self.registry.by_mcp_tool(reference) {
1549                Some(c) => Outcome::Node(format!("capability:{}", c.id)),
1550                None => Outcome::Missing(format!(
1551                    "no capability is exposed as the MCP tool `{reference}`; correct the name or declare the exposure"
1552                )),
1553            },
1554        }
1555    }
1556}
1557
1558/// A node for something the graph names and this repository does not hold as an object.
1559fn external_node(kind: &str, name: &str, path: Option<&str>) -> Node {
1560    Node {
1561        id: format!("{kind}:{name}"),
1562        kind: kind.into(),
1563        label: name.rsplit('/').next().unwrap_or(name).to_string(),
1564        summary: Some("named by the layer; not an object of this index".to_string()),
1565        route: None,
1566        source: path.map(str::to_string),
1567        status: None,
1568        external: true,
1569        facts: BTreeMap::new(),
1570    }
1571}
1572
1573/// What is true of a node in a running process, and of nothing on a published page.
1574///
1575/// A graph is definitions: they are the same in a static build and in a server, and they
1576/// are what `compose` derives. This is the overlay a consumer with a process to ask
1577/// lays over them, keyed by node id. It is deliberately not a field of [`Node`]: a node
1578/// that could carry runtime state would carry it into the static projection, where it
1579/// would be a value nobody can refresh and a reader cannot distinguish from a current
1580/// one.
1581#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1582pub struct RuntimeState {
1583    /// Node id to what the process says about it now.
1584    pub nodes: BTreeMap<String, NodeState>,
1585}
1586
1587/// One node's runtime state: what a process observed, never what a file declared.
1588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1589pub struct NodeState {
1590    /// A status word the observing process defines.
1591    pub status: String,
1592    /// One line about the observation, when there is something to say.
1593    #[serde(skip_serializing_if = "Option::is_none")]
1594    pub detail: Option<String>,
1595}
1596
1597impl RuntimeState {
1598    /// The observations that name a node of this graph, and no others.
1599    ///
1600    /// An observing process knows about subsystems, not about one graph's vocabulary, so
1601    /// what it offers is wider than what any graph can show. Narrowing here rather than at
1602    /// each consumer is what keeps the overlay keyed by node id: an entry that survives
1603    /// names a node, and a renderer can look every key up.
1604    ///
1605    /// ```
1606    /// use majordomus_cli::graph::{Builder, Node, NodeState, RuntimeState};
1607    /// let mut b = Builder::new("g", "G", "one node", "the composed registry")
1608    ///     .node_kind("module", "a subsystem");
1609    /// b.node(Node {
1610    ///     id: "module:mesh".into(), kind: "module".into(), label: "mesh".into(),
1611    ///     summary: None, route: None, source: None, status: None, external: false,
1612    ///     facts: Default::default(),
1613    /// });
1614    /// let graph = b.finish();
1615    ///
1616    /// let mut observed = RuntimeState::default();
1617    /// for id in ["module:mesh", "module:not-in-this-graph"] {
1618    ///     observed.nodes.insert(id.into(), NodeState { status: "ok".into(), detail: None });
1619    /// }
1620    ///
1621    /// let overlay = observed.narrowed_to(&graph);
1622    /// assert_eq!(overlay.nodes.keys().map(String::as_str).collect::<Vec<_>>(), ["module:mesh"]);
1623    /// ```
1624    pub fn narrowed_to(&self, graph: &Graph) -> RuntimeState {
1625        let ids: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
1626        RuntimeState {
1627            nodes: self
1628                .nodes
1629                .iter()
1630                .filter(|(id, _)| ids.contains(id.as_str()))
1631                .map(|(id, state)| (id.clone(), state.clone()))
1632                .collect(),
1633        }
1634    }
1635
1636    /// What the process says about one node now, if anything.
1637    ///
1638    /// `None` is the ordinary answer, not a failure: a graph names more than any one
1639    /// process observes, so a renderer draws the declared status alone for those nodes.
1640    ///
1641    /// ```
1642    /// use majordomus_cli::graph::{NodeState, RuntimeState};
1643    /// let mut observed = RuntimeState::default();
1644    /// observed.nodes.insert(
1645    ///     "module:mesh".into(),
1646    ///     NodeState { status: "ready".into(), detail: Some("2 peers".into()) },
1647    /// );
1648    ///
1649    /// assert_eq!(observed.of("module:mesh").map(|s| s.status.as_str()), Some("ready"));
1650    /// assert!(observed.of("module:nothing-observed-it").is_none());
1651    /// ```
1652    pub fn of(&self, node_id: &str) -> Option<&NodeState> {
1653        self.nodes.get(node_id)
1654    }
1655}
1656
1657/// The second projection of a graph: the definitions a static build publishes, and beside
1658/// them what a process observes about them now.
1659///
1660/// The two are held apart on purpose. `graph` is byte-for-byte the value
1661/// [`derive()`] produced, so a reader can compare a served graph against a published one and
1662/// get equality; `runtime` is the overlay, which no published artifact carries and which
1663/// nothing but a live process can fill. A consumer that merged them would produce a
1664/// document that looks like the static projection, cannot be compared with it, and carries
1665/// values that go stale the moment they are written.
1666///
1667/// ```
1668/// use majordomus_cli::graph::{Builder, Node, NodeState, ObservedGraph, RuntimeState};
1669/// let mut b = Builder::new("g", "G", "one node", "the composed registry")
1670///     .node_kind("module", "a subsystem");
1671/// b.node(Node {
1672///     id: "module:mesh".into(), kind: "module".into(), label: "mesh".into(),
1673///     summary: None, route: None, source: None, status: Some("declared".into()),
1674///     external: false, facts: Default::default(),
1675/// });
1676/// let graph = b.finish();
1677///
1678/// let mut observed = RuntimeState::default();
1679/// observed.nodes.insert("module:mesh".into(), NodeState { status: "ready".into(), detail: None });
1680/// let served = ObservedGraph::new(graph.clone(), &observed);
1681///
1682/// // the definitions are the published value, byte for byte; the observation sits beside them
1683/// assert_eq!(served.graph, graph);
1684/// assert_eq!(served.graph.nodes[0].status.as_deref(), Some("declared"));
1685/// assert_eq!(served.runtime.of("module:mesh").map(|s| s.status.as_str()), Some("ready"));
1686/// ```
1687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1688pub struct ObservedGraph {
1689    /// The composed definitions, unchanged.
1690    pub graph: Graph,
1691    /// What this process observes about them, keyed by node id, narrowed to nodes the
1692    /// graph holds.
1693    pub runtime: RuntimeState,
1694}
1695
1696impl ObservedGraph {
1697    /// Lay an observation over a graph. The graph is not touched, and observations that
1698    /// name no node of it are dropped.
1699    ///
1700    /// ```
1701    /// use majordomus_cli::graph::{Builder, Node, NodeState, ObservedGraph, RuntimeState};
1702    /// let mut b = Builder::new("g", "G", "one node", "the composed registry")
1703    ///     .node_kind("module", "a subsystem");
1704    /// b.node(Node {
1705    ///     id: "module:mesh".into(), kind: "module".into(), label: "mesh".into(),
1706    ///     summary: None, route: None, source: None, status: None, external: false,
1707    ///     facts: Default::default(),
1708    /// });
1709    ///
1710    /// let mut observed = RuntimeState::default();
1711    /// for id in ["module:mesh", "module:a-subsystem-this-graph-does-not-name"] {
1712    ///     observed.nodes.insert(id.into(), NodeState { status: "ok".into(), detail: None });
1713    /// }
1714    ///
1715    /// let served = ObservedGraph::new(b.finish(), &observed);
1716    /// assert_eq!(served.runtime.nodes.len(), 1);
1717    /// assert!(served.runtime.of("module:a-subsystem-this-graph-does-not-name").is_none());
1718    /// ```
1719    pub fn new(graph: Graph, observed: &RuntimeState) -> ObservedGraph {
1720        let runtime = observed.narrowed_to(&graph);
1721        ObservedGraph { graph, runtime }
1722    }
1723}
1724
1725/// A string field of an object's parsed front matter.
1726fn metadata_string(metadata: &Value, key: &str) -> Option<String> {
1727    metadata
1728        .get(key)
1729        .and_then(Value::as_str)
1730        .map(str::to_string)
1731}
1732
1733/// A list-of-strings field of an object's parsed front matter; empty when absent or of
1734/// another shape. A single string is read as a list of one, because the layer's YAML
1735/// subset writes both.
1736pub(crate) fn metadata_strings(metadata: &Value, key: &str) -> Vec<String> {
1737    match metadata.get(key) {
1738        Some(Value::Array(a)) => a
1739            .iter()
1740            .filter_map(Value::as_str)
1741            .map(str::to_string)
1742            .collect(),
1743        Some(Value::String(s)) => vec![s.clone()],
1744        _ => Vec::new(),
1745    }
1746}
1747
1748fn kind_word(kind: CapabilityKind) -> String {
1749    kind.as_str().into()
1750}
1751
1752fn enum_word<T: Serialize>(v: &T) -> String {
1753    serde_json::to_value(v)
1754        .ok()
1755        .and_then(|v| v.as_str().map(str::to_string))
1756        .unwrap_or_default()
1757}
1758
1759/// Where the Cockpit shows an object of the index, and where it shows a capability: the
1760/// one place a route is built from an identity, so a page that moves moves once.
1761pub fn routes(object: &Object) -> String {
1762    object_route(&object.uri)
1763}
1764
1765/// The Cockpit route of a capability.
1766pub fn capability_page(c: &Capability) -> String {
1767    capability_route(c.id.as_str())
1768}
1769
1770/// The Why catalogue as a graph: every operational moment with the audiences that
1771/// recognise it, the areas it falls under, and the mechanisms of this repository it names
1772/// — the commands, the capabilities of this executable, the claims, the rules and the use
1773/// cases. Read backwards it answers the question a page asks: which moments does this
1774/// capability answer, and which does this rule govern.
1775///
1776/// Derived from the front matter of the moments and from nothing else. No edge here is
1777/// authored twice: a moment names an audience, and the audience's membership is this
1778/// graph read in the other direction.
1779fn why_graph(registry: &CapabilityRegistry, index: &Index) -> Graph {
1780    let catalogue = crate::why::Catalogue::build(index, registry);
1781    let mut b = Builder::new(
1782        "why",
1783        "The moments, and what answers them",
1784        "Every operational moment this tool is a response to, with who recognises it, the operational area it falls under, and the commands, capabilities, claims, rules and use cases that answer it. A moment that named something this repository does not have would fail validation rather than draw an edge to nothing.",
1785        "the front matter of every object of kind `moment`",
1786    )
1787    .node_kind("moment", "one operational failure mode")
1788    .node_kind("audience", "who recognises it")
1789    .node_kind("area", "the operational area it falls under")
1790    .node_kind("command", "a command that answers it")
1791    .node_kind("capability", "a capability of this executable that answers it")
1792    .node_kind("claim", "a claim that says what is guaranteed here")
1793    .node_kind("rule", "a rule of the effective set that governs it")
1794    .node_kind("use-case", "a use case that shows the way out")
1795    .edge_kind("recognised_by", "that audience recognises this moment")
1796    .edge_kind("falls_under", "this moment falls under that area")
1797    .edge_kind("answered_by", "that command or capability answers this moment")
1798    .edge_kind("backed_by", "that claim says what is guaranteed here")
1799    .edge_kind("governed_by", "that rule governs this moment")
1800    .edge_kind("resolved_by", "that use case shows the way out")
1801    .edge_kind("related_to", "the moment names that one as related");
1802
1803    'outer: for m in catalogue
1804        .all()
1805        .iter()
1806        .filter(|m| m.status == crate::why::STABLE)
1807    {
1808        let id = format!("moment:{}", m.id);
1809        if !b.node(Node {
1810            id: id.clone(),
1811            kind: "moment".into(),
1812            label: m.label().to_string(),
1813            summary: Some(m.summary.clone()),
1814            route: Some(m.route.clone()),
1815            source: Some(m.source.clone()),
1816            status: Some(m.severity.clone()),
1817            external: false,
1818            // the moment's own vocabulary is not modelled as facts yet; an empty map is
1819            // the honest statement, and `skip_serializing_if` keeps it out of the output
1820            facts: Default::default(),
1821        }) {
1822            break;
1823        }
1824        for (values, kind, edge) in [
1825            (&m.audiences, "audience", "recognised_by"),
1826            (&m.areas, "area", "falls_under"),
1827            (&m.commands, "command", "answered_by"),
1828            (&m.capabilities, "capability", "answered_by"),
1829            (&m.claims, "claim", "backed_by"),
1830            (&m.doctrines, "rule", "governed_by"),
1831            (&m.use_cases, "use-case", "resolved_by"),
1832            (&m.related, "moment", "related_to"),
1833        ] {
1834            for name in values {
1835                let target = format!("{kind}:{name}");
1836                if !b.has(&target) {
1837                    let route = match kind {
1838                        "audience" => Some(format!("{}audiences/{name}/", crate::why::ROUTE)),
1839                        "area" => Some(format!("{}areas/{name}/", crate::why::ROUTE)),
1840                        "moment" => Some(format!("{}{name}/", crate::why::ROUTE)),
1841                        "command" => Some(format!("/commands/{name}/")),
1842                        "claim" => Some(format!("/guarantees/{name}/")),
1843                        "use-case" => Some(format!("/use-cases/{name}/")),
1844                        _ => None,
1845                    };
1846                    if !b.node(Node {
1847                        id: target.clone(),
1848                        kind: kind.into(),
1849                        label: name.clone(),
1850                        summary: None,
1851                        route,
1852                        source: None,
1853                        status: None,
1854                        external: false,
1855                        facts: Default::default(),
1856                    }) {
1857                        break 'outer;
1858                    }
1859                }
1860                b.edge(&id, &target, edge);
1861            }
1862        }
1863    }
1864    b.finish()
1865}
1866
1867/// The product as a graph: every feature, the modules, commands and kinds it is made of,
1868/// and the interfaces that follow — the command line, the HTTP API, MCP, the Cockpit and
1869/// the documentation — each edge derived from what the feature names and what the registry
1870/// projects. Read from the middle it answers what a module is for; read from the right it
1871/// answers what an interface carries. Drawn on the homepage; nothing on that page is drawn
1872/// by hand.
1873fn product_graph(registry: &CapabilityRegistry, index: &Index) -> Graph {
1874    let why = crate::why::Catalogue::build(index, registry);
1875    let web = Topology::new(crate::web::discover::native(
1876        crate::web::discover::Runtime::full(),
1877    ));
1878    let model = crate::product::ProductModel::build(index, registry, &why, &web);
1879    let mut b = Builder::new(
1880        "product",
1881        "The product, composed",
1882        "Every stable feature of the product, the capability modules, shell commands and object kinds it is made of, and the interfaces those project onto: the command line, the HTTP API, MCP, the Cockpit and the documentation. Every edge is derived from a feature's typed references and the registry's exposures; a feature that named something the repository does not have would fail validation rather than draw an edge to nothing.",
1883        "the features of the layer, the capability registry and the command registry",
1884    )
1885    .node_kind("feature", "one product feature, as its file declares it")
1886    .node_kind("module", "a capability module of the executable")
1887    .node_kind("command", "a public command of the shell tool")
1888    .node_kind("kind", "one kind of object of the layer")
1889    .node_kind("surface", "an interface a feature is exposed through")
1890    .edge_kind("made_of", "the feature is made of that module, command or kind")
1891    .edge_kind("exposed_through", "the module, command or kind is reached through that interface")
1892    .edge_kind("related_to", "the feature names that one as related");
1893
1894    for (id, title, route) in crate::product::SURFACES {
1895        b.node(Node {
1896            id: format!("surface:{id}"),
1897            kind: "surface".into(),
1898            label: (*title).to_string(),
1899            summary: None,
1900            route: Some((*route).to_string()),
1901            source: None,
1902            status: None,
1903            external: false,
1904            facts: BTreeMap::new(),
1905        });
1906    }
1907    'outer: for r in model.public() {
1908        let f = &r.feature;
1909        let fid = format!("feature:{}", f.id);
1910        if !b.node(Node {
1911            id: fid.clone(),
1912            kind: "feature".into(),
1913            label: f.label().to_string(),
1914            summary: Some(f.summary.clone()),
1915            route: Some(f.route.clone()),
1916            source: Some(f.source.clone()),
1917            status: Some(if f.featured {
1918                "featured".into()
1919            } else {
1920                f.status.clone()
1921            }),
1922            external: false,
1923            facts: BTreeMap::new(),
1924        }) {
1925            break;
1926        }
1927        for m in &r.module_refs {
1928            let id = format!("module:{}", m.id);
1929            if !b.has(&id)
1930                && !b.node(Node {
1931                    id: id.clone(),
1932                    kind: "module".into(),
1933                    label: m.id.clone(),
1934                    summary: Some(m.title.clone()),
1935                    route: Some(format!(
1936                        "/registry/modules/{}/",
1937                        m.id.replace(['.', '_'], "-")
1938                    )),
1939                    source: Some(m.source_path.clone()),
1940                    status: m.stability.clone(),
1941                    external: false,
1942                    facts: BTreeMap::new(),
1943                })
1944            {
1945                break 'outer;
1946            }
1947            b.edge(&fid, &id, "made_of");
1948            let caps = &m.capabilities;
1949            if caps.iter().any(|c| c.cli.is_some()) {
1950                b.edge(&id, "surface:cli", "exposed_through");
1951            }
1952            if caps.iter().any(|c| c.route.is_some()) {
1953                b.edge(&id, "surface:api", "exposed_through");
1954            }
1955            if caps
1956                .iter()
1957                .any(|c| c.tool.is_some() || c.resource.is_some())
1958            {
1959                b.edge(&id, "surface:mcp", "exposed_through");
1960            }
1961            b.edge(&id, "surface:cockpit", "exposed_through");
1962        }
1963        for c in &r.command_refs {
1964            let id = format!("command:{}", c.id);
1965            if !b.has(&id)
1966                && !b.node(Node {
1967                    id: id.clone(),
1968                    kind: "command".into(),
1969                    label: format!("majordomus {}", c.id),
1970                    summary: Some(c.summary.clone()),
1971                    route: Some(format!("/commands/{}/", c.id)),
1972                    source: Some("share/commands.yaml".into()),
1973                    status: c.stage.clone(),
1974                    external: false,
1975                    facts: BTreeMap::new(),
1976                })
1977            {
1978                break 'outer;
1979            }
1980            b.edge(&fid, &id, "made_of");
1981            b.edge(&id, "surface:cli", "exposed_through");
1982        }
1983        for k in &r.kind_refs {
1984            let id = format!("kind:{}", k.name);
1985            if !b.has(&id)
1986                && !b.node(Node {
1987                    id: id.clone(),
1988                    kind: "kind".into(),
1989                    label: k.name.clone(),
1990                    summary: Some(format!("{} object(s) of the layer", k.objects)),
1991                    // A kind is not a module and has no page of its own: the route used to be
1992                    // built as if it were one, and every kind node linked a 404. Its objects are
1993                    // reached through the Cockpit and MCP edges below; the node renders unlinked.
1994                    route: None,
1995                    source: Some("share/kinds.yaml".into()),
1996                    status: None,
1997                    external: false,
1998                    facts: BTreeMap::new(),
1999                })
2000            {
2001                break 'outer;
2002            }
2003            b.edge(&fid, &id, "made_of");
2004            // every object of the layer is an MCP resource and has a Cockpit page
2005            b.edge(&id, "surface:mcp", "exposed_through");
2006            b.edge(&id, "surface:cockpit", "exposed_through");
2007        }
2008        if !r.doc_refs.is_empty() {
2009            b.edge(&fid, "surface:docs", "exposed_through");
2010        }
2011        for rel in &f.related {
2012            b.edge(&fid, &format!("feature:{rel}"), "related_to");
2013        }
2014    }
2015    b.finish()
2016}
2017
2018#[cfg(test)]
2019mod tests {
2020    use super::*;
2021
2022    fn node(id: &str) -> Node {
2023        Node {
2024            id: id.into(),
2025            kind: "x".into(),
2026            label: id.into(),
2027            summary: None,
2028            route: None,
2029            source: None,
2030            status: None,
2031            external: false,
2032            facts: BTreeMap::new(),
2033        }
2034    }
2035
2036    #[test]
2037    fn an_edge_to_a_missing_node_is_dropped() {
2038        let mut b = Builder::new("t", "T", "d", "s");
2039        b.node(node("a"));
2040        b.edge("a", "gone", "k");
2041        let g = b.finish();
2042        assert_eq!(g.edges, vec![]);
2043        assert!(g.metadata.acyclic);
2044    }
2045
2046    #[test]
2047    fn a_cycle_is_reported_and_not_hidden() {
2048        let mut b = Builder::new("t", "T", "d", "s");
2049        b.node(node("a"));
2050        b.node(node("b"));
2051        b.edge("a", "b", "k");
2052        b.edge("b", "a", "k");
2053        assert!(!b.finish().metadata.acyclic);
2054    }
2055
2056    #[test]
2057    fn nodes_are_sorted_and_deduplicated() {
2058        let mut b = Builder::new("t", "T", "d", "s");
2059        b.node(node("b"));
2060        b.node(node("a"));
2061        b.node(node("a"));
2062        let g = b.finish();
2063        assert_eq!(
2064            g.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(),
2065            ["a", "b"]
2066        );
2067    }
2068
2069    #[test]
2070    fn metadata_strings_reads_a_list_and_a_lone_string() {
2071        let v = serde_json::json!({ "a": ["x", "y"], "b": "z", "c": 1 });
2072        assert_eq!(metadata_strings(&v, "a"), ["x", "y"]);
2073        assert_eq!(metadata_strings(&v, "b"), ["z"]);
2074        assert!(metadata_strings(&v, "c").is_empty());
2075        assert!(metadata_strings(&v, "missing").is_empty());
2076    }
2077    fn object(kind: &str, identity: &str, title: &str) -> Object {
2078        Object {
2079            kind: kind.into(),
2080            identity: identity.into(),
2081            uri: format!("majordomus://{kind}/{identity}"),
2082            title: Some(title.into()),
2083            description: None,
2084            metadata: serde_json::json!({}),
2085            body: String::new(),
2086            content: String::new(),
2087            media_type: "text/markdown",
2088            provenance: crate::model::Provenance {
2089                path: format!(".ai/repo/{kind}s/{identity}.md"),
2090                directory: format!(".ai/repo/{kind}s"),
2091                source_class: kind.into(),
2092                section: None,
2093                bytes: 0,
2094                member: None,
2095            },
2096        }
2097    }
2098
2099    fn registry() -> CapabilityRegistry {
2100        CapabilityRegistry::builder()
2101            .with_builtin(crate::capability::builtin::all())
2102            .build()
2103            .unwrap()
2104    }
2105
2106    #[test]
2107    fn a_kind_nobody_wrote_here_is_still_a_kind_of_the_graph() {
2108        // the point of the composition: the derivation names no kind, so a kind the layer
2109        // gains appears without this file being edited
2110        let g = compose(&registry(), &[object("gizmo", "one", "A gizmo")]);
2111        assert!(g.node_kinds.contains_key("gizmo"));
2112        assert!(g.nodes.iter().any(|n| n.id == "kind:gizmo"));
2113        assert!(g
2114            .nodes
2115            .iter()
2116            .any(|n| n.id == "majordomus://gizmo/one" && n.kind == "gizmo"));
2117        assert!(g
2118            .edges
2119            .iter()
2120            .any(|e| e.source == "majordomus://gizmo/one" && e.target == "kind:gizmo"));
2121    }
2122
2123    #[test]
2124    fn an_object_is_identified_by_its_uri_and_not_by_its_title() {
2125        let before = compose(&registry(), &[object("rule", "one", "The old words")]);
2126        let after = compose(&registry(), &[object("rule", "one", "Entirely new words")]);
2127        let id = |g: &Graph| {
2128            g.nodes
2129                .iter()
2130                .find(|n| n.kind == "rule")
2131                .map(|n| n.id.clone())
2132        };
2133        assert_eq!(id(&before), id(&after));
2134        assert_eq!(id(&before).as_deref(), Some("majordomus://rule/one"));
2135    }
2136
2137    #[test]
2138    fn many_objects_of_one_kind_share_the_one_kind_node() {
2139        let g = compose(
2140            &registry(),
2141            &[
2142                object("rule", "one", "One"),
2143                object("rule", "two", "Two"),
2144                object("rule", "three", "Three"),
2145            ],
2146        );
2147        assert_eq!(g.nodes.iter().filter(|n| n.id == "kind:rule").count(), 1);
2148        assert_eq!(g.nodes.iter().filter(|n| n.kind == "rule").count(), 3);
2149    }
2150
2151    #[test]
2152    fn the_composition_does_not_depend_on_the_order_it_read_things_in() {
2153        let r = registry();
2154        let forwards = vec![
2155            object("rule", "a", "A"),
2156            object("adr", "b", "B"),
2157            object("skill", "c", "C"),
2158        ];
2159        let backwards: Vec<Object> = forwards.iter().rev().cloned().collect();
2160        assert_eq!(compose(&r, &forwards), compose(&r, &backwards));
2161    }
2162
2163    #[test]
2164    fn nodes_and_edges_come_out_sorted() {
2165        let g = compose(
2166            &registry(),
2167            &[object("rule", "z", "Z"), object("rule", "a", "A")],
2168        );
2169        let mut sorted = g.nodes.clone();
2170        sorted.sort();
2171        assert_eq!(g.nodes, sorted);
2172        let mut edges = g.edges.clone();
2173        edges.sort();
2174        assert_eq!(g.edges, edges);
2175    }
2176
2177    #[test]
2178    fn a_graph_that_stopped_at_the_bound_is_refused_as_data() {
2179        let objects: Vec<Object> = (0..MAX_NODES + 1)
2180            .map(|i| object("rule", &format!("r{i:05}"), "A rule"))
2181            .collect();
2182        let refused = whole(compose(&registry(), &objects)).unwrap_err();
2183        assert!(refused.contains("`composed` graph stopped at"), "{refused}");
2184        assert!(whole(compose(&registry(), &objects[..3])).is_ok());
2185    }
2186
2187    #[test]
2188    fn the_composed_graph_read_as_data_is_whole_past_the_display_bound() {
2189        let objects: Vec<Object> = (0..MAX_NODES + 3)
2190            .map(|i| object("rule", &format!("r{i:05}"), "A rule"))
2191            .collect();
2192        let shown = compose(&registry(), &objects);
2193        assert!(
2194            shown.metadata.truncated,
2195            "a graph a person is shown stops at the bound"
2196        );
2197        assert_eq!(shown.nodes.len(), MAX_NODES);
2198        let whole = compose_with(&registry(), &objects, usize::MAX);
2199        assert!(!whole.metadata.truncated);
2200        let last = format!("majordomus://rule/r{:05}", MAX_NODES + 2);
2201        assert!(
2202            whole.nodes.iter().any(|n| n.id == last),
2203            "the object sorted last is in the graph read as data"
2204        );
2205    }
2206
2207    #[test]
2208    fn every_capability_reaches_its_module_and_its_projections() {
2209        let g = compose(&registry(), &[]);
2210        let c = g
2211            .nodes
2212            .iter()
2213            .find(|n| n.kind == "capability")
2214            .expect("the registry composes capabilities");
2215        assert!(g
2216            .edges
2217            .iter()
2218            .any(|e| e.target == c.id && e.kind == "composes"));
2219        assert!(g
2220            .edges
2221            .iter()
2222            .any(|e| e.source == c.id && e.target == "projection:cockpit"));
2223    }
2224
2225    #[test]
2226    fn the_graph_holds_definitions_and_the_overlay_holds_what_a_process_saw() {
2227        let g = compose(&registry(), &[object("rule", "one", "One")]);
2228        let json = serde_json::to_string(&g).unwrap();
2229        assert!(!json.contains("\"runtime\""));
2230        let mut state = RuntimeState::default();
2231        state.nodes.insert(
2232            "majordomus://rule/one".into(),
2233            NodeState {
2234                status: "ok".into(),
2235                detail: None,
2236            },
2237        );
2238        assert_eq!(state.nodes.len(), 1);
2239    }
2240    fn object_with(kind: &str, identity: &str, metadata: serde_json::Value) -> Object {
2241        Object {
2242            metadata,
2243            ..object(kind, identity, "A thing")
2244        }
2245    }
2246
2247    #[test]
2248    fn a_rule_reaches_the_rule_it_depends_on_across_its_version() {
2249        // identities carry a version; a reference names the rule, not the version it was
2250        // written against
2251        let objects = vec![
2252            object_with("rule", "project.first@1", serde_json::json!({})),
2253            object_with(
2254                "rule",
2255                "project.second@1",
2256                serde_json::json!({ "depends_on": ["project.first"] }),
2257            ),
2258        ];
2259        let g = compose(&registry(), &objects);
2260        assert!(g
2261            .edges
2262            .iter()
2263            .any(|e| e.source == "majordomus://rule/project.second@1"
2264                && e.target == "majordomus://rule/project.first@1"
2265                && e.kind == "depends_on"));
2266        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2267    }
2268
2269    #[test]
2270    fn a_reference_out_of_the_layer_is_a_boundary_and_not_a_finding() {
2271        let objects = vec![object_with(
2272            "adr",
2273            "0001-a-decision",
2274            serde_json::json!({ "id": "adr-0001", "related": ["file:docs/COCKPIT.md"] }),
2275        )];
2276        let g = compose(&registry(), &objects);
2277        let node = g
2278            .nodes
2279            .iter()
2280            .find(|n| n.id == "file:docs/COCKPIT.md")
2281            .expect("a file the layer names is drawn as an external node");
2282        assert!(node.external);
2283        assert!(g
2284            .edges
2285            .iter()
2286            .any(|e| e.target == node.id && e.kind == "put_in_force"));
2287        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2288    }
2289
2290    #[test]
2291    fn a_reference_to_a_kind_we_hold_that_names_nothing_is_a_finding() {
2292        let objects = vec![
2293            object_with("rule", "project.real@1", serde_json::json!({})),
2294            object_with(
2295                "adr",
2296                "0001-a-decision",
2297                serde_json::json!({ "id": "adr-0001", "related": ["rule:project.imaginary"] }),
2298            ),
2299        ];
2300        let found = unresolved_relations(&registry(), &objects);
2301        assert_eq!(found.len(), 1, "one reference names nothing: {found:?}");
2302        let f = &found[0];
2303        assert_eq!(f.declared_in, ".ai/repo/adrs/0001-a-decision.md");
2304        assert_eq!(f.key, "related");
2305        assert_eq!(f.reference, "rule:project.imaginary");
2306        assert!(
2307            f.correction.contains("project.imaginary") && f.correction.contains("register"),
2308            "the correction says what to do: {}",
2309            f.correction
2310        );
2311    }
2312
2313    #[test]
2314    fn a_finding_is_never_drawn_as_an_edge() {
2315        let objects = vec![
2316            object_with("rule", "project.real@1", serde_json::json!({})),
2317            object_with(
2318                "adr",
2319                "0001-a-decision",
2320                serde_json::json!({ "id": "adr-0001", "related": ["rule:project.imaginary"] }),
2321            ),
2322        ];
2323        let g = compose(&registry(), &objects);
2324        assert!(
2325            !g.nodes.iter().any(|n| n.label == "project.imaginary"),
2326            "a reference that resolves to nothing is a finding, not a phantom node"
2327        );
2328        assert!(!g.edges.iter().any(|e| e.kind == "put_in_force"));
2329    }
2330
2331    #[test]
2332    fn a_decision_stands_in_for_the_one_it_names_by_declared_id() {
2333        let objects = vec![
2334            object_with(
2335                "adr",
2336                "0001-the-first",
2337                serde_json::json!({ "id": "adr-0001" }),
2338            ),
2339            object_with(
2340                "adr",
2341                "0002-the-second",
2342                serde_json::json!({ "id": "adr-0002", "supersedes": ["adr-0001"] }),
2343            ),
2344        ];
2345        let g = compose(&registry(), &objects);
2346        assert!(g
2347            .edges
2348            .iter()
2349            .any(|e| e.source == "majordomus://adr/0002-the-second"
2350                && e.target == "majordomus://adr/0001-the-first"
2351                && e.kind == "supersedes"));
2352    }
2353
2354    #[test]
2355    fn a_use_case_reaches_what_it_runs_exercises_and_evidences() {
2356        let objects = vec![
2357            object_with("rule", "majordomus.a-doctrine@1", serde_json::json!({})),
2358            object_with("claim", "claim-one", serde_json::json!({})),
2359            object_with(
2360                "use-case",
2361                "a-scenario",
2362                serde_json::json!({
2363                    "commands": ["doctor"],
2364                    "doctrines": ["majordomus.a-doctrine"],
2365                    "claims": ["claim-one"],
2366                }),
2367            ),
2368        ];
2369        let g = compose(&registry(), &objects);
2370        let from = "majordomus://use-case/a-scenario";
2371        let edge = |kind: &str| g.edges.iter().find(|e| e.source == from && e.kind == kind);
2372        assert_eq!(
2373            edge("runs").map(|e| e.target.as_str()),
2374            Some("command:doctor")
2375        );
2376        assert_eq!(
2377            edge("exercises").map(|e| e.target.as_str()),
2378            Some("majordomus://rule/majordomus.a-doctrine@1"),
2379            "a doctrine the layer holds resolves to the rule itself"
2380        );
2381        assert_eq!(
2382            edge("evidences").map(|e| e.target.as_str()),
2383            Some("majordomus://claim/claim-one"),
2384            "the claims of docs/CLAIMS.yaml are indexed objects, so the edge reaches the claim itself"
2385        );
2386        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2387    }
2388
2389    #[test]
2390    fn every_edge_has_both_ends_in_the_graph() {
2391        let objects = vec![
2392            object_with(
2393                "context",
2394                "a-document",
2395                serde_json::json!({ "tracks": ["lib/session.sh"] }),
2396            ),
2397            object_with("rule", "project.real@1", serde_json::json!({})),
2398        ];
2399        let g = compose(&registry(), &objects);
2400        let ids: BTreeSet<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
2401        for e in &g.edges {
2402            assert!(ids.contains(e.source.as_str()), "source missing: {e:?}");
2403            assert!(ids.contains(e.target.as_str()), "target missing: {e:?}");
2404        }
2405        assert!(g.edges.iter().any(|e| e.kind == "tracks"));
2406    }
2407    #[test]
2408    fn a_skill_names_its_sibling_by_identity_and_that_resolves() {
2409        // the convention this test pins was learned from the layer rather than assumed:
2410        // read as `<kind>:<name>` these two references were findings, and they were not
2411        let objects = vec![
2412            object_with("skill", "repo-review", serde_json::json!({})),
2413            object_with(
2414                "skill",
2415                "implement",
2416                serde_json::json!({ "related": ["repo-review"] }),
2417            ),
2418        ];
2419        let g = compose(&registry(), &objects);
2420        assert!(g
2421            .edges
2422            .iter()
2423            .any(|e| e.source == "majordomus://skill/implement"
2424                && e.target == "majordomus://skill/repo-review"
2425                && e.kind == "related_to"));
2426        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2427    }
2428    #[test]
2429    fn a_rule_carries_its_class_and_its_statement_and_an_issue_does_not_carry_its_prose() {
2430        let long = "x".repeat(FACT_LIMIT + 1);
2431        let objects = vec![
2432            object_with(
2433                "rule",
2434                "project.thing@1",
2435                serde_json::json!({
2436                    "id": "project.thing",
2437                    "class": "blocking",
2438                    "statement": "A capability is defined once and every interface is derived from it.",
2439                    "tags": ["architecture", "capabilities"],
2440                }),
2441            ),
2442            object_with(
2443                "issue",
2444                "I0001",
2445                serde_json::json!({ "priority": "p0", "objective": long }),
2446            ),
2447        ];
2448        let g = compose(&registry(), &objects);
2449        let rule = g
2450            .nodes
2451            .iter()
2452            .find(|n| n.kind == "rule")
2453            .expect("the rule is a node");
2454        assert_eq!(
2455            rule.facts.get("class"),
2456            Some(&Fact::Text("blocking".into())),
2457            "how a rule is enforced is a fact about it"
2458        );
2459        assert!(matches!(rule.facts.get("statement"), Some(Fact::Text(_))));
2460        assert_eq!(
2461            rule.facts.get("tags"),
2462            Some(&Fact::List(vec![
2463                "architecture".into(),
2464                "capabilities".into()
2465            ]))
2466        );
2467        assert!(
2468            !rule.facts.contains_key("id") && !rule.facts.contains_key("title"),
2469            "the node already shows these; carrying them again says one thing twice"
2470        );
2471        let issue = g.nodes.iter().find(|n| n.kind == "issue").unwrap();
2472        assert_eq!(issue.facts.get("priority"), Some(&Fact::Text("p0".into())));
2473        assert!(
2474            !issue.facts.contains_key("objective"),
2475            "prose belongs to the object, which the node's route reaches"
2476        );
2477    }
2478
2479    #[test]
2480    fn a_claim_reaches_what_defines_implements_and_proves_it() {
2481        // sources.yaml indexes implementations and tests so that this chain resolves to
2482        // real nodes instead of to three strings
2483        let mut implementation = object("implementation", "lib/session.sh", "session");
2484        implementation.provenance.path = "lib/session.sh".into();
2485        let mut case = object("test", "test/cases/01_a.sh", "a case");
2486        case.provenance.path = "test/cases/01_a.sh".into();
2487        let mut source = object("document", "docs/A.md", "a document");
2488        source.provenance.path = "docs/A.md".into();
2489        let objects = vec![
2490            implementation,
2491            case,
2492            source,
2493            object_with(
2494                "claim",
2495                "a-claim",
2496                serde_json::json!({
2497                    "source": "docs/A.md",
2498                    "implementation": "lib/session.sh",
2499                    "test": "test/cases/01_a.sh",
2500                }),
2501            ),
2502        ];
2503        let g = compose(&registry(), &objects);
2504        let from = "majordomus://claim/a-claim";
2505        for (edge, target) in [
2506            ("defined_in", "majordomus://document/docs/A.md"),
2507            (
2508                "implemented_by",
2509                "majordomus://implementation/lib/session.sh",
2510            ),
2511            ("tested_by", "majordomus://test/test/cases/01_a.sh"),
2512        ] {
2513            assert!(
2514                g.edges
2515                    .iter()
2516                    .any(|e| e.source == from && e.kind == edge && e.target == target),
2517                "the claim should reach {target} by {edge}"
2518            );
2519        }
2520        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2521    }
2522
2523    #[test]
2524    fn a_claim_that_nothing_implements_yet_says_so_and_that_is_not_a_finding() {
2525        let objects = vec![object_with(
2526            "claim",
2527            "a-claim",
2528            serde_json::json!({ "implementation": "-", "test": "-" }),
2529        )];
2530        let g = compose(&registry(), &objects);
2531        assert!(
2532            !g.edges.iter().any(|e| e.kind == "implemented_by"),
2533            "an absence is not a reference"
2534        );
2535        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2536    }
2537
2538    #[test]
2539    fn the_plan_is_a_graph_of_the_graph() {
2540        let objects = vec![
2541            object_with("milestone", "a-milestone", serde_json::json!({})),
2542            object_with(
2543                "issue",
2544                "I0001",
2545                serde_json::json!({ "milestone": "a-milestone" }),
2546            ),
2547            object_with(
2548                "issue",
2549                "I0002",
2550                serde_json::json!({ "milestone": "a-milestone", "depends_on": ["I0001"] }),
2551            ),
2552        ];
2553        let g = compose(&registry(), &objects);
2554        assert!(g
2555            .edges
2556            .iter()
2557            .any(|e| e.source == "majordomus://issue/I0002"
2558                && e.target == "majordomus://issue/I0001"
2559                && e.kind == "depends_on"));
2560        assert_eq!(
2561            g.edges
2562                .iter()
2563                .filter(
2564                    |e| e.kind == "belongs_to" && e.target == "majordomus://milestone/a-milestone"
2565                )
2566                .count(),
2567            2
2568        );
2569        assert_eq!(unresolved_relations(&registry(), &objects), vec![]);
2570    }
2571
2572    #[test]
2573    fn one_relationship_gets_one_edge_however_many_files_declare_it() {
2574        // the application names the use case and the use case names the application; that
2575        // is one relationship, and it runs from the scenario to the situation it serves
2576        let objects = vec![
2577            object_with(
2578                "application",
2579                "an-application",
2580                serde_json::json!({ "use_cases": ["a-scenario"] }),
2581            ),
2582            object_with(
2583                "use-case",
2584                "a-scenario",
2585                serde_json::json!({ "applications": ["an-application"] }),
2586            ),
2587        ];
2588        let g = compose(&registry(), &objects);
2589        let serves: Vec<&Edge> = g.edges.iter().filter(|e| e.kind == "serves").collect();
2590        assert_eq!(serves.len(), 1, "one relationship, one edge: {serves:?}");
2591        assert_eq!(serves[0].source, "majordomus://use-case/a-scenario");
2592        assert_eq!(serves[0].target, "majordomus://application/an-application");
2593    }
2594
2595    #[test]
2596    fn a_use_case_reaches_the_capability_behind_the_tool_it_names() {
2597        let objects = vec![object_with(
2598            "use-case",
2599            "a-scenario",
2600            serde_json::json!({ "mcp_tools": ["majordomus_get"] }),
2601        )];
2602        let g = compose(&registry(), &objects);
2603        assert!(
2604            g.edges
2605                .iter()
2606                .any(|e| e.source == "majordomus://use-case/a-scenario"
2607                    && e.kind == "exercises"
2608                    && e.target == "capability:objects.get"),
2609            "the registry knows which capability answers to a tool name"
2610        );
2611        let named_wrongly = vec![object_with(
2612            "use-case",
2613            "a-scenario",
2614            serde_json::json!({ "mcp_tools": ["majordomus_imaginary"] }),
2615        )];
2616        let found = unresolved_relations(&registry(), &named_wrongly);
2617        assert_eq!(found.len(), 1);
2618        assert!(found[0].correction.contains("MCP tool"));
2619    }
2620
2621    /// The second projection is the first one plus an overlay, and nothing else: strip the
2622    /// runtime half and what is left is byte-for-byte the graph a static build publishes.
2623    /// This is the property that lets a reader compare a served graph with a published one;
2624    /// a consumer that decorated the nodes instead would break it silently.
2625    #[test]
2626    fn the_runtime_projection_is_the_static_one_with_an_overlay_beside_it() {
2627        let objects = vec![object_with("use-case", "a-scenario", serde_json::json!({}))];
2628        let static_graph = compose(&registry(), &objects);
2629        let observed = RuntimeState {
2630            nodes: [(
2631                "majordomus://use-case/a-scenario".to_string(),
2632                NodeState {
2633                    status: "ok".into(),
2634                    detail: Some("this process answered for it".into()),
2635                },
2636            )]
2637            .into_iter()
2638            .collect(),
2639        };
2640
2641        let observed_graph = ObservedGraph::new(static_graph.clone(), &observed);
2642
2643        assert_eq!(
2644            observed_graph.graph, static_graph,
2645            "the definitions are untouched by the overlay"
2646        );
2647        assert_eq!(
2648            serde_json::to_value(&observed_graph.graph).expect("the graph serialises"),
2649            serde_json::to_value(&static_graph).expect("the graph serialises"),
2650            "stripping the runtime half yields the published document"
2651        );
2652        assert!(
2653            !serde_json::to_string(&static_graph)
2654                .expect("the graph serialises")
2655                .contains("this process answered for it"),
2656            "no observation reaches the static projection"
2657        );
2658    }
2659
2660    /// An observing process knows about subsystems, not about one graph's vocabulary, so
2661    /// it offers more than any graph can show. What survives names a node — which is what
2662    /// lets a renderer look every key up instead of guarding each one.
2663    #[test]
2664    fn an_observation_that_names_no_node_is_dropped() {
2665        let objects = vec![object_with("use-case", "a-scenario", serde_json::json!({}))];
2666        let g = compose(&registry(), &objects);
2667        let state = |word: &str| NodeState {
2668            status: word.into(),
2669            detail: None,
2670        };
2671        let observed = RuntimeState {
2672            nodes: [
2673                ("majordomus://use-case/a-scenario".to_string(), state("ok")),
2674                ("module:not-in-this-graph".to_string(), state("degraded")),
2675            ]
2676            .into_iter()
2677            .collect(),
2678        };
2679
2680        let narrowed = observed.narrowed_to(&g);
2681
2682        assert_eq!(narrowed.nodes.len(), 1);
2683        assert_eq!(
2684            narrowed
2685                .of("majordomus://use-case/a-scenario")
2686                .map(|s| s.status.as_str()),
2687            Some("ok")
2688        );
2689        assert!(
2690            narrowed.of("module:not-in-this-graph").is_none(),
2691            "an observation about something this graph does not draw is not shown on it"
2692        );
2693    }
2694}