# Section 2: node_ops Integration

## Overview

This section modifies `src/godotiq/tools/bridge/node_ops.py` to accept an optional `scene_data` parameter and run spatial validation on operations that include `validate: true`. The validation is atomic: if any single operation is BLOCKED, the entire batch is rejected and no bridge call is made.

**Depends on:** Section 01 (spatial_validator module must exist at `src/godotiq/tools/bridge/spatial_validator.py` with `validate_move` and `validate_scale` functions).

**Blocks:** Section 03 (MCP wrapper), Section 05 (verification).

## File to Modify

`src/godotiq/tools/bridge/node_ops.py`

## Tests (Write First)

Add the following integration tests to `tests/test_bridge/test_spatial_validator.py`. These tests validate the node_ops integration behavior and should be appended after the Section 01 validator tests in the same file. All tests mock the bridge to isolate validation logic.

The tests use this mocking pattern:

```python
from unittest.mock import AsyncMock, patch
from godotiq.tools.bridge.node_ops import node_ops

mock_bridge = AsyncMock()
mock_bridge.request.return_value = {"results": [{"op": "move", "node": "Shelf_01", "status": "ok"}]}
with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
    result = await node_ops(operations=ops, scene_data=scene_data)
```

### Test class: `TestNodeOpsValidation`

Seven tests are required:

1. **`test_no_validate_flag_bypasses_validation`** -- Operations without `validate: true` in any dict, with `scene_data` provided. Bridge should be called normally. The response should have `"results"` and no `"validation"` key.

2. **`test_no_scene_data_bypasses_validation`** -- Operations with `validate: true` present, but `scene_data=None`. Bridge should be called normally. This ensures backward compatibility when the MCP wrapper cannot resolve scene data.

3. **`test_blocked_prevents_bridge_call`** -- An operation with `validate: true`, `op: "move"`, and a position that would be BLOCKED (e.g., position `[5.0, 0.0, 0.0]` with a wall node at `[5.1, 0.0, 0.0]`). Assert that `bridge.request` is **never called** (`mock_bridge.request.assert_not_called()`). Assert the return dict has `"status": "BLOCKED"` and a `"validation"` key and `"operations_blocked"` count.

4. **`test_warning_allows_bridge_call`** -- An operation with `validate: true`, `op: "move"`, and a position that triggers a WARNING (e.g., 0.3m from a wall). Assert that `bridge.request` **is** called. Assert the return dict has both `"results"` (from bridge) and `"validation"` (with the warning).

5. **`test_mixed_batch_atomic_blocking`** -- Two operations: one with `validate: true` that gets BLOCKED, and one without `validate` that would normally succeed. Assert `bridge.request` is **never called** (atomic: one BLOCKED blocks all). Assert `"operations_blocked"` reflects only the count of blocked validated ops.

6. **`test_add_child_with_position_triggers_validation`** -- An `add_child` operation with `position` and `validate: true`. Verify that `validate_move` is triggered (by constructing scene_data with a wall that would cause BLOCKED). Assert `"status": "BLOCKED"`.

7. **`test_scale_op_triggers_validate_scale`** -- A `scale` operation with `validate: true` and scale values that would trigger a WARNING from `validate_scale` (e.g., scale `[0.01, 0.01, 0.01]` with siblings at scale `[1.0, 1.0, 1.0]`). Assert the result includes `"validation"` and that bridge is called (WARNING does not block).

## Implementation Details

### Modified Function Signature

The `node_ops` function in `node_ops.py` gains an optional `scene_data` parameter:

```python
async def node_ops(operations: list[dict], scene_data: dict | None = None) -> dict:
    """Execute batch node operations with optional spatial validation.

    When scene_data is provided and an operation has validate:true,
    the operation is checked against scene spatial context before execution.
    """
```

### New Import

Add at the top of `node_ops.py`:

```python
from godotiq.tools.bridge.spatial_validator import validate_move, validate_scale
```

### Validation Flow

Insert validation logic **after** the existing parameter checks (empty list / max operations) but **before** the bridge call. The logic is:

1. **Check if validation is needed**: Scan the `operations` list. If `scene_data is None` or no operation has `"validate": True`, skip validation entirely and proceed to the bridge call as before.

2. **Run validation for each flagged operation**: Iterate through operations. For each operation where `op.get("validate")` is truthy:
   - If `op["op"] == "move"` and `"position"` is present: call `validate_move(op["position"], op.get("node", ""), scene_data)`
   - If `op["op"] == "scale"` and `"scale"` is present: call `validate_scale(op["scale"], op.get("node", ""), scene_data)`
   - If `op["op"] == "add_child"` and `"position"` is present: call `validate_move(op["position"], op.get("node", op.get("name", "")), scene_data)`
   - Collect each validation result (the dict returned by the validator) into a `validations` list.

3. **Check for blocks**: Count how many validation results have `severity == "BLOCKED"`. If any exist:
   - Return immediately with:
     ```python
     {
         "status": "BLOCKED",
         "validation": validations,
         "message": "One or more operations blocked by spatial validation. No changes made.",
         "operations_blocked": blocked_count
     }
     ```
   - Do **not** call `bridge.request()`.

4. **All OK or WARNING**: Proceed with the normal bridge call. After getting the bridge response, attach the validation results:
   ```python
   result = await bridge.request("node_ops", params={"operations": operations})
   if validations:
       result["validation"] = validations
   return result
   ```

### Backward Compatibility

The existing function signature `node_ops(operations: list[dict])` is extended with a keyword-only-style optional parameter `scene_data: dict | None = None`. All existing callers that do not pass `scene_data` get `None`, which means validation is skipped and behavior is identical to before this change.

The existing tests in `tests/test_bridge/test_node_ops.py` must continue to pass without modification. Those tests call `node_ops(operations=ops)` without `scene_data`, so they always hit the "skip validation" path.

### Key Design Decisions

- **Atomic batch semantics**: If operation 3 out of 5 is BLOCKED, none of the 5 operations execute. This prevents partial state changes that would be confusing to undo. The `"operations_blocked"` count in the response tells the agent how many ops specifically failed validation (not the total batch size).

- **Validation results always attached when present**: Even when all checks pass as OK, if validation was performed (i.e., `validations` list is non-empty), the list is attached to the response under the `"validation"` key. This gives the agent full visibility into what was checked.

- **No stripping of `validate` key**: The `validate: true` key is passed through to the bridge along with the rest of the operation dict. The Godot addon ignores unknown keys, so this is safe and requires no cleanup.

### Complete Function Structure (Pseudocode)

```
async def node_ops(operations, scene_data=None):
    # Existing checks (empty, max limit)
    ...

    # NEW: Validation
    validations = []
    has_validate = scene_data is not None and any(op.get("validate") for op in operations)

    if has_validate:
        for op in operations:
            if not op.get("validate"):
                continue
            result = None
            if op.get("op") == "move" and "position" in op:
                result = validate_move(op["position"], op.get("node", ""), scene_data)
            elif op.get("op") == "scale" and "scale" in op:
                result = validate_scale(op["scale"], op.get("node", ""), scene_data)
            elif op.get("op") == "add_child" and "position" in op:
                result = validate_move(op["position"], op.get("node", op.get("name", "")), scene_data)
            if result is not None:
                validations.append(result)

        blocked_count = sum(1 for v in validations if v.get("severity") == "BLOCKED")
        if blocked_count > 0:
            return {
                "status": "BLOCKED",
                "validation": validations,
                "message": "One or more operations blocked by spatial validation. No changes made.",
                "operations_blocked": blocked_count,
            }

    # Existing bridge call
    bridge = await get_bridge()
    result = await bridge.request("node_ops", params={"operations": operations})

    # Attach validation results if any
    if validations:
        result["validation"] = validations

    return result
```

## Verification Checklist

After implementation:

1. All 7 new integration tests in `TestNodeOpsValidation` pass.
2. All existing tests in `tests/test_bridge/test_node_ops.py` still pass (backward compatibility).
3. The `node_ops` function still raises `ValueError` for empty or oversized operations lists, regardless of `scene_data`.
4. When `scene_data=None`, the function never imports or calls validator functions (lazy evaluation via the `has_validate` guard).
