---
phase: 02-action-registry
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/controllers/__init__.py
  - src/ztlctl/controllers/base.py
  - src/ztlctl/controllers/check.py
  - src/ztlctl/controllers/upgrade.py
  - src/ztlctl/controllers/export.py
  - src/ztlctl/controllers/graph.py
  - src/ztlctl/controllers/vector.py
  - src/ztlctl/controllers/reweave.py
  - tests/controllers/__init__.py
  - tests/controllers/test_base.py
  - tests/controllers/test_check.py
  - tests/controllers/test_upgrade.py
autonomous: true
requirements:
  - ACTN-02

must_haves:
  truths:
    - "BaseController accepts a Vault and exposes _vault and _dispatch_event()"
    - "Each controller wraps its corresponding service via lazy local imports"
    - "Controller methods return ServiceResult (same contract as services)"
    - "Controllers construct services per-call, not as instance variables"
  artifacts:
    - path: "src/ztlctl/controllers/base.py"
      provides: "BaseController abstract base class"
      contains: "class BaseController"
    - path: "src/ztlctl/controllers/check.py"
      provides: "CheckController wrapping CheckService"
      exports: ["CheckController"]
    - path: "src/ztlctl/controllers/graph.py"
      provides: "GraphController wrapping GraphService"
      exports: ["GraphController"]
    - path: "tests/controllers/test_check.py"
      provides: "CheckController integration tests"
      min_lines: 30
  key_links:
    - from: "src/ztlctl/controllers/check.py"
      to: "src/ztlctl/services/check.py"
      via: "lazy local import inside methods"
      pattern: "from ztlctl\\.services\\.check import CheckService"
    - from: "src/ztlctl/controllers/base.py"
      to: "src/ztlctl/services/base.py"
      via: "mirrors BaseService structure"
      pattern: "class BaseController"
---

<objective>
Create the controller layer: BaseController and the first batch of 6 controllers (Check, Upgrade, Export, Graph, Vector, Reweave). These are the read-heavy and simpler services — lowest migration risk per the research-recommended order.

Purpose: Controllers are the orchestration layer that wraps service calls. They are the single interface for the registry layer. The `_impl` functions in `mcp/tools.py` are the structural template — controllers formalize that exact pattern.

Output: `src/ztlctl/controllers/` package with BaseController + 6 controllers, plus integration tests for representative controllers.
</objective>

<execution_context>
@/Users/shparki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shparki/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-action-registry/02-CONTEXT.md
@.planning/phases/02-action-registry/02-RESEARCH.md

<interfaces>
<!-- BaseService pattern to mirror -->

From src/ztlctl/services/base.py:
```python
class BaseService:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault

    def _dispatch_event(
        self,
        hook_name: str,
        payload: dict[str, Any],
        warnings: list[str],
        *,
        session_id: str | None = None,
    ) -> int | None:
        bus = self._vault.event_bus
        if bus is None:
            return None
        try:
            return cast(int, bus.dispatch(hook_name, payload, session_id=session_id))
        except Exception:
            logger.debug("Event dispatch failed for %s", hook_name, exc_info=True)
            warnings.append(f"Event dispatch failed for {hook_name}")
            return None
```

From src/ztlctl/services/result.py:
```python
class ServiceResult(BaseModel):
    model_config = {"frozen": True}
    ok: bool
    op: str
    data: dict[str, Any] = Field(default_factory=dict)
    warnings: list[str] = Field(default_factory=list)
    error: ServiceError | None = None
    meta: dict[str, Any] | None = None
```

<!-- Public methods to wrap for each controller -->

CheckService: check(min_severity="warning"), fix(level="safe"), rebuild(), rollback()
UpgradeService: check_pending(), apply(), stamp_current()
ExportService: export_markdown(output_dir, ...), export_indexes(output_dir, ...), export_graph(output_dir, format, ...), export_dashboard(output_dir, ...)
GraphService: related(content_id, depth=1, max_results=20), themes(), rank(top=20), path(source_id, target_id), gaps(top=20), bridges(top=20), unlink(source_id, target_id, both=False), materialize_metrics()
VectorService: reindex_all() (only public method for controller)
ReweaveService: reweave(content_id=None, threshold=0.3, max_links=10, dry_run=False), prune(content_id=None, max_age_days=None, dry_run=False), undo(reweave_id=None)
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create BaseController and read-heavy controllers (Check, Upgrade, Export, Graph, Vector)</name>
  <files>
    src/ztlctl/controllers/__init__.py,
    src/ztlctl/controllers/base.py,
    src/ztlctl/controllers/check.py,
    src/ztlctl/controllers/upgrade.py,
    src/ztlctl/controllers/export.py,
    src/ztlctl/controllers/graph.py,
    src/ztlctl/controllers/vector.py
  </files>
  <read_first>
    src/ztlctl/services/base.py,
    src/ztlctl/services/check.py,
    src/ztlctl/services/upgrade.py,
    src/ztlctl/services/export.py,
    src/ztlctl/services/graph.py,
    src/ztlctl/services/vector.py,
    src/ztlctl/mcp/tools.py
  </read_first>
  <action>
Create `src/ztlctl/controllers/` package.

**BaseController** (`src/ztlctl/controllers/base.py`):
Mirror `BaseService` exactly. Accept `Vault` in `__init__`, store as `self._vault`. Copy `_dispatch_event()` implementation verbatim from `BaseService`. Use `from __future__ import annotations` and `TYPE_CHECKING` guard for Vault import. Include docstring stating the invariant: "All controller methods return ServiceResult. Plugin failures are warnings, never errors."

```python
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
    from ztlctl.infrastructure.vault import Vault
logger = logging.getLogger(__name__)

class BaseController:
    """Abstract base for all controller-layer classes.
    INVARIANT: All controller methods return ServiceResult.
    INVARIANT: Plugin failures are warnings, never errors.
    """
    def __init__(self, vault: Vault) -> None:
        self._vault = vault

    def _dispatch_event(self, hook_name: str, payload: dict[str, Any], warnings: list[str], *, session_id: str | None = None) -> int | None:
        """Dispatch a lifecycle event. No-op if event bus not initialized."""
        bus = self._vault.event_bus
        if bus is None:
            return None
        try:
            return cast(int, bus.dispatch(hook_name, payload, session_id=session_id))
        except Exception:
            logger.debug("Event dispatch failed for %s", hook_name, exc_info=True)
            warnings.append(f"Event dispatch failed for {hook_name}")
            return None
```

**Each controller** follows this exact pattern (using CheckController as example):
```python
from __future__ import annotations
from typing import TYPE_CHECKING
from ztlctl.controllers.base import BaseController
if TYPE_CHECKING:
    from ztlctl.services.result import ServiceResult

class CheckController(BaseController):
    def check(self, *, min_severity: str = "warning") -> ServiceResult:
        from ztlctl.services.check import CheckService
        return CheckService(self._vault).check(min_severity=min_severity)

    def fix(self, *, level: str = "safe") -> ServiceResult:
        from ztlctl.services.check import CheckService
        return CheckService(self._vault).fix(level=level)

    def rebuild(self) -> ServiceResult:
        from ztlctl.services.check import CheckService
        return CheckService(self._vault).rebuild()

    def rollback(self) -> ServiceResult:
        from ztlctl.services.check import CheckService
        return CheckService(self._vault).rollback()
```

Apply the same pattern to all 5 controllers. Match each service's exact public method signatures:

- **UpgradeController**: check_pending(), apply(), stamp_current()
- **ExportController**: export_markdown(output_dir, ...), export_indexes(output_dir, ...), export_graph(output_dir, format, ...), export_dashboard(output_dir, ...)
- **GraphController**: related(content_id, depth=1, max_results=20), themes(), rank(top=20), path(source_id, target_id), gaps(top=20), bridges(top=20), unlink(source_id, target_id, both=False), materialize_metrics()
- **VectorController**: reindex_all()

Read each service file to get exact method signatures including all kwargs and type annotations. Controllers must accept the same parameters and pass them through. Use lazy local imports (`from ztlctl.services.X import XService`) inside each method body — never at module level.

`src/ztlctl/controllers/__init__.py` should re-export all controller classes for convenience.

Do NOT add `@traced` to controller methods — services already have it. Per research open question 2, keeping service spans is the simpler migration path.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run mypy src/ztlctl/controllers/ && uv run ruff check src/ztlctl/controllers/</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/controllers/base.py contains "class BaseController:"
    - src/ztlctl/controllers/base.py contains "def __init__(self, vault: Vault) -> None:"
    - src/ztlctl/controllers/base.py contains "def _dispatch_event("
    - src/ztlctl/controllers/check.py contains "class CheckController(BaseController):"
    - src/ztlctl/controllers/check.py contains "from ztlctl.services.check import CheckService"
    - src/ztlctl/controllers/check.py contains "def check(" and "def fix(" and "def rebuild(" and "def rollback("
    - src/ztlctl/controllers/upgrade.py contains "class UpgradeController(BaseController):"
    - src/ztlctl/controllers/export.py contains "class ExportController(BaseController):"
    - src/ztlctl/controllers/graph.py contains "class GraphController(BaseController):"
    - src/ztlctl/controllers/graph.py contains "def related(" and "def themes(" and "def rank(" and "def path(" and "def gaps(" and "def bridges(" and "def unlink(" and "def materialize_metrics("
    - src/ztlctl/controllers/vector.py contains "class VectorController(BaseController):"
    - All controller methods use lazy local imports (no service imports at module level in controller files)
    - `uv run mypy src/ztlctl/controllers/` exits 0
    - `uv run ruff check src/ztlctl/controllers/` exits 0
  </acceptance_criteria>
  <done>BaseController and 5 controllers (Check, Upgrade, Export, Graph, Vector) exist as thin wrappers over their respective services. All use lazy local imports. mypy and ruff clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create ReweaveController and integration tests for representative controllers</name>
  <files>
    src/ztlctl/controllers/reweave.py,
    tests/controllers/__init__.py,
    tests/controllers/test_base.py,
    tests/controllers/test_check.py,
    tests/controllers/test_upgrade.py
  </files>
  <read_first>
    src/ztlctl/services/reweave.py,
    src/ztlctl/controllers/base.py,
    src/ztlctl/controllers/check.py,
    src/ztlctl/controllers/upgrade.py,
    tests/services/test_check.py,
    tests/services/test_upgrade.py
  </read_first>
  <behavior>
    - BaseController.__init__ stores vault as _vault
    - BaseController._dispatch_event returns None when event_bus is None
    - CheckController(vault).check() returns same ServiceResult as CheckService(vault).check()
    - CheckController(vault).rebuild() returns same ServiceResult as CheckService(vault).rebuild()
    - UpgradeController(vault).check_pending() returns ServiceResult
    - ReweaveController(vault).reweave() returns ServiceResult
  </behavior>
  <action>
**ReweaveController** (`src/ztlctl/controllers/reweave.py`):
Read `src/ztlctl/services/reweave.py` for exact method signatures. Create:
- `reweave(self, *, content_id: str | None = None, threshold: float = 0.3, max_links: int = 10, dry_run: bool = False) -> ServiceResult`
- `prune(self, *, content_id: str | None = None, max_age_days: int | None = None, dry_run: bool = False) -> ServiceResult`
- `undo(self, *, reweave_id: int | None = None) -> ServiceResult`

All use lazy `from ztlctl.services.reweave import ReweaveService` inside method bodies.

**Tests:**

Create `tests/controllers/__init__.py` (empty).

`tests/controllers/test_base.py`:
- Test BaseController.__init__ stores vault as _vault
- Test _dispatch_event returns None when vault.event_bus is None (use a mock Vault with event_bus=None)
- Test _dispatch_event catches exceptions and appends warning (mock bus.dispatch to raise)

`tests/controllers/test_check.py`:
- Use the same vault fixtures from existing test infrastructure (check how `tests/services/test_check.py` creates its vault fixture)
- Test CheckController(vault).check() returns a ServiceResult with ok=True (on a healthy vault)
- Test CheckController(vault).rebuild() returns a ServiceResult

`tests/controllers/test_upgrade.py`:
- Test UpgradeController(vault).check_pending() returns a ServiceResult
- Test UpgradeController(vault).stamp_current() returns a ServiceResult

For vault fixtures: look at how existing service tests construct vaults. The pattern is typically a `tmp_path` based vault via test fixtures. Use `conftest.py` in `tests/controllers/` if needed, or import existing fixtures.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/controllers/ -x -q</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/controllers/reweave.py contains "class ReweaveController(BaseController):"
    - src/ztlctl/controllers/reweave.py contains "def reweave(" and "def prune(" and "def undo("
    - tests/controllers/test_base.py contains "class TestBaseController"
    - tests/controllers/test_base.py contains "test_dispatch_event_noop_when_no_bus"
    - tests/controllers/test_check.py contains "class TestCheckController"
    - tests/controllers/test_check.py contains "test_check_returns_service_result"
    - tests/controllers/test_upgrade.py contains "class TestUpgradeController"
    - `uv run pytest tests/controllers/ -x -q` exits 0
  </acceptance_criteria>
  <done>ReweaveController exists. Integration tests prove BaseController, CheckController, and UpgradeController produce correct ServiceResult outputs. All tests pass.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/controllers/ -x -q` — all controller tests green
- `uv run mypy src/ztlctl/controllers/` — strict type checking passes
- `uv run ruff check src/ztlctl/controllers/` — no lint errors
- `uv run pytest` — full suite still green (no regressions)
</verification>

<success_criteria>
- BaseController with _vault and _dispatch_event() mirroring BaseService
- 6 controllers (Check, Upgrade, Export, Graph, Vector, Reweave) wrapping their services
- All controllers use lazy local imports for services
- Integration tests for BaseController, CheckController, UpgradeController
- mypy strict clean, ruff clean, all tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/02-action-registry/02-02-SUMMARY.md`
</output>
