---
phase: 07-network-node-fault-injection
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - vmcluster_mcp/config.py
  - vmcluster_mcp/net/fault_registry.py
  - tests/test_fault_registry.py
autonomous: true
requirements:
  - FAULT-07

must_haves:
  truths:
    - "FaultRegistry persists active faults to disk as JSON with atomic tmp-rename writes"
    - "FaultRegistry.load() restores persisted state; returns empty registry if file missing"
    - "FaultRegistry.register() stores a new fault entry keyed by handle_id and saves immediately"
    - "FaultRegistry.remove() deletes a fault entry by handle_id and saves immediately"
    - "FaultRegistry.list_faults() returns all active fault entries for a given cluster"
    - "Startup reconciliation compares persisted entries against live iptables/tc state and prunes stale entries"
    - "Config gains fault_registry field with VMCLUSTER_FAULT_REGISTRY env var support"
  artifacts:
    - path: "vmcluster_mcp/net/fault_registry.py"
      provides: "FaultRegistry class with atomic persistence plus reconcile() method"
      min_lines: 100
    - path: "vmcluster_mcp/config.py"
      provides: "fault_registry config field"
      contains: "fault_registry"
    - path: "tests/test_fault_registry.py"
      provides: "Unit tests for registry CRUD, atomic writes, reconciliation"
      min_lines: 60
  key_links:
    - from: "vmcluster_mcp/net/fault_registry.py"
      to: "vmcluster_mcp/models.py"
      via: "import NetFaultHandle, NetFaultInfo"
      pattern: "from vmcluster_mcp\\.models import.*NetFault"
    - from: "vmcluster_mcp/net/fault_registry.py"
      to: "vmcluster_mcp/artifact/registry.py"
      via: "mirrors atomic write pattern (write-to-.tmp then os.replace)"
      pattern: "os\\.replace"
    - from: "vmcluster_mcp/config.py"
      to: "vmcluster_mcp/net/fault_registry.py"
      via: "config.fault_registry provides registry file path"
      pattern: "fault_registry"
---

<objective>
Create the fault registry core: a disk-backed, handle-keyed registry of active network faults with atomic JSON persistence and startup reconciliation against live iptables/tc state.

Purpose: All other Phase 7 tools depend on the registry to track, list, and clean up active faults — and the registry must survive MCP server restarts per FAULT-07. This plan mirrors the atomic persistence pattern established by `ArtifactRegistry` in Phase 5.

Output: `net/fault_registry.py` with `FaultRegistry` class, updated `config.py` with fault_registry path, comprehensive tests.
</objective>

<execution_context>
@~/.copilot/get-shit-done/workflows/execute-plan.md
@~/.copilot/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-network-node-fault-injection/07-CONTEXT.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From vmcluster_mcp/models.py:
```python
class NetFaultHandle(BaseModel):
    handle_id: str
    fault_type: Literal["partition", "impairment"]
    affected_nodes: list[str]
    created_at: str

class NetFaultInfo(BaseModel):
    handle_id: str
    fault_type: Literal["partition", "impairment"]
    affected_nodes: list[str]
    created_at: str
    parameters: dict[str, str | int | float]

class ToolResult(BaseModel, Generic[T]):
    success: bool
    result: T | None = None
    error: ErrorDetail | None = None
```

From vmcluster_mcp/config.py:
```python
_ENV_MAP = {
    "VMCLUSTER_TOPOLOGY_DIR": "topology_dir",
    "VMCLUSTER_OVERLAY_DIR": "overlay_dir",
    "VMCLUSTER_ARTIFACT_REGISTRY": "artifact_registry",
    "VMCLUSTER_ARTIFACT_STORE_DIR": "artifact_store_dir",
    "VMCLUSTER_SSH_KEY_PATH": "ssh_key_path",
    "VMCLUSTER_SSH_USER": "ssh_user",
    "VMCLUSTER_LIBVIRT_URI": "libvirt_uri",
    "VMCLUSTER_LOG_LEVEL": "log_level",
}

class Config(BaseModel):
    topology_dir: str = "/etc/vmcluster/topologies"
    overlay_dir: str = "/var/lib/vmcluster/overlays"
    artifact_registry: str = "/var/lib/vmcluster/artifacts/registry.json"
    artifact_store_dir: str = "/var/lib/vmcluster/artifacts/trees"
    ssh_key_path: str = "/etc/vmcluster/ssh/vmcluster_id_ed25519"
    ssh_user: str = "root"
    libvirt_uri: str = "qemu:///system"
    log_level: str = "INFO"
```

From vmcluster_mcp/artifact/registry.py (atomic write pattern to mirror):
```python
class ArtifactRegistry:
    def __init__(self, registry_path: str) -> None:
        self._path = registry_path
        self._registry: dict[str, dict] = {}

    def load(self) -> None:
        if os.path.isfile(self._path):
            with open(self._path) as f:
                self._registry = json.load(f)
        else:
            self._registry = {}

    def save(self) -> None:
        os.makedirs(os.path.dirname(self._path), exist_ok=True)
        tmp_path = f"{self._path}.tmp"
        with open(tmp_path, "w") as f:
            json.dump(self._registry, f, indent=2)
        os.replace(tmp_path, self._path)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Config update + FaultRegistry with atomic JSON persistence</name>
  <files>vmcluster_mcp/config.py, vmcluster_mcp/net/fault_registry.py, tests/test_fault_registry.py</files>
  <read_first>
    - vmcluster_mcp/config.py (full file — add fault_registry field + env var mapping)
    - vmcluster_mcp/artifact/registry.py (atomic write pattern — mirror save/load for fault registry)
    - vmcluster_mcp/models.py (NetFaultHandle, NetFaultInfo — reuse, do not redesign per 07-CONTEXT.md)
    - .planning/phases/07-network-node-fault-injection/07-CONTEXT.md (D-03, D-04, D-05)
  </read_first>
  <behavior>
    - Test: Config includes fault_registry field with default "/var/lib/vmcluster/faults/registry.json"
    - Test: VMCLUSTER_FAULT_REGISTRY env var overrides config.fault_registry
    - Test: FaultRegistry.load() returns empty registry when file does not exist
    - Test: FaultRegistry.save() writes JSON via tmp file + os.replace (atomic write per D-03)
    - Test: FaultRegistry.register() stores entry keyed by handle_id with cluster_name, fault_type, affected_nodes, created_at, parameters, rule_identifiers; calls save()
    - Test: FaultRegistry.get(handle_id) retrieves a stored entry; returns None for unknown handle
    - Test: FaultRegistry.remove(handle_id) deletes entry and calls save()
    - Test: FaultRegistry.list_faults(cluster_name) returns only entries matching cluster_name
    - Test: FaultRegistry.list_faults(cluster_name) returns empty list when no faults for cluster
    - Test: Round-trip: register → save → new instance → load → get returns same data
  </behavior>
  <action>
1. **Update `vmcluster_mcp/config.py`:**
   - Add `"VMCLUSTER_FAULT_REGISTRY": "fault_registry"` to `_ENV_MAP`
   - Add `fault_registry: str = "/var/lib/vmcluster/faults/registry.json"` to `Config`

2. **Create `vmcluster_mcp/net/fault_registry.py`:**
   - `from __future__ import annotations`
   - `logger = logging.getLogger("vmcluster_mcp.net")`
   - Internal registry dict keyed by `handle_id`, values are dicts with fields: `cluster_name`, `fault_type`, `affected_nodes`, `created_at`, `parameters`, `rule_identifiers` (D-05 metadata for handle-based healing and restart recovery)
   - `class FaultRegistry`:
     - `__init__(self, registry_path: str)`: Store path, `_registry: dict[str, dict] = {}`
     - `load(self) -> None`: Read JSON if exists, populate `_registry`; if missing, `_registry = {}`
     - `save(self) -> None`: `os.makedirs(parent, exist_ok=True)`, write to `{path}.tmp`, `os.replace()` to `{path}` (D-03 atomic write, mirrors ArtifactRegistry.save)
     - `register(self, handle_id, cluster_name, fault_type, affected_nodes, parameters, rule_identifiers) -> NetFaultHandle`: Build entry dict, store in `_registry[handle_id]`, call `save()`, return `NetFaultHandle`
     - `get(self, handle_id: str) -> dict | None`: Lookup by handle_id
     - `remove(self, handle_id: str) -> bool`: Delete entry if present, call `save()`, return True if removed
     - `list_faults(self, cluster_name: str) -> list[NetFaultInfo]`: Return all entries matching cluster_name as `NetFaultInfo` instances (including parameters)
     - `all_handles(self) -> list[str]`: Return all handle_ids (for reconciliation iteration)

3. **Create `tests/test_fault_registry.py`** — use `tmp_path` fixture for registry file. Test all behaviors above plus round-trip persistence.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_fault_registry.py -x -v && ruff check vmcluster_mcp/net/fault_registry.py vmcluster_mcp/config.py</automated>
  </verify>
  <done>FaultRegistry class with atomic JSON persistence, Config updated with fault_registry field, all tests pass, ruff clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Startup reconciliation against live iptables/tc state</name>
  <files>vmcluster_mcp/net/fault_registry.py, tests/test_fault_registry.py</files>
  <read_first>
    - vmcluster_mcp/net/fault_registry.py (Task 1 output — FaultRegistry class)
    - .planning/phases/07-network-node-fault-injection/07-CONTEXT.md (D-04, D-07)
  </read_first>
  <behavior>
    - Test: reconcile() with partition entry whose iptables comment tag exists in live output → entry retained
    - Test: reconcile() with partition entry whose iptables comment tag is missing → entry pruned from registry
    - Test: reconcile() with impairment entry whose tc qdisc exists on expected interface → entry retained
    - Test: reconcile() with impairment entry whose tc qdisc is missing → entry pruned from registry
    - Test: reconcile() calls save() after pruning stale entries
    - Test: reconcile() with no stale entries does not call save()
    - Test: reconcile() with empty registry is a no-op
  </behavior>
  <action>
1. **Add reconciliation to `FaultRegistry`:**
   - `async def reconcile(self) -> int`: Returns count of stale entries pruned.
     - For each entry in `_registry`:
       - If `fault_type == "partition"`: Run `iptables -L FORWARD -n --line-numbers -v` via `asyncio.create_subprocess_exec`, check stdout for the comment tag `vmcluster:{cluster}:{handle_id}` (D-07). If comment not found, mark entry as stale.
       - If `fault_type == "impairment"`: Run `tc qdisc show dev {interface}` via `asyncio.create_subprocess_exec` for the interface stored in `rule_identifiers`. If the expected netem qdisc is not present, mark entry as stale.
     - Remove all stale entries from `_registry`.
     - If any entries were pruned, call `save()`.
     - Log each pruned entry at WARNING level.
     - Return count of pruned entries.

2. **Add helper** `_run_host_cmd(cmd: list[str]) -> tuple[int, str]`: Run a host command via `asyncio.create_subprocess_exec`, return (exit_code, stdout). Used by both reconcile() and later by fault injection/cleanup functions.

3. **Extend tests** in `tests/test_fault_registry.py` — mock `asyncio.create_subprocess_exec` to simulate iptables/tc output. Test all reconciliation behaviors above.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_fault_registry.py -x -v && ruff check vmcluster_mcp/net/fault_registry.py</automated>
  </verify>
  <done>FaultRegistry.reconcile() validates persisted entries against live kernel state, prunes stale entries atomically, all tests pass.</done>
</task>

</tasks>

<verification>
- `python -m pytest tests/test_fault_registry.py -x -v` — all registry + reconciliation tests pass
- `ruff check vmcluster_mcp/net/fault_registry.py vmcluster_mcp/config.py` — no lint errors
- Config round-trip: `fault_registry` field loaded from env var or YAML
- Registry round-trip: register → restart (new instance + load) → list_faults returns same entries
</verification>

<success_criteria>
- FaultRegistry persists to disk with atomic writes and survives simulated restart
- Reconciliation prunes entries whose iptables/tc rules no longer exist on the host
- Config.fault_registry is configurable via VMCLUSTER_FAULT_REGISTRY env var
- All tests pass, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/07-network-node-fault-injection/07-01-SUMMARY.md`
</output>
