Now I have all the context needed. Let me generate the section content.

# Section 1: Spatial Validator Module

## Overview

This section creates a new pure Python module at `src/godotiq/tools/bridge/spatial_validator.py` containing spatial validation functions. The module is entirely standalone -- it takes plain dicts as input and returns plain dicts as output. It has no imports from the session layer, bridge layer, or MCP framework, making it fully testable in isolation without any mocking.

The validator implements four spatial checks: wall proximity, node overlap, sibling outlier distance, and scale outlier. These checks protect agents from making invalid spatial modifications (e.g., moving a shelf inside a wall).

## Dependencies

- **No dependencies on other sections.** This section can be implemented first.
- Blocked by nothing. Blocks section-02 (node_ops integration) and section-05 (verification).

## Files to Create

- `src/godotiq/tools/bridge/spatial_validator.py` -- the validator module
- `tests/test_bridge/test_spatial_validator.py` -- tests (this section covers ONLY the validator tests; integration tests are in section-02)

## Tests (Write First)

Create `tests/test_bridge/test_spatial_validator.py`. All validator tests are synchronous (no async needed) since the validator is pure Python with no I/O.

The test file should import:
```python
from godotiq.tools.bridge.spatial_validator import validate_move, validate_scale, _get_name_prefix
```

Use a helper to build scene data dicts:
```python
def _scene_data(nodes):
    return {"nodes": nodes}
```

### _get_name_prefix tests

```python
class TestGetNamePrefix:
    # Test: "Shelf_01" -> "Shelf_"
    # Test: "Shelf_02" -> "Shelf_" (same prefix as 01)
    # Test: "PrinterSlot_P3" -> "PrinterSlot_" (3-char alpha suffix)
    # Test: "Wall_East" -> "" (4-char suffix exceeds max 3)
    # Test: "MainCamera" -> "" (no underscore pattern)
```

The prefix extraction logic: find the last underscore in the name. If the substring after that underscore is 1-3 characters long (numeric or alpha), the prefix is everything up to and including the underscore. Otherwise, return `""`.

### validate_move tests

```python
class TestValidateMove:
    # Test: empty scene data returns OK severity
    #   Input: any position, {"nodes": []}
    #   Expected: severity == "OK", valid == True

    # Test: position with fewer than 3 values returns BLOCKED
    #   Input: position=[1], node_name="Foo", scene_data with any nodes
    #   Expected: severity == "BLOCKED", valid == False

    # Test: position far from all walls returns OK with valid=True
    #   Input: position=[10, 0, 10], scene with a StaticBody3D wall at [0, 0, 0]
    #   Expected: severity == "OK", valid == True

    # Test: position < 0.2m from StaticBody3D returns BLOCKED, message mentions wall name
    #   Input: position=[0.1, 0, 0], wall node "Wall_East" type "StaticBody3D" at [0, 0, 0]
    #   Expected: severity == "BLOCKED", valid == False, "Wall_East" in message

    # Test: position 0.2-0.5m from wall returns WARNING
    #   Input: position=[0.3, 0, 0], wall at [0, 0, 0]
    #   Expected: severity == "WARNING"

    # Test: position same as self node returns OK (self-exclusion)
    #   Input: node_name="Shelf_01", position=[5, 0, 0]
    #          scene has node "Shelf_01" at [5, 0, 0] (same depth)
    #   Expected: OK (the validator must exclude the node being moved from all checks)

    # Test: position < 0.05m from same-depth node returns BLOCKED with "overlap" in message
    #   Input: position=[1.01, 0, 0], other node "Box" at [1.0, 0, 0], both at depth=1
    #   Expected: severity == "BLOCKED", "overlap" in result message (case-insensitive)

    # Test: position same as node at different depth returns OK (depth filtering)
    #   Input: position=[1, 0, 0], other node at [1, 0, 0] but different depth
    #   Expected: OK (overlap check only compares same-depth nodes)

    # Test: position 50m from Shelf_01-03 siblings returns WARNING with "sibling" in message
    #   Input: node "Shelf_04", position=[50, 0, 0]
    #          siblings: Shelf_01 at [1,0,0], Shelf_02 at [2,0,0], Shelf_03 at [3,0,0]
    #   Expected: severity == "WARNING", "sibling" in message (case-insensitive)

    # Test: wall at Y=0, target at Y=5 -> OK (Y-axis sanity check skips wall)
    #   Input: position=[0.1, 5, 0], wall at [0, 0, 0]
    #   Expected: OK (Y difference > 3m, wall pair skipped)

    # Test: wall identified by name containing "Wall" (not just StaticBody type)
    #   Input: position=[0.1, 0, 0], node named "Wall_East" type "Node3D" at [0, 0, 0]
    #   Expected: BLOCKED (name-based wall detection)

    # Test: wall identified by name containing "Floor" or "Ceiling" or "Ground"
    #   Input: position=[0.1, 0, 0], node named "Floor" type "Node3D" at [0, 0, 0]
    #   Expected: BLOCKED
```

### validate_scale tests

```python
class TestValidateScale:
    # Test: scale matching siblings returns OK
    #   Input: node "Shelf_04", scale=[1.0, 1.0, 1.0]
    #          siblings Shelf_01-03 all with scale [1.0, 1.0, 1.0]
    #   Expected: severity == "OK"

    # Test: scale 40x smaller than siblings returns WARNING
    #   Input: node "Shelf_04", scale=[0.025, 0.025, 0.025]
    #          siblings Shelf_01-03 all with scale [1.0, 1.0, 1.0]
    #   Expected: severity == "WARNING"

    # Test: unique node name with no siblings returns OK
    #   Input: node "MainCamera", scale=[5.0, 5.0, 5.0], no matching prefix siblings
    #   Expected: OK (no siblings to compare against)

    # Test: scale with fewer than 3 values returns BLOCKED
    #   Input: scale=[1.0] (less than 3 values)
    #   Expected: severity == "BLOCKED"
```

### _extract_nodes helper tests

```python
class TestExtractNodes:
    # Test: extracts nodes from "nodes" key
    # Test: returns empty list for empty dict
    # Test: handles nodes with full field names (name, type, position, scale)
```

Import `_extract_nodes` from the module if exposed, or test indirectly through `validate_move`.

## Implementation Details

### File: `src/godotiq/tools/bridge/spatial_validator.py`

#### Module-Level Constants

Define tunable threshold constants at module level:

```python
WALL_BLOCKED_DISTANCE = 0.2    # meters, XZ distance -> BLOCKED
WALL_WARNING_DISTANCE = 0.5    # meters, XZ distance -> WARNING
WALL_Y_SKIP_THRESHOLD = 3.0   # meters, Y difference to skip wall pair
OVERLAP_DISTANCE = 0.05        # meters, 3D distance -> BLOCKED
SIBLING_OUTLIER_FACTOR = 3.0   # target > factor * avg inter-sibling distance
SIBLING_MIN_DISTANCE = 5.0     # meters, target must also be > this from nearest sibling
SCALE_OUTLIER_FACTOR = 3.0     # target > factor * avg or < 1/factor * avg
NAME_SUFFIX_MAX_LEN = 3        # max suffix length for name prefix extraction
```

#### Wall Detection

A node is considered a "wall" if:
- Its `type` field contains `"StaticBody"` (case-insensitive), OR
- Its `name` field contains any of: `"Wall"`, `"Floor"`, `"Ceiling"`, `"Ground"`, `"Barrier"` (case-insensitive)

Implement as a helper function `_is_wall(node: dict) -> bool`.

#### Name Prefix Extraction

```python
def _get_name_prefix(name: str) -> str:
    """Extract naming prefix for sibling detection.

    Finds the last underscore; if the suffix after it is 1-3 chars, returns
    everything up to and including the underscore. Otherwise returns "".

    Examples: "Shelf_01" -> "Shelf_", "Wall_East" -> "" (4 chars), "MainCamera" -> ""
    """
```

#### Node Extraction

```python
def _extract_nodes(scene_data: dict) -> list[dict]:
    """Extract the nodes list from scene_data dict. Returns [] if missing/empty."""
```

#### Distance Helpers

- **XZ distance**: `math.sqrt((x2-x1)**2 + (z2-z1)**2)` -- used for wall proximity (ignores Y)
- **3D distance**: `math.sqrt((x2-x1)**2 + (y2-y1)**2 + (z2-z1)**2)` -- used for overlap detection

Use the `math` standard library module.

#### validate_move

```python
def validate_move(target_position: list[float], node_name: str, scene_data: dict) -> dict:
    """Validate a move operation against spatial context.

    Returns: {valid: bool, severity: "OK"|"WARNING"|"BLOCKED", message: str,
              suggestion: None, checks: list[dict]}
    """
```

Logic flow:

1. **Input validation**: If `target_position` has fewer than 3 values, return BLOCKED immediately with a clear message.
2. **Extract nodes**: Get node list from `scene_data`. If empty, return OK (no data to validate against).
3. **Check 1 -- Wall Proximity**:
   - Filter nodes to walls (using `_is_wall`)
   - Exclude the node being moved (compare by `name`)
   - For each wall: skip if Y-axis difference > `WALL_Y_SKIP_THRESHOLD`
   - Compute XZ distance to each remaining wall
   - Find the nearest wall and its distance
   - If distance < `WALL_BLOCKED_DISTANCE` -> check severity = BLOCKED
   - If distance < `WALL_WARNING_DISTANCE` -> check severity = WARNING
   - Otherwise -> check severity = OK
   - Build check detail string mentioning distance and nearest wall name
4. **Check 2 -- Node Overlap**:
   - Filter to nodes at the same `depth` as the target (if depth info is available; if no depth field on nodes, skip this filter)
   - Exclude self (by name)
   - Compute 3D distance to each remaining node
   - If any distance < `OVERLAP_DISTANCE` -> check severity = BLOCKED
   - Detail string mentions the overlapping node name
5. **Check 3 -- Sibling Outlier**:
   - Extract name prefix using `_get_name_prefix(node_name)`
   - If prefix is empty, skip (no sibling detection possible) -> check severity = OK
   - Find all nodes with the same prefix (excluding self)
   - If fewer than 2 siblings, skip -> OK
   - Compute average inter-sibling distance (pairwise average of all siblings' positions)
   - Compute distance from target to nearest sibling
   - If target distance > `SIBLING_OUTLIER_FACTOR * avg` AND target distance > `SIBLING_MIN_DISTANCE` from nearest sibling -> WARNING
6. **Aggregate results**:
   - Overall severity = worst severity across all checks (BLOCKED > WARNING > OK)
   - `valid` = True if severity is not BLOCKED
   - `message` = summary string combining check details
   - `suggestion` = None (reserved for v2)
   - `checks` = list of per-check result dicts

#### validate_scale

```python
def validate_scale(target_scale: list[float], node_name: str, scene_data: dict) -> dict:
    """Validate a scale operation against similar nodes in the scene.

    Returns: same shape as validate_move
    """
```

Logic flow:

1. **Input validation**: If `target_scale` has fewer than 3 values, return BLOCKED.
2. **Extract nodes** from scene_data.
3. **Check 4 -- Scale Outlier**:
   - Extract name prefix using `_get_name_prefix(node_name)`
   - If no prefix or fewer than 2 siblings -> OK
   - Compute average X-scale across siblings (using `scale[0]` field)
   - If target X-scale > `SCALE_OUTLIER_FACTOR * avg` or < `avg / SCALE_OUTLIER_FACTOR` -> WARNING
4. Return result dict with the single check.

### Scene Data Format Expected

The validator expects this dict structure (documented here for context; the conversion happens in section-03):

```python
{
    "nodes": [
        {
            "name": "Wall_East",       # str, required
            "type": "StaticBody3D",    # str, required
            "position": [5.0, 0.0, 0.0],  # list of 3 floats, required
            "scale": [1.0, 1.0, 1.0],     # list of 3 floats, optional (needed for scale checks)
            "depth": 1                     # int, optional (needed for overlap depth filtering)
        }
    ]
}
```

### Result Dict Format

Every validation function returns this shape:

```python
{
    "valid": True,           # False if severity is BLOCKED
    "severity": "OK",        # "OK", "WARNING", or "BLOCKED"
    "message": "Position [1.8, 0.0, -0.5] valid. 2.3m from nearest wall (Wall_East); No overlapping nodes",
    "suggestion": None,      # Reserved for v2
    "checks": [
        {"check": "wall_distance", "severity": "OK", "detail": "2.3m from nearest wall (Wall_East)"},
        {"check": "overlap", "severity": "OK", "detail": "No overlapping nodes"},
        {"check": "sibling_distance", "severity": "OK", "detail": "1.2m from nearest sibling Shelf_03"}
    ]
}
```

Check names used: `"wall_distance"`, `"overlap"`, `"sibling_distance"`, `"scale_outlier"`.

## Implementation Notes

- Use only the `math` standard library module. No external dependencies.
- All functions are synchronous (not async). The validator does no I/O.
- The `_get_name_prefix` function should be importable for direct testing (prefixed with underscore but not mangled).
- Severity aggregation: use a simple priority mapping `{"BLOCKED": 2, "WARNING": 1, "OK": 0}` and take the max.
- When computing inter-sibling average distance, use all pairwise distances between sibling positions (not just consecutive pairs).
- The existing `test_bridge/test_node_ops.py` file must NOT be modified. All new tests go in the new `test_spatial_validator.py` file.