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