Skip to main content

soma_domain/
actions.rs

1//! Soma's own action catalog: the invariant action-spec table below and
2//! request-parsing shared identically across REST, CLI, and MCP dispatch.
3//!
4//! See the module-placement note at the bottom of this file for why this
5//! lives in `soma-domain` rather than `soma-application`.
6//!
7//! Note to editors: `xtask/src/patterns/actions.rs` and
8//! `xtask/src/scripts_lane_d.rs` text-scan this file's *type and constant
9//! names* (not just doc comments) starting from each name's first
10//! occurrence in the file to locate the action-spec table below. Keep those
11//! exact identifiers out of prose above the table itself, or the scan
12//! anchors on the wrong occurrence and silently mis-parses. The rationale
13//! comment at the bottom of this file exists specifically to stay clear of
14//! that hazard.
15
16use serde::Serialize;
17use serde_json::Value;
18
19#[path = "actions_help.rs"]
20mod help;
21#[path = "actions_python.rs"]
22mod python;
23pub use help::rest_help;
24
25// ── Action error types ────────────────────────────────────────────────────────
26
27/// Top-level error for action parsing and validation.
28#[derive(Debug, thiserror::Error)]
29pub enum ActionError {
30    /// A request failed input validation (missing/wrong-typed field, unknown action, etc.).
31    #[error(transparent)]
32    Validation(#[from] ActionValidationError),
33}
34
35impl ActionError {
36    /// Returns the inner [`ActionValidationError`] when this is a validation failure.
37    pub fn as_validation(&self) -> Option<&ActionValidationError> {
38        match self {
39            Self::Validation(error) => Some(error),
40        }
41    }
42}
43
44/// Structured validation failures produced while parsing an action request.
45#[derive(Debug, thiserror::Error)]
46pub enum ActionValidationError {
47    /// No `action` was supplied.
48    #[error("action is required")]
49    MissingAction,
50    /// A required field was absent or empty.
51    #[error("`{field}` is required and must not be empty")]
52    MissingField {
53        /// Name of the missing field.
54        field: String,
55    },
56    /// A field was present but had the wrong JSON type (expected a string).
57    #[error("`{field}` must be a string")]
58    WrongType {
59        /// Name of the wrongly-typed field.
60        field: String,
61    },
62    /// The action exists but is MCP-only and cannot be called over REST.
63    #[error("action={action} is not available over REST; use MCP or action=help for documentation")]
64    NotAvailableOverRest {
65        /// The requested action name.
66        action: String,
67    },
68    /// The requested action name is not recognised.
69    #[error("unknown soma action: {action}; use action=help for documentation")]
70    UnknownAction {
71        /// The unrecognised action name.
72        action: String,
73    },
74}
75
76/// Convenience alias for [`ActionValidationError`].
77pub type ValidationError = ActionValidationError;
78
79impl ActionValidationError {
80    /// Returns a stable machine-readable error code for this variant.
81    pub fn code(&self) -> &'static str {
82        match self {
83            Self::MissingAction => "missing_action",
84            Self::MissingField { .. } => "missing_field",
85            Self::WrongType { .. } => "wrong_type",
86            Self::NotAvailableOverRest { .. } => "not_available_over_rest",
87            Self::UnknownAction { .. } => "unknown_action",
88        }
89    }
90
91    /// Returns the name of the offending request field, if any.
92    pub fn field(&self) -> Option<&str> {
93        match self {
94            Self::MissingAction => Some("action"),
95            Self::MissingField { field } | Self::WrongType { field } => Some(field.as_str()),
96            Self::NotAvailableOverRest { .. } | Self::UnknownAction { .. } => Some("action"),
97        }
98    }
99
100    /// Returns the offending action name for action-related variants, if any.
101    pub fn bad_value(&self) -> Option<&str> {
102        match self {
103            Self::NotAvailableOverRest { action } | Self::UnknownAction { action } => {
104                Some(action.as_str())
105            }
106            Self::MissingAction | Self::MissingField { .. } | Self::WrongType { .. } => None,
107        }
108    }
109
110    /// Returns human-readable guidance for how to fix the request.
111    pub fn remediation(&self) -> String {
112        match self {
113            Self::MissingAction => {
114                format!(
115                    "Set `action` to one of: {}. Use action=help for the full schema.",
116                    action_names().join(", ")
117                )
118            }
119            Self::MissingField { field } => {
120                format!("Provide a non-empty `{field}` value, or use action=help for examples.")
121            }
122            Self::WrongType { field } => {
123                format!("Pass `{field}` as a JSON string, or use action=help for examples.")
124            }
125            Self::NotAvailableOverRest { action } => {
126                format!("Call action={action} through MCP, or call action=help over REST.")
127            }
128            Self::UnknownAction { .. } => {
129                format!(
130                    "Retry with one of the supported actions: {}. Use action=help for examples.",
131                    action_names().join(", ")
132                )
133            }
134        }
135    }
136}
137
138// Scope constants and `scopes_satisfy` live in `crate::scopes` alongside
139// `ADMIN_SCOPE` (formerly split across contracts' actions.rs and scopes.rs).
140// Re-exported here so `soma_domain::actions::{READ_SCOPE, WRITE_SCOPE,
141// DENY_SCOPE, scopes_satisfy}` keeps working for every call site that
142// imports scope names through the action-metadata path.
143pub use crate::scopes::{DENY_SCOPE, READ_SCOPE, WRITE_SCOPE, scopes_satisfy};
144
145/// Which transports an action is reachable over.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ActionTransport {
148    /// Available over MCP, CLI, and REST.
149    Any,
150    /// Available over MCP only (e.g. actions requiring client-side elicitation).
151    McpOnly,
152}
153
154impl ActionTransport {
155    /// Whether the action is reachable over MCP.
156    pub fn mcp(self) -> bool {
157        matches!(self, Self::Any | Self::McpOnly)
158    }
159
160    /// Whether the action is reachable over the CLI.
161    pub fn cli(self) -> bool {
162        matches!(self, Self::Any)
163    }
164
165    /// Whether the action is reachable over REST.
166    pub fn rest(self) -> bool {
167        matches!(self, Self::Any)
168    }
169}
170
171/// Relative cost / side-effect hint advertised for an action.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum ActionCost {
174    /// Fast, cheap, and side-effect free.
175    Cheap,
176    /// Moderately expensive to compute.
177    Moderate,
178    /// Expensive (heavy compute or slow I/O).
179    Expensive,
180    /// Performs a write / mutating operation.
181    Write,
182}
183
184impl ActionCost {
185    /// Returns the lowercase string label for this cost tier.
186    pub fn as_str(self) -> &'static str {
187        match self {
188            Self::Cheap => "cheap",
189            Self::Moderate => "moderate",
190            Self::Expensive => "expensive",
191            Self::Write => "write",
192        }
193    }
194}
195
196/// Static specification of a single action parameter.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub struct ParamSpec {
199    /// Parameter name.
200    pub name: &'static str,
201    /// JSON type of the parameter (e.g. `"string"`).
202    pub ty: &'static str,
203    /// Whether the parameter is required.
204    pub required: bool,
205    /// Human-readable description of the parameter.
206    pub description: &'static str,
207    /// Maximum allowed length, when the parameter is length-bounded.
208    pub max_len: Option<usize>,
209    /// Permitted values when the parameter is an enum; empty otherwise.
210    pub enum_values: &'static [&'static str],
211}
212
213/// Static specification of a single CLI flag.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct CliFlagSpec {
216    /// Flag name (e.g. `--name`).
217    pub name: &'static str,
218    /// Metavariable for the flag's value, if it takes one.
219    pub value_name: Option<&'static str>,
220    /// Whether the flag is required.
221    pub required: bool,
222    /// Human-readable description of the flag.
223    pub description: &'static str,
224}
225
226/// Static specification of an action's CLI surface.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub struct CliSpec {
229    /// Subcommand name.
230    pub command: &'static str,
231    /// Usage string shown in help.
232    pub usage: &'static str,
233    /// Flags accepted by the subcommand.
234    pub flags: &'static [CliFlagSpec],
235    /// Human-readable description of the subcommand.
236    pub description: &'static str,
237}
238
239/// Canonical, invariant metadata for a single Soma action.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct ActionSpec {
242    /// Action name (the `action` dispatch key).
243    pub name: &'static str,
244    /// Human-readable description of what the action does.
245    pub description: &'static str,
246    /// Scope required to invoke the action, or `None` if public.
247    pub required_scope: Option<&'static str>,
248    /// Transports the action is reachable over.
249    pub transport: ActionTransport,
250    /// REST method for the action's direct route, if any.
251    pub rest_method: Option<&'static str>,
252    /// REST path for the action's direct route, if any.
253    pub rest_path: Option<&'static str>,
254    /// Whether the action is destructive and requires confirmation.
255    pub destructive: bool,
256    /// Whether the action requires admin privileges.
257    pub requires_admin: bool,
258    /// Advertised cost / side-effect tier.
259    pub cost: ActionCost,
260    /// Parameter specifications.
261    pub params: &'static [ParamSpec],
262    /// Name of the returned result type.
263    pub returns: &'static str,
264    /// CLI surface for the action, if it has one.
265    pub cli: Option<CliSpec>,
266}
267
268const GREET_PARAMS: &[ParamSpec] = &[ParamSpec {
269    name: "name",
270    ty: "string",
271    required: false,
272    description: "Name to greet. Omit to greet the world.",
273    max_len: Some(4096),
274    enum_values: &[],
275}];
276
277const ECHO_PARAMS: &[ParamSpec] = &[ParamSpec {
278    name: "message",
279    ty: "string",
280    required: true,
281    description: "Message to echo back. Must not be empty.",
282    max_len: Some(4096),
283    enum_values: &[],
284}];
285
286const GREET_CLI_FLAGS: &[CliFlagSpec] = &[CliFlagSpec {
287    name: "--name",
288    value_name: Some("NAME"),
289    required: false,
290    description: "Name to greet. Omit to greet the world.",
291}];
292
293const ECHO_CLI_FLAGS: &[CliFlagSpec] = &[CliFlagSpec {
294    name: "--message",
295    value_name: Some("MSG"),
296    required: true,
297    description: "Message to echo back. Must not be empty.",
298}];
299
300/// The canonical table of every Soma action and its invariant metadata.
301///
302/// This is the single source of truth for action names, scopes, and transport
303/// availability across REST, CLI, and MCP dispatch.
304pub const ACTION_SPECS: &[ActionSpec] = &[
305    ActionSpec {
306        name: "greet",
307        description: "Return a greeting.",
308        required_scope: Some(READ_SCOPE),
309        transport: ActionTransport::Any,
310        rest_method: Some("POST"),
311        rest_path: Some("/v1/greet"),
312        destructive: false,
313        requires_admin: false,
314        cost: ActionCost::Cheap,
315        params: GREET_PARAMS,
316        returns: "Greeting",
317        cli: Some(CliSpec {
318            command: "greet",
319            usage: "soma greet [--name NAME]",
320            flags: GREET_CLI_FLAGS,
321            description: "Greet NAME, or the world when omitted.",
322        }),
323    },
324    ActionSpec {
325        name: "echo",
326        description: "Echo a message back unchanged.",
327        required_scope: Some(READ_SCOPE),
328        transport: ActionTransport::Any,
329        rest_method: Some("POST"),
330        rest_path: Some("/v1/echo"),
331        destructive: false,
332        requires_admin: false,
333        cost: ActionCost::Cheap,
334        params: ECHO_PARAMS,
335        returns: "EchoResult",
336        cli: Some(CliSpec {
337            command: "echo",
338            usage: "soma echo --message MSG",
339            flags: ECHO_CLI_FLAGS,
340            description: "Echo MSG back unchanged.",
341        }),
342    },
343    ActionSpec {
344        name: "status",
345        description: "Return server status and configuration info.",
346        required_scope: Some(READ_SCOPE),
347        transport: ActionTransport::Any,
348        rest_method: Some("GET"),
349        rest_path: Some("/v1/status"),
350        destructive: false,
351        requires_admin: false,
352        cost: ActionCost::Cheap,
353        params: &[],
354        returns: "Status",
355        cli: Some(CliSpec {
356            command: "status",
357            usage: "soma status",
358            flags: &[],
359            description: "Show service status.",
360        }),
361    },
362    python::ENVIRONMENT_STATUS,
363    python::ENVIRONMENT_PRUNE_PLAN,
364    python::ENVIRONMENT_PRUNE,
365    python::ENVIRONMENT_REPAIR,
366    python::ENVIRONMENT_UPDATE,
367    python::WORKER_STATUS,
368    python::WORKER_CANCEL,
369    python::WORKER_RESET,
370    python::GENERATION_STATUS,
371    python::GENERATION_ROLLBACK,
372    python::GRADUATION_STATUS,
373    python::GRADUATION_APPLY,
374    ActionSpec {
375        name: "elicit_name",
376        description: "Ask the MCP client to collect a name, then return a personalised greeting.",
377        required_scope: Some(READ_SCOPE),
378        transport: ActionTransport::McpOnly,
379        rest_method: None,
380        rest_path: None,
381        destructive: false,
382        requires_admin: false,
383        cost: ActionCost::Cheap,
384        params: &[],
385        returns: "Greeting",
386        cli: None,
387    },
388    ActionSpec {
389        name: "scaffold_intent",
390        description: "Collect scaffold setup intent through MCP elicitation and return JSON for the scaffold-project skill.",
391        required_scope: Some(READ_SCOPE),
392        transport: ActionTransport::McpOnly,
393        rest_method: None,
394        rest_path: None,
395        destructive: false,
396        requires_admin: false,
397        cost: ActionCost::Moderate,
398        params: &[],
399        returns: "ScaffoldIntentReport",
400        cli: None,
401    },
402    ActionSpec {
403        name: "help",
404        description: "Show the action reference.",
405        required_scope: None,
406        transport: ActionTransport::Any,
407        rest_method: Some("GET"),
408        rest_path: Some("/v1/help"),
409        destructive: false,
410        requires_admin: false,
411        cost: ActionCost::Cheap,
412        params: &[],
413        returns: "HelpPayload",
414        cli: Some(CliSpec {
415            command: "help",
416            usage: "soma help",
417            flags: &[],
418            description: "Show JSON action reference.",
419        }),
420    },
421];
422
423/// Returns the names of every known action.
424pub fn action_names() -> Vec<&'static str> {
425    ACTION_SPECS.iter().map(|spec| spec.name).collect()
426}
427
428/// Returns whether `action` is a known action name.
429pub fn is_known_action(action: &str) -> bool {
430    ACTION_SPECS.iter().any(|spec| spec.name == action)
431}
432
433/// Returns the names of actions reachable over REST.
434pub fn rest_action_names() -> Vec<&'static str> {
435    ACTION_SPECS
436        .iter()
437        .filter(|spec| spec.transport.rest())
438        .map(|spec| spec.name)
439        .collect()
440}
441
442/// Returns the names of actions that expose a CLI surface.
443pub fn cli_action_names() -> Vec<&'static str> {
444    ACTION_SPECS
445        .iter()
446        .filter(|spec| spec.cli.is_some())
447        .map(|spec| spec.name)
448        .collect()
449}
450
451/// Returns the CLI subcommand names for every action that has one.
452pub fn cli_commands() -> Vec<&'static str> {
453    ACTION_SPECS
454        .iter()
455        .filter_map(|spec| spec.cli.map(|cli| cli.command))
456        .collect()
457}
458
459/// Returns whether `action` is reachable over REST.
460pub fn is_rest_action(action: &str) -> bool {
461    action_spec(action)
462        .map(|spec| spec.transport.rest())
463        .unwrap_or(false)
464}
465
466/// Returns the names of actions that are MCP-only.
467pub fn mcp_only_action_names() -> Vec<&'static str> {
468    ACTION_SPECS
469        .iter()
470        .filter(|spec| spec.transport == ActionTransport::McpOnly)
471        .map(|spec| spec.name)
472        .collect()
473}
474
475/// Returns the scope required to invoke `action`.
476///
477/// Unknown actions return [`DENY_SCOPE`] so they fail closed.
478pub fn required_scope_for_action(action: &str) -> Option<&'static str> {
479    action_spec(action)
480        .map(|spec| spec.required_scope)
481        .unwrap_or(Some(DENY_SCOPE))
482}
483
484/// Looks up the [`ActionSpec`] for `action`, if it exists.
485pub fn action_spec(action: &str) -> Option<&'static ActionSpec> {
486    ACTION_SPECS.iter().find(|spec| spec.name == action)
487}
488
489/// Confirmation gate for destructive actions, shared by every surface.
490///
491/// Returns `Err` with a structured validation error when `action` is marked
492/// `destructive` in [`ACTION_SPECS`] and the caller did not pass
493/// `"confirm": true` in `params`. Non-destructive actions (and unknown actions,
494/// which fail later in scope/dispatch) always pass. Cheap and side-effect free —
495/// call it on every dispatch so a future destructive action is gated by default
496/// rather than by remembering to add a check.
497pub fn require_confirmation_if_destructive(
498    action: &str,
499    params: &Value,
500) -> Result<(), Box<crate::errors::ToolError>> {
501    let Some(spec) = action_spec(action) else {
502        return Ok(());
503    };
504    if !spec.destructive {
505        return Ok(());
506    }
507    let confirmed = params
508        .get("confirm")
509        .and_then(Value::as_bool)
510        .unwrap_or(false);
511    if confirmed {
512        return Ok(());
513    }
514    // Boxed because ToolError is a large struct and this is a hot, mostly-Ok path.
515    Err(Box::new(
516        crate::errors::ToolError::validation(
517            "confirmation_required",
518            format!("action '{action}' is destructive and requires \"confirm\": true"),
519            "Re-send the request with \"confirm\": true to proceed.",
520        )
521        .with_field("confirm"),
522    ))
523}
524
525/// Serializable flags describing which surfaces expose an action.
526#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
527pub struct SurfaceAvailability {
528    /// Reachable over MCP.
529    pub mcp: bool,
530    /// Reachable over the CLI.
531    pub cli: bool,
532    /// Reachable over REST.
533    pub rest: bool,
534    /// Reachable from the web UI.
535    pub web_ui: bool,
536}
537
538/// Serializable documentation for a single action parameter.
539#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
540pub struct ParamDoc {
541    /// Parameter name.
542    pub name: String,
543    /// JSON type of the parameter.
544    pub ty: String,
545    /// Whether the parameter is required.
546    pub required: bool,
547    /// Human-readable description of the parameter.
548    pub description: String,
549    /// Maximum allowed length, when length-bounded.
550    pub max_len: Option<usize>,
551    /// Permitted values when the parameter is an enum.
552    pub enum_values: Vec<String>,
553}
554
555/// Serializable, catalog-facing documentation for a single action.
556#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
557pub struct ActionDoc {
558    /// Owning service name (always `"soma"`).
559    pub service: String,
560    /// Action name.
561    pub action: String,
562    /// Human-readable description of the action.
563    pub description: String,
564    /// Whether the action is destructive.
565    pub destructive: bool,
566    /// Whether the action requires admin privileges.
567    pub requires_admin: bool,
568    /// Advertised cost tier label.
569    pub cost: String,
570    /// Scope required to invoke the action, or `None` if public.
571    pub required_scope: Option<String>,
572    /// Parameter documentation.
573    pub params: Vec<ParamDoc>,
574    /// Name of the returned result type.
575    pub returns: String,
576    /// Which surfaces expose the action.
577    pub surface_availability: SurfaceAvailability,
578    /// Human-readable summary of the action's auth requirements.
579    pub auth_posture: String,
580    /// Explanation of why the action is MCP-only, when applicable.
581    pub mcp_only_exception: Option<String>,
582    /// CLI documentation for the action, if it has a CLI surface.
583    pub cli: Option<CliDoc>,
584}
585
586/// Serializable documentation for a single CLI flag.
587#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
588pub struct CliFlagDoc {
589    /// Flag name.
590    pub name: String,
591    /// Metavariable for the flag's value, if it takes one.
592    pub value_name: Option<String>,
593    /// Whether the flag is required.
594    pub required: bool,
595    /// Human-readable description of the flag.
596    pub description: String,
597}
598
599/// Serializable documentation for an action's CLI surface.
600#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
601pub struct CliDoc {
602    /// Subcommand name.
603    pub command: String,
604    /// Usage string shown in help.
605    pub usage: String,
606    /// Human-readable description of the subcommand.
607    pub description: String,
608    /// Flags accepted by the subcommand.
609    pub flags: Vec<CliFlagDoc>,
610}
611
612/// Builds the serializable action catalog from [`ACTION_SPECS`].
613pub fn action_catalog() -> Vec<ActionDoc> {
614    ACTION_SPECS
615        .iter()
616        .map(|spec| ActionDoc {
617            service: "soma".to_owned(),
618            action: spec.name.to_owned(),
619            description: spec.description.to_owned(),
620            destructive: spec.destructive,
621            requires_admin: spec.requires_admin,
622            cost: spec.cost.as_str().to_owned(),
623            required_scope: spec.required_scope.map(ToOwned::to_owned),
624            params: spec
625                .params
626                .iter()
627                .map(|param| ParamDoc {
628                    name: param.name.to_owned(),
629                    ty: param.ty.to_owned(),
630                    required: param.required,
631                    description: param.description.to_owned(),
632                    max_len: param.max_len,
633                    enum_values: param
634                        .enum_values
635                        .iter()
636                        .map(|value| (*value).to_owned())
637                        .collect(),
638                })
639                .collect(),
640            returns: spec.returns.to_owned(),
641            surface_availability: SurfaceAvailability {
642                mcp: spec.transport.mcp(),
643                cli: spec.transport.cli(),
644                rest: spec.transport.rest(),
645                web_ui: false,
646            },
647            auth_posture: match spec.required_scope {
648                Some(scope) => format!("requires `{scope}` on authenticated transports"),
649                None => "public action; no action scope required".to_owned(),
650            },
651            mcp_only_exception: (spec.transport == ActionTransport::McpOnly)
652                .then(|| "MCP-only because it requires client-rendered elicitation.".to_owned()),
653            cli: spec.cli.map(|cli| CliDoc {
654                command: cli.command.to_owned(),
655                usage: cli.usage.to_owned(),
656                description: cli.description.to_owned(),
657                flags: cli
658                    .flags
659                    .iter()
660                    .map(|flag| CliFlagDoc {
661                        name: flag.name.to_owned(),
662                        value_name: flag.value_name.map(ToOwned::to_owned),
663                        required: flag.required,
664                        description: flag.description.to_owned(),
665                    })
666                    .collect(),
667            }),
668        })
669        .collect()
670}
671
672/// A parsed, validated action request ready for dispatch.
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum SomaAction {
675    /// Return a greeting, optionally addressed to `name`.
676    Greet {
677        /// Name to greet; greets the world when absent.
678        name: Option<String>,
679    },
680    /// Echo `message` back unchanged.
681    Echo {
682        /// Message to echo back.
683        message: String,
684    },
685    /// Return server status and configuration info.
686    Status,
687    /// Inspect immutable Python environment cache state.
688    PythonEnvironmentStatus,
689    /// Plan a bounded prune of stale non-ready Python environment entries.
690    PythonEnvironmentPrunePlan {
691        /// Oldest modification timestamp selected by the plan.
692        stale_before_unix_seconds: u64,
693        /// Maximum number of entries included in the plan.
694        max_entries: usize,
695    },
696    /// Apply a bounded prune of stale non-ready Python environment entries.
697    PythonEnvironmentPrune {
698        /// Oldest modification timestamp selected by the plan.
699        stale_before_unix_seconds: u64,
700        /// Maximum number of entries removed.
701        max_entries: usize,
702    },
703    /// Repair one managed Python provider environment.
704    PythonEnvironmentRepair {
705        /// Provider path relative to the configured provider root, or an absolute managed path.
706        provider_path: String,
707    },
708    /// Prepare and atomically activate one managed Python provider update.
709    PythonEnvironmentUpdate {
710        /// Provider path relative to the configured provider root, or an absolute managed path.
711        provider_path: String,
712    },
713    /// Inspect persistent Python worker state and bounded redacted logs.
714    PythonWorkerStatus,
715    /// Cancel one active persistent Python invocation.
716    PythonWorkerCancel {
717        /// Loaded persistent Python provider name.
718        provider: String,
719    },
720    /// Clear one persistent Python worker crash-loop quarantine.
721    PythonWorkerReset {
722        /// Loaded persistent Python provider name.
723        provider: String,
724    },
725    /// Inspect active and retained Python provider generations.
726    PythonGenerationStatus,
727    /// Atomically reactivate one retained generation.
728    PythonGenerationRollback {
729        /// Retained generation identifier.
730        generation_id: u64,
731    },
732    /// Inspect one Python graduation workspace.
733    PythonGraduationStatus {
734        /// Absolute graduation workspace path.
735        workspace: String,
736    },
737    /// Run one confirmed Python graduation mutation.
738    PythonGraduationApply {
739        /// Operation name.
740        operation: String,
741        /// Absolute graduation workspace path.
742        workspace: String,
743        /// Optional Python source path.
744        source: Option<String>,
745        /// Optional component path.
746        component: Option<String>,
747        /// Optional fixture corpus path.
748        fixtures: Option<String>,
749        /// Optional directory containing authenticated pure-Python wheels.
750        wheelhouse: Option<String>,
751    },
752    /// Show the action reference.
753    Help,
754    /// Ask the MCP client to collect a name, then greet it (MCP-only).
755    ElicitName,
756    /// Collect scaffold setup intent via MCP elicitation (MCP-only).
757    ScaffoldIntent,
758}
759
760impl SomaAction {
761    /// Returns the canonical action name for this variant.
762    pub fn name(&self) -> &'static str {
763        match self {
764            Self::Greet { .. } => "greet",
765            Self::Echo { .. } => "echo",
766            Self::Status => "status",
767            Self::PythonEnvironmentStatus => "python_environment_status",
768            Self::PythonEnvironmentPrunePlan { .. } => "python_environment_prune_plan",
769            Self::PythonEnvironmentPrune { .. } => "python_environment_prune",
770            Self::PythonEnvironmentRepair { .. } => "python_environment_repair",
771            Self::PythonEnvironmentUpdate { .. } => "python_environment_update",
772            Self::PythonWorkerStatus => "python_worker_status",
773            Self::PythonWorkerCancel { .. } => "python_worker_cancel",
774            Self::PythonWorkerReset { .. } => "python_worker_reset",
775            Self::PythonGenerationStatus => "python_generation_status",
776            Self::PythonGenerationRollback { .. } => "python_generation_rollback",
777            Self::PythonGraduationStatus { .. } => "python_graduation_status",
778            Self::PythonGraduationApply { .. } => "python_graduation_apply",
779            Self::Help => "help",
780            Self::ElicitName => "elicit_name",
781            Self::ScaffoldIntent => "scaffold_intent",
782        }
783    }
784
785    /// Parses an action from an MCP tool-call argument object.
786    pub fn from_mcp_args(args: &Value) -> anyhow::Result<Self> {
787        let action = match args.get("action") {
788            None => return Err(action_error(ValidationError::MissingAction)),
789            Some(Value::String(action)) => action.as_str(),
790            Some(_) => {
791                return Err(action_error(ValidationError::WrongType {
792                    field: "action".into(),
793                }));
794            }
795        };
796        Self::from_params(action, args)
797    }
798
799    /// Parses an action from a REST request, rejecting MCP-only actions.
800    pub fn from_rest(action: &str, params: &Value) -> anyhow::Result<Self> {
801        if action.is_empty() {
802            return Err(action_error(ValidationError::MissingAction));
803        }
804        if action_spec(action)
805            .map(|spec| spec.transport == ActionTransport::McpOnly)
806            .unwrap_or(false)
807        {
808            return Err(action_error(ValidationError::NotAvailableOverRest {
809                action: action.to_owned(),
810            }));
811        }
812        Self::from_params(action, params)
813    }
814
815    fn from_params(action: &str, params: &Value) -> anyhow::Result<Self> {
816        match action {
817            "greet" => Ok(Self::Greet {
818                name: optional_string_param(params, "name")?,
819            }),
820            "echo" => {
821                let message = optional_string_param(params, "message")?
822                    .filter(|m| !m.is_empty())
823                    .ok_or_else(|| {
824                        action_error(ValidationError::MissingField {
825                            field: "message".into(),
826                        })
827                    })?;
828                Ok(Self::Echo { message })
829            }
830            "status" => Ok(Self::Status),
831            "python_environment_status" => Ok(Self::PythonEnvironmentStatus),
832            "python_environment_prune_plan" => Ok(Self::PythonEnvironmentPrunePlan {
833                stale_before_unix_seconds: required_u64_param(params, "stale_before_unix_seconds")?,
834                max_entries: bounded_entries(params)?,
835            }),
836            "python_environment_prune" => Ok(Self::PythonEnvironmentPrune {
837                stale_before_unix_seconds: required_u64_param(params, "stale_before_unix_seconds")?,
838                max_entries: bounded_entries(params)?,
839            }),
840            "python_environment_repair" => Ok(Self::PythonEnvironmentRepair {
841                provider_path: required_string_param(params, "provider_path")?,
842            }),
843            "python_environment_update" => Ok(Self::PythonEnvironmentUpdate {
844                provider_path: required_string_param(params, "provider_path")?,
845            }),
846            "python_worker_status" => Ok(Self::PythonWorkerStatus),
847            "python_worker_cancel" => Ok(Self::PythonWorkerCancel {
848                provider: required_string_param(params, "provider")?,
849            }),
850            "python_worker_reset" => Ok(Self::PythonWorkerReset {
851                provider: required_string_param(params, "provider")?,
852            }),
853            "python_generation_status" => Ok(Self::PythonGenerationStatus),
854            "python_generation_rollback" => Ok(Self::PythonGenerationRollback {
855                generation_id: required_u64_param(params, "generation_id")?,
856            }),
857            "python_graduation_status" => Ok(Self::PythonGraduationStatus {
858                workspace: required_string_param(params, "workspace")?,
859            }),
860            "python_graduation_apply" => Ok(Self::PythonGraduationApply {
861                operation: required_string_param(params, "operation")?,
862                workspace: required_string_param(params, "workspace")?,
863                source: optional_string_param(params, "source")?,
864                component: optional_string_param(params, "component")?,
865                fixtures: optional_string_param(params, "fixtures")?,
866                wheelhouse: optional_string_param(params, "wheelhouse")?,
867            }),
868            "help" => Ok(Self::Help),
869            "elicit_name" => Ok(Self::ElicitName),
870            "scaffold_intent" => Ok(Self::ScaffoldIntent),
871            other => Err(action_error(ValidationError::UnknownAction {
872                action: other.to_owned(),
873            })),
874        }
875    }
876}
877
878fn optional_string_param(params: &Value, name: &str) -> Result<Option<String>, ValidationError> {
879    match params.get(name) {
880        None => Ok(None),
881        Some(value) => value
882            .as_str()
883            .map(|s| Some(s.to_owned()))
884            .ok_or_else(|| ValidationError::WrongType { field: name.into() }),
885    }
886}
887
888fn required_string_param(params: &Value, name: &str) -> Result<String, ValidationError> {
889    optional_string_param(params, name)?
890        .filter(|value| !value.is_empty())
891        .ok_or_else(|| ValidationError::MissingField { field: name.into() })
892}
893
894fn required_u64_param(params: &Value, name: &str) -> Result<u64, ValidationError> {
895    params
896        .get(name)
897        .and_then(Value::as_u64)
898        .ok_or_else(|| ValidationError::WrongType { field: name.into() })
899}
900
901fn bounded_entries(params: &Value) -> Result<usize, ValidationError> {
902    let entries = params.get("max_entries").map_or(Ok(100_u64), |value| {
903        value.as_u64().ok_or_else(|| ValidationError::WrongType {
904            field: "max_entries".into(),
905        })
906    })?;
907    if !(1..=1000).contains(&entries) {
908        return Err(ValidationError::WrongType {
909            field: "max_entries".into(),
910        });
911    }
912    Ok(entries as usize)
913}
914
915fn action_error(error: ValidationError) -> anyhow::Error {
916    error.into()
917}
918
919/// Extracts an [`ActionValidationError`] from a boxed [`anyhow::Error`], if present.
920pub fn action_validation_error(error: &anyhow::Error) -> Option<&ActionValidationError> {
921    if let Some(error) = error.downcast_ref::<ActionError>() {
922        return error.as_validation();
923    }
924    error.downcast_ref::<ActionValidationError>()
925}
926
927/// Returns whether `error` is (or wraps) an action validation error.
928pub fn is_validation_error(error: &anyhow::Error) -> bool {
929    action_validation_error(error).is_some()
930}
931
932// ── Module-placement rationale ───────────────────────────────────────────────
933//
934// This module lives in soma-domain rather than soma-application (plan
935// section 6.2 nominally assigns "product use-case request/results" to
936// soma-application) even though PR 19 folded the legacy soma-service crate's
937// business layer (`SomaService`, the static-Rust provider catalog) directly
938// into soma-application, which would no longer create a cross-crate cycle if
939// this table moved there too. It stays in soma-domain because ACTION_SPECS is
940// an invariant product contract (action names, scopes, transport
941// availability), not application orchestration logic, and every consumer
942// (application, api, cli, mcp, integrations, runtime, apps/soma, xtask) can
943// depend on soma-domain without cycles regardless of what else changes inside
944// soma-application.
945
946#[cfg(test)]
947#[path = "actions_tests.rs"]
948mod tests;