Shared memory and context tools for agentic work.
Code Rooms
// === m1nd-mcp/src/perspective/validation.rs ===
// Theme 9: Input Validation and Parameter Bounds.
// Centralized validation layer called at the top of every handler.
use m1nd_core::error::{M1ndError, M1ndResult};
use super::state::{LockScope, LockScopeConfig, PerspectiveLens, RouteFamily};
// ---------------------------------------------------------------------------
// Validated types (output of validation layer)
/// Validated lens — all fields clamped and normalized.
#[derive(Clone, Debug)]
pub struct ValidatedLens {
pub dimensions: Vec<String>,
pub route_families: Vec<RouteFamily>,
pub xlr: bool,
pub include_ghost_edges: bool,
pub include_structural_holes: bool,
pub top_k: u32,
pub namespaces: Vec<String>,
pub tags: Vec<String>,
pub node_types: Vec<String>,
}
/// Validated pagination parameters.
pub struct ValidatedPagination {
pub page: u32,
pub page_size: u32,
pub total_items: usize,
pub total_pages: u32,
pub offset: usize,
/// Whether page_size was clamped.
pub page_size_clamped: bool,
/// Validated lock scope.
pub struct ValidatedScope {
pub scope_type: LockScope,
pub root_nodes: Vec<String>,
pub radius: Option<u32>,
pub query: Option<String>,
pub path_nodes: Option<Vec<String>>,
/// Validated route reference — exactly one of route_id or route_index.
pub enum ValidatedRouteRef {
ById(String),
ByIndex(u32),
// Known dimensions (for validation)
const VALID_DIMENSIONS: &[&str] = &["structural", "semantic", "temporal", "causal"];
fn valid_dimensions_hint() -> String {
VALID_DIMENSIONS.join(", ")
// Validation functions
/// Validate a PerspectiveLens against the graph.
/// Returns ValidatedLens with all fields clamped and normalized.
///
/// Rules (Theme 9):
/// - dimensions: non-empty, case-insensitive, unknown → reject with valid options.
/// - route_families: empty = all. Case-insensitive via serde.
/// - top_k: clamped to [1, min(requested, graph_node_count)].
/// - namespaces/tags/node_types: empty = all (no filter).
pub fn validate_lens(lens: &PerspectiveLens, graph_node_count: usize) -> M1ndResult<ValidatedLens> {
// Dimensions: must be non-empty
let dimensions: Vec<String> = if lens.dimensions.is_empty() {
VALID_DIMENSIONS.iter().map(|s| s.to_string()).collect()
} else {
let mut normalized = Vec::with_capacity(lens.dimensions.len());
for d in &lens.dimensions {
let lower = d.to_ascii_lowercase();
if !VALID_DIMENSIONS.contains(&lower.as_str()) {
return Err(M1ndError::InvalidParams {
tool: "perspective".into(),
detail: format!(
"unknown dimension '{}'. Valid dimensions: {}. Retry with one of those values or omit `dimensions` to use the default set.",
d,
valid_dimensions_hint()
),
});
normalized.push(lower);
normalized
};
// top_k: clamp to [1, graph_node_count]
let max_k = graph_node_count.max(1) as u32;
let top_k = lens.top_k.max(1).min(max_k);
Ok(ValidatedLens {
dimensions,
route_families: lens.route_families.clone(),
xlr: lens.xlr,
include_ghost_edges: lens.include_ghost_edges,
include_structural_holes: lens.include_structural_holes,
top_k,
namespaces: lens.namespaces.clone(),
tags: lens.tags.clone(),
node_types: lens.node_types.clone(),
})
/// Validate pagination parameters.
/// - page: >= 1. Reject 0 with INVALID_PAGE.
/// - page_size: clamped to [1, 10]. Default 6.
pub fn validate_pagination(
page: u32,
page_size: u32,
total_items: usize,
) -> M1ndResult<ValidatedPagination> {
if page == 0 {
detail: "invalid page `0`. Pages start at `1`. Retry with `page=1`, or inspect `total_pages` from a prior `perspective_routes` response.".into(),
let clamped_size = page_size.clamp(1, 10);
let page_size_clamped = clamped_size != page_size;
let total_pages = if total_items == 0 {
1
(total_items as u32).div_ceil(clamped_size)
let safe_page = page.min(total_pages);
let offset = ((safe_page - 1) * clamped_size) as usize;
Ok(ValidatedPagination {
page: safe_page,
page_size: clamped_size,
total_items,
total_pages,
offset,
page_size_clamped,
/// Validate a lock scope configuration.
/// - root_nodes: non-empty. Each resolved against graph.
/// - radius: subgraph min 1, max 4. Node scope: exactly 0.
/// - path: consecutive pairs must have direct edges (validated at handler time, not here).
pub fn validate_lock_scope(
scope: &LockScopeConfig,
known_nodes: &[String],
) -> M1ndResult<ValidatedScope> {
// root_nodes: non-empty
if scope.root_nodes.is_empty() {
tool: "lock.create".into(),
detail: "root_nodes must be non-empty".into(),
// Validate root nodes exist in graph
let mut invalid_roots = Vec::new();
for root in &scope.root_nodes {
if !known_nodes.contains(root) {
invalid_roots.push(root.clone());
if !invalid_roots.is_empty() {
detail: format!("unknown root nodes: {:?}", invalid_roots),
// Radius validation
let radius = match scope.scope_type {
LockScope::Subgraph => {
let r = scope.radius.unwrap_or(2);
if !(1..=4).contains(&r) {
detail: format!("subgraph radius must be 1-4, got {}", r),
Some(r)
LockScope::Node => Some(0),
LockScope::QueryNeighborhood => {
if scope.query.is_none() {
detail: "query_neighborhood scope requires a query".into(),
None
LockScope::Path => {
if scope.path_nodes.as_ref().is_none_or(|p| p.is_empty()) {
detail: "path scope requires non-empty path_nodes".into(),
Ok(ValidatedScope {
scope_type: scope.scope_type.clone(),
root_nodes: scope.root_nodes.clone(),
radius,
query: scope.query.clone(),
path_nodes: scope.path_nodes.clone(),
/// Validate a route reference (exactly one of route_id or route_index).
/// Returns AMBIGUOUS_ROUTE_REF if both provided, MISSING_ROUTE_REF if neither.
pub fn validate_route_ref(
route_id: &Option<String>,
route_index: &Option<u32>,
tool: &str,
) -> M1ndResult<ValidatedRouteRef> {
match (route_id, route_index) {
(Some(id), None) => Ok(ValidatedRouteRef::ById(id.clone())),
(None, Some(idx)) => {
if *idx == 0 {
tool: tool.into(),
detail: "invalid `route_index=0`. Route indexes are 1-based. Use the `route_index` returned by `perspective_routes`, or pass `route_id` instead.".into(),
Ok(ValidatedRouteRef::ByIndex(*idx))
(Some(_), Some(_)) => Err(M1ndError::InvalidParams {
detail: "provide exactly one of `route_id` or `route_index`, not both. Use `perspective_routes` first if you need to discover the current route refs.".into(),
}),
(None, None) => Err(M1ndError::InvalidParams {
detail: "provide either `route_id` or `route_index`. Use `perspective_routes` to list the current page of routes first.".into(),
// Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_lens_defaults() {
let lens = PerspectiveLens::default();
let result = validate_lens(&lens, 100).unwrap();
assert_eq!(result.dimensions.len(), 4);
assert_eq!(result.top_k, 8);
fn validate_lens_rejects_unknown_dimension() {
let lens = PerspectiveLens {
dimensions: vec!["structural".into(), "magic".into()],
..PerspectiveLens::default()
let result = validate_lens(&lens, 100);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unknown dimension 'magic'"));
assert!(err.contains("Valid dimensions: structural, semantic, temporal, causal"));
assert!(err.contains("omit `dimensions`"));
fn validate_lens_normalizes_case() {
dimensions: vec!["STRUCTURAL".into(), "Semantic".into()],
assert_eq!(result.dimensions, vec!["structural", "semantic"]);
fn validate_lens_clamps_top_k() {
top_k: 1000,
let result = validate_lens(&lens, 50).unwrap();
assert_eq!(result.top_k, 50);
fn validate_lens_empty_dimensions_defaults_to_all() {
dimensions: Vec::new(),
fn validate_pagination_rejects_page_zero() {
let result = validate_pagination(0, 6, 20);
assert!(err.contains("invalid page `0`"));
assert!(err.contains("Pages start at `1`"));
assert!(err.contains("perspective_routes"));
fn validate_pagination_clamps_page_size() {
let result = validate_pagination(1, 50, 20).unwrap();
assert_eq!(result.page_size, 10);
assert!(result.page_size_clamped);
fn validate_pagination_correct_total_pages() {
let result = validate_pagination(1, 6, 20).unwrap();
assert_eq!(result.total_pages, 4); // ceil(20/6) = 4
fn validate_pagination_clamps_page_to_max() {
let result = validate_pagination(100, 6, 20).unwrap();
assert_eq!(result.page, 4); // max page
fn validate_lock_scope_rejects_empty_roots() {
let scope = LockScopeConfig {
scope_type: LockScope::Node,
root_nodes: Vec::new(),
radius: None,
query: None,
path_nodes: None,
let result = validate_lock_scope(&scope, &["a".into()]);
fn validate_lock_scope_rejects_invalid_radius() {
scope_type: LockScope::Subgraph,
root_nodes: vec!["a".into()],
radius: Some(10),
fn validate_route_ref_rejects_both() {
let result = validate_route_ref(&Some("R_abc".into()), &Some(1), "test");
assert!(err.contains("provide exactly one of `route_id` or `route_index`"));
fn validate_route_ref_rejects_neither() {
let result = validate_route_ref(&None, &None, "test");
assert!(err.contains("provide either `route_id` or `route_index`"));
fn validate_route_ref_rejects_zero_index() {
let result = validate_route_ref(&None, &Some(0), "test");
assert!(err.contains("invalid `route_index=0`"));
assert!(err.contains("1-based"));
fn validate_route_ref_accepts_id() {
let result = validate_route_ref(&Some("R_abc".into()), &None, "test").unwrap();
matches!(result, ValidatedRouteRef::ById(_));
fn validate_route_ref_accepts_index() {
let result = validate_route_ref(&None, &Some(3), "test").unwrap();
matches!(result, ValidatedRouteRef::ByIndex(3));