Skip to main content

majordomus_cli/capability/builtin/
session_domain.rs

1//! The `session_domain` module: the typed session domain, projected.
2//!
3//! The module is `session_domain` and not `session` because the registry composes builtin
4//! modules and declarative kinds into one namespace, and `session` is already a **kind** —
5//! the closed episode records under `.ai/repo/sessions/`. The registry refuses the
6//! collision rather than letting one shadow the other, which is how this was found: twelve
7//! "module \'session\' is composed twice" diagnostics, one per record. The capability ids
8//! carry the module\'s name; the tool names and routes are the short ones, because a tool
9//! name is a projection and not the identity.
10//!
11//! Two capabilities, and deliberately only two.
12//!
13//! `session.machine` answers the lifecycle state machine as data. It exists because the
14//! same machine is currently drawn three times — in `docs/CONTINUITY.md`, in
15//! `lib/session.sh`'s comments and in the Cockpit — and three drawings of one machine
16//! drift. The `lib/session.sh` drawing still had the task guard in it after ADR 0052
17//! removed the guard from the code. The answer here is derived from
18//! [`crate::session::EpisodeState`] and [`crate::session::Transition`] and from nothing
19//! else, so a transition that is added to the type appears in every surface and a diagram
20//! that disagrees with the code becomes impossible rather than merely discouraged.
21//!
22//! `session.identity` answers the six identities of this checkout, each with the canonical
23//! value this executable computes and each store's own spelling beside it. It exists
24//! because a forensic read on 2026-09-11 found the repository recorded as an absolute
25//! `.git` path *and* as a remote URL, the worktree as an absolute path *and* as a digest,
26//! and the session as an episode id *and* as a provider's UUID — three words over six
27//! things — and no surface anywhere said so.
28//!
29//! **There is no third episode listing here.** `episodes.*` (the MCP connection's episode)
30//! and `lifecycle.*` (session observability) are being added in the same week by two other
31//! workers, and a third projection over the same store would be this subsystem's own
32//! favourite defect committed by the module written to stop it. What this module adds is
33//! the domain those projections should eventually read from; ADR 0047 records that
34//! repointing as a step of the cutover, not of this change.
35//!
36//! ```
37//! use majordomus_cli::capability::builtin::session_domain;
38//!
39//! let module = session_domain::module();
40//! let ids: Vec<&str> = module.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
41//! assert_eq!(ids, ["session_domain.machine", "session_domain.identity"]);
42//! // every one of them reads: the domain's write path reaches no surface (ADR 0047)
43//! assert!(module.capabilities.iter().all(|e| e.capability.kind.is_read_only()));
44//! ```
45
46use schemars::JsonSchema;
47use serde::{Deserialize, Serialize};
48
49use crate::capability::handler::{CapabilityError, Context};
50use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
51use crate::capability::module::ModuleDescriptor;
52use crate::capability::CachePolicy;
53use crate::session::{self, IdentityFacet, Machine};
54use crate::{capability, module};
55
56use super::{get, Empty};
57
58/// The URI under which `session.machine` is read as an MCP resource.
59pub const MACHINE_URI: &str = "majordomus://session/machine";
60
61/// The URI under which `session.identity` is read as an MCP resource.
62pub const IDENTITY_URI: &str = "majordomus://session/identity";
63
64/// The lifecycle machine, with the decision it implements, so that a reader meeting it in
65/// a projection can go and read why it is shaped this way.
66///
67/// ```
68/// use majordomus_cli::capability::builtin::MachineReport;
69/// use majordomus_cli::session::Machine;
70///
71/// // the report is the machine plus one citation; the machine itself is derived from the
72/// // types and carries no second list
73/// let report = MachineReport { machine: Machine::describe(), decision: "adr-0047".into() };
74/// assert!(report.machine.transitions.iter().any(|t| !t.moves_state), "a checkpoint is an event");
75/// assert!(report.decision.contains("0047"));
76/// ```
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
78pub struct MachineReport {
79    /// The states, the transitions, and what the machine does not depend on.
80    #[serde(flatten)]
81    pub machine: Machine,
82    /// The decision this machine is the implementation of, so a reader meeting it in a
83    /// projection can go and read why it is shaped this way.
84    pub decision: String,
85}
86
87/// The identities of this checkout: one facet per subject, with a standing note about what
88/// a reader must not do with them.
89///
90/// ```
91/// use majordomus_cli::capability::builtin::IdentityReport;
92/// use majordomus_cli::session::identities;
93///
94/// let dir = tempfile::tempdir().unwrap();
95/// let report = IdentityReport {
96///     checkout: dir.path().display().to_string(),
97///     facets: identities(dir.path()),
98///     findings: Vec::new(),
99/// };
100/// let subjects: Vec<&str> = report.facets.iter().map(|f| f.subject.as_str()).collect();
101/// assert_eq!(subjects, ["repository", "checkout", "episode", "provider_session"]);
102/// ```
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104pub struct IdentityReport {
105    /// The checkout this answer is about. An absolute path, because this capability is
106    /// served to the worker in front of this checkout and never published — the same
107    /// exposure rule `continuity.state` follows and for the same reason (ADR 0014).
108    pub checkout: String,
109    /// One entry per subject, in the order a reader meets them: repository, checkout,
110    /// episode, provider session.
111    pub facets: Vec<IdentityFacet>,
112    /// What a reader should know before comparing any two of these. Empty is not the good
113    /// case here — the conflation is a property of the stores, not of this checkout — so
114    /// the standing note is always present.
115    pub findings: Vec<String>,
116}
117
118fn machine(_: &Context, _: Empty) -> Result<MachineReport, CapabilityError> {
119    Ok(MachineReport {
120        machine: Machine::describe(),
121        decision: ".ai/repo/adrs/0052-the-session-lifecycle-is-the-episodes-not-the-tasks.md \
122                   and .ai/repo/adrs/0047-the-session-domain-is-typed-and-its-identities-are-not-interchangeable.md"
123            .to_string(),
124    })
125}
126
127fn identity(ctx: &Context, _: Empty) -> Result<IdentityReport, CapabilityError> {
128    let root = std::path::PathBuf::from(&ctx.index.repository.root);
129    let facets = session::identities(&root);
130    Ok(IdentityReport {
131        checkout: root.to_string_lossy().to_string(),
132        facets,
133        findings: vec![
134            "the local half spells the repository as a path and the shared half as a remote \
135             URL; the two can never be equal, so comparing them across halves answers `different \
136             repository` for two records of one"
137                .to_string(),
138            "a provider session is an external correlation id and not the session identity: \
139             an episode opened by hand has none, and a worker that reconnects keeps its work \
140             while the value changes under it"
141                .to_string(),
142        ],
143    })
144}
145
146/// The module: two read-only capabilities over the typed session domain, declared once and
147/// projected to MCP and HTTP from that declaration alone.
148///
149/// ```
150/// use majordomus_cli::capability::builtin::session_domain::module;
151/// assert_eq!(module().id.as_str(), "session_domain");
152/// assert_eq!(module().capabilities.len(), 2);
153/// ```
154pub fn module() -> ModuleDescriptor {
155    module! {
156        id: "session_domain",
157        title: "The session domain",
158        description: "The typed session domain: the lifecycle state machine an episode moves through, declared once in the executable and projected here rather than redrawn per surface, and the identities this checkout records — the repository, the checkout, the episode and the provider's own session — each with the canonical value and each store's own spelling of it, because the stores spell six things as three words and nothing said so.",
159        stability: Stability::BehaviorallyVerified,
160        capabilities: [
161            capability! {
162                id: "session_domain.machine",
163                title: "The episode lifecycle, as data",
164                description: "Every state an episode can be in, whether it is terminal and what it may move to; every transition — open, resume, checkpoint, detach, close, recover — with the states it runs between and whether it moves the state at all. Derived from the types, so a diagram that disagrees with the code cannot exist. What the machine deliberately does not depend on is stated rather than left to be inferred: task state, which is ADR 0052.",
165                input: Empty,
166                output: MachineReport,
167                stability: Stability::BehaviorallyVerified,
168                exposure: Exposure {
169                    mcp: Some(McpExposure {
170                        tool: Some("majordomus_session_machine".into()),
171                        resource: Some(McpResource { uri: MACHINE_URI.into(), name: "session-machine".into() }),
172                    }),
173                    http: get("/api/v1/session/machine"),
174                    cli: None,
175                },
176                tags: ["session", "continuity", "lifecycle"],
177                // The machine is a function of the compiled types: it cannot change while
178                // the process runs, so the only reason to recompute it is to prove that it
179                // is still derived. A long TTL and one entry.
180                cache: CachePolicy::Process { max_entries: 1, ttl_seconds: Some(600) },
181                handler: machine,
182            },
183            capability! {
184                id: "session_domain.identity",
185                title: "What identifies this checkout, and how each store spells it",
186                description: "The repository, the checkout, the open episode and the provider session, each with the canonical value this executable computes and each store's own spelling beside it. The stores record the repository sometimes as an absolute .git path and sometimes as a remote URL, the worktree as a path and as a digest, and the session as both an episode id and a provider UUID; this is the surface that says so, so that a reader never compares two spellings of different things and concludes they disagree.",
187                input: Empty,
188                output: IdentityReport,
189                stability: Stability::BehaviorallyVerified,
190                exposure: Exposure {
191                    mcp: Some(McpExposure {
192                        tool: Some("majordomus_session_identity".into()),
193                        resource: Some(McpResource { uri: IDENTITY_URI.into(), name: "session-identity".into() }),
194                    }),
195                    http: get("/api/v1/session/identity"),
196                    cli: None,
197                },
198                tags: ["session", "identity", "continuity"],
199                // Short-lived, like `continuity.state`: the open episode it reads is
200                // written by another process, and a reader that cached it for a minute
201                // would answer with an episode that had already closed.
202                cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
203                handler: identity,
204            },
205        ],
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    /// The declaration is the only place the ids, the tool names, the resource URIs and the
214    /// routes exist. A refactor that dropped one of them would still compile and every
215    /// suite that tests the report itself would still pass; this is the assertion that
216    /// would not — which is what `project.rust-command-tested-in-file` asks.
217    #[test]
218    fn the_declaration_yields_the_identity_and_the_projections_it_claims() {
219        let m = module();
220        assert_eq!(m.id.as_str(), "session_domain");
221        let ids: Vec<&str> = m
222            .capabilities
223            .iter()
224            .map(|e| e.capability.id.as_str())
225            .collect();
226        assert_eq!(ids, ["session_domain.machine", "session_domain.identity"]);
227        assert!(ids.iter().all(|id| id.starts_with("session_domain.")));
228
229        for (n, tool, uri, route) in [
230            (
231                0usize,
232                "majordomus_session_machine",
233                MACHINE_URI,
234                "/api/v1/session/machine",
235            ),
236            (
237                1,
238                "majordomus_session_identity",
239                IDENTITY_URI,
240                "/api/v1/session/identity",
241            ),
242        ] {
243            let c = &m.capabilities[n].capability;
244            let mcp = c.exposure.mcp.as_ref().expect("an MCP projection");
245            assert_eq!(mcp.tool.as_deref(), Some(tool));
246            assert_eq!(mcp.resource.as_ref().map(|r| r.uri.as_str()), Some(uri));
247            assert_eq!(
248                c.exposure.http.as_ref().map(|h| h.path.as_str()),
249                Some(route)
250            );
251            // every capability of this module reads; the domain's write path is library
252            // API and reaches no surface (ADR 0047)
253            assert!(c.kind.is_read_only() && c.kind.is_executable());
254        }
255    }
256
257    #[test]
258    fn the_machine_projection_is_derived_and_carries_no_second_list() {
259        let report = Machine::describe();
260        assert_eq!(report.states.len(), crate::session::EpisodeState::ALL.len());
261        assert_eq!(
262            report.transitions.len(),
263            crate::session::Transition::ALL.len()
264        );
265        // the one thing the machine is stated to be independent of
266        assert!(report
267            .independent_of
268            .iter()
269            .any(|s| s.contains("task") && s.contains("0052")));
270    }
271}