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}